// ==UserScript== // @name 视频倍速播放快捷键 // @version 2.1.0 // @description 为网页视频添加统一的倍速播放快捷键:→ 方向键点按快进、长按倍速,← 方向键后退;长按 3 倍速,双击长按 6 倍速。适配了哔哩哔哩、抖音、小红书、知乎、微博、X、Facebook、Instagram、YouTube、腾讯视频、爱奇艺、优酷、PPTV、芒果TV、乐视视频、搜狐视频、咪咕视频、今日头条、极客时间 // @author sakura-flutter // @namespace https://github.com/sakura-flutter/tampermonkey-scripts // @license GPL-3.0 // @run-at document-start // @match *://www.bilibili.com/* // @match *://www.douyin.com/* // @match *://www.xiaohongshu.com/* // @match *://www.zhihu.com/* // @match *://*.weibo.com/* // @match *://x.com/* // @match *://www.facebook.com/* // @match *://www.instagram.com/* // @match *://www.youtube.com/* // @match *://v.qq.com/* // @match *://www.iqiyi.com/* // @match *://www.youku.com/* // @match *://v.youku.com/* // @match *://www.pptv.com/* // @match *://v.pptv.com/* // @match *://www.mgtv.com/* // @match *://tv.le.com/* // @match *://www.le.com/* // @match *://tv.sohu.com/* // @match *://www.miguvideo.com/* // @match *://www.toutiao.com/* // @match *://time.geekbang.org/* // @downloadURL https://update.greasyfork.icu/scripts/562075/%E8%A7%86%E9%A2%91%E5%80%8D%E9%80%9F%E6%92%AD%E6%94%BE%E5%BF%AB%E6%8D%B7%E9%94%AE.user.js // @updateURL https://update.greasyfork.icu/scripts/562075/%E8%A7%86%E9%A2%91%E5%80%8D%E9%80%9F%E6%92%AD%E6%94%BE%E5%BF%AB%E6%8D%B7%E9%94%AE.meta.js // ==/UserScript== /******/ (() => { // webpackBootstrap /******/ "use strict"; ;// ./src/scripts/playback-rate/multi-press.ts // ===================================================== // Name: 多击键盘事件 // Author: AI // ===================================================== /** * 多击长按事件配置 */ /** * 多击长按事件详情 */ /** * 事件回调函数类型 */ /** * 多击长按键盘事件处理器 * * 功能特点: * - 支持单次、两次、三次、四次等任意多击检测 * - 支持长按检测(按下后持续一段时间) * - 支持双击长按、三击长按等组合模式 * - 可配置时间阈值 * - 支持长按期间重复触发 * * @example * ```typescript * // 创建处理器 * const handler = new MultiPress({ * pressInterval: 300, // 多击间隔 300ms * longPressThreshold: 500, // 长按阈值 500ms * enableRepeat: true, // 启用重复触发 * repeatInterval: 100 // 每 100ms 重复一次 * }); * * // 监听单击长按(按一次并长按) * handler.on('Space', 1, (event) => { * console.log('空格单击长按', event.pressDuration); * }); * * // 监听双击长按(按两次后第二次长按) * handler.on('Enter', 2, (event) => { * console.log('回车双击长按', event.pressCount); * }); * * // 监听三击长按 * handler.on('ArrowUp', 3, (event) => { * console.log('上箭头三击长按'); * }); * * // 启动监听 * handler.start(); * * // 停止监听 * handler.stop(); * ``` */ class MultiPress { constructor(config) { const defaultConfig = { pressInterval: 150, longPressThreshold: 350, enableRepeat: false, repeatInterval: 100, onKeydown() {}, onKeyup() {} }; this.config = { ...defaultConfig, ...config }; this.listeners = new Map(); this.keyStates = new Map(); this.isActive = false; this.boundKeyDown = this.handleKeyDown.bind(this); this.boundKeyUp = this.handleKeyUp.bind(this); } /** * 注册事件监听器 * @param key 按键代码(如 'Space', 'Enter', 'a', 'A', 'ArrowUp' 等) * @param pressCount 按键次数(1=单次, 2=两次, 3=三次, ...) * @param callback 回调函数 */ on(key, pressCount, callback) { if (!this.listeners.has(key)) { this.listeners.set(key, new Map()); } const keyListeners = this.listeners.get(key); if (!keyListeners.has(pressCount)) { keyListeners.set(pressCount, []); } keyListeners.get(pressCount).push(callback); } /** * 移除事件监听器 * @param key 按键代码 * @param pressCount 按键次数 * @param callback 要移除的回调函数(可选,不传则移除该按键和按键次数的所有监听器) */ off(key, pressCount, callback) { if (!this.listeners.has(key)) return; const keyListeners = this.listeners.get(key); if (pressCount === undefined) { // 移除该按键的所有监听器 this.listeners.delete(key); return; } if (!keyListeners.has(pressCount)) return; if (callback === undefined) { // 移除该按键和按键次数的所有监听器 keyListeners.delete(pressCount); } else { // 移除特定回调 const callbacks = keyListeners.get(pressCount); const index = callbacks.indexOf(callback); if (index !== -1) { callbacks.splice(index, 1); } if (callbacks.length === 0) { keyListeners.delete(pressCount); } } if (keyListeners.size === 0) { this.listeners.delete(key); } } /** * 获取当前配置 */ getConfig() { return { ...this.config }; } /** * 更新配置 */ updateConfig(config) { this.config = { ...this.config, ...config }; } /** * 启动键盘事件监听 */ start() { if (this.isActive) return; this.isActive = true; window.addEventListener('keydown', this.boundKeyDown, true); window.addEventListener('keyup', this.boundKeyUp, true); } /** * 停止键盘事件监听 */ stop() { if (!this.isActive) return; this.isActive = false; window.removeEventListener('keydown', this.boundKeyDown, true); window.removeEventListener('keyup', this.boundKeyUp, true); this.clearAllTimers(); } /** * 销毁处理器,清理所有资源 */ destroy() { this.stop(); this.listeners.clear(); this.keyStates.clear(); } handleKeyDown(event) { this.config.onKeydown(event); const key = event.key; // 避免重复触发(按住不放) if (event.repeat) return; const now = Date.now(); let state = this.keyStates.get(key); if (!state) { state = { pressCount: 0, lastKeyDownTime: 0, lastKeyUpTime: 0, longPressTimer: null, repeatTimer: null, pressResetTimer: null, pressTriggerTimer: null, isLongPressing: false, pressStartTime: 0, lastEvent: null }; this.keyStates.set(key, state); } // 判断是否为连续点击 const timeSinceLastUp = now - state.lastKeyUpTime; if (timeSinceLastUp <= this.config.pressInterval && state.lastKeyUpTime > 0) { // 连续点击,增加计数 state.pressCount++; } else { // 新的点击序列 state.pressCount = 1; } state.lastKeyDownTime = now; state.pressStartTime = now; state.isLongPressing = false; state.lastEvent = event; // 清除之前的定时器(包括按键触发定时器) this.clearTimers(state); if (state.pressTriggerTimer !== null) { clearTimeout(state.pressTriggerTimer); state.pressTriggerTimer = null; } // 设置长按检测定时器 state.longPressTimer = window.setTimeout(() => { state.isLongPressing = true; this.triggerEvent(key, state.pressCount, true, event, now, false); // 如果启用重复触发 if (this.config.enableRepeat) { state.repeatTimer = window.setInterval(() => { if (state.isLongPressing) { // TODO: 这里 `event.repeat` 应该为 true this.triggerEvent(key, state.pressCount, true, event, state.pressStartTime, true); } }, this.config.repeatInterval); } }, this.config.longPressThreshold); } handleKeyUp(event) { this.config.onKeyup(event); const key = event.key; const state = this.keyStates.get(key); if (!state) return; const now = Date.now(); state.lastKeyUpTime = now; // 清除长按和重复定时器 this.clearTimers(state); // 如果没有触发长按,需要延迟触发以等待可能的后续点击 if (!state.isLongPressing) { // 清除之前的延迟触发定时器 if (state.pressTriggerTimer !== null) { clearTimeout(state.pressTriggerTimer); state.pressTriggerTimer = null; } // 保存当前的 pressCount 和 event const currentPressCount = state.pressCount; const currentEvent = state.lastEvent || event; // 设置延迟触发定时器,等待 pressInterval 时间 // 如果在这个时间内没有新的点击,则触发当前次数的回调 state.pressTriggerTimer = window.setTimeout(() => { // 触发当前点击次数的事件 this.triggerEvent(key, currentPressCount, false, currentEvent); // 重置按键计数 state.pressCount = 0; state.pressTriggerTimer = null; }, this.config.pressInterval); } else { // 如果已经触发长按,只需要重置状态 state.isLongPressing = false; // 设置按键计数重置定时器 state.pressResetTimer = window.setTimeout(() => { state.pressCount = 0; }, this.config.pressInterval); } } triggerEvent(key, pressCount, isLongPress, originalEvent, pressStartTime, isRepeat = false) { const keyListeners = this.listeners.get(key); if (!keyListeners) return; const callbacks = keyListeners.get(pressCount); if (!callbacks || callbacks.length === 0) return; const eventDetail = { key, pressCount, isLongPress, isRepeat, pressDuration: pressStartTime ? Date.now() - pressStartTime : undefined, originalEvent }; callbacks.forEach(callback => { try { callback(eventDetail); } catch (error) { console.error('MultiPress callback error:', error); } }); } clearTimers(state) { if (state.longPressTimer !== null) { clearTimeout(state.longPressTimer); state.longPressTimer = null; } if (state.repeatTimer !== null) { clearInterval(state.repeatTimer); state.repeatTimer = null; } } clearAllTimers() { this.keyStates.forEach(state => { this.clearTimers(state); if (state.pressResetTimer !== null) { clearTimeout(state.pressResetTimer); state.pressResetTimer = null; } if (state.pressTriggerTimer !== null) { clearTimeout(state.pressTriggerTimer); state.pressTriggerTimer = null; } }); } } /** * 便捷工厂函数 */ function createMultiPress(config) { return new MultiPress(config); } ;// ./src/utils/selector.ts const $ = document.querySelector.bind(document); const $$ = document.querySelectorAll.bind(document); ;// ./src/utils/log.ts const isDebug = "production" !== 'production'; function warn(...args) { isDebug && warn.force(...args); } warn.force = function (...args) { console.warn('%c warn ', 'background: #ffa500; padding: 1px; color: #fff;', ...args); }; function error(...args) { isDebug && error.force(...args); } error.force = function (...args) { console.error('%c error ', 'background: red; padding: 1px; color: #fff;', ...args); }; function table(...args) { isDebug && console.table(...args); } ;// ./src/scripts/playback-rate/utils.ts /** 判断视频是否正在播放 */ function isPlaying(video) { return !video.paused && !video.ended; } /** 判断视频是否有声音(非静音) */ function isAudible(video) { return !video.muted && video.volume > 0; } /** 计算矩形中心到视口中心的距离 */ function getDistanceFromViewportCenter(rect) { const viewportCenterX = window.innerWidth / 2; const viewportCenterY = window.innerHeight / 2; const videoCenterX = rect.left + rect.width / 2; const videoCenterY = rect.top + rect.height / 2; const dx = videoCenterX - viewportCenterX; const dy = videoCenterY - viewportCenterY; // 这里不需要 `Math.sqrt` 开根号,避免计算开销,比较时平方距离也是有效的 return dx * dx + dy * dy; } /** * 查找页面中最符合条件的视频元素 * * 多个视频元素时的权重优先级: * 1. 播放状态 (播放中 > 其他):只有播放中才需要倍速 * 2. 音频状态 (有声 > 静音):如果有多个视频同时播放,优先处理有声音的,因为静音的通常是广告或背景视频, * 理想情况下不会出现多个有声音的视频同时播放 * 3. 元素大小 (大 > 小):大尺寸的视频通常是主要内容,虽然背景视频尺寸可能更大但通常都是静音的 * 4. 视口距离 (距离视口中心近 > 远):短视频或信息流页面可滚动时,优先处理视口中心附近的视频 */ function findBestVideoElement() { // 优先级 1 播放状态:播放中优先 const videos = Array.from($$('video')).filter(video => isPlaying(video)); if (videos.length === 0) { warn('视频元素为空'); return null; } videos.sort((a, b) => { // 优先级 1 播放状态:播放中优先 // const playingA = isPlaying(a) // const playingB = isPlaying(b) // if (playingA !== playingB) { // return playingA ? -1 : 1 // } // 优先级 2 音频状态:非静音优先 const audibleA = isAudible(a); const audibleB = isAudible(b); if (audibleA !== audibleB) { return audibleA ? -1 : 1; } const rectA = a.getBoundingClientRect(); const rectB = b.getBoundingClientRect(); // 优先级 3 元素大小:大尺寸优先 const sizeA = rectA.width * rectA.height; const sizeB = rectB.width * rectB.height; // 允许 100 像素的误差视为相等,或者直接比较 if (sizeA !== sizeB) { return sizeB - sizeA; } // 优先级 4 视口距离:距离视口中心越近越优先 (距离越小越好) const distA = getDistanceFromViewportCenter(rectA); const distB = getDistanceFromViewportCenter(rectB); return distA - distB; }); warn(videos); // 返回排序后的第一个元素,即最优匹配 return videos[0]; } /** * 检测当前活动元素是否为输入元素 */ function isInputActive() { let activeElement = document.activeElement; if (!activeElement) return false; while (activeElement.shadowRoot?.activeElement) { activeElement = activeElement.shadowRoot.activeElement; } const tagName = activeElement.tagName; return tagName === 'INPUT' || tagName === 'TEXTAREA' || activeElement instanceof HTMLElement && activeElement.isContentEditable; } ;// ./src/scripts/playback-rate/index.ts // 由于 sohu 阻止了键盘事件,需要在捕获阶段监听 new class PlaybackRateController { /** 触发按键 */ triggerKeys = ['ArrowLeft', 'ArrowRight']; /** 按键次数 -> 倍速 映射 */ rateMap = { 1: 3, 2: 6, 3: 9 }; currentTriggerKey = null; videoPlaybackRate = 1; /** 是否正在倍速播放 */ isBoosting = false; /** 当前视频元素 */ _video = null; /** 当前视频元素 */ get video() { return this._video; } set video(video) { // 处于倍速时不能置空视频元素,否则播放速度无法恢复 if (this.isBoosting && video === null) { return; } this._video = video; } /** * 判断当前是否处于输入状态, * 如果是,不处理任何快捷键。避免冲突,比如输入状态下按方向键。 */ isInputActive = false; constructor() { this.multiPress = createMultiPress({ pressInterval: 100, longPressThreshold: 200, enableRepeat: true, onKeydown: event => { /** * 按下方向键时如果有视频元素,则阻止网站本身行为 */ if (!this.triggerKeys.includes(event.key)) return; // 只在首次按下时获取状态,重复按下时不再获取避免影响性能 if (event.repeat === false) { if (this.isInputActive = isInputActive()) return; this.video ??= findBestVideoElement(); } if (this.isInputActive) return; if (this.video) { event.stopPropagation(); event.stopImmediatePropagation(); event.preventDefault(); } // ← ArrowLeft // 这里无多击判断延迟 if (event.key === 'ArrowLeft') { this.handleSeek('backward'); } }, onKeyup: event => { // 松开方向键时如果有视频元素,则阻止网站本身行为 // 虽然 keyup 不一定需要停止传播,但为了逻辑一致性避免页面响应 keyup if (this.triggerKeys.includes(event.key) && this.video) { event.stopPropagation(); event.stopImmediatePropagation(); event.preventDefault(); } this.handleKeyUp(event); } }); this.init(); } init() { // → ArrowRight for (const pressCount of Object.keys(this.rateMap)) { this.multiPress.on('ArrowRight', Number(pressCount), event => { if (event.isRepeat || this.isInputActive) return; if (event.isLongPress) { this.handleSpeed(event); } else { this.handleSeek('forward'); } }); } this.multiPress.start(); } handleSpeed(event) { warn('speed'); if (this.isBoosting || !event.isLongPress) return; const { video } = this; if (!video) return; this.isBoosting = true; this.currentTriggerKey = event.key; this.videoPlaybackRate = video.playbackRate; video.playbackRate = this.rateMap[event.pressCount] ?? this.videoPlaybackRate; } handleKeyUp(event) { if (this.isBoosting && event.key === this.currentTriggerKey) { warn('恢复播放速度'); this.video.playbackRate = this.videoPlaybackRate; this.isBoosting = false; this.currentTriggerKey = null; this.video = null; } } /** 前进或后退 */ handleSeek(direction = 'forward') { warn('seek'); const { video } = this; if (video) { video.currentTime += direction === 'forward' ? 5 : -5; } this.video = null; } destroy() { this.multiPress.stop(); } }(); /******/ })() ;