// ==UserScript== // @name 哔哩哔哩视频倍速扩展 3x/4x // @tag 哔哩哔哩 bilibili B站 // @namespace https://github.com/ // @version 3.4.0 // @description 为哔哩哔哩原生倍速菜单,添加3倍速和4倍速;用local storage保存最多6个视频的倍速,实现记忆倍速的功能;无任何定时器轮询,几乎零开销。 // @author deran // @match *://www.bilibili.com/video/* // @match *://www.bilibili.com/bangumi/play/* // @grant none // @run-at document-end // @license MIT // ==/UserScript== // 立即执行函数,隔离作用域,避免全局变量污染 (function() { 'use strict'; // 开启严格模式,规避隐性语法错误 /********** 全局配置项 **********/ const EXTRA_SPEEDS = [3, 4]; // 自定义倍速档位,可按需增减 const STORAGE_KEY = 'bili_custom_rates'; // 单Key存储所有倍速记录 const MAX_ITEMS = 6; // 最多保留6个视频的倍速记录,超出淘汰最久未使用 const VIDEO_SELECTOR = 'video'; // 视频元素选择器 // 倍速菜单选择器,按优先级排列,兼容不同版本bpx播放器 const MENU_SELECTORS = [ '.bpx-player-ctrl-playbackrate-menu', '.bpx-player-ctrl-speed-menu' ]; // 倍速按钮选择器,用于hover兜底触发注入 const SPEED_BTN_SELECTOR = '.bpx-player-ctrl-playbackrate'; /****************************** * 存储模块:单Key映射表方案 * 实现原理: * 1. 所有记录存在同一个localStorage键中,结构为 { 视频id: {rate, time} } * 2. 读写仅操作一个键,完全不需要遍历整个localStorage * 3. LRU清理直接在对象内完成,数据量极小,开销可忽略 ******************************/ /** * 提取当前视频唯一标识 * 功能:为单视频独立记忆提供主键 * 实现原理:从URL匹配BV号/ep号,同视频多分P共享同一条记录 * @returns {string} 视频唯一ID */ function getVideoId() { const path = location.pathname; // 匹配普通视频BV号 const bvMatch = path.match(/\/video\/(BV[a-zA-Z0-9]+)/); if (bvMatch) return bvMatch[1]; // 匹配番剧ep号 const epMatch = path.match(/\/bangumi\/play\/(ep\d+)/); if (epMatch) return epMatch[1]; // 兜底返回完整路径 return path; } /** * 读取全部倍速记录 * 功能:从localStorage获取完整的映射表 * @returns {Object} 倍速记录映射表 */ function getAllRates() { const raw = localStorage.getItem(STORAGE_KEY); try { return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } } /** * 保存当前视频的倍速设置 * 功能:更新映射表并持久化,自动刷新使用时间 * @param {number} rate - 倍速值 */ function saveRate(rate) { const id = getVideoId(); const all = getAllRates(); // 更新当前记录的倍速和使用时间 all[id] = { rate, time: Date.now() }; localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } /** * 读取当前视频的记忆倍速 * 功能:获取当前视频的历史倍速,同时刷新使用时间标记为最近使用 * @returns {number|null} 记忆的倍速值,无记录返回null */ function loadRate() { const id = getVideoId(); const all = getAllRates(); const record = all[id]; if (!record) return null; // 读取时刷新时间,更新为最近使用 saveRate(record.rate); return record.rate; } /** * LRU容量清理 * 功能:控制记录总数不超过上限,超出淘汰最久未使用 * 实现原理:仅页面加载时执行一次,直接操作映射表,无需遍历localStorage */ function cleanStorage() { const all = getAllRates(); const keys = Object.keys(all); // 未超出上限直接返回 if (keys.length <= MAX_ITEMS) return; // 按使用时间升序排序,最久未使用的在前 keys.sort((a, b) => all[a].time - all[b].time); // 删除超出部分 keys.slice(0, keys.length - MAX_ITEMS).forEach(key => { delete all[key]; }); // 写回存储 localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } /****************************** * DOM工具模块 ******************************/ /** * 等待目标DOM元素出现 * 功能:异步等待动态渲染的元素,找到后立即停止监听,零后续开销 * @param {string} selector - CSS选择器 * @param {HTMLElement} parent - 监听父节点 * @returns {Promise} 目标元素 */ function waitForElement(selector, parent = document.body) { return new Promise(resolve => { // 先同步检查,元素已存在直接返回 const target = parent.querySelector(selector); if (target) return resolve(target); // 创建DOM观察者 const observer = new MutationObserver(() => { const target = parent.querySelector(selector); if (target) { observer.disconnect(); // 找到后立即断开,释放资源 resolve(target); } }); // 监听子节点变化与深层子树 observer.observe(parent, { childList: true, subtree: true }); }); } /** * 获取当前倍速菜单DOM * 功能:按优先级匹配选择器,返回第一个有效菜单 * @returns {HTMLElement|null} 菜单元素 */ function getMenuEl() { for (const sel of MENU_SELECTORS) { const el = document.querySelector(sel); // 确保菜单有子元素,是有效渲染后的状态 if (el && el.children.length > 0) return el; } return null; } /****************************** * 菜单注入模块 ******************************/ /** * 向原生倍速菜单注入自定义选项 * 实现原理: * 1. 自动匹配有效菜单,去重后注入 * 2. 复用原生选项类名,样式交互完全一致 * 3. 注入后打标记,防止重复执行 */ function injectMenuOptions() { const menu = getMenuEl(); // 菜单不存在或已注入过,直接返回 if (!menu || menu.dataset.customInjected) return; // 收集原生已有倍速,自动去重 const existing = new Set(); const items = menu.querySelectorAll('li, .bpx-player-ctrl-playbackrate-menu-item'); items.forEach(item => { const v = parseFloat(item.dataset.value); if (!isNaN(v)) existing.add(v); }); // 过滤已存在的倍速,从大到小排序,和原生顺序一致 const speeds = EXTRA_SPEEDS .filter(r => !existing.has(r)) .sort((a, b) => b - a); // 无新增项则标记为已处理 if (speeds.length === 0) { menu.dataset.customInjected = 'true'; return; } // 复用第一个原生选项的类名,保证样式完全匹配 const firstItem = items[0]; // 文档片段批量插入,减少DOM重排 const frag = document.createDocumentFragment(); speeds.forEach(rate => { const li = document.createElement('li'); if (firstItem) li.className = firstItem.className; li.dataset.value = rate; li.textContent = `${rate}.0x`; // 点击设置倍速并保存 li.addEventListener('click', () => { const video = document.querySelector(VIDEO_SELECTOR); if (video) { video.playbackRate = rate; saveRate(rate); } }); frag.appendChild(li); }); // 插入到菜单顶部 menu.insertBefore(frag, menu.firstChild); menu.dataset.customInjected = 'true'; } /****************************** * 倍速控制模块 ******************************/ /** * 初始化倍速恢复与状态同步 * 功能:加载记忆倍速,绑定倍速变化事件 */ async function initRateControl() { const video = await waitForElement(VIDEO_SELECTOR); // 已绑定过则跳过,防止内存泄漏 if (!video || video.dataset.customRateBound) return; video.dataset.customRateBound = 'true'; // 恢复当前视频的记忆倍速 const saved = loadRate(); if (saved) video.playbackRate = saved; // 监听倍速变化:自动保存 + 同步菜单选中态 video.addEventListener('ratechange', () => { const cur = video.playbackRate; saveRate(cur); // 同步菜单选中高亮 const menu = getMenuEl(); if (!menu) return; menu.querySelectorAll('li').forEach(item => { const val = parseFloat(item.dataset.value); // 浮点精度容错 item.classList.toggle('bpx-state-active', Math.abs(val - cur) < 0.01); }); }); } /****************************** * SPA路由适配与兜底注入 ******************************/ /** * 路由变化时重新初始化 * 功能:适配B站无刷新跳转视频/分P/剧集的场景 */ function onRouteChange() { setTimeout(() => { initRateControl(); injectMenuOptions(); }, 0); } // 劫持history API监听前端路由跳转 const _push = history.pushState; history.pushState = function(...args) { _push.apply(this, args); onRouteChange(); }; const _replace = history.replaceState; history.replaceState = function(...args) { _replace.apply(this, args); onRouteChange(); }; window.addEventListener('popstate', onRouteChange); /** * 倍速按钮hover兜底注入 * 功能:B站菜单为懒加载,鼠标悬停才生成DOM,hover时强制检查注入 */ async function initHoverFallback() { const btn = await waitForElement(SPEED_BTN_SELECTOR); if (!btn || btn.dataset.fallbackBound) return; btn.dataset.fallbackBound = 'true'; // 鼠标移入时检查一次菜单,确保注入成功 btn.addEventListener('mouseenter', injectMenuOptions); } /********** 页面初始化 **********/ cleanStorage(); // 执行一次容量清理 initRateControl(); // 初始化倍速控制 initHoverFallback(); // 启动hover兜底注入 injectMenuOptions(); // 尝试首次注入 })();