BiliBili 高级倍速功能

BiliBili倍速插件,支持自定义速度、记忆上一次速度、快捷键调速。

// ==UserScript==
// @name            BiliBili 高级倍速功能
// @namespace       cec8225d12878f0fc33997eb79a69894
// @version         1.7.1
// @description     BiliBili倍速插件,支持自定义速度、记忆上一次速度、快捷键调速。
// @author          TheBszk
// @match           https://www.bilibili.com/video/*
// @match           https://www.bilibili.com/list/*
// @match           https://www.bilibili.com/bangumi/play/*
// @match           https://www.bilibili.com/cheese/play/*
// @match           https://www.bilibili.com/festival/*
// @icon            https://www.bilibili.com/favicon.ico
// @license         AGPL
// ==/UserScript==

(function () {
	"use strict";
	const CUSTOM_RATE_ARRAY = "custom_rate_array";
	const CUSTOM_RATE = "custom_rate";
	const CUSTOM_ShowTimeState = "custom_showtimestate";
	const CUSTOM_ArrowRightSpeed = "custom_arrowrightspeed";
	const CUSTOM_SwitchCustomSpeed = "custom_switchcustomspeed";
	const CUSTOM_Volume = "custom_volume";
	const CUSTOM_GlobalVolumeAdjustment = "custom_globalvolumeadjustment";

	if (!localStorage.getItem(CUSTOM_ArrowRightSpeed)) {
		localStorage.setItem(CUSTOM_ArrowRightSpeed, "2x"); //设置默认值
	}

	function getPageType() {
		const path = window.location.pathname;

		if (path.startsWith("/video/")) {
			return "video";
		} else if (path.startsWith("/list/")) {
			return "list";
		} else if (path.startsWith("/bangumi/play/")) {
			return "bangumi";
		} else if (path.startsWith("/cheese/play/")) {
			return "cheese";
		} else if (path.startsWith("/festival/")) {
			return "festival";
		} else {
			return "unknown";
		}
	}

	const pageType = getPageType();

	if (pageType == "video" || pageType == "list" || pageType == "bangumi" || pageType == "cheese" || pageType == "festival") {
		var MENUCLASS = "bpx-player-ctrl-playbackrate-menu";
		var MENUCLASS_ITEM = "bpx-player-ctrl-playbackrate-menu-item";
		var MENUCLASS_ACTIVE = "bpx-state-active";
	} else {
		return;
	}

	function getRate() {
		let rate = localStorage.getItem(CUSTOM_RATE);
		if (rate <= 0) {
			rate = 1;
		}
		return rate;
	}

	function getRateArray() {
		let storageData = localStorage.getItem(CUSTOM_RATE_ARRAY);
		let rates;
		if (storageData == null) {
			rates = [];
		} else {
			rates = storageData.split(",");
		}
		if (rates.length === 0) {
			//如果没有,则初始化一个默认的
			rates = [0.5, 1.0, 1.5, 2, 2.5, 3.0, 4.0];
			localStorage.setItem(CUSTOM_RATE_ARRAY, rates.join(","));
		}
		return rates;
	}

	// 创建显示元素
	function createTip() {
		var elem = document.createElement("div");
		elem.style.display = "none";
		elem.style.position = "absolute";
		elem.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
		elem.style.color = "white";
		elem.style.padding = "5px";
		elem.style.borderRadius = "5px";
		elem.style.zIndex = "1000";
		elem.style.fontSize = "22px";
		return elem;
	}

	var timeDisplay = createTip();
	timeDisplay.style.top = "20px";
	timeDisplay.style.right = "20px";

	let _showtime;

	function setShowTimeState(state) {
		localStorage.setItem(CUSTOM_ShowTimeState, state);
		if (state == true) {
			timeDisplay.style.display = "block";

			if (!_showtime) {
				_showtime = setInterval(FlashShowTime, 1000);
			}
		} else {
			timeDisplay.style.display = "none";

			if (_showtime) {
				clearInterval(_showtime);
				_showtime = 0;
			}
		}
	}

	var tipDisplay = createTip();
	tipDisplay.style.right = "20px";

	let hideTimer;
	let bpx_player_control_entity = document.querySelector(".bpx-player-control-entity");
	let bpx_player_control_wrap = document.querySelector(".bpx-player-control-wrap");
	function showTip(title, time) {
		let h;
		if (bpx_player_control_entity.getAttribute("data-shadow-show") == "true") {
			h = 0;
		} else {
			h = bpx_player_control_wrap.clientHeight;
		}

		tipDisplay.style.bottom = h + 20 + "px";
		tipDisplay.textContent = title;
		tipDisplay.style.display = "block";

		if (!hideTimer) {
			clearTimeout(hideTimer);
		}

		hideTimer = setTimeout(function () {
			tipDisplay.style.display = "none";
		}, time);
	}

	function showPlayRate(rate) {
		showTip(`速度: ${rate}x`, 1200);
	}

	function showVolume(volume) {
		showTip(`音量: ${volume}%`, 1200);
	}

	class SettingPopup {
		popup_dragend_move(e) {
			this.popup.style.left = e.clientX - this.offsetX + this.startX + "px";
			this.popup.style.top = e.clientY - this.offsetY + this.startY + "px";
		}
		constructor() {
			this.speedlist = getRateArray().join(",");
			this.ArrowRightTime = localStorage.getItem(CUSTOM_ArrowRightSpeed);
			this.SwitchCustomSpeed = localStorage.getItem(CUSTOM_SwitchCustomSpeed) == "true" ? true : false;
			this.GlobalVolumeAdjustment = localStorage.getItem(CUSTOM_GlobalVolumeAdjustment) == "true" ? true : false;
			let v = localStorage.getItem(CUSTOM_Volume);
			if (v == null || v == "") {
				this.volume = -1;
			} else {
				this.volume = parseInt(v);
			}
		}
		create(handle) {
			this.popup = document.createElement("div");
			this.popup.innerHTML = `
				<div class="popup-title" id="popupTitle">
					<span>BiliBili 高级倍速功能</span>
					<button class="close-button">×</button>
				</div>
				<div class="popup-content">
					<label for="SpeedList">自定义倍速列表:</label>
					<input type="text" id="SpeedList" placeholder="以英文逗号隔开" />

					<label for="ArrowRightSpeed">长按右光标键速度:</label>
					<input type="text" id="ArrowRightSpeed" placeholder="例: 2 为固定二倍速, 2x 为当前速度两倍" />

					<label title="默认为对应速度(如 按2为2倍速、按3为3倍速)"><input type="checkbox" id="SwitchCustomSpeed" /> 0~9/Ctrl+0~9 快捷键切换自定义列表速度</label>
					<br />
					<label title="↑ / ↓ 全局调整音量 ; 优化滚轮调整音量体验\n支持 0 ~ 500%(过高会有轻微失真)\n仅支持快捷键与鼠标滚轮调整\n\n[!] 本项修改需要刷新网页后生效"><input type="checkbox" id="GlobalVolumeAdjustment"/> 接管音量控制</label>
					<br />
					<label title="暂不支持取消"><input type="checkbox" disabled checked /> 增加快捷键: 字幕切换(Z)</label>
					<br />
					<label title="暂不支持取消"><input type="checkbox" disabled checked /> 增加快捷键: 网页全屏(G)</label>
					<br />
					<label title="暂不支持取消"><input type="checkbox" disabled checked /> 增加快捷键: 宽屏模式(H)</label>
					<br />
					<label title="暂不支持取消"><input type="checkbox" disabled checked /> 双击字幕复制内容</label>
				</div>
				<div id="popup-tips">关闭设置窗口自动保存 | 鼠标停留查看更多信息</div>
			`;
			this.popup.classList.add("popup-container");
			this.popupcss = document.createElement("style");
			this.popupcss.innerHTML = `
			.popup-container {
				width: 330px;
				position: absolute;
				z-index: 999999;
				background-color: #fff;
				border: 1px solid #ccc;
				border-radius: 8px;
				box-shadow: 0 0 10px rgba(33,150,243,0.5);
			}

			.popup-container .popup-title {
				position: relative;
				background-color: #3498db;
				color: #fff;
				padding: 10px;
				cursor: move;
				border-top-left-radius: 8px;
				border-top-right-radius: 8px;
				user-select: none;
			}
			.popup-container .popup-content {
				padding: 20px;
			}
			.popup-container .close-button {
				position: absolute;
				top: 0px;
				right: 0px;
				height: 100%;
				background-color: #3498db;
				color: #fff;
				border: none;
				padding: 0px 13px;
				font-size: 24px;
				cursor: pointer;
				border-top-right-radius: 8px;
				transition: background-color 0.3s ease, transform 0.3s ease;
			}
			.popup-container .close-button:hover {
				background-color: #e74c3c;
			}
			.popup-container label {
				font-size: 14px;
			}
			.popup-container #popup-tips {
				color: #555555;
				font-size: 14px;
				padding: 4px 0px 4px 10px;
				border-top: 1px solid #ccc;
			}
			.popup-container .button {
				display: block;
				padding: 10px;
				background-color: #3498db;
				color: #fff;
				text-align: center;
				text-decoration: none;
				border-radius: 5px;
				cursor: pointer;
				transition: background-color 0.3s ease;
				border: none;
			}
			.popup-container .button:hover {
				background-color: #2980b9;
			}
			.popup-container select,
			input[type="text"] {
				display: block;
				margin-bottom: 10px;
				padding: 8px;
				border: 1px solid #ccc;
				border-radius: 4px;
				width: 100%;
				box-sizing: border-box;
				outline: none;
			}
			.popup-container input[type="text"]:focus {
				border: 1px solid #2980b9;
			}
			.popup-container input[type="radio"] {
				margin-right: 5px;
			}`;
			document.body.appendChild(this.popup);
			document.head.appendChild(this.popupcss);

			this.popup_dragend_move = this.popup_dragend_move.bind(this);
			this.popup.querySelector("#popupTitle").addEventListener("mousedown", (e) => {
				this.offsetX = e.clientX;
				this.offsetY = e.clientY;
				this.startX = parseInt(this.popup.style.left);
				this.startY = parseInt(this.popup.style.top);
				document.addEventListener("mousemove", this.popup_dragend_move);
				document.addEventListener("mouseup", (e) => {
					document.removeEventListener("mousemove", this.popup_dragend_move);
				});
			});
			this.popup.querySelector(".close-button").addEventListener("click", (e) => {
				this.close();
			});
			this.handle = handle;
		}
		show() {
			this.popup.querySelector("#SpeedList").value = this.speedlist;
			this.popup.querySelector("#ArrowRightSpeed").value = this.ArrowRightTime;
			this.popup.querySelector("#SwitchCustomSpeed").checked = this.SwitchCustomSpeed;
			this.popup.querySelector("#GlobalVolumeAdjustment").checked = this.GlobalVolumeAdjustment;
			this.popup.style.display = "block";
			let left = (window.innerWidth - this.popup.offsetWidth) / 2;
			let top = (window.innerHeight - this.popup.offsetHeight) / 2;

			this.popup.style.left = left + "px";
			this.popup.style.top = top + "px";
		}
		close() {
			let sl, ars;
			// 读取元素的值
			sl = this.popup.querySelector("#SpeedList").value;
			ars = this.popup.querySelector("#ArrowRightSpeed").value;
			this.SwitchCustomSpeed = this.popup.querySelector("#SwitchCustomSpeed").checked;
			this.GlobalVolumeAdjustment = this.popup.querySelector("#GlobalVolumeAdjustment").checked;

			let sl_ = null,
				ars_ = null;
			//进行处理
			//自定义速度列表
			if (!(sl === null || sl.trim() === "")) {
				let rates = sl
					.split(",")
					.map((s) => s.trim())
					.filter((s) => s);

				if (rates.length > 0) {
					// 检查输入是否全部为有效数字
					if (rates.every((s) => isFinite(s))) {
						localStorage.setItem(CUSTOM_RATE_ARRAY, rates.join(","));
						this.speedlist = sl;
						sl_ = rates;
					}
				}
			}

			//右光标键速度
			if (parseInt(ars) > 0) {
				localStorage.setItem(CUSTOM_ArrowRightSpeed, ars);
				this.ArrowRightTime = ars;
				ars_ = ars;
			}

			localStorage.setItem(CUSTOM_SwitchCustomSpeed, this.SwitchCustomSpeed);
			localStorage.setItem(CUSTOM_GlobalVolumeAdjustment, this.GlobalVolumeAdjustment);
			this.handle(sl_, ars_);

			this.popup.remove();
		}
	}
	let setting = new SettingPopup();

	class PlayRateMenu {
		init(menu) {
			this.videoObj = document.querySelector("video");
			if (this.videoObj) {
				if (setting.GlobalVolumeAdjustment) {
					let context = new (window.AudioContext || window.webkitAudioContext)();
					this.gain = context.createGain();
					context.createMediaElementSource(this.videoObj).connect(this.gain);
					this.gain.connect(context.destination);
					this.volumeNumElem = document.querySelector(".bpx-player-ctrl-volume-number");
					if (setting.volume != -1) {
						this.setVolume(setting.volume / 100, false);
					}
					this.videoObj.addEventListener("volumechange", () => {
						if (this.gain) {
							this.gain.gain.value = 1;
						}
					});
				}
			} else {
				this.videoObj = document.querySelector("bwp-video"); //b站自研wasm软解视频播放器
			}

			if (!this.videoObj) {
				return false;
			}

			this.saveSetting = this.saveSetting.bind(this);
			this.menu = menu;
			this.rates = getRateArray();
			this.videoObj.addEventListener("loadedmetadata", () => {
				this.setRate(getRate());
			});

			return true;
		}

		insertRate(rateValue) {
			this.rates.push(rateValue);
			this.render();
		}

		insertItem(content, rate, event) {
			const item = document.createElement("li");
			item.textContent = content;
			item.classList.add(MENUCLASS_ITEM);
			item.setAttribute("data-value", rate);
			item.addEventListener("click", event);
			this.menu.appendChild(item);
		}

		saveSetting(sl, ars) {
			if (sl != null) {
				this.rates = sl;
				this.render();
				let nowRate = getRate();
				if (this.rates.indexOf(nowRate) === -1) {
					this.setRate(1);
				} else {
					this.setRate(nowRate);
				}
			}
		}

		render() {
			this.menu.innerHTML = "";
			this.rates.sort((a, b) => b - a); //排序

			this.rates.forEach((rate) => {
				this.insertItem(rate % 1 == 0 ? rate + ".0x" : rate + "x", rate, (e) => {
					e.stopPropagation();
					const rateValue = e.target.getAttribute("data-value");
					this.setVideoRate(rateValue);
					this.setActiveRate(rateValue);
					localStorage.setItem(CUSTOM_RATE, rateValue);
				});
			});

			//插入一个设置按钮
			this.insertItem("设置", 0, (e) => {
				e.stopPropagation();
				setting.create(this.saveSetting);
				setting.show();
			});
		}

		setActiveRate(rateValue) {
			const items = this.menu.querySelectorAll(`.${MENUCLASS_ITEM}`);
			items.forEach((item) => {
				const value = item.getAttribute("data-value");
				if (value === rateValue) {
					item.classList.add(MENUCLASS_ACTIVE);
				} else {
					item.classList.remove(MENUCLASS_ACTIVE);
				}
			});
		}

		getDuration() {
			return this.videoObj.duration;
		}

		getCurrentTime() {
			return this.videoObj.currentTime;
		}

		setVideoRate(rate) {
			this.videoObj.playbackRate = parseFloat(rate);
		}
		getVideoRate() {
			return this.videoObj.playbackRate;
		}

		//使用此函数前提:速度列表必须存在该速度值
		setRate(rate) {
			const item = document.querySelector(`.${MENUCLASS_ITEM}[data-value="${rate}"]`);
			if (item) {
				item.classList.add(MENUCLASS_ACTIVE);
				item.click();
			} else {
				console.error("未找到匹配元素");
			}
		}

		changeRate(up) {
			let nowRate = getRate();
			let index = this.rates.indexOf(nowRate);
			if ((index == 0 && up) || (index == this.rates.length && !up)) {
				return nowRate;
			} else {
				index += up ? -1 : 1;
				this.setRate(this.rates[index]);
				return this.rates[index];
			}
		}
		getVolume() {
			if (this.videoObj.volume == 1.0) {
				return this.gain.gain.value;
			} else {
				return this.videoObj.volume;
			}
		}

		setVolume(volume, show) {
			if (!this.gain && volume > 1.0) {
				volume = 1.0;
			}

			if (volume <= 1.0) {
				this.videoObj.volume = volume;
				if (this.gain) {
					this.gain.gain.value = 1;
				}
			} else {
				this.videoObj.volume = 1;
				this.gain.gain.value = volume;
			}

			let sv = (volume * 100).toFixed(0);
			localStorage.setItem(CUSTOM_Volume, sv);
			this.volumeNumElem.textContent = sv;
			if (show == true) {
				showVolume(sv);
			}
		}
	}

	let menu = new PlayRateMenu();

	let _interval = setInterval(function () {
		let element = document.querySelector(`.${MENUCLASS}`);
		if (element) {
			if (menu.init(element)) {
				menu.render();
				menu.setRate(getRate());
				let bpx_player_video_warp = document.querySelector(".bpx-player-video-wrap");
				bpx_player_video_warp.appendChild(tipDisplay);
				bpx_player_video_warp.appendChild(timeDisplay);
				if (setting.GlobalVolumeAdjustment) {
					bpx_player_video_warp.addEventListener("mousewheel", (e) => {
						e.preventDefault();
						e.stopImmediatePropagation();
						let volume = menu.getVolume() + parseInt(e.wheelDelta / 120) * 0.05;
						if (volume > 5.0) {
							volume = 5.0;
						} else if (volume < 0) {
							volume = 0;
						}
						menu.setVolume(volume, true);
					});
				}

				setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "true");
				clearInterval(_interval);
			} else {
				console.warn("获取视频元素失败!");
			}

			//双击复制字幕内容
			let subtitle_panel = document.querySelector(".bpx-player-subtitle-panel-major-group");
			if (subtitle_panel) {
				subtitle_panel.addEventListener("dblclick", function () {
					let text = document.querySelector(".bpx-player-subtitle-panel-major-group span").textContent;

					//如果是歌词会存在音乐符号,要清除
					let musicSymbol = "♪";
					if (text.startsWith(musicSymbol)) {
						text = text.slice(musicSymbol.length);
						if (text.endsWith(musicSymbol)) {
							text = text.slice(0, -musicSymbol.length);
						}
					}
					navigator.clipboard.writeText(text);
				});
			}
		}
	}, 500);

	let ArrowRightTime = 0;
	let OldRate = 0;

	document.addEventListener(
		"keydown",
		function (e) {
			e = e || window.event;
			if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA" || e.target.tagName == "BILI-COMMENTS" || e.target.isContentEditable) {
				return;
			}
			if (e.ctrlKey == true && e.code == "ArrowUp") {
				let rate = menu.changeRate(true);
				showPlayRate(rate);
			} else if (e.ctrlKey == true && e.code == "ArrowDown") {
				let rate = menu.changeRate(false);
				showPlayRate(rate);
			} else if (e.code == "ArrowRight" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
				if (ArrowRightTime == 0) {
					ArrowRightTime = e.timeStamp;
				} else {
					if (e.timeStamp - ArrowRightTime > 500) {
						if (OldRate == 0) {
							OldRate = getRate();
							if (typeof setting.ArrowRightTime === "string" && setting.ArrowRightTime.indexOf("x") != -1) {
								menu.setVideoRate(OldRate * parseInt(setting.ArrowRightTime));
								showPlayRate(OldRate * parseInt(setting.ArrowRightTime));
							} else {
								menu.setVideoRate(parseInt(setting.ArrowRightTime));
								showPlayRate(parseInt(setting.ArrowRightTime));
							}
						}
					}
				}
			} else if ("0" <= e.key && e.key <= "9") {
				e.preventDefault();
				e.stopImmediatePropagation();
				let num = parseInt(e.key - "0");
				let speed;
				if (setting.SwitchCustomSpeed) {
					if (!(1 <= num && num <= menu.rates.length)) {
						return;
					}
					speed = menu.rates[menu.rates.length - num];
				} else {
					if (num == 0) {
						speed = 0.5;
					} else {
						speed = num;
					}
				}

				if (e.ctrlKey) {
					menu.setVideoRate(speed);
					menu.setActiveRate(speed);
					showPlayRate(speed);
					localStorage.setItem(CUSTOM_RATE, speed);
				} else {
					if (OldRate == 0) {
						OldRate = getRate();
						menu.setVideoRate(speed);
						showPlayRate(speed);
					}
				}
			} else if (e.code == "KeyZ" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
				let subtitle_btn = document.querySelector("#bilibili-player .bpx-player-ctrl-subtitle span");
				if (subtitle_btn) {
					subtitle_btn.click();
				}
			} else if (e.code == "KeyG" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
				document.querySelector("#bilibili-player .bpx-player-ctrl-web span").click();
			} else if (e.code == "KeyH" && !e.ctrlKey && !e.shiftKey && !e.altKey) {
				document.querySelector("#bilibili-player .bpx-player-ctrl-wide span").click();
			} else if ((e.code == "ArrowUp" || e.code == "ArrowDown") && !e.ctrlKey && !e.shiftKey && !e.altKey) {
				if (setting.GlobalVolumeAdjustment) {
					e.preventDefault();
					e.stopImmediatePropagation();
					let volume = menu.getVolume();
					if (e.code == "ArrowUp") {
						volume = volume + 0.1;
						if (volume > 5.0) {
							volume = 5.0;
						}
					} else {
						volume = volume - 0.1;
						if (volume < 0) {
							volume = 0;
						}
					}
					menu.setVolume(volume, true);
				}
			}
		},
		true
	);

	document.addEventListener("keyup", function (e) {
		if (e.code == "ArrowRight" || ("0" <= e.key && e.key <= "9")) {
			ArrowRightTime = 0;
			if (OldRate != 0) {
				menu.setVideoRate(OldRate);
				showPlayRate(OldRate);
				OldRate = 0;
				e.preventDefault();
			}
		} else if (e.code == "F2") {
			setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "false");
		}
	});

	window.addEventListener("focus", function () {
		menu.setRate(getRate());
		setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "true");
		if (setting.GlobalVolumeAdjustment) {
			let volume = localStorage.getItem(CUSTOM_Volume);
			if (volume != -1) {
				menu.setVolume(volume / 100, false);
			}
		}
	});

	function formatTime(s) {
		var m = parseInt(s / 60);
		var ss = parseInt(s % 60);
		return (m > 9 ? `${m}` : `0${m}`) + ":" + (ss > 9 ? `${ss}` : `0${ss}`);
	}

	function FlashShowTime() {
		var rate = menu.getVideoRate();
		timeDisplay.textContent = formatTime(menu.getCurrentTime() / rate) + "/" + formatTime(menu.getDuration() / rate);
	}
})();