From 038f4021b19d2923ff68f2659bddce1c0017c5d8 Mon Sep 17 00:00:00 2001 From: ShowMak3r19 Date: Sun, 5 Jul 2026 13:59:15 +0800 Subject: [PATCH] Respect explicit normal playback speed choices Emby can temporarily reset video playbackRate to 1x during playback lifecycle changes. The previous protection loop forced the old saved speed back onto the player, which made an explicit return to normal speed difficult to keep. This change saves explicit speed-menu selections, treats 1x as a valid stored value, and restores the previous non-normal speed only when 1x appears as an automatic reset. Constraint: Must preserve cross-episode playback speed inheritance while allowing users to intentionally store 1x Rejected: Keep the 1x protection interval | it overrides intentional normal-speed selections Confidence: high Scope-risk: narrow Directive: Do not treat every 1x ratechange as a user choice; Emby emits automatic 1x resets during playback lifecycle events Tested: node --check RememberPlaybackSpeed.js Tested: Node fake video/localStorage harness for initial restore, automatic 1x reset recovery, and explicit 1x menu selection Not-tested: Live Emby Web or Emby Theater manual session --- RememberPlaybackSpeed.js | 117 +++++++++++++++++++++++++++++++++------ 1 file changed, 99 insertions(+), 18 deletions(-) diff --git a/RememberPlaybackSpeed.js b/RememberPlaybackSpeed.js index 094bdb9..252efaa 100644 --- a/RememberPlaybackSpeed.js +++ b/RememberPlaybackSpeed.js @@ -16,11 +16,13 @@ class RememberPlaybackSpeed { this.STORAGE_KEY = 'emby_playback_speed'; this.isRestoring = false; this.isTheater = isTheater; + this.normalSpeedDecisionTimer = null; this.init(); } init() { console.log('[倍速记忆] 初始化'); + this.bindSpeedControlMonitor(); this.waitForPlayer(); } @@ -42,7 +44,7 @@ class RememberPlaybackSpeed { if (this.isRestoring) return; const savedSpeed = this.getSavedSpeed(); - if (!savedSpeed || savedSpeed === 1) return; + if (!this.isValidSpeed(savedSpeed)) return; this.isRestoring = true; console.log(`[倍速记忆] 恢复倍速: ${savedSpeed}x`); @@ -62,18 +64,6 @@ class RememberPlaybackSpeed { if (success) { console.log(`[倍速记忆] ✓ 恢复成功: ${savedSpeed}x`); this.isRestoring = false; - - // 保护期:防止被重置为 1x - let count = 0; - const protect = setInterval(() => { - document.querySelectorAll('video').forEach(video => { - if (video.playbackRate === 1) { - video.playbackRate = savedSpeed; - console.log(`[倍速记忆] 防止重置为 1x`); - } - }); - if (++count >= 10) clearInterval(protect); - }, 500); } else { setTimeout(applySpeed, 300); } @@ -93,14 +83,24 @@ class RememberPlaybackSpeed { // 监听倍速变化(保存用户修改) video.addEventListener('ratechange', () => { const speed = video.playbackRate; - const isPlaying = !video.paused || video.currentTime > 0; - if (isPlaying && speed !== 1 && !this.isRestoring) { - this.saveSpeed(speed); - console.log(`[倍速记忆] 保存倍速: ${speed}x`); + if (!this.isValidSpeed(speed) || this.isRestoring) return; + + const savedSpeed = this.getSavedSpeed(); + if ( + this.isNormalSpeed(speed) && + this.isValidSpeed(savedSpeed) && + !this.isNormalSpeed(savedSpeed) + ) { + this.handleNormalSpeedChange(video, restoreSpeed); + return; } + + this.cancelNormalSpeedDecision(); + this.saveSpeed(speed); + console.log(`[倍速记忆] 保存倍速: ${speed}x`); }); - + // 关键时机恢复倍速 ['loadedmetadata', 'canplay', 'play'].forEach(event => { video.addEventListener(event, () => { @@ -134,6 +134,79 @@ class RememberPlaybackSpeed { console.log('[倍速记忆] 监控已启动'); } + bindSpeedControlMonitor() { + document.addEventListener('click', event => { + const selectedSpeed = this.parseSpeedFromEvent(event); + if (this.isValidSpeed(selectedSpeed)) { + this.cancelNormalSpeedDecision(); + this.saveSpeed(selectedSpeed); + console.log(`[倍速记忆] 检测到倍速菜单选择: ${selectedSpeed}x`); + } + }, true); + } + + handleNormalSpeedChange(video, restoreSpeed) { + this.cancelNormalSpeedDecision(); + + this.normalSpeedDecisionTimer = setTimeout(() => { + this.normalSpeedDecisionTimer = null; + + const savedSpeed = this.getSavedSpeed(); + if (!this.isValidSpeed(savedSpeed) || this.isNormalSpeed(savedSpeed)) return; + if (!this.isNormalSpeed(video.playbackRate)) return; + + console.log(`[倍速记忆] 检测到播放器自动重置为 1x,恢复 ${savedSpeed}x`); + restoreSpeed(); + }, 400); + } + + cancelNormalSpeedDecision() { + if (this.normalSpeedDecisionTimer) { + clearTimeout(this.normalSpeedDecisionTimer); + this.normalSpeedDecisionTimer = null; + } + } + + parseSpeedFromEvent(event) { + const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target]; + + for (const element of path) { + if (!element || element === document || element === window) continue; + + const candidates = [ + element.getAttribute && element.getAttribute('aria-label'), + element.getAttribute && element.getAttribute('title'), + element.getAttribute && element.getAttribute('data-value'), + element.getAttribute && element.getAttribute('value'), + element.textContent + ]; + + for (const candidate of candidates) { + const speed = this.parseSpeedLabel(candidate); + if (this.isValidSpeed(speed)) return speed; + } + } + + return null; + } + + parseSpeedLabel(text) { + if (!text) return null; + + const label = text.trim().replace(/\s+/g, ' '); + if (!label || label.length > 24) return null; + + if (/^(normal|default|标准|正常|原速)$/i.test(label)) return 1; + + const match = label.match(/^(\d+(?:\.\d+)?)\s*(?:x|倍)$/i) || + label.match(/^x\s*(\d+(?:\.\d+)?)$/i); + + if (!match) return null; + + const speed = parseFloat(match[1]); + return speed > 0 && speed <= 16 ? speed : null; + } + // localStorage 操作 saveSpeed(speed) { if (this.isTheater) { @@ -149,6 +222,14 @@ class RememberPlaybackSpeed { const speed = localStorage.getItem(this.STORAGE_KEY); return speed ? parseFloat(speed) : null; } + + isValidSpeed(speed) { + return Number.isFinite(speed) && speed > 0; + } + + isNormalSpeed(speed) { + return Math.abs(speed - 1) < 0.01; + } } // 页面加载后自动启动