BiliBili 高级倍速功能

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

As of 2023-12-21. See the latest version.

// ==UserScript==
// @name            BiliBili 高级倍速功能
// @namespace       cec8225d12878f0fc33997eb79a69894
// @version         1.4.1
// @description     BiliBili倍速插件,支持自定义速度、记忆上一次速度、快捷键调速。
// @author          TheBszk
// @match           https://www.bilibili.com/video/*
// @match           https://www.bilibili.com/list/*
// @match           https://www.bilibili.com/bangumi/play/*
// @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";

	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 {
			return "unknown";
		}
	}

	const pageType = getPageType();

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

	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 speedDisplay = createTip();
	speedDisplay.style.bottom = "20px";
	speedDisplay.style.right = "20px";

	let hideTimer;
	function showPlayRate(rate) {
		speedDisplay.textContent = `速度: ${rate}x`;
		speedDisplay.style.display = "block";

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

		hideTimer = setTimeout(function () {
			speedDisplay.style.display = "none";
		}, 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);
		}
		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="textInput">自定义倍速列表:</label>
					<input type="text" id="speedList" placeholder="以英文逗号隔开" />

					<label for="textInput">长按右光标键速度:</label>
					<input type="text" id="arrowRightSpeed" placeholder="例: 2 为固定二倍速, 2x 为当前速度两倍" />
				</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.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;

			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;
			}

			this.handle(sl_, ars_);

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

	class PlayRateMenu {
		init(menu) {
			this.videoObj = document.querySelector("video");
			this.saveSetting = this.saveSetting.bind(this);
			if (!this.videoObj) {
				this.videoObj = document.querySelector("bwp-video");
			}
			if (!this.videoObj) {
				return false;
			}
			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];
			}
		}
	}

	let menu = new PlayRateMenu();

	let _interval = setInterval(function () {
		let element = document.querySelector(`.${MENUCLASS}`);
		if (element) {
			if (menu.init(element)) {
				menu.render();
				menu.setRate(getRate());
				document.querySelector(".bpx-player-video-wrap").appendChild(speedDisplay);
				document.querySelector(".bpx-player-video-wrap").appendChild(timeDisplay);
				setShowTimeState(localStorage.getItem(CUSTOM_ShowTimeState) == "true");
				clearInterval(_interval);
			} else {
				console.warn("获取视频元素失败!");
			}
		}
	}, 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.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") {
				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");
				if (num == 0) {
					num = 0.5;
				}

				if (e.ctrlKey) {
					menu.setVideoRate(num);
					menu.setActiveRate(num);
					showPlayRate(num);
					localStorage.setItem(CUSTOM_RATE, num);
				} else {
					if (OldRate == 0) {
						OldRate = getRate();
						menu.setVideoRate(num);
						showPlayRate(num);
					}
				}
			}
		},
		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");
	});

	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);
	}
})();