// ==UserScript== // @name 视频倍速播放增强版 // @name:en Enhanced Video Speed Controller // @namespace http://tampermonkey.net/ // @version 1.3.7 // @description 长按右方向键倍速播放,松开恢复原速。按+/-键调整倍速,按]/[键快速调整倍速,按P键恢复1.0倍速。上/下方向键调节音量,回车键切换全屏。左/右方向键快退/快进5秒。支持YouTube、Bilibili等大多数视频网站,可通过点击选择控制多个视频。 // @description:en Hold right arrow key for speed playback, release to restore. Press +/- to adjust speed, press ]/[ for quick speed adjustment, press P to restore 1.0x speed. Up/Down arrows control volume, Enter toggles fullscreen. Left/Right arrows for 5s rewind/forward. Supports YouTube, Bilibili and most video websites. Click to select which video to control when multiple videos exist. // @author ternece // @license MIT // @match *://*.youtube.com/* // @match *://*.bilibili.com/video/* // @match *://*/* // @icon https://www.google.com/s2/favicons?sz=64&domain=greasyfork.org // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_notification // @downloadURL none // ==/UserScript== (function () { "use strict"; // 默认设置 const DEFAULT_SETTINGS = { defaultRate: 1.0, // 默认播放速度 targetRate: 2.5, // 长按右键时的倍速 quickRateStep: 0.5, // 按[]键调整速度的步长 targetRateStep: 0.5 // 按 +/- 键调整目标倍速的步长 }; // 显示通知 (保留在外部,因为它依赖 GM_notification) function showNotification(message) { if (typeof GM_notification !== 'undefined') { GM_notification({ text: message, title: '视频倍速控制器', timeout: 3000 }); } else { // 如果 GM_notification 不可用,则使用浮动消息作为备用 showFloatingMessage(message); } } // 显示浮动提示 (保留在外部,因为它是一个独立的UI工具函数) function showFloatingMessage(message) { const messageElement = document.createElement("div"); messageElement.textContent = message; messageElement.style.position = "fixed"; messageElement.style.top = "10px"; messageElement.style.left = "50%"; messageElement.style.transform = "translateX(-50%)"; messageElement.style.backgroundColor = "rgba(0, 0, 0, 0.8)"; messageElement.style.color = "white"; messageElement.style.padding = "8px 16px"; messageElement.style.borderRadius = "4px"; messageElement.style.zIndex = "10000"; messageElement.style.fontFamily = "Arial, sans-serif"; messageElement.style.fontSize = "14px"; messageElement.style.transition = "opacity 0.5s ease-out"; document.body.appendChild(messageElement); setTimeout(() => { messageElement.style.opacity = "0"; setTimeout(() => { document.body.removeChild(messageElement); }, 500); }, 2000); } class VideoController { constructor() { // 调试开关 this.DEBUG = false; // 长按判定时间(毫秒) this.LONG_PRESS_DELAY = 200; // 1. 状态 (State) this.settings = { defaultRate: GM_getValue('defaultRate', DEFAULT_SETTINGS.defaultRate), targetRate: GM_getValue('targetRate', DEFAULT_SETTINGS.targetRate), quickRateStep: GM_getValue('quickRateStep', DEFAULT_SETTINGS.quickRateStep), targetRateStep: GM_getValue('targetRateStep', DEFAULT_SETTINGS.targetRateStep) }; this.tempEnabledDomains = GM_getValue('tempEnabledDomains', []); this.currentDomain = window.location.hostname; this.currentUrl = location.href; this.lastManualRateChangeTime = 0; this.activeVideo = null; this.videoControlButtons = new Map(); this.rightKeyTimer = null; this.downCount = 0; this.originalRate = 1.0; this.targetRate = this.settings.targetRate; this.currentQuickRate = 1.0; this.keyHandlers = {}; // 监听器和观察器引用 this.keydownListener = null; this.keyupListener = null; this.videoObserver = null; this.urlObserver = null; this.videoChangeObserver = null; this.activeObservers = new Set(); this._initializeKeyHandlers(); } // 2. 核心启动与检查逻辑 start() { if (!this.shouldEnableScript()) { this.registerEnableCommand(); return; } this.registerMenuCommands(); this.startInitializationProcess(); } shouldEnableScript() { if (this.currentDomain.includes('youtube.com') || (this.currentDomain.includes('bilibili.com') && window.location.pathname.includes('/video/'))) { return true; } return this.tempEnabledDomains.includes(this.currentDomain); } // 3. 菜单命令注册 registerEnableCommand() { GM_registerMenuCommand('在当前网站启用视频倍速控制', () => { if (!this.tempEnabledDomains.includes(this.currentDomain)) { this.tempEnabledDomains.push(this.currentDomain); GM_setValue('tempEnabledDomains', this.tempEnabledDomains); showNotification(`已在 ${this.currentDomain} 启用视频倍速控制,请刷新页面`); } else { showNotification(`${this.currentDomain} 已经在启用列表中`); } }); } registerMenuCommands() { GM_registerMenuCommand('设置默认播放速度', () => this.updateSetting('defaultRate', '请输入默认播放速度 (0.1-16)')); GM_registerMenuCommand('设置长按右键倍速', () => this.updateSetting('targetRate', '请输入长按右键时的倍速 (0.1-16)')); GM_registerMenuCommand('设置快速调速步长', () => this.updateSetting('quickRateStep', '请输入按 [ 或 ] 键调整速度的步长 (0.1-3)', 3)); GM_registerMenuCommand('设置目标倍速调整步长', () => this.updateSetting('targetRateStep', '请输入按 +/- 键调整目标倍速的步长 (0.1-16)')); if (this.tempEnabledDomains.includes(this.currentDomain)) { GM_registerMenuCommand('从临时启用列表中移除当前网站', () => { const index = this.tempEnabledDomains.indexOf(this.currentDomain); if (index !== -1) { this.tempEnabledDomains.splice(index, 1); GM_setValue('tempEnabledDomains', this.tempEnabledDomains); showNotification(`已从临时启用列表中移除 ${this.currentDomain},请刷新页面`); } }); } GM_registerMenuCommand('查看所有临时启用的网站', () => { if (this.tempEnabledDomains.length === 0) { alert('当前没有临时启用的网站'); } else { alert('临时启用的网站列表:\n\n' + this.tempEnabledDomains.join('\n')); } }); } updateSetting(key, promptMessage, max = 16) { const newValue = prompt(promptMessage, this.settings[key]); if (newValue !== null) { const value = parseFloat(newValue); if (!isNaN(value) && value >= 0.1 && value <= max) { this.settings[key] = value; GM_setValue(key, value); showFloatingMessage(`设置已更新: ${value}`); if (key === 'defaultRate' && this.activeVideo) { this.activeVideo.playbackRate = value; } } else { alert(`请输入有效的值 (0.1-${max})`); } } } // 4. 初始化流程 async startInitializationProcess() { let retryCount = 0; const maxRetries = 3; const tryInit = async () => { try { await this.init(); this.watchUrlChange(); } catch (error) { if (error && (error.type === "no_video" || error.type === "timeout")) { return; // 停止重试 } console.warn("启动失败:", error); if (retryCount < maxRetries) { retryCount++; setTimeout(tryInit, 2000); } } }; tryInit(); } async init() { this.cleanup(); try { let video = await this.waitForVideoElement(); if (!video) { const deepVideos = this.deepFindVideoElements(); if (deepVideos.length > 0) { video = deepVideos[0]; this.setupVideos(deepVideos); showFloatingMessage(`通过深度查找发现了 ${deepVideos.length} 个视频`); } else { throw { type: "no_video" }; } } console.log("找到视频元素:", video); this.activeVideo = video; this.setupObservers(); this.setupEventListeners(); } catch (error) { console.error("初始化失败:", error); if (error && (error.type === "timeout" || error.type === "no_video")) { setTimeout(() => this.init().catch(console.error), 5000); } throw error; } } // 5. 清理与监听 cleanup() { if (this.keydownListener) { document.removeEventListener("keydown", this.keydownListener, true); this.keydownListener = null; } if (this.keyupListener) { document.removeEventListener("keyup", this.keyupListener, true); this.keyupListener = null; } this.activeObservers.forEach(observer => observer.disconnect()); this.activeObservers.clear(); this.videoControlButtons.forEach(button => button.remove()); this.videoControlButtons.clear(); this.activeVideo = null; } setupObservers() { // 观察新视频 this.videoObserver = new MutationObserver(() => this.detectAndSetupVideos()); this.videoObserver.observe(document.body, { childList: true, subtree: true }); this.activeObservers.add(this.videoObserver); // 观察视频元素变化 if (this.activeVideo && this.activeVideo.parentElement) { this.videoChangeObserver = new MutationObserver((mutations) => { const hasVideoChanges = mutations.some(m => Array.from(m.removedNodes).some(n => n.tagName === 'VIDEO')); if (hasVideoChanges) { console.log("视频元素变化,重新初始化"); this.init().catch(console.error); } }); this.videoChangeObserver.observe(this.activeVideo.parentElement, { childList: true, subtree: true }); this.activeObservers.add(this.videoChangeObserver); } } watchUrlChange() { const handleStateChange = () => { if (location.href !== this.currentUrl) { this.currentUrl = location.href; console.log("URL变化,重新初始化"); setTimeout(() => this.init().catch(console.error), 1000); } }; // 使用 History API 监听 const originalPushState = history.pushState; history.pushState = function() { originalPushState.apply(this, arguments); handleStateChange(); }; const originalReplaceState = history.replaceState; history.replaceState = function() { originalReplaceState.apply(this, arguments); handleStateChange(); }; window.addEventListener('popstate', handleStateChange); // 使用 MutationObserver 作为备用 this.urlObserver = new MutationObserver(handleStateChange); this.urlObserver.observe(document.body, { childList: true, subtree: true }); this.activeObservers.add(this.urlObserver); } // 6. 事件监听器设置 setupEventListeners() { this.keydownListener = this.handleKeyDown.bind(this); this.keyupListener = this.handleKeyUp.bind(this); document.addEventListener("keydown", this.keydownListener, true); document.addEventListener("keyup", this.keyupListener, true); } // 7. 视频查找与设置 waitForVideoElement() { return new Promise((resolve, reject) => { const maxAttempts = 20; let attempts = 0; const check = () => { const video = this.detectAndSetupVideos(); if (video) { observer.disconnect(); resolve(video); } else if (++attempts >= maxAttempts) { observer.disconnect(); reject({ type: "no_video" }); } }; const observer = new MutationObserver(check); observer.observe(document.body, { childList: true, subtree: true }); this.activeObservers.add(observer); check(); // 立即检查 setTimeout(() => { observer.disconnect(); reject({ type: "timeout" }); }, 10000); }); } deepFindVideoElements() { console.log('开始深度查找视频元素...'); const foundVideos = new Set(); const find = (element, depth = 0) => { if (depth > 10) return; if (element.tagName === 'VIDEO') foundVideos.add(element); if (element.shadowRoot) find(element.shadowRoot, depth + 1); if (element.contentDocument) find(element.contentDocument, depth + 1); Array.from(element.children || []).forEach(child => find(child, depth + 1)); }; find(document.body); console.log(`深度查找完成,共找到 ${foundVideos.size} 个视频元素`); return Array.from(foundVideos); } detectAndSetupVideos() { const videos = this.findAllVideos(); if (videos.length === 0) return null; this.setupVideos(videos); return this.activeVideo || videos[0]; } findAllVideos() { const allVideos = new Set(document.querySelectorAll('video')); const findIn = (root) => { try { root.querySelectorAll('video').forEach(v => allVideos.add(v)); root.querySelectorAll('iframe').forEach(f => { try { if (f.contentDocument) findIn(f.contentDocument); } catch(e) {/* cross-origin */} }); root.querySelectorAll('*').forEach(el => { if (el.shadowRoot) findIn(el.shadowRoot); }); } catch(e) {/* ignore */} }; findIn(document); return Array.from(allVideos); } setupVideos(videos) { if (videos.length === 1) { const video = videos[0]; if (video.readyState >= 1 && !this.activeVideo) { this.activeVideo = video; this.setDefaultRate(video); } } else if (videos.length > 1) { // 对于B站和油管,进行特殊的主视频判断 const isBilibili = this.currentDomain.includes('bilibili.com'); const isYoutube = this.currentDomain.includes('youtube.com'); if (isBilibili || isYoutube) { if (!this.activeVideo || !videos.includes(this.activeVideo)) { let mainVideo; if(isBilibili) mainVideo = videos.find(v => !v.paused) || videos.find(v => v.getBoundingClientRect().width > 400); if(isYoutube) mainVideo = videos.find(v => v.classList.contains('html5-main-video')); this.activeVideo = mainVideo || videos[0]; this.setDefaultRate(this.activeVideo); } } else { // 其他网站,创建控制按钮 videos.forEach((video, index) => { if (!this.videoControlButtons.has(video) && video.readyState >= 1) { this.createVideoControlButton(video, index + 1); this.setDefaultRate(video); if (!this.activeVideo) this.activeVideo = video; } }); } } } setDefaultRate(video) { if (Date.now() - this.lastManualRateChangeTime > 5000) { video.playbackRate = this.settings.defaultRate; } } createVideoControlButton(video, index) { const button = document.createElement('div'); Object.assign(button.style, { position: 'absolute', top: '10px', left: '10px', backgroundColor: 'rgba(0, 0, 0, 0.6)', color: 'white', padding: '5px 10px', borderRadius: '4px', fontSize: '12px', fontFamily: 'Arial, sans-serif', cursor: 'pointer', zIndex: '9999', transition: 'background-color 0.3s', userSelect: 'none' }); button.innerHTML = `视频 ${index}`; if (!this.activeVideo) { this.activeVideo = video; button.style.backgroundColor = 'rgba(0, 128, 255, 0.8)'; } button.addEventListener('click', () => { this.videoControlButtons.forEach(btn => btn.style.backgroundColor = 'rgba(0, 0, 0, 0.6)'); this.activeVideo = video; button.style.backgroundColor = 'rgba(0, 128, 255, 0.8)'; showFloatingMessage(`已切换到视频 ${index} 控制`); }); const container = video.parentElement || document.body; const style = window.getComputedStyle(container); if (style.position === 'static') container.style.position = 'relative'; container.appendChild(button); this.videoControlButtons.set(video, button); } // 8. 按键事件处理 handleKeyDown(e) { const path = e.composedPath(); const isInputFocused = path.some(el => el.isContentEditable || ['INPUT', 'TEXTAREA'].includes(el.tagName)); if (isInputFocused || !this.activeVideo) { return; } const handler = this.keyHandlers[e.code]; if (handler) { e.preventDefault(); e.stopImmediatePropagation(); handler(); } } handleKeyUp(e) { if (e.code === 'ArrowRight') { clearTimeout(this.rightKeyTimer); this.rightKeyTimer = null; if (this.downCount < 3) { //判定为短按 this.seek(5); } else { //判定为长按 if(this.activeVideo) { this.activeVideo.playbackRate = this.originalRate; showFloatingMessage(`恢复播放速度: ${this.originalRate.toFixed(1)}x`); } } this.downCount = 0; } } // 9. 按键处理器和具体功能实现 _initializeKeyHandlers() { this.keyHandlers = { 'ArrowUp': this._handleVolumeUp.bind(this), 'ArrowDown': this._handleVolumeDown.bind(this), 'Enter': this._handleToggleFullScreen.bind(this), 'Space': this._handleTogglePlayPause.bind(this), 'ArrowLeft': this._handleSeekBackward.bind(this), 'ArrowRight': this._handleRightArrowPress.bind(this), 'Equal': this._handleIncreaseTargetRate.bind(this), 'Minus': this._handleDecreaseTargetRate.bind(this), 'BracketRight': this._handleIncreasePlaybackRate.bind(this), 'BracketLeft': this._handleDecreasePlaybackRate.bind(this), 'KeyP': this._handleResetPlaybackRate.bind(this), 'Comma': this._handleFrameStepBackward.bind(this), 'Period': this._handleFrameStepForward.bind(this), }; } _handleVolumeUp() { this.adjustVolume(0.1); } _handleVolumeDown() { this.adjustVolume(-0.1); } _handleToggleFullScreen() { this.toggleFullScreen(); } _handleTogglePlayPause() { this.togglePlayPause(); } _handleSeekBackward() { this.seek(-5); } _handleRightArrowPress() { this.handleRightArrowPress(); } _handleIncreaseTargetRate() { this.adjustTargetRate(this.settings.targetRateStep); } _handleDecreaseTargetRate() { this.adjustTargetRate(-this.settings.targetRateStep); } _handleIncreasePlaybackRate() { this.adjustPlaybackRate(this.settings.quickRateStep); } _handleDecreasePlaybackRate() { this.adjustPlaybackRate(-this.settings.quickRateStep); } _handleResetPlaybackRate() { this.resetPlaybackRate(); } _handleFrameStepBackward() { this.frameStep(-1); } _handleFrameStepForward() { this.frameStep(1); } adjustVolume(delta) { this.activeVideo.volume = Math.max(0, Math.min(1, this.activeVideo.volume + delta)); showFloatingMessage(`音量:${Math.round(this.activeVideo.volume * 100)}%`); } toggleFullScreen() { let fsButton = null; // 针对B站的特殊处理,优先寻找真正的全屏按钮 if (this.currentDomain.includes('bilibili.com')) { // '.bpx-player-ctrl-full' 是新版播放器的浏览器全屏按钮 // '.bilibili-player-video-btn-fullscreen' 是旧版的 fsButton = document.querySelector('.bpx-player-ctrl-full') || document.querySelector('.bilibili-player-video-btn-fullscreen'); } // 针对YouTube else if (this.currentDomain.includes('youtube.com')) { fsButton = document.querySelector('.ytp-fullscreen-button'); } // 如果特定网站的按钮被找到,则点击 if (fsButton) { fsButton.click(); return; } // 通用备用方案:使用原生API console.log('未找到特定网站的全屏按钮,使用原生API。'); if (!document.fullscreenElement) { if (this.activeVideo.requestFullscreen) { this.activeVideo.requestFullscreen(); } else if (this.activeVideo.webkitRequestFullscreen) { this.activeVideo.webkitRequestFullscreen(); } else if (this.activeVideo.msRequestFullscreen) { this.activeVideo.msRequestFullscreen(); } showFloatingMessage('进入全屏'); } else { if (document.exitFullscreen) { document.exitFullscreen(); } else if (document.webkitExitFullscreen) { document.webkitExitFullscreen(); } else if (document.msExitFullscreen) { document.msExitFullscreen(); } showFloatingMessage('退出全屏'); } } togglePlayPause() { if (this.activeVideo.paused) { this.activeVideo.play(); showFloatingMessage('播放'); } else { this.activeVideo.pause(); showFloatingMessage('暂停'); } } seek(delta) { if (this.activeVideo.paused) this.activeVideo.play(); this.activeVideo.currentTime = Math.max(0, this.activeVideo.currentTime + delta); showFloatingMessage(`快${delta > 0 ? '进' : '退'} ${Math.abs(delta)} 秒`); } // 此方法逻辑复杂,保留原名,仅在 handler 中调用 handleRightArrowPress() { if (this.activeVideo.paused) this.activeVideo.play(); if (this.downCount === 0) { this.originalRate = this.activeVideo.playbackRate; this.rightKeyTimer = setTimeout(() => { this.activeVideo.playbackRate = this.targetRate; showFloatingMessage(`倍速播放: ${this.targetRate}x`); this.downCount = 3; // 设置为长按状态 }, this.LONG_PRESS_DELAY); } this.downCount++; } adjustTargetRate(delta) { this.targetRate = Math.max(0.1, Math.min(16, this.targetRate + delta)); this.lastManualRateChangeTime = Date.now(); showFloatingMessage(`目标倍速设置为: ${this.targetRate.toFixed(1)}x`); } adjustPlaybackRate(delta) { const newRate = Math.max(0.1, Math.min(16, this.activeVideo.playbackRate + delta)); this.activeVideo.playbackRate = newRate; this.lastManualRateChangeTime = Date.now(); showFloatingMessage(`播放速度: ${newRate.toFixed(1)}x`); } resetPlaybackRate() { this.activeVideo.playbackRate = 1.0; this.lastManualRateChangeTime = Date.now(); showFloatingMessage(`播放速度重置为 1.0x`); } frameStep(direction) { if (this.activeVideo.paused) { this.activeVideo.currentTime += (direction / 30); // 假设30fps showFloatingMessage(direction > 0 ? `下一帧` : `上一帧`); } } } // 启动脚本 const controller = new VideoController(); controller.start(); })();