From 85d1c1389f40766268526520c27f77f354d721ef Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Tue, 25 Aug 2026 22:40:45 +0800 Subject: [PATCH 1/7] feat: persist caption style and CC state across app restarts YouTube TV stores caption style in localStorage with a 30-day TTL and sometimes never writes it at all, so Caption style settings reset when the app restarts. Updated the config store for captionssettingschanged, re-apply it via updateSubtitlesUserSettings on startup, and back up / restore the raw yt-player-caption-* keys with a far-future expiration. Adds a "Remember Caption Style" toggle under Subtitle Settings (default on). --- mods/config.js | 3 + mods/features/captionStylePersistence.js | 160 +++++++++++++++++++++++ mods/translations/resources/en.json | 3 +- mods/ui/settings.js | 4 + mods/userScript.js | 1 + 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 mods/features/captionStylePersistence.js diff --git a/mods/config.js b/mods/config.js index c7168057..e70562aa 100644 --- a/mods/config.js +++ b/mods/config.js @@ -30,6 +30,9 @@ const defaultConfig = { enableWhosWatchingMenuOnAppExit: false, enableShowUserLanguage: true, enableShowOtherLanguages: false, + enableCaptionStylePersistence: true, + captionStyleSettings: null, + captionRawKeyBackups: {}, showWelcomeToast: true, enablePreviousNextButtons: true, enableSuperThanksButton: false, diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js new file mode 100644 index 00000000..9b0823fa --- /dev/null +++ b/mods/features/captionStylePersistence.js @@ -0,0 +1,160 @@ +import { configRead, configWrite, configChangeEmitter } from '../config.js'; + +const SELECTORS = { + PLAYER: '.html5-video-player', +}; + +const EVENTS = { + YT_STATE_CHANGE: 'onStateChange', + YT_CAPTIONS_SETTINGS_CHANGED: 'captionssettingschanged', + YT_CAPTIONS_TRACKLIST_CHANGED: 'onCaptionsTrackListChanged', + CONFIG_CHANGE: 'configChange', +}; + +const CONFIG_KEYS = { + ENABLED: 'enableCaptionStylePersistence', + STYLE: 'captionStyleSettings', + RAW_BACKUPS: 'captionRawKeyBackups', +}; + +const YT_KEYS = [ + 'yt-player-caption-display-settings', + 'yt-player-sticky-caption', + 'yt-player-caption-sticky-language', +]; + +const FAR_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; +const SAVE_DEBOUNCE_MS = 500; +const APPLY_RETRY_MS = 500; +const APPLY_MAX_ATTEMPTS = 20; + +function restoreAndRefreshRawKeys() { + if (!configRead(CONFIG_KEYS.ENABLED)) return; + + const backups = configRead(CONFIG_KEYS.RAW_BACKUPS) || {}; + let backupsChanged = false; + + for (const key of YT_KEYS) { + try { + const stored = localStorage[key]; + if (stored) { + const wrapper = JSON.parse(stored); + if (backups[key] !== wrapper.data) { + backups[key] = wrapper.data; + backupsChanged = true; + } + localStorage[key] = JSON.stringify({ data: wrapper.data, expiration: Date.now() + FAR_FUTURE_MS, creation: Date.now() }); + } else if (backups[key] !== undefined) { + localStorage[key] = JSON.stringify({ data: backups[key], expiration: Date.now() + FAR_FUTURE_MS, creation: Date.now() }); + } + } catch (e) { + console.warn('[CaptionStyle] Failed to refresh key', key, e); + } + } + + if (backupsChanged) configWrite(CONFIG_KEYS.RAW_BACKUPS, backups); +} + +class CaptionStyleHandler { + #player = null; + #attachTimeout = null; + #saveTimeout = null; + #applyTimeout = null; + #applyAttempts = 0; + #hasAppliedStyle = false; + + constructor() { + this.init(); + } + + init() { + this.#pollForPlayer(); + this.#setupConfigListener(); + } + + #pollForPlayer() { + clearTimeout(this.#attachTimeout); + + const playerElement = document.querySelector(SELECTORS.PLAYER); + + if (!playerElement) { + this.#attachTimeout = setTimeout(() => this.#pollForPlayer(), 500); + return; + } + + this.#player = playerElement; + + this.#player.addEventListener(EVENTS.YT_CAPTIONS_SETTINGS_CHANGED, this.#handleSettingsChanged); + this.#player.addEventListener(EVENTS.YT_CAPTIONS_TRACKLIST_CHANGED, this.#handleTrackListChanged); + this.#player.addEventListener(EVENTS.YT_STATE_CHANGE, this.#handleStateChange); + + this.#tryApplyStyle(); + } + + #setupConfigListener() { + configChangeEmitter.addEventListener(EVENTS.CONFIG_CHANGE, (ev) => { + if (ev.detail?.key === CONFIG_KEYS.ENABLED && ev.detail?.value) { + restoreAndRefreshRawKeys(); + this.#hasAppliedStyle = false; + this.#applyAttempts = 0; + this.#tryApplyStyle(); + } + }); + } + + #handleSettingsChanged = () => { + clearTimeout(this.#saveTimeout); + this.#saveTimeout = setTimeout(() => this.#saveStyle(), SAVE_DEBOUNCE_MS); + }; + + #handleTrackListChanged = () => { + this.#applyAttempts = 0; + this.#tryApplyStyle(); + }; + + #handleStateChange = () => { + const state = this.#player?.getPlayerStateObject?.(); + if (state?.isPlaying) this.#tryApplyStyle(); + }; + + #saveStyle() { + if (!configRead(CONFIG_KEYS.ENABLED)) return; + + const settings = this.#player?.getSubtitlesUserSettings?.(); + if (!settings) return; + + if (JSON.stringify(settings) !== JSON.stringify(configRead(CONFIG_KEYS.STYLE))) { + configWrite(CONFIG_KEYS.STYLE, JSON.parse(JSON.stringify(settings))); + } + restoreAndRefreshRawKeys(); + } + + #tryApplyStyle = () => { + clearTimeout(this.#applyTimeout); + + if (this.#hasAppliedStyle || !configRead(CONFIG_KEYS.ENABLED)) return; + + const savedStyle = configRead(CONFIG_KEYS.STYLE); + if (!savedStyle) return; + + const settings = this.#player?.getSubtitlesUserSettings?.(); + if (!settings) { + if (this.#applyAttempts < APPLY_MAX_ATTEMPTS) { + this.#applyAttempts++; + this.#applyTimeout = setTimeout(this.#tryApplyStyle, APPLY_RETRY_MS); + } + return; + } + + try { + this.#player.updateSubtitlesUserSettings(JSON.parse(JSON.stringify(savedStyle)), true); + this.#hasAppliedStyle = true; + } catch (e) { + console.warn('[CaptionStyle] Failed to apply caption style:', e); + } + }; +} + +restoreAndRefreshRawKeys(); + +window.captionStyleHandler = new CaptionStyleHandler(); diff --git a/mods/translations/resources/en.json b/mods/translations/resources/en.json index b1a91328..86fd212f 100644 --- a/mods/translations/resources/en.json +++ b/mods/translations/resources/en.json @@ -68,7 +68,8 @@ "title": "Subtitle Settings", "options": { "showLocalSubtitle": "Show Local Subtitle", - "showHiddenSubtitles": "Show Hidden Subtitles" + "showHiddenSubtitles": "Show Hidden Subtitles", + "persistCaptionStyle": "Remember Caption Style" } }, "videoPlayer": { diff --git a/mods/ui/settings.js b/mods/ui/settings.js index 08d1de77..43b57fb2 100644 --- a/mods/ui/settings.js +++ b/mods/ui/settings.js @@ -320,6 +320,10 @@ export default function modernUI(update, parameters) { { name: t('settings.options.subtitles.options.showHiddenSubtitles'), value: 'enableShowOtherLanguages' + }, + { + name: t('settings.options.subtitles.options.persistCaptionStyle'), + value: 'enableCaptionStylePersistence' } ] }, diff --git a/mods/userScript.js b/mods/userScript.js index 7b017e46..307c7def 100644 --- a/mods/userScript.js +++ b/mods/userScript.js @@ -16,6 +16,7 @@ import "./ui/theme.js"; import "./ui/settings.js"; import "./ui/disableWhosWatching.js"; import "./features/moreSubtitles.js"; +import "./features/captionStylePersistence.js"; import "./features/updater.js"; import "./features/pictureInPicture.js"; import "./features/preferredVideoQuality.js"; From 4a49e09aaa270ffd59d1d3e8dc6bfbc875c7cef7 Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Wed, 26 Aug 2026 15:11:40 +0800 Subject: [PATCH 2/7] fix: restore captions on/off state via player API The TV app never writes yt-player-sticky-caption, so backing up that key could not restore the CC state. Track isSubtitlesOn() on captionschanged (ignoring videos with no caption tracks). When the saved state is on, call toggleSubtitlesOn() once per video after the track list loads. --- mods/config.js | 1 + mods/features/captionStylePersistence.js | 65 +++++++++++++++++++++++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/mods/config.js b/mods/config.js index e70562aa..dfb76cdb 100644 --- a/mods/config.js +++ b/mods/config.js @@ -32,6 +32,7 @@ const defaultConfig = { enableShowOtherLanguages: false, enableCaptionStylePersistence: true, captionStyleSettings: null, + captionsEnabled: null, captionRawKeyBackups: {}, showWelcomeToast: true, enablePreviousNextButtons: true, diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js index 9b0823fa..89e5293b 100644 --- a/mods/features/captionStylePersistence.js +++ b/mods/features/captionStylePersistence.js @@ -7,6 +7,7 @@ const SELECTORS = { const EVENTS = { YT_STATE_CHANGE: 'onStateChange', YT_CAPTIONS_SETTINGS_CHANGED: 'captionssettingschanged', + YT_CAPTIONS_CHANGED: 'captionschanged', YT_CAPTIONS_TRACKLIST_CHANGED: 'onCaptionsTrackListChanged', CONFIG_CHANGE: 'configChange', }; @@ -14,6 +15,7 @@ const EVENTS = { const CONFIG_KEYS = { ENABLED: 'enableCaptionStylePersistence', STYLE: 'captionStyleSettings', + CAPTIONS_ON: 'captionsEnabled', RAW_BACKUPS: 'captionRawKeyBackups', }; @@ -62,6 +64,8 @@ class CaptionStyleHandler { #applyTimeout = null; #applyAttempts = 0; #hasAppliedStyle = false; + #lastVideoId = null; + #captionsRestored = false; constructor() { this.init(); @@ -85,6 +89,7 @@ class CaptionStyleHandler { this.#player = playerElement; this.#player.addEventListener(EVENTS.YT_CAPTIONS_SETTINGS_CHANGED, this.#handleSettingsChanged); + this.#player.addEventListener(EVENTS.YT_CAPTIONS_CHANGED, this.#handleCaptionsChanged); this.#player.addEventListener(EVENTS.YT_CAPTIONS_TRACKLIST_CHANGED, this.#handleTrackListChanged); this.#player.addEventListener(EVENTS.YT_STATE_CHANGE, this.#handleStateChange); @@ -97,7 +102,9 @@ class CaptionStyleHandler { restoreAndRefreshRawKeys(); this.#hasAppliedStyle = false; this.#applyAttempts = 0; + this.#captionsRestored = false; this.#tryApplyStyle(); + this.#tryRestoreCaptions(); } }); } @@ -107,16 +114,41 @@ class CaptionStyleHandler { this.#saveTimeout = setTimeout(() => this.#saveStyle(), SAVE_DEBOUNCE_MS); }; + #handleCaptionsChanged = () => { + this.#saveCaptionsEnabled(); + }; + #handleTrackListChanged = () => { this.#applyAttempts = 0; this.#tryApplyStyle(); + this.#tryRestoreCaptions(); }; #handleStateChange = () => { const state = this.#player?.getPlayerStateObject?.(); - if (state?.isPlaying) this.#tryApplyStyle(); + const videoId = this.#player?.getVideoData?.()?.video_id; + + if (videoId !== this.#lastVideoId) { + this.#lastVideoId = videoId; + this.#captionsRestored = false; + } + + if (state?.isPlaying) { + this.#tryApplyStyle(); + this.#tryRestoreCaptions(); + if (this.#captionsRestored) this.#saveCaptionsEnabled(); + } }; + #getTracklist() { + try { + const tracklist = this.#player?.getOption?.('captions', 'tracklist'); + return Array.isArray(tracklist) ? tracklist : []; + } catch (e) { + return []; + } + } + #saveStyle() { if (!configRead(CONFIG_KEYS.ENABLED)) return; @@ -129,6 +161,37 @@ class CaptionStyleHandler { restoreAndRefreshRawKeys(); } + #saveCaptionsEnabled() { + if (!configRead(CONFIG_KEYS.ENABLED)) return; + + const captionsOn = this.#player?.isSubtitlesOn?.(); + if (typeof captionsOn !== 'boolean') return; + if (!captionsOn && !this.#getTracklist().length) return; + + if (captionsOn !== configRead(CONFIG_KEYS.CAPTIONS_ON)) { + configWrite(CONFIG_KEYS.CAPTIONS_ON, captionsOn); + } + } + + #tryRestoreCaptions() { + if (this.#captionsRestored || !configRead(CONFIG_KEYS.ENABLED)) return; + + if (configRead(CONFIG_KEYS.CAPTIONS_ON) !== true) { + this.#captionsRestored = true; + return; + } + + if (!this.#getTracklist().length) return; + + this.#captionsRestored = true; + + try { + if (!this.#player.isSubtitlesOn()) this.#player.toggleSubtitlesOn(); + } catch (e) { + console.warn('[CaptionStyle] Failed to restore captions:', e); + } + } + #tryApplyStyle = () => { clearTimeout(this.#applyTimeout); From 94d95701ee83ae625e9c4ad21b41a45fbfe33155 Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Wed, 26 Aug 2026 15:26:51 +0800 Subject: [PATCH 3/7] fix: never let caption player API calls throw into YouTube's event dispatch Guard isSubtitlesOn() with try/catch, only read the CC state once the track list exists, and defer toggleSubtitlesOn() out of the player's event handler with setTimeout. --- mods/features/captionStylePersistence.js | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js index 89e5293b..d625b035 100644 --- a/mods/features/captionStylePersistence.js +++ b/mods/features/captionStylePersistence.js @@ -163,10 +163,15 @@ class CaptionStyleHandler { #saveCaptionsEnabled() { if (!configRead(CONFIG_KEYS.ENABLED)) return; + if (!this.#getTracklist().length) return; - const captionsOn = this.#player?.isSubtitlesOn?.(); + let captionsOn; + try { + captionsOn = this.#player.isSubtitlesOn(); + } catch (e) { + return; + } if (typeof captionsOn !== 'boolean') return; - if (!captionsOn && !this.#getTracklist().length) return; if (captionsOn !== configRead(CONFIG_KEYS.CAPTIONS_ON)) { configWrite(CONFIG_KEYS.CAPTIONS_ON, captionsOn); @@ -185,11 +190,13 @@ class CaptionStyleHandler { this.#captionsRestored = true; - try { - if (!this.#player.isSubtitlesOn()) this.#player.toggleSubtitlesOn(); - } catch (e) { - console.warn('[CaptionStyle] Failed to restore captions:', e); - } + setTimeout(() => { + try { + if (!this.#player.isSubtitlesOn()) this.#player.toggleSubtitlesOn(); + } catch (e) { + console.warn('[CaptionStyle] Failed to restore captions:', e); + } + }, 0); } #tryApplyStyle = () => { From 4943c4bbac11c375d393981e6c450a4fc1c7ca66 Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Wed, 26 Aug 2026 15:40:43 +0800 Subject: [PATCH 4/7] fix: detect caption tracks from the player response as a fallback getOption('captions', 'tracklist') can be empty on the TV player even when a video has captions, which blocked both saving and restoring the CC state. Fall back to the caption tracks in getPlayerResponse(). --- mods/features/captionStylePersistence.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js index d625b035..1d75d449 100644 --- a/mods/features/captionStylePersistence.js +++ b/mods/features/captionStylePersistence.js @@ -143,7 +143,9 @@ class CaptionStyleHandler { #getTracklist() { try { const tracklist = this.#player?.getOption?.('captions', 'tracklist'); - return Array.isArray(tracklist) ? tracklist : []; + if (Array.isArray(tracklist) && tracklist.length) return tracklist; + const captionTracks = this.#player?.getPlayerResponse?.()?.captions?.playerCaptionsTracklistRenderer?.captionTracks; + return Array.isArray(captionTracks) ? captionTracks : []; } catch (e) { return []; } From dfc9af415da7cc8ddb368013ca64042ceee7c641 Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Wed, 26 Aug 2026 16:07:39 +0800 Subject: [PATCH 5/7] fix: ignore captions-off readings outside active playback The player fires captionschanged with an empty track and isSubtitlesOn() false both while a video is starting and when it is unloaded on returning to the feed, which recorded a bogus "off" state. Only accept an off reading while the player is playing or paused. --- mods/features/captionStylePersistence.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js index 1d75d449..b8dd2aea 100644 --- a/mods/features/captionStylePersistence.js +++ b/mods/features/captionStylePersistence.js @@ -25,6 +25,11 @@ const YT_KEYS = [ 'yt-player-caption-sticky-language', ]; +const PLAYER_STATES = { + PLAYING: 1, + PAUSED: 2, +}; + const FAR_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; const SAVE_DEBOUNCE_MS = 500; const APPLY_RETRY_MS = 500; @@ -151,6 +156,15 @@ class CaptionStyleHandler { } } + #isPlaybackActive() { + try { + const state = this.#player.getPlayerState(); + return state === PLAYER_STATES.PLAYING || state === PLAYER_STATES.PAUSED; + } catch (e) { + return false; + } + } + #saveStyle() { if (!configRead(CONFIG_KEYS.ENABLED)) return; @@ -174,6 +188,7 @@ class CaptionStyleHandler { return; } if (typeof captionsOn !== 'boolean') return; + if (!captionsOn && !this.#isPlaybackActive()) return; if (captionsOn !== configRead(CONFIG_KEYS.CAPTIONS_ON)) { configWrite(CONFIG_KEYS.CAPTIONS_ON, captionsOn); From 82c9b9de1cb585ca69e9f0e53ab49f1a3ced796d Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Wed, 26 Aug 2026 16:28:16 +0800 Subject: [PATCH 6/7] fix: restore captions by replaying the app's own caption command The TV app keeps its own captions on/off state and overrides the player, so toggling via the player API gets reverted. Wrap resolveCommand to observe caption commands, record the one that turned captions on (judged by isSubtitlesOn() before and after), and replay it once a captioned video is playing, retrying a few times if the app switches captions back off. --- mods/config.js | 1 + mods/features/captionStylePersistence.js | 141 +++++++++++++++-------- 2 files changed, 96 insertions(+), 46 deletions(-) diff --git a/mods/config.js b/mods/config.js index dfb76cdb..6e6880bf 100644 --- a/mods/config.js +++ b/mods/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableCaptionStylePersistence: true, captionStyleSettings: null, captionsEnabled: null, + captionsOnCommand: null, captionRawKeyBackups: {}, showWelcomeToast: true, enablePreviousNextButtons: true, diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js index b8dd2aea..37a068a3 100644 --- a/mods/features/captionStylePersistence.js +++ b/mods/features/captionStylePersistence.js @@ -7,7 +7,6 @@ const SELECTORS = { const EVENTS = { YT_STATE_CHANGE: 'onStateChange', YT_CAPTIONS_SETTINGS_CHANGED: 'captionssettingschanged', - YT_CAPTIONS_CHANGED: 'captionschanged', YT_CAPTIONS_TRACKLIST_CHANGED: 'onCaptionsTrackListChanged', CONFIG_CHANGE: 'configChange', }; @@ -16,6 +15,7 @@ const CONFIG_KEYS = { ENABLED: 'enableCaptionStylePersistence', STYLE: 'captionStyleSettings', CAPTIONS_ON: 'captionsEnabled', + CAPTIONS_ON_COMMAND: 'captionsOnCommand', RAW_BACKUPS: 'captionRawKeyBackups', }; @@ -25,15 +25,16 @@ const YT_KEYS = [ 'yt-player-caption-sticky-language', ]; -const PLAYER_STATES = { - PLAYING: 1, - PAUSED: 2, -}; +const CAPTION_COMMAND_KEY = /subtitle|caption/i; +const CAPTION_COMMAND_MAX_DEPTH = 6; const FAR_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; const SAVE_DEBOUNCE_MS = 500; const APPLY_RETRY_MS = 500; const APPLY_MAX_ATTEMPTS = 20; +const COMMAND_OUTCOME_MS = 1000; +const RESTORE_RETRY_MS = 1000; +const RESTORE_MAX_ATTEMPTS = 5; function restoreAndRefreshRawKeys() { if (!configRead(CONFIG_KEYS.ENABLED)) return; @@ -62,12 +63,33 @@ function restoreAndRefreshRawKeys() { if (backupsChanged) configWrite(CONFIG_KEYS.RAW_BACKUPS, backups); } +function findCaptionCommand(cmd, depth = 0) { + if (!cmd || typeof cmd !== 'object' || depth > CAPTION_COMMAND_MAX_DEPTH) return null; + + for (const key in cmd) { + if (CAPTION_COMMAND_KEY.test(key) && cmd[key] && typeof cmd[key] === 'object') { + return { [key]: cmd[key] }; + } + } + + for (const key in cmd) { + const found = findCaptionCommand(cmd[key], depth + 1); + if (found) return found; + } + + return null; +} + class CaptionStyleHandler { #player = null; + #resolveCommand = null; #attachTimeout = null; + #patchTimeout = null; #saveTimeout = null; #applyTimeout = null; + #restoreTimeout = null; #applyAttempts = 0; + #restoreAttempts = 0; #hasAppliedStyle = false; #lastVideoId = null; #captionsRestored = false; @@ -78,6 +100,7 @@ class CaptionStyleHandler { init() { this.#pollForPlayer(); + this.#patchResolveCommand(); this.#setupConfigListener(); } @@ -94,13 +117,41 @@ class CaptionStyleHandler { this.#player = playerElement; this.#player.addEventListener(EVENTS.YT_CAPTIONS_SETTINGS_CHANGED, this.#handleSettingsChanged); - this.#player.addEventListener(EVENTS.YT_CAPTIONS_CHANGED, this.#handleCaptionsChanged); this.#player.addEventListener(EVENTS.YT_CAPTIONS_TRACKLIST_CHANGED, this.#handleTrackListChanged); this.#player.addEventListener(EVENTS.YT_STATE_CHANGE, this.#handleStateChange); this.#tryApplyStyle(); } + #patchResolveCommand() { + clearTimeout(this.#patchTimeout); + + const yttvInstance = window._yttv && Object.values(window._yttv).find( + (obj) => obj && obj.instance && typeof obj.instance.resolveCommand === 'function' + ); + + if (!yttvInstance) { + this.#patchTimeout = setTimeout(() => this.#patchResolveCommand(), 500); + return; + } + + if (yttvInstance.instance.resolveCommand.isPatchedByCaptionPersistence) { + this.#resolveCommand = (cmd) => yttvInstance.instance.resolveCommand(cmd); + return; + } + + const originalResolveCommand = yttvInstance.instance.resolveCommand; + const handler = this; + this.#resolveCommand = (cmd) => originalResolveCommand.call(yttvInstance.instance, cmd); + + yttvInstance.instance.resolveCommand = function (cmd, _) { + const captionCommand = findCaptionCommand(cmd); + if (captionCommand) handler.#observeCaptionCommand(captionCommand); + return originalResolveCommand.call(this, cmd, _); + }; + yttvInstance.instance.resolveCommand.isPatchedByCaptionPersistence = true; + } + #setupConfigListener() { configChangeEmitter.addEventListener(EVENTS.CONFIG_CHANGE, (ev) => { if (ev.detail?.key === CONFIG_KEYS.ENABLED && ev.detail?.value) { @@ -119,10 +170,6 @@ class CaptionStyleHandler { this.#saveTimeout = setTimeout(() => this.#saveStyle(), SAVE_DEBOUNCE_MS); }; - #handleCaptionsChanged = () => { - this.#saveCaptionsEnabled(); - }; - #handleTrackListChanged = () => { this.#applyAttempts = 0; this.#tryApplyStyle(); @@ -136,35 +183,47 @@ class CaptionStyleHandler { if (videoId !== this.#lastVideoId) { this.#lastVideoId = videoId; this.#captionsRestored = false; + this.#restoreAttempts = 0; } if (state?.isPlaying) { this.#tryApplyStyle(); this.#tryRestoreCaptions(); - if (this.#captionsRestored) this.#saveCaptionsEnabled(); } }; - #getTracklist() { + #isSubtitlesOn() { try { - const tracklist = this.#player?.getOption?.('captions', 'tracklist'); - if (Array.isArray(tracklist) && tracklist.length) return tracklist; - const captionTracks = this.#player?.getPlayerResponse?.()?.captions?.playerCaptionsTracklistRenderer?.captionTracks; - return Array.isArray(captionTracks) ? captionTracks : []; + return this.#player.isSubtitlesOn() === true; } catch (e) { - return []; + return false; } } - #isPlaybackActive() { + #hasCaptionTracks() { try { - const state = this.#player.getPlayerState(); - return state === PLAYER_STATES.PLAYING || state === PLAYER_STATES.PAUSED; + const captionTracks = this.#player?.getPlayerResponse?.()?.captions?.playerCaptionsTracklistRenderer?.captionTracks; + return Array.isArray(captionTracks) && captionTracks.length > 0; } catch (e) { return false; } } + #observeCaptionCommand(captionCommand) { + if (!configRead(CONFIG_KEYS.ENABLED) || !this.#player) return; + + const wasOn = this.#isSubtitlesOn(); + + setTimeout(() => { + const isOn = this.#isSubtitlesOn(); + if (isOn === wasOn) return; + + this.#captionsRestored = true; + configWrite(CONFIG_KEYS.CAPTIONS_ON, isOn); + if (isOn) configWrite(CONFIG_KEYS.CAPTIONS_ON_COMMAND, JSON.parse(JSON.stringify(captionCommand))); + }, COMMAND_OUTCOME_MS); + } + #saveStyle() { if (!configRead(CONFIG_KEYS.ENABLED)) return; @@ -177,43 +236,33 @@ class CaptionStyleHandler { restoreAndRefreshRawKeys(); } - #saveCaptionsEnabled() { - if (!configRead(CONFIG_KEYS.ENABLED)) return; - if (!this.#getTracklist().length) return; + #tryRestoreCaptions() { + clearTimeout(this.#restoreTimeout); - let captionsOn; - try { - captionsOn = this.#player.isSubtitlesOn(); - } catch (e) { - return; - } - if (typeof captionsOn !== 'boolean') return; - if (!captionsOn && !this.#isPlaybackActive()) return; + if (this.#captionsRestored || !configRead(CONFIG_KEYS.ENABLED)) return; - if (captionsOn !== configRead(CONFIG_KEYS.CAPTIONS_ON)) { - configWrite(CONFIG_KEYS.CAPTIONS_ON, captionsOn); + const command = configRead(CONFIG_KEYS.CAPTIONS_ON_COMMAND); + if (configRead(CONFIG_KEYS.CAPTIONS_ON) !== true || !command || !this.#resolveCommand) { + this.#captionsRestored = true; + return; } - } - #tryRestoreCaptions() { - if (this.#captionsRestored || !configRead(CONFIG_KEYS.ENABLED)) return; + if (!this.#hasCaptionTracks() || !this.#player?.getPlayerStateObject?.()?.isPlaying) return; - if (configRead(CONFIG_KEYS.CAPTIONS_ON) !== true) { + if (this.#isSubtitlesOn() || this.#restoreAttempts >= RESTORE_MAX_ATTEMPTS) { this.#captionsRestored = true; return; } - if (!this.#getTracklist().length) return; + this.#restoreAttempts++; - this.#captionsRestored = true; + try { + this.#resolveCommand(JSON.parse(JSON.stringify(command))); + } catch (e) { + console.warn('[CaptionStyle] Failed to restore captions:', e); + } - setTimeout(() => { - try { - if (!this.#player.isSubtitlesOn()) this.#player.toggleSubtitlesOn(); - } catch (e) { - console.warn('[CaptionStyle] Failed to restore captions:', e); - } - }, 0); + this.#restoreTimeout = setTimeout(() => this.#tryRestoreCaptions(), RESTORE_RETRY_MS); } #tryApplyStyle = () => { From b5c19b5218f8fa1a6df0babdbde687ed30cff266 Mon Sep 17 00:00:00 2001 From: Oscar Barrett Date: Wed, 26 Aug 2026 16:39:33 +0800 Subject: [PATCH 7/7] fix: only match real caption commands when observing resolveCommand Matching any key containing "subtitle" caught the subtitle text field of toast and popup renderers, so unrelated commands were observed and replayed. Match only caption command/endpoint/action keys at the top level or inside commandExecutorCommand, and discard a stored command that no longer matches. --- mods/features/captionStylePersistence.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/mods/features/captionStylePersistence.js b/mods/features/captionStylePersistence.js index 37a068a3..c8305bce 100644 --- a/mods/features/captionStylePersistence.js +++ b/mods/features/captionStylePersistence.js @@ -25,8 +25,7 @@ const YT_KEYS = [ 'yt-player-caption-sticky-language', ]; -const CAPTION_COMMAND_KEY = /subtitle|caption/i; -const CAPTION_COMMAND_MAX_DEPTH = 6; +const CAPTION_COMMAND_KEY = /^[a-z]*(subtitles?|captions?)[a-z]*(command|endpoint|action)$/i; const FAR_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; const SAVE_DEBOUNCE_MS = 500; @@ -63,8 +62,8 @@ function restoreAndRefreshRawKeys() { if (backupsChanged) configWrite(CONFIG_KEYS.RAW_BACKUPS, backups); } -function findCaptionCommand(cmd, depth = 0) { - if (!cmd || typeof cmd !== 'object' || depth > CAPTION_COMMAND_MAX_DEPTH) return null; +function findCaptionCommand(cmd, nested = false) { + if (!cmd || typeof cmd !== 'object') return null; for (const key in cmd) { if (CAPTION_COMMAND_KEY.test(key) && cmd[key] && typeof cmd[key] === 'object') { @@ -72,9 +71,12 @@ function findCaptionCommand(cmd, depth = 0) { } } - for (const key in cmd) { - const found = findCaptionCommand(cmd[key], depth + 1); - if (found) return found; + const commands = cmd.commandExecutorCommand?.commands; + if (!nested && Array.isArray(commands)) { + for (const command of commands) { + const found = findCaptionCommand(command, true); + if (found) return found; + } } return null; @@ -242,7 +244,11 @@ class CaptionStyleHandler { if (this.#captionsRestored || !configRead(CONFIG_KEYS.ENABLED)) return; const command = configRead(CONFIG_KEYS.CAPTIONS_ON_COMMAND); - if (configRead(CONFIG_KEYS.CAPTIONS_ON) !== true || !command || !this.#resolveCommand) { + if (command && !findCaptionCommand(command)) { + configWrite(CONFIG_KEYS.CAPTIONS_ON_COMMAND, null); + configWrite(CONFIG_KEYS.CAPTIONS_ON, null); + } + if (configRead(CONFIG_KEYS.CAPTIONS_ON) !== true || !configRead(CONFIG_KEYS.CAPTIONS_ON_COMMAND) || !this.#resolveCommand) { this.#captionsRestored = true; return; }