// ==UserScript== // @name YouTube Dual Native Subs // @namespace https://github.com/luismusaj646-prog/dual-subtitles // @version 4.1.0 // @description Native dual subtitles for YouTube // @license GPL-3.0-only // @homepageURL https://github.com/luismusaj646-prog/dual-subtitles // @supportURL https://github.com/luismusaj646-prog/dual-subtitles/issues // @match https://www.youtube.com/watch* // @run-at document-idle // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @connect www.youtube.com // ==/UserScript== (function () { 'use strict'; var SCRIPT_NAME = 'yt-dual-subs'; var SCRIPT_VERSION = '4.1.0'; var SETTINGS_KEY = 'yds_native_settings_v2'; var RUNTIME_KEY = '__ydsRuntime'; var DEBUG_API_KEY = '__ydsDebug'; var LOG_PREFIX = '[tm-script][' + SCRIPT_NAME + ']'; var TRANSCRIPT_LABEL_PATTERN = /(show transcript|open transcript|transcript|字幕记录|字幕記錄|文字稿|逐字稿|转录稿|轉錄稿|transkript anzeigen|transkript öffnen|mostrar transcripci[oó]n|abrir transcripci[oó]n|показать расшифровку видео|расшифровка)/i; var CONFIG = { initDelayMs: 320, domDebounceMs: 180, retryDelayMs: 1200, routePollMs: 1200, maxTrackRetries: 8, nativeTimedTextHintWaitMs: 2200, nativeTimedTextParamKeys: [ 'potc', 'pot', 'xorb', 'xobt', 'xovt', 'cbr', 'cbrver', 'c', 'cver', 'cplayer', 'cos', 'cosver', 'cplatform' ], rateLimitBackoffMs: 60000, selectors: { watchPath: '/watch', rootVideo: 'video', player: '.html5-video-player', captionContainer: '.ytp-caption-window-container', playerControls: '.ytp-chrome-bottom', nativeCaptionText: '.caption-window .ytp-caption-segment, .ytp-caption-segment, .caption-visual-line', subtitlesButton: '.ytp-subtitles-button', transcriptPanel: 'ytd-engagement-panel-section-list-renderer', transcriptRenderer: 'ytd-transcript-renderer', transcriptSegment: 'ytd-transcript-segment-renderer, transcript-segment-view-model', transcriptText: '.segment-text, yt-formatted-string, .yt-core-attributed-string[role="text"]', transcriptTime: '.segment-timestamp, .ytwTranscriptSegmentViewModelTimestamp', transcriptChipButton: 'button[aria-label], yt-button-shape button[aria-label], button[title], [role="button"][aria-label]', transcriptMenuButton: 'ytd-menu-renderer :is(yt-button-shape button, button#button.style-scope.ytd-menu-renderer, ytd-video-primary-info-renderer button, button[aria-haspopup=\"true\"])[aria-label*=\"more actions\" i], ytd-button-renderer button:is([aria-label*=\"transcript\" i],[title*=\"transcript\" i])', transcriptMenuItems: 'ytd-menu-service-item-renderer, tp-yt-paper-item, yt-formatted-string.style-scope.ytd-menu-service-item-renderer', transcriptDescriptionButton: 'button[aria-label*=\"transcript\" i], button[aria-label*=\"字幕\" i], button[aria-label*=\"文字稿\" i], button[title*=\"transcript\" i], #description-inline-expander [aria-label*=\"transcript\" i]', transcriptLanguageDropdown: 'ytd-transcript-footer-renderer yt-dropdown-menu tp-yt-paper-button, ytd-transcript-footer-renderer yt-dropdown-menu button', transcriptVisibleListboxes: 'tp-yt-iron-dropdown:not([aria-hidden=\"true\"]) tp-yt-paper-listbox', metadataTopRow: 'ytd-watch-metadata #top-row, #above-the-fold #top-row', metadataActions: 'ytd-watch-metadata #actions, #above-the-fold #actions', metadataActionButtons: 'ytd-watch-metadata #actions #top-level-buttons-computed, #above-the-fold #actions #top-level-buttons-computed, ytd-watch-metadata #actions, #above-the-fold #actions' }, ids: { uiSlot: 'yds-page-slot', launcher: 'yds-launcher-root', panel: 'yds-panel-root', nativeWindow: 'yds-native-window', debugBox: 'yds-debug-box', hiddenTranscriptStyle: 'yds-hidden-transcript-style' }, historyEventName: 'yds-history-change', debugQueryParam: 'ydsDebug=1', defaultDebug: false }; var DEFAULTS = { targetLang: 'zh-Hans', targetLangBySource: {}, sourceTrackIndex: 0, enabled: true, displayMode: 'dual', panelOpen: false, debug: CONFIG.defaultDebug, launcherPosition: null, panelPosition: null, sourceFontSize: 28, targetFontSize: 28, lineGap: 6, bottomOffset: 9, sourceColor: '#ffffff', targetColor: '#00e5ff', fontFamily: 'system', smartPosition: true, syncNativeStyle: true }; var FONT_OPTIONS = [ { value: 'system', label: '\u7CFB\u7EDF\u9ED8\u8BA4', css: 'system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif' }, { value: 'youtube', label: 'YouTube Sans', css: '"YouTube Sans","Roboto",Arial,sans-serif' }, { value: 'arial', label: 'Arial', css: 'Arial,"Helvetica Neue",sans-serif' }, { value: 'roboto', label: 'Roboto', css: '"Roboto",Arial,sans-serif' }, { value: 'segoe', label: 'Segoe UI', css: '"Segoe UI",Arial,sans-serif' }, { value: 'microsoft-yahei', label: '\u5FAE\u8F6F\u96C5\u9ED1', css: '"Microsoft YaHei","Segoe UI",Arial,sans-serif' }, { value: 'noto-sans', label: 'Noto Sans', css: '"Noto Sans","Noto Sans SC",Arial,sans-serif' }, { value: 'serif', label: '\u886C\u7EBF', css: 'Georgia,"Times New Roman",serif' }, { value: 'mono', label: '\u7B49\u5BBD', css: '"Cascadia Mono","Consolas",monospace' } ]; var TEXT = { title: '\u53CC\u5B57\u5E55', launcher: 'X', reload: '\u91CD\u8F7D', sourceTrack: '\u5F53\u524D\u539F\u8F68', displayMode: '\u663E\u793A\u6A21\u5F0F', modeDual: '\u539F\u6587 + \u8BD1\u6587', modeSource: '\u53EA\u663E\u793A\u539F\u6587', modeTarget: '\u53EA\u663E\u793A\u8BD1\u6587', targetLang: '\u76EE\u6807\u8BED\u8A00', targetSearch: '\u641C\u7D22\u8BED\u8A00', targetSearchPlaceholder: '\u8F93\u5165\u8BED\u8A00\u6216\u4EE3\u7801', trackIndex: '\u539F\u5B57\u5E55\u8F68', styleTitle: '\u5B57\u5E55\u6837\u5F0F', sourceFontSize: '\u539F\u6587\u5B57\u53F7', targetFontSize: '\u8BD1\u6587\u5B57\u53F7', lineGap: '\u884C\u95F4\u8DDD', bottomOffset: '\u5E95\u90E8\u4F4D\u7F6E', fontFamily: '\u5B57\u4F53', sourceColor: '\u539F\u6587\u989C\u8272', targetColor: '\u8BD1\u6587\u989C\u8272', advancedTitle: '\u9AD8\u7EA7', resetStyle: '\u91CD\u7F6E\u6837\u5F0F', debug: 'debug', injected: '\u811A\u672C\u5DF2\u6CE8\u5165', waitingWatchPage: '\u7B49\u5F85 watch \u9875\u9762', waitingPlayer: '\u7B49\u5F85\u64AD\u653E\u5668\u5B8C\u6210\u52A0\u8F7D', waitingTracks: '\u7B49\u5F85\u5B57\u5E55\u8F68\u51FA\u73B0', loading: '\u6B63\u5728\u52A0\u8F7D...', noTrack: '\u65E0\u53EF\u7528\u5B57\u5E55\u8F68', noTrackDetail: '\u8FD9\u4E2A\u89C6\u9891\u6CA1\u6709\u53EF\u7528\u5B57\u5E55\u8F68', noCue: '\u5B57\u5E55\u6CA1\u62FF\u5230\u6709\u6548\u5185\u5BB9', nativeReady: '\u53CC\u5B57\u5E55\u5DF2\u542F\u7528', sourceOnly: '\u53EA\u62FF\u5230\u539F\u5B57\u5E55', rateLimited: '\u7FFB\u8BD1\u88AB\u9650\u6D41\uFF0C60\u79D2\u540E\u518D\u8BD5', rateLimitedShort: '\u7FFB\u8BD1\u88AB\u9650\u6D41\uFF0C\u7A0D\u540E\u518D\u8BD5', loadFailed: '\u52A0\u8F7D\u5931\u8D25: ', disabled: '\u53CC\u5B57\u5E55\u5DF2\u5173\u95ED', enableDualSubs: '\u5F00\u542F\u53CC\u5B57\u5E55', disableDualSubs: '\u5173\u95ED\u53CC\u5B57\u5E55', unselected: '\u672A\u9009\u62E9' }; if (window[RUNTIME_KEY] && typeof window[RUNTIME_KEY].destroy === 'function') { window[RUNTIME_KEY].destroy('reinject'); } GM_addStyle( '.yds-page-slot{' + 'position:relative;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto;height:36px;' + 'margin:0 0 0 8px;z-index:2200;vertical-align:middle;' + '}' + '.yds-launcher{' + 'position:static;z-index:2200;width:36px;height:36px;min-width:36px;padding:0;border:0;border-radius:18px;' + 'display:flex;align-items:center;justify-content:center;background:var(--yt-spec-badge-chip-background,#f2f2f2);' + 'color:var(--yt-spec-text-primary,#0f0f0f);font:500 14px/36px Roboto,Arial,sans-serif;cursor:pointer;user-select:none;' + 'box-shadow:none;outline:0;' + '}' + '.yds-launcher:hover,.yds-launcher[aria-expanded=\"true\"]{background:var(--yt-spec-mono-tonal-hover,#e5e5e5);}' + '.yds-launcher:focus-visible{box-shadow:0 0 0 2px var(--yt-spec-themed-blue,#065fd4);}' + '.yds-launcher.yds-detached{position:fixed;top:16px;right:16px;background:var(--yt-spec-badge-chip-background,#f2f2f2);}' + '.yds-panel{' + 'position:absolute;top:44px;right:0;z-index:2201;width:360px;max-width:min(360px,calc(100vw - 24px));' + 'max-height:min(78vh,640px);overflow:auto;box-sizing:border-box;padding:8px 0;border:0;border-radius:12px;' + 'background:var(--yt-spec-menu-background,var(--yt-spec-base-background,#fff));color:var(--yt-spec-text-primary,#0f0f0f);' + 'font:400 14px/20px Roboto,Arial,sans-serif;box-shadow:0 4px 32px rgba(0,0,0,.16);color-scheme:light dark;' + '}' + '.yds-panel.yds-detached{position:fixed;top:56px;right:16px;max-height:78vh;}' + '.yds-panel[dir=\"ltr\"]{right:0;left:auto;}' + '.yds-panel input,.yds-panel button,.yds-panel select{font:inherit;}' + '.yds-panel input,.yds-panel select{box-sizing:border-box;}' + '.yds-panel input[type="text"],.yds-panel input[type="number"],.yds-panel select{' + 'height:36px;border-radius:8px;border:1px solid var(--yt-spec-10-percent-layer,rgba(0,0,0,.1));' + 'background:var(--yt-spec-base-background,#fff);color:var(--yt-spec-text-primary,#0f0f0f);padding:0 32px 0 12px;' + '}' + '.yds-panel select{width:100%;}' + '.yds-panel option{background:var(--yt-spec-menu-background,var(--yt-spec-base-background,#fff));color:var(--yt-spec-text-primary,#0f0f0f);}' + '.yds-panel input[type="color"]{width:36px;height:36px;padding:0;border:0;background:transparent;}' + '.yds-panel input[type="range"]{min-width:0;accent-color:var(--yt-spec-themed-blue,#065fd4);}' + '.yds-panel button{height:36px;border-radius:18px;border:0;background:var(--yt-spec-badge-chip-background,#f2f2f2);color:var(--yt-spec-text-primary,#0f0f0f);cursor:pointer;padding:0 14px;}' + '.yds-panel button:hover{background:var(--yt-spec-mono-tonal-hover,#e5e5e5);}' + '.yds-panel label,.yds-field,.yds-select-field{' + 'display:grid;grid-template-columns:112px minmax(0,1fr);align-items:center;gap:12px;min-height:48px;' + 'box-sizing:border-box;margin:0;padding:6px 16px;color:var(--yt-spec-text-primary,#0f0f0f);' + '}' + '.yds-panel label:hover,.yds-field:hover,.yds-select-field:hover{background:var(--yt-spec-mono-tonal-hover,rgba(0,0,0,.06));}' + '.yds-section-title{margin:8px 0 0;padding:10px 16px 6px;font:500 14px/20px Roboto,Arial,sans-serif;color:var(--yt-spec-text-primary,#0f0f0f);}' + '.yds-field{grid-template-columns:112px minmax(0,1fr) 56px;}' + '.yds-field>span,.yds-select-field>span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}' + '.yds-field input[type="number"]{width:56px;text-align:center;padding:0 6px;}' + '.yds-field-unit{font-size:12px;color:var(--yt-spec-text-secondary,#606060);margin-left:2px;}' + '.yds-row{display:flex;gap:8px;align-items:center;min-height:40px;padding:4px 16px;}' + '.yds-row>*{flex:1;}' + '.yds-title-row{border-bottom:1px solid var(--yt-spec-10-percent-layer,rgba(0,0,0,.1));margin-bottom:4px;padding-bottom:8px;}' + '.yds-title-row strong{font:500 16px/22px Roboto,Arial,sans-serif;}' + '.yds-close-btn{flex:0 0 36px !important;padding:0 !important;}' + '.yds-enabled-btn{width:calc(100% - 32px);margin:6px 16px;}' + '.yds-toggle{display:flex;align-items:center;gap:12px;min-height:40px;margin:0;padding:6px 16px;}' + '.yds-toggle:hover{background:var(--yt-spec-mono-tonal-hover,rgba(0,0,0,.06));}' + '.yds-toggle input{flex:0 0 auto;}' + '.yds-advanced{margin:4px 0 0;border-top:1px solid var(--yt-spec-10-percent-layer,rgba(0,0,0,.1));}' + '.yds-advanced summary{cursor:pointer;list-style:none;min-height:40px;padding:10px 16px;box-sizing:border-box;color:var(--yt-spec-text-primary,#0f0f0f);}' + '.yds-advanced summary::-webkit-details-marker{display:none;}' + '.yds-advanced summary:hover{background:var(--yt-spec-mono-tonal-hover,rgba(0,0,0,.06));}' + '.yds-status{padding:6px 16px;font-size:12px;line-height:18px;color:var(--yt-spec-text-secondary,#606060);white-space:pre-wrap;word-break:break-word;}' + '.yds-debug{margin:6px 16px 10px;max-height:220px;overflow:auto;padding:8px;border-radius:8px;background:var(--yt-spec-badge-chip-background,#f2f2f2);font:12px/16px Consolas,monospace;white-space:pre-wrap;word-break:break-word;color:var(--yt-spec-text-primary,#0f0f0f);}' + '.yds-debug[hidden]{display:none;}' + 'html[dark] .yds-launcher,body[dark] .yds-launcher,ytd-app[dark] .yds-launcher,.dark .yds-launcher{' + 'background:#272727;color:#f1f1f1;' + '}' + 'html[dark] .yds-launcher:hover,html[dark] .yds-launcher[aria-expanded=\"true\"],body[dark] .yds-launcher:hover,body[dark] .yds-launcher[aria-expanded=\"true\"],ytd-app[dark] .yds-launcher:hover,ytd-app[dark] .yds-launcher[aria-expanded=\"true\"],.dark .yds-launcher:hover,.dark .yds-launcher[aria-expanded=\"true\"]{' + 'background:#3f3f3f;' + '}' + 'html[dark] .yds-panel,body[dark] .yds-panel,ytd-app[dark] .yds-panel,.dark .yds-panel{' + 'background:#282828;color:#f1f1f1;box-shadow:0 4px 32px rgba(0,0,0,.48);color-scheme:dark;' + '}' + 'html[dark] .yds-panel input[type="text"],html[dark] .yds-panel input[type="number"],html[dark] .yds-panel select,body[dark] .yds-panel input[type="text"],body[dark] .yds-panel input[type="number"],body[dark] .yds-panel select,ytd-app[dark] .yds-panel input[type="text"],ytd-app[dark] .yds-panel input[type="number"],ytd-app[dark] .yds-panel select,.dark .yds-panel input[type="text"],.dark .yds-panel input[type="number"],.dark .yds-panel select{' + 'background:#121212;color:#f1f1f1;border-color:#3f3f3f;' + '}' + 'html[dark] .yds-panel option,body[dark] .yds-panel option,ytd-app[dark] .yds-panel option,.dark .yds-panel option{' + 'background:#282828;color:#f1f1f1;' + '}' + 'html[dark] .yds-panel button,body[dark] .yds-panel button,ytd-app[dark] .yds-panel button,.dark .yds-panel button{' + 'background:#3f3f3f;color:#f1f1f1;' + '}' + 'html[dark] .yds-panel button:hover,body[dark] .yds-panel button:hover,ytd-app[dark] .yds-panel button:hover,.dark .yds-panel button:hover{' + 'background:#535353;' + '}' + 'html[dark] .yds-title-row,html[dark] .yds-advanced,body[dark] .yds-title-row,body[dark] .yds-advanced,ytd-app[dark] .yds-title-row,ytd-app[dark] .yds-advanced,.dark .yds-title-row,.dark .yds-advanced{' + 'border-color:#3f3f3f;' + '}' + 'html[dark] .yds-panel label,html[dark] .yds-field,html[dark] .yds-select-field,html[dark] .yds-section-title,html[dark] .yds-advanced summary,body[dark] .yds-panel label,body[dark] .yds-field,body[dark] .yds-select-field,body[dark] .yds-section-title,body[dark] .yds-advanced summary,ytd-app[dark] .yds-panel label,ytd-app[dark] .yds-field,ytd-app[dark] .yds-select-field,ytd-app[dark] .yds-section-title,ytd-app[dark] .yds-advanced summary,.dark .yds-panel label,.dark .yds-field,.dark .yds-select-field,.dark .yds-section-title,.dark .yds-advanced summary{' + 'color:#f1f1f1;' + '}' + 'html[dark] .yds-panel label:hover,html[dark] .yds-field:hover,html[dark] .yds-select-field:hover,html[dark] .yds-toggle:hover,html[dark] .yds-advanced summary:hover,body[dark] .yds-panel label:hover,body[dark] .yds-field:hover,body[dark] .yds-select-field:hover,body[dark] .yds-toggle:hover,body[dark] .yds-advanced summary:hover,ytd-app[dark] .yds-panel label:hover,ytd-app[dark] .yds-field:hover,ytd-app[dark] .yds-select-field:hover,ytd-app[dark] .yds-toggle:hover,ytd-app[dark] .yds-advanced summary:hover,.dark .yds-panel label:hover,.dark .yds-field:hover,.dark .yds-select-field:hover,.dark .yds-toggle:hover,.dark .yds-advanced summary:hover{' + 'background:#3f3f3f;' + '}' + 'html[dark] .yds-status,html[dark] .yds-field-unit,body[dark] .yds-status,body[dark] .yds-field-unit,ytd-app[dark] .yds-status,ytd-app[dark] .yds-field-unit,.dark .yds-status,.dark .yds-field-unit{' + 'color:#aaa;' + '}' + 'html[dark] .yds-debug,body[dark] .yds-debug,ytd-app[dark] .yds-debug,.dark .yds-debug{' + 'background:#1f1f1f;color:#f1f1f1;' + '}' + '.ytp-caption-window-container.yds-native-mode .caption-window{display:none !important;}' + '.html5-video-player .yds-native-window{' + 'position:absolute;left:0;right:0;bottom:9%;z-index:63;padding:0 24px;box-sizing:border-box;' + 'display:flex;flex-direction:column;align-items:center;text-align:center;pointer-events:none;' + 'text-shadow:0 2px 4px rgba(0,0,0,.85);' + '}' + '.html5-video-player .yds-native-line{' + 'display:block;max-width:100%;font:600 28px/1.24 system-ui,sans-serif;white-space:pre-line;word-break:break-word;' + '}' + '.html5-video-player .yds-native-line-b{margin-top:6px;color:#00e5ff;}' ); var state = loadSettings(); var logger = createLogger(function () { return isDebugEnabled(state); }); var fetchDiagnostics = { source: '', target: '' }; var nativeTimedTextHints = { videoId: '', byLang: {}, last: null }; var runtime = createRuntime(); window[RUNTIME_KEY] = runtime; runtime.boot(); function createRuntime() { var app = { activeRequestId: 0, backoffUntil: 0, cuesA: [], cuesB: [], defaultTrackIndex: -1, lastSourceName: '', lastCaptionSource: '', lastFallback: '', lastVideoId: '', lastUrl: '', loading: false, loopId: 0, pendingLoadKey: '', phase: 'boot', status: '', translationLanguages: [], timers: { init: 0, urlPoll: 0 }, tracks: [], trackRetryCount: 0, teardown: [], observer: null }; var ui = { launcher: null, panel: null, sourceName: null, status: null, displayMode: null, targetSearch: null, targetLang: null, trackIndex: null, sourceFontSize: null, targetFontSize: null, lineGap: null, bottomOffset: null, fontFamily: null, sourceColor: null, targetColor: null, enabledBtn: null, debugToggle: null, debugBox: null }; function boot() { setPhase('boot'); installTimedTextObserver(); buildUi(); mountUi(); exposeDebugApi(); bindGlobalListeners(); setStatus(TEXT.injected); logger.debug('boot', collectSnapshot()); scheduleInit('boot', 80); } function destroy(reason) { clearTimeout(app.timers.init); clearInterval(app.timers.urlPoll); if (app.observer) app.observer.disconnect(); stopLoop(); clearNativeCaptionWindow(); unmountUi(); while (app.teardown.length) { try { app.teardown.pop()(); } catch (err) { logger.error('teardown failed', err); } } if (getPageWindow()[DEBUG_API_KEY] && getPageWindow()[DEBUG_API_KEY].runtime === api) { delete getPageWindow()[DEBUG_API_KEY]; } if (window[RUNTIME_KEY] === api) { delete window[RUNTIME_KEY]; } logger.debug('destroy', { reason: reason || 'unknown' }); } function buildUi() { if (ui.launcher && ui.panel) return; ui.launcher = document.createElement('button'); ui.launcher.id = CONFIG.ids.launcher; ui.launcher.type = 'button'; ui.launcher.className = 'yds-launcher'; ui.launcher.textContent = TEXT.launcher; ui.launcher.title = TEXT.title; ui.launcher.addEventListener('click', function () { state.panelOpen = !state.panelOpen; saveSettings(state); mountUi(); }); ui.panel = document.createElement('div'); ui.panel.id = CONFIG.ids.panel; ui.panel.className = 'yds-panel'; ui.panel.dir = 'ltr'; var titleRow = document.createElement('div'); titleRow.className = 'yds-row'; titleRow.className += ' yds-title-row'; var title = document.createElement('strong'); title.textContent = TEXT.title; var reloadBtn = document.createElement('button'); reloadBtn.type = 'button'; reloadBtn.textContent = TEXT.reload; reloadBtn.addEventListener('click', function () { reloadDualSubsSoon('manual-reload'); }); var closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'yds-close-btn'; closeBtn.textContent = 'x'; closeBtn.title = '\u9690\u85CF'; closeBtn.addEventListener('click', function () { state.panelOpen = false; saveSettings(state); mountUi(); }); titleRow.appendChild(title); titleRow.appendChild(reloadBtn); titleRow.appendChild(closeBtn); ui.panel.appendChild(titleRow); ui.enabledBtn = document.createElement('button'); ui.enabledBtn.type = 'button'; ui.enabledBtn.className = 'yds-enabled-btn'; ui.enabledBtn.addEventListener('click', function () { setDualSubsEnabled(!state.enabled, 'toggle-button'); }); ui.panel.appendChild(ui.enabledBtn); ui.displayMode = createDisplayModeField(); var sourceLabel = document.createElement('label'); sourceLabel.textContent = TEXT.sourceTrack; ui.sourceName = document.createElement('div'); ui.sourceName.className = 'yds-status'; sourceLabel.appendChild(ui.sourceName); ui.panel.appendChild(sourceLabel); var targetSearchLabel = document.createElement('label'); targetSearchLabel.textContent = TEXT.targetSearch; ui.targetSearch = document.createElement('input'); ui.targetSearch.type = 'text'; ui.targetSearch.placeholder = TEXT.targetSearchPlaceholder; ui.targetSearch.setAttribute('data-yds-control', 'target-lang-search'); ui.targetSearch.addEventListener('input', function () { syncTargetLanguageOptions(true); }); targetSearchLabel.appendChild(ui.targetSearch); ui.panel.appendChild(targetSearchLabel); var targetLabel = document.createElement('label'); targetLabel.textContent = TEXT.targetLang; ui.targetLang = document.createElement('select'); ui.targetLang.setAttribute('data-yds-control', 'target-lang'); ui.targetLang.addEventListener('change', function () { state.targetLang = String(ui.targetLang.value || DEFAULTS.targetLang).trim() || DEFAULTS.targetLang; rememberTargetForCurrentSource(); saveSettings(state); reloadDualSubsSoon('target-lang-change'); }); targetLabel.appendChild(ui.targetLang); ui.panel.appendChild(targetLabel); var trackLabel = document.createElement('label'); trackLabel.textContent = TEXT.trackIndex; ui.trackIndex = document.createElement('select'); ui.trackIndex.setAttribute('data-yds-control', 'source-track-index'); ui.trackIndex.addEventListener('change', function () { var next = parseInt(ui.trackIndex.value || '0', 10); state.sourceTrackIndex = isNaN(next) ? 0 : Math.max(0, next); applyRememberedTargetForTrack(app.tracks[state.sourceTrackIndex], true); saveSettings(state); reloadDualSubsSoon('track-index-change'); }); trackLabel.appendChild(ui.trackIndex); ui.panel.appendChild(trackLabel); var styleTitle = document.createElement('div'); styleTitle.className = 'yds-section-title'; styleTitle.textContent = TEXT.styleTitle; ui.panel.appendChild(styleTitle); ui.sourceFontSize = createNumberRangeField(TEXT.sourceFontSize, 'sourceFontSize', 16, 56, 1, 'px'); ui.targetFontSize = createNumberRangeField(TEXT.targetFontSize, 'targetFontSize', 16, 56, 1, 'px'); ui.lineGap = createNumberRangeField(TEXT.lineGap, 'lineGap', 0, 24, 1, 'px'); ui.bottomOffset = createNumberRangeField(TEXT.bottomOffset, 'bottomOffset', 2, 28, 1, '%'); ui.fontFamily = createFontField(); ui.sourceColor = createColorField(TEXT.sourceColor, 'sourceColor'); ui.targetColor = createColorField(TEXT.targetColor, 'targetColor'); var resetStyleBtn = document.createElement('button'); resetStyleBtn.type = 'button'; resetStyleBtn.textContent = TEXT.resetStyle; resetStyleBtn.addEventListener('click', function () { resetSubtitleStyle(); }); ui.panel.appendChild(resetStyleBtn); var advanced = document.createElement('details'); advanced.className = 'yds-advanced'; var advancedSummary = document.createElement('summary'); advancedSummary.textContent = TEXT.advancedTitle; advanced.appendChild(advancedSummary); var toggleRow = document.createElement('label'); toggleRow.className = 'yds-toggle'; ui.debugToggle = document.createElement('input'); ui.debugToggle.type = 'checkbox'; ui.debugToggle.addEventListener('change', function () { state.debug = !!ui.debugToggle.checked; saveSettings(state); logger.debug('debug toggled', { enabled: state.debug }); syncUi(); }); var toggleText = document.createElement('span'); toggleText.textContent = TEXT.debug; toggleRow.appendChild(ui.debugToggle); toggleRow.appendChild(toggleText); advanced.appendChild(toggleRow); ui.status = document.createElement('div'); ui.status.className = 'yds-status'; ui.panel.appendChild(ui.status); ui.debugBox = document.createElement('div'); ui.debugBox.id = CONFIG.ids.debugBox; ui.debugBox.className = 'yds-debug'; advanced.appendChild(ui.debugBox); ui.panel.appendChild(advanced); syncUi(); } function createDisplayModeField() { var row = document.createElement('label'); row.textContent = TEXT.displayMode; var select = document.createElement('select'); select.setAttribute('data-yds-control', 'display-mode'); [ { value: 'dual', label: TEXT.modeDual }, { value: 'source', label: TEXT.modeSource }, { value: 'target', label: TEXT.modeTarget } ].forEach(function (option) { var node = document.createElement('option'); node.value = option.value; node.textContent = option.label; select.appendChild(node); }); select.addEventListener('change', function () { state.displayMode = normalizeDisplayMode(select.value); saveSettings(state); renderCurrentCaption(); syncUi(); }); row.appendChild(select); ui.panel.appendChild(row); return select; } function createNumberRangeField(labelText, stateKey, min, max, step, unit) { var row = document.createElement('div'); row.className = 'yds-field'; var label = document.createElement('span'); label.textContent = labelText; var range = document.createElement('input'); range.type = 'range'; range.min = String(min); range.max = String(max); range.step = String(step); var number = document.createElement('input'); number.type = 'number'; number.min = String(min); number.max = String(max); number.step = String(step); number.setAttribute('data-yds-control', stateKey); function commit(value) { state[stateKey] = clampNumber(parseFloat(value), min, max, DEFAULTS[stateKey]); range.value = String(state[stateKey]); number.value = String(state[stateKey]); saveSettings(state); applySubtitleStyle(); } range.addEventListener('input', function () { commit(range.value); }); number.addEventListener('change', function () { commit(number.value); }); row.appendChild(label); row.appendChild(range); row.appendChild(number); ui.panel.appendChild(row); return { number: number, range: range, unit: unit }; } function createFontField() { var row = document.createElement('div'); row.className = 'yds-select-field'; var label = document.createElement('span'); label.textContent = TEXT.fontFamily; var select = document.createElement('select'); select.setAttribute('data-yds-control', 'font-family'); FONT_OPTIONS.forEach(function (option) { var node = document.createElement('option'); node.value = option.value; node.textContent = option.label; select.appendChild(node); }); select.addEventListener('change', function () { state.fontFamily = normalizeFontFamily(select.value); saveSettings(state); applySubtitleStyle(); }); row.appendChild(label); row.appendChild(select); ui.panel.appendChild(row); return select; } function createColorField(labelText, stateKey) { var row = document.createElement('div'); row.className = 'yds-field'; var label = document.createElement('span'); label.textContent = labelText; var preview = document.createElement('span'); preview.className = 'yds-field-unit'; preview.textContent = '\u25CF'; var color = document.createElement('input'); color.type = 'color'; color.setAttribute('data-yds-control', stateKey); color.addEventListener('input', function () { state[stateKey] = normalizeColor(color.value, DEFAULTS[stateKey]); saveSettings(state); applySubtitleStyle(); syncColorPreview(preview, state[stateKey]); }); row.appendChild(label); row.appendChild(preview); row.appendChild(color); ui.panel.appendChild(row); return { input: color, preview: preview }; } function mountUi() { if (!isWatchPage()) { unmountUi(); return; } var host = ensurePageControlsSlot(); if (!host) { unmountUi(); syncUi(); return; } var detached = false; setDetachedClass(ui.launcher, detached); setDetachedClass(ui.panel, detached); if (ui.launcher) ui.launcher.setAttribute('aria-expanded', state.panelOpen ? 'true' : 'false'); if (ui.launcher && ui.launcher.parentNode !== host) host.appendChild(ui.launcher); if (state.panelOpen) { if (ui.panel && ui.panel.parentNode !== host) host.appendChild(ui.panel); } else if (ui.panel && ui.panel.isConnected) { ui.panel.remove(); } if (detached) { applyStoredPosition(ui.launcher, state.launcherPosition); applyStoredPosition(ui.panel, state.panelPosition); } else { clearInlinePosition(ui.launcher); clearInlinePosition(ui.panel); } syncUi(); } function unmountUi() { if (ui.panel && ui.panel.isConnected) ui.panel.remove(); if (ui.launcher && ui.launcher.isConnected) ui.launcher.remove(); var slot = document.getElementById(CONFIG.ids.uiSlot); if (slot && !slot.childNodes.length) slot.remove(); } function ensurePageControlsSlot() { var host = getPageControlsHost(); if (!host) return null; var slot = document.getElementById(CONFIG.ids.uiSlot); if (!slot) { slot = document.createElement('div'); slot.id = CONFIG.ids.uiSlot; slot.className = 'yds-page-slot'; } if (slot.parentNode !== host) { host.appendChild(slot); } return slot; } function getPageControlsHost() { return document.querySelector(CONFIG.selectors.metadataActionButtons) || document.querySelector(CONFIG.selectors.metadataActions) || document.querySelector(CONFIG.selectors.metadataTopRow); } function uiMountedInBestHost() { if (!ui.launcher || !ui.launcher.isConnected) return false; var host = getPageControlsHost(); if (!host) return true; var slot = document.getElementById(CONFIG.ids.uiSlot); return !!slot && slot.parentNode === host && ui.launcher.parentNode === slot; } function syncUi() { syncTargetLanguageOptions(); syncSourceTrackOptions(); if (ui.displayMode) ui.displayMode.value = normalizeDisplayMode(state.displayMode); if (ui.targetLang) ui.targetLang.value = state.targetLang; if (ui.trackIndex) ui.trackIndex.value = String(state.sourceTrackIndex); if (ui.sourceName) ui.sourceName.textContent = app.lastSourceName || TEXT.unselected; if (ui.status) ui.status.textContent = app.status; if (ui.debugToggle) ui.debugToggle.checked = !!state.debug; if (ui.enabledBtn) ui.enabledBtn.textContent = state.enabled ? TEXT.disableDualSubs : TEXT.enableDualSubs; syncStyleControls(); applySubtitleStyle(); if (ui.debugBox) { ui.debugBox.hidden = !isDebugEnabled(state); ui.debugBox.textContent = formatDebugText(); } } function syncTargetLanguageOptions(force) { if (!ui.targetLang) return; var rawOptions = (app.translationLanguages || []).slice(); var query = ui.targetSearch ? normalizeSearchText(ui.targetSearch.value) : ''; var options = query ? rawOptions.filter(function (option) { return normalizeSearchText(option.languageCode + ' ' + option.name).indexOf(query) !== -1; }) : rawOptions; var hasCurrent = false; var i; for (i = 0; i < options.length; i++) { if (options[i].languageCode === state.targetLang) { hasCurrent = true; break; } } if (!hasCurrent) { options.unshift({ languageCode: state.targetLang, name: state.targetLang }); } var signature = options.map(function (option) { return option.languageCode + ':' + option.name; }).join('|') + '|q=' + query; if (!force && ui.targetLang.getAttribute('data-options-signature') === signature) return; ui.targetLang.textContent = ''; options.forEach(function (option) { var node = document.createElement('option'); node.value = option.languageCode; node.textContent = option.name + ' (' + option.languageCode + ')'; ui.targetLang.appendChild(node); }); ui.targetLang.setAttribute('data-options-signature', signature); } function syncSourceTrackOptions() { if (!ui.trackIndex) return; var tracks = app.tracks || []; var options = []; var i; for (i = 0; i < tracks.length; i++) { options.push({ index: i, label: formatTrackLabel(tracks[i], i) }); } if (!options.length) { options.push({ index: state.sourceTrackIndex, label: '#' + state.sourceTrackIndex }); } var signature = options.map(function (option) { return option.index + ':' + option.label; }).join('|'); if (ui.trackIndex.getAttribute('data-options-signature') === signature) return; ui.trackIndex.textContent = ''; options.forEach(function (option) { var node = document.createElement('option'); node.value = String(option.index); node.textContent = option.label; ui.trackIndex.appendChild(node); }); ui.trackIndex.setAttribute('data-options-signature', signature); } function getCurrentSourceTrack() { return app.tracks && app.tracks[state.sourceTrackIndex] ? app.tracks[state.sourceTrackIndex] : null; } function getSourceMemoryKey(track) { if (!track || !track.languageCode) return ''; return String(track.languageCode || '').trim(); } function rememberTargetForCurrentSource() { rememberTargetForTrack(getCurrentSourceTrack(), state.targetLang); } function rememberTargetForTrack(track, targetLang) { var key = getSourceMemoryKey(track); if (!key || !targetLang) return false; if (!state.targetLangBySource || typeof state.targetLangBySource !== 'object') state.targetLangBySource = {}; state.targetLangBySource[key] = String(targetLang); return true; } function applyRememberedTargetForTrack(track, fallbackToDefault) { var key = getSourceMemoryKey(track); var remembered = key && state.targetLangBySource ? state.targetLangBySource[key] : ''; if (!remembered && fallbackToDefault) remembered = DEFAULTS.targetLang; if (!remembered || remembered === state.targetLang) return false; state.targetLang = remembered; return true; } function syncStyleControls() { syncNumberRange(ui.sourceFontSize, state.sourceFontSize); syncNumberRange(ui.targetFontSize, state.targetFontSize); syncNumberRange(ui.lineGap, state.lineGap); syncNumberRange(ui.bottomOffset, state.bottomOffset); if (ui.fontFamily) ui.fontFamily.value = normalizeFontFamily(state.fontFamily); syncColor(ui.sourceColor, state.sourceColor); syncColor(ui.targetColor, state.targetColor); } function syncNumberRange(control, value) { if (!control) return; control.range.value = String(value); control.number.value = String(value); } function syncColor(control, value) { if (!control) return; control.input.value = normalizeColor(value, '#ffffff'); syncColorPreview(control.preview, control.input.value); } function syncColorPreview(node, value) { if (!node) return; node.style.color = normalizeColor(value, '#ffffff'); } function resetSubtitleStyle() { state.sourceFontSize = DEFAULTS.sourceFontSize; state.targetFontSize = DEFAULTS.targetFontSize; state.lineGap = DEFAULTS.lineGap; state.bottomOffset = DEFAULTS.bottomOffset; state.sourceColor = DEFAULTS.sourceColor; state.targetColor = DEFAULTS.targetColor; state.fontFamily = DEFAULTS.fontFamily; saveSettings(state); syncUi(); } function setDetachedClass(node, detached) { if (!node) return; node.classList.toggle('yds-detached', !!detached); } function clearInlinePosition(node) { if (!node) return; node.style.left = ''; node.style.top = ''; node.style.right = ''; node.style.bottom = ''; } function applyStoredPosition(node, position) { if (!node) return; if (!position || typeof position.left !== 'number' || typeof position.top !== 'number') { node.style.left = ''; node.style.top = ''; node.style.right = ''; node.style.bottom = ''; return; } node.style.left = position.left + 'px'; node.style.top = position.top + 'px'; node.style.right = 'auto'; node.style.bottom = 'auto'; } function enableDragging(node, handle, stateKey) { if (!node || !handle) return; var drag = null; function onPointerMove(event) { if (!drag) return; var nextLeft = clampToViewport(drag.startLeft + (event.clientX - drag.startX), node.offsetWidth, window.innerWidth); var nextTop = clampToViewport(drag.startTop + (event.clientY - drag.startY), node.offsetHeight, window.innerHeight); applyStoredPosition(node, { left: nextLeft, top: nextTop }); } function onPointerUp() { if (!drag) return; state[stateKey] = { left: parseFloat(node.style.left) || 0, top: parseFloat(node.style.top) || 0 }; saveSettings(state); drag = null; window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerUp); } handle.addEventListener('pointerdown', function (event) { if (event.button != null && event.button !== 0) return; if (isInteractiveTarget(event.target)) return; var rect = node.getBoundingClientRect(); drag = { startX: event.clientX, startY: event.clientY, startLeft: rect.left, startTop: rect.top }; window.addEventListener('pointermove', onPointerMove); window.addEventListener('pointerup', onPointerUp); window.addEventListener('pointercancel', onPointerUp); event.preventDefault(); }); app.teardown.push(function () { window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointerup', onPointerUp); window.removeEventListener('pointercancel', onPointerUp); }); } function setPhase(phase) { app.phase = phase; syncUi(); } function setStatus(text) { app.status = String(text || ''); syncUi(); logger.debug('status', { phase: app.phase, text: app.status }); } function setDualSubsEnabled(enabled, reason) { state.enabled = !!enabled; saveSettings(state); if (state.enabled) { reloadDualSubsSoon(reason || 'enabled'); return; } app.pendingLoadKey = ''; app.backoffUntil = 0; app.loading = false; app.cuesA = []; app.cuesB = []; stopLoop(); clearNativeCaptionWindow(); setPhase('disabled'); setStatus(TEXT.disabled); syncUi(); } function reloadDualSubsSoon(reason) { if (!state.enabled) { setDualSubsEnabled(false, reason || 'setting-change-disabled'); return; } app.pendingLoadKey = ''; app.backoffUntil = 0; app.cuesA = []; app.cuesB = []; stopLoop(); clearNativeCaptionWindow(); setPhase('load-start'); setStatus(TEXT.loading); if (!isWatchPage() || !getVideo() || !getPlayer()) { scheduleInit(reason || 'setting-change', 0); return; } loadDualSubs(true, reason || 'setting-change'); } function formatDebugText() { var snapshot = collectSnapshot(); return [ 'version=' + snapshot.version, 'page=' + snapshot.pageType, 'url=' + snapshot.url, 'videoId=' + (snapshot.videoId || '-'), 'phase=' + snapshot.phase, 'loading=' + snapshot.loading, 'enabled=' + snapshot.enabled, 'display-mode=' + snapshot.displayMode, 'caption-source=' + (snapshot.captionSource || '-'), 'default-track=' + snapshot.defaultTrackIndex, 'source=' + (snapshot.source || '-'), 'tracks=' + snapshot.tracks.length, 'cues=' + snapshot.cuesA + '/' + snapshot.cuesB, 'transcript-trigger=' + (snapshot.transcriptTrigger || '-'), 'fallback=' + (snapshot.fallback || '-'), 'fetch-source=' + (snapshot.fetch.source || '-'), 'fetch-target=' + (snapshot.fetch.target || '-'), 'dom=video:' + snapshot.dom.video + ',player:' + snapshot.dom.player + ',captions:' + snapshot.dom.captionContainer, 'injected=launcher:' + snapshot.dom.launcher + ',panel:' + snapshot.dom.panel + ',native:' + snapshot.dom.nativeWindow ].join('\n'); } function bindGlobalListeners() { ensureHistoryHook(); addWindowListener('yt-navigate-finish', function () { handleNavigation('yt-navigate-finish'); }); addWindowListener('yt-page-data-updated', function () { handleNavigation('yt-page-data-updated'); }); addWindowListener('ytp-history-navigate', function () { handleNavigation('ytp-history-navigate'); }); addWindowListener(CONFIG.historyEventName, function () { handleNavigation(CONFIG.historyEventName); }); addWindowListener('popstate', function () { handleNavigation('popstate'); }); addWindowListener('load', function () { handleNavigation('window-load'); }); app.timers.urlPoll = window.setInterval(function () { if (location.href !== app.lastUrl) { handleNavigation('url-poll'); return; } if (isWatchPage() && (!getVideo() || !getPlayer() || !uiMountedInBestHost())) { scheduleInit('url-poll-health', CONFIG.domDebounceMs); } }, CONFIG.routePollMs); var observationTarget = getObservationTarget(); if (typeof MutationObserver === 'function' && observationTarget) { app.observer = new MutationObserver(function (mutations) { if (!isWatchPage()) return; if (!hasRelevantMutation(mutations)) return; scheduleInit('dom-mutation', CONFIG.domDebounceMs); }); app.observer.observe(observationTarget, { childList: true, subtree: observationTarget !== document.body && observationTarget !== document.documentElement }); } } function addWindowListener(type, handler) { window.addEventListener(type, handler); app.teardown.push(function () { window.removeEventListener(type, handler); }); } function handleNavigation(reason) { logger.debug('navigation', { reason: reason, href: location.href }); scheduleInit(reason, CONFIG.initDelayMs); } function scheduleInit(reason, delayMs) { clearTimeout(app.timers.init); app.timers.init = window.setTimeout(function () { initForPage(reason); }, typeof delayMs === 'number' ? delayMs : CONFIG.initDelayMs); } function initForPage(reason) { app.lastUrl = location.href; mountUi(); exposeDebugApi(); if (!isWatchPage()) { resetForNonWatch(); return; } var videoId = getVideoId(); if (videoId !== app.lastVideoId) { resetVideoState(videoId, reason); } if (!getVideo() || !getPlayer()) { setPhase('wait-player'); setStatus(TEXT.waitingPlayer); scheduleInit('wait-player', CONFIG.retryDelayMs); return; } loadDualSubs(false, reason || 'init'); } function resetForNonWatch() { app.activeRequestId += 1; app.loading = false; app.defaultTrackIndex = -1; app.lastCaptionSource = ''; app.lastFallback = ''; app.lastVideoId = ''; app.lastSourceName = ''; app.cuesA = []; app.cuesB = []; clearFetchDiagnostics(); app.tracks = []; app.trackRetryCount = 0; app.pendingLoadKey = ''; app.translationLanguages = []; setPhase('idle'); setStatus(TEXT.waitingWatchPage); stopLoop(); clearNativeCaptionWindow(); } function resetVideoState(videoId, reason) { logger.debug('reset video state', { from: app.lastVideoId || '', to: videoId || '', reason: reason || 'unknown' }); app.activeRequestId += 1; app.loading = false; app.defaultTrackIndex = -1; app.lastCaptionSource = ''; app.lastFallback = ''; app.lastVideoId = videoId || ''; app.lastSourceName = ''; app.cuesA = []; app.cuesB = []; clearFetchDiagnostics(); app.tracks = []; app.trackRetryCount = 0; app.pendingLoadKey = ''; app.translationLanguages = []; stopLoop(); clearNativeCaptionWindow(); syncUi(); } function loadDualSubs(force, reason) { if (!isWatchPage()) return; if (!state.enabled) { app.loading = false; app.pendingLoadKey = ''; app.cuesA = []; app.cuesB = []; stopLoop(); clearNativeCaptionWindow(); setPhase('disabled'); setStatus(TEXT.disabled); return; } var videoId = getVideoId(); var loadKey = [videoId, state.targetLang, state.sourceTrackIndex].join('|'); if (!force && !app.loading && app.pendingLoadKey === loadKey && (app.cuesA.length || app.cuesB.length)) { logger.debug('skip completed load', { reason: reason, loadKey: loadKey }); if (!app.loopId) startLoop(); return; } if (!force && app.loading && app.pendingLoadKey === loadKey) { logger.debug('skip duplicate load', { reason: reason, loadKey: loadKey }); return; } var now = Date.now(); if (!force && app.backoffUntil && now < app.backoffUntil) { setPhase('backoff'); setStatus(TEXT.rateLimitedShort); return; } app.pendingLoadKey = loadKey; app.loading = true; app.activeRequestId += 1; var requestId = app.activeRequestId; clearFetchDiagnostics(); app.lastFallback = ''; ensureCaptionsEnabled(); setPhase('load-start'); setStatus(TEXT.loading); getBestCaptionData(videoId).then(function (captionData) { if (!isActiveRequest(requestId, videoId)) return; var tracks = captionData.tracks || []; app.tracks = tracks; app.defaultTrackIndex = typeof captionData.defaultTrackIndex === 'number' ? captionData.defaultTrackIndex : -1; app.lastCaptionSource = captionData.source || ''; app.lastFallback = ''; app.translationLanguages = getTranslationLanguages(captionData.playerResponse, tracks, null); logger.debug('caption tracks resolved', { loadKey: loadKey, source: captionData.source, tracks: tracks.length }); var selected = chooseTrack(tracks, state.sourceTrackIndex, state.targetLang); if (!selected.track) { app.loading = false; app.cuesA = []; app.cuesB = []; app.lastSourceName = TEXT.noTrack; stopLoop(); if (!tracks.length && app.trackRetryCount < CONFIG.maxTrackRetries) { app.trackRetryCount += 1; setPhase('wait-tracks'); setStatus(TEXT.waitingTracks + ' (' + app.trackRetryCount + '/' + CONFIG.maxTrackRetries + ')'); scheduleInit('wait-tracks', CONFIG.retryDelayMs); } else { setPhase('no-tracks'); setStatus(TEXT.noTrackDetail); } syncUi(); return null; } app.trackRetryCount = 0; applyRememberedTargetForTrack(selected.track); state.sourceTrackIndex = selected.index; rememberTargetForTrack(selected.track, state.targetLang); saveSettings(state); app.pendingLoadKey = [videoId, state.targetLang, state.sourceTrackIndex].join('|'); app.lastSourceName = formatTrackLabel(selected.track, selected.index); app.translationLanguages = getTranslationLanguages(captionData.playerResponse, tracks, selected.track); syncUi(); logger.debug('track pair', { sourceLang: selected.track.languageCode || '', targetLang: state.targetLang, targetMode: 'translated' }); return fetchBestPair(selected.track, null, state.targetLang).then(function (result) { if (!isActiveRequest(requestId, videoId)) return; if (result.fallback) { app.lastFallback = app.lastFallback ? app.lastFallback + ' | ' + result.fallback : result.fallback; } if (!result.cuesA.length && !result.cuesB.length && canUseDefaultTrackFallback(selected.track, tracks[captionData.defaultTrackIndex], captionData.defaultTrackIndex, selected.index)) { var fallbackTrack = tracks[captionData.defaultTrackIndex]; app.lastFallback = 'default-track:' + selected.index + '->' + captionData.defaultTrackIndex; logger.debug('retry with default caption track', { from: selected.index, to: captionData.defaultTrackIndex, targetLang: state.targetLang }); return fetchBestPair(fallbackTrack, null, state.targetLang).then(function (fallbackResult) { if (!isActiveRequest(requestId, videoId)) return; if (fallbackResult.fallback) { app.lastFallback = app.lastFallback ? app.lastFallback + ' | ' + fallbackResult.fallback : fallbackResult.fallback; } if (fallbackResult.cuesA.length || fallbackResult.cuesB.length) { app.lastSourceName = formatTrackLabel(fallbackTrack, captionData.defaultTrackIndex) + ' [fallback]'; syncUi(); return applyLoadedCues(fallbackResult); } app.lastFallback = app.lastFallback + ':empty'; return applyLoadedCues(result); }); } return applyLoadedCues(result); }); }).catch(function (err) { if (!isActiveRequest(requestId, videoId)) return; app.cuesA = []; app.cuesB = []; stopLoop(); if (err && err.status === 429) { app.backoffUntil = Date.now() + CONFIG.rateLimitBackoffMs; setPhase('backoff'); setStatus(TEXT.rateLimited); } else { setPhase('load-error'); setStatus(TEXT.loadFailed + formatError(err)); } logger.error('loadDualSubs failed', err); }).finally(function () { if (!isRequestCurrent(requestId)) return; app.loading = false; syncUi(); }); function applyLoadedCues(result) { if (!isActiveRequest(requestId, videoId)) return; app.backoffUntil = 0; app.cuesA = result.cuesA || []; app.cuesB = result.cuesB || []; if (!app.cuesA.length && !app.cuesB.length) { setPhase('no-cues'); setStatus(TEXT.noCue); stopLoop(); return; } setPhase('ready'); if (!app.cuesB.length) { setStatus(TEXT.sourceOnly); } else { setStatus(TEXT.nativeReady); } startLoop(); } } function isRequestCurrent(requestId) { return requestId === app.activeRequestId; } function isActiveRequest(requestId, videoId) { return isRequestCurrent(requestId) && videoId === getVideoId() && isWatchPage(); } function startLoop() { stopLoop(); function tick() { if (!isWatchPage()) { stopLoop(); return; } var video = getVideo(); if (!video) { app.loopId = requestAnimationFrame(tick); return; } renderCurrentCaption(); app.loopId = requestAnimationFrame(tick); } app.loopId = requestAnimationFrame(tick); } function renderCurrentCaption() { if (!state.enabled) { clearNativeCaptionWindow(); return; } var video = getVideo(); if (!video) return; var mode = normalizeDisplayMode(state.displayMode); var textA = mode === 'target' ? '' : findCueText(app.cuesA, video.currentTime); var textB = mode === 'source' ? '' : findCueText(app.cuesB, video.currentTime); renderNativeCaption(textA, textB); } function stopLoop() { if (app.loopId) cancelAnimationFrame(app.loopId); app.loopId = 0; clearNativeCaptionWindow(); } function exposeDebugApi() { var pageWindow = getPageWindow(); pageWindow[DEBUG_API_KEY] = { runtime: api, reload: function () { reloadDualSubsSoon('debug-reload'); }, scheduleInit: function (reason) { scheduleInit(reason || 'debug', 0); }, setDebug: function (enabled) { state.debug = !!enabled; saveSettings(state); syncUi(); return collectSnapshot(); }, setEnabled: function (enabled) { setDualSubsEnabled(!!enabled, 'debug-set-enabled'); return collectSnapshot(); }, snapshot: function () { return collectSnapshot(); } }; } function collectSnapshot() { var tracks = []; var i; for (i = 0; i < app.tracks.length; i++) { tracks.push({ index: i, lang: app.tracks[i].languageCode || '', name: getTrackName(app.tracks[i]), hasBaseUrl: !!app.tracks[i].baseUrl }); } return { captionSource: app.lastCaptionSource, cuesA: app.cuesA.length, cuesB: app.cuesB.length, defaultTrackIndex: app.defaultTrackIndex, dom: { captionContainer: !!getCaptionContainer(), launcher: !!document.getElementById(CONFIG.ids.launcher), nativeWindow: !!document.getElementById(CONFIG.ids.nativeWindow), panel: !!document.getElementById(CONFIG.ids.panel), player: !!getPlayer(), video: !!getVideo() }, fetch: { source: fetchDiagnostics.source, target: fetchDiagnostics.target }, loading: app.loading, fallback: app.lastFallback, nativeTimedTextHint: describeNativeTimedTextHint(), pageType: isWatchPage() ? 'watch' : 'other', phase: app.phase, source: app.lastSourceName, status: app.status, enabled: state.enabled, displayMode: state.displayMode, smartPosition: state.smartPosition, syncNativeStyle: state.syncNativeStyle, transcriptTrigger: describeTranscriptTrigger(), tracks: tracks, targetLang: state.targetLang, translationLanguages: app.translationLanguages, url: location.href, version: SCRIPT_VERSION, videoId: getVideoId() }; } var api = { boot: boot, destroy: destroy, snapshot: collectSnapshot }; return api; } function loadSettings() { var stored = GM_getValue(SETTINGS_KEY, {}); var merged = {}; var key; for (key in DEFAULTS) merged[key] = DEFAULTS[key]; if (stored && typeof stored === 'object') { for (key in stored) merged[key] = stored[key]; } return normalizeSettings(merged); } function saveSettings(nextState) { var normalized = normalizeSettings(nextState); GM_setValue(SETTINGS_KEY, { debug: !!normalized.debug, enabled: !!normalized.enabled, panelOpen: false, launcherPosition: normalizePosition(normalized.launcherPosition), panelPosition: normalizePosition(normalized.panelPosition), sourceTrackIndex: normalized.sourceTrackIndex, targetLang: normalized.targetLang, targetLangBySource: normalized.targetLangBySource, displayMode: normalized.displayMode, sourceFontSize: normalized.sourceFontSize, targetFontSize: normalized.targetFontSize, lineGap: normalized.lineGap, bottomOffset: normalized.bottomOffset, sourceColor: normalized.sourceColor, targetColor: normalized.targetColor, fontFamily: normalized.fontFamily, smartPosition: true, syncNativeStyle: true }); } function normalizeSettings(input) { var output = {}; var key; input = input || {}; for (key in DEFAULTS) output[key] = DEFAULTS[key]; for (key in input) output[key] = input[key]; output.debug = !!output.debug; output.enabled = output.enabled !== false; output.panelOpen = false; output.launcherPosition = normalizePosition(output.launcherPosition); output.panelPosition = normalizePosition(output.panelPosition); output.sourceTrackIndex = Math.max(0, parseInt(output.sourceTrackIndex || '0', 10) || 0); output.targetLang = String(output.targetLang || DEFAULTS.targetLang).trim() || DEFAULTS.targetLang; output.targetLangBySource = normalizeTargetLangBySource(output.targetLangBySource); output.displayMode = normalizeDisplayMode(output.displayMode); output.sourceFontSize = clampNumber(parseFloat(output.sourceFontSize), 16, 56, DEFAULTS.sourceFontSize); output.targetFontSize = clampNumber(parseFloat(output.targetFontSize), 16, 56, DEFAULTS.targetFontSize); output.lineGap = clampNumber(parseFloat(output.lineGap), 0, 24, DEFAULTS.lineGap); output.bottomOffset = clampNumber(parseFloat(output.bottomOffset), 2, 28, DEFAULTS.bottomOffset); output.sourceColor = normalizeColor(output.sourceColor, DEFAULTS.sourceColor); output.targetColor = normalizeColor(output.targetColor, DEFAULTS.targetColor); output.fontFamily = normalizeFontFamily(output.fontFamily); output.smartPosition = true; output.syncNativeStyle = true; return output; } function normalizeTargetLangBySource(value) { var output = {}; var key; if (!value || typeof value !== 'object') return output; for (key in value) { if (!Object.prototype.hasOwnProperty.call(value, key)) continue; var source = String(key || '').trim(); var target = String(value[key] || '').trim(); if (source && target) output[source] = target; } return output; } function normalizePosition(position) { if (!position || typeof position.left !== 'number' || typeof position.top !== 'number') return null; return { left: Math.max(0, Math.round(position.left)), top: Math.max(0, Math.round(position.top)) }; } function clampNumber(value, min, max, fallback) { if (!isFinite(value)) return fallback; if (value < min) return min; if (value > max) return max; return Math.round(value); } function normalizeColor(value, fallback) { var text = String(value || '').trim(); if (/^#[0-9a-f]{6}$/i.test(text)) return text.toLowerCase(); return fallback; } function normalizeSearchText(value) { return String(value || '').toLowerCase().replace(/\s+/g, ' ').trim(); } function normalizeFontFamily(value) { var text = String(value || '').trim(); var i; for (i = 0; i < FONT_OPTIONS.length; i++) { if (FONT_OPTIONS[i].value === text) return text; } return DEFAULTS.fontFamily; } function normalizeDisplayMode(value) { var text = String(value || '').trim(); if (text === 'source' || text === 'target' || text === 'dual') return text; return DEFAULTS.displayMode; } function getFontCss(value) { var normalized = normalizeFontFamily(value); var i; for (i = 0; i < FONT_OPTIONS.length; i++) { if (FONT_OPTIONS[i].value === normalized) return FONT_OPTIONS[i].css; } return FONT_OPTIONS[0].css; } function clearFetchDiagnostics() { fetchDiagnostics.source = ''; fetchDiagnostics.target = ''; } function setFetchDiagnostic(label, value) { fetchDiagnostics[label] = value; } function appendFetchDiagnostic(label, value) { fetchDiagnostics[label] = fetchDiagnostics[label] ? fetchDiagnostics[label] + ' | ' + value : value; } function createLogger(isEnabled) { function emit(level, message, meta) { var fn = console[level] || console.log; if (typeof meta === 'undefined') { fn.call(console, LOG_PREFIX + ' ' + message); } else { fn.call(console, LOG_PREFIX + ' ' + message, meta); } } return { debug: function (message, meta) { if (!isEnabled()) return; emit('debug', message, meta); }, error: function (message, meta) { emit('error', message, meta); } }; } function isDebugEnabled(currentState) { return !!currentState.debug || location.search.indexOf(CONFIG.debugQueryParam) !== -1; } function isWatchPage() { return location.pathname === CONFIG.selectors.watchPath; } function getRoot() { return document.body || document.documentElement; } function getPageWindow() { return typeof unsafeWindow !== 'undefined' ? unsafeWindow : window; } function getPageFetch() { var pageWindow = getPageWindow(); if (pageWindow && typeof pageWindow.fetch === 'function') { return pageWindow.fetch.bind(pageWindow); } if (typeof fetch === 'function') { return fetch.bind(window); } return null; } function installTimedTextObserver() { var pageWindow = getPageWindow(); var observerKey = '__ydsTimedTextObserver'; if (!pageWindow || pageWindow[observerKey]) return; pageWindow[observerKey] = { installedAt: Date.now() }; try { if (typeof pageWindow.fetch === 'function') { var originalFetch = pageWindow.fetch; pageWindow.fetch = function () { rememberRequestTimedTextUrl(arguments[0]); return originalFetch.apply(this, arguments); }; } } catch (err) { logger.error('fetch observer install failed', err); } try { if (pageWindow.XMLHttpRequest && pageWindow.XMLHttpRequest.prototype) { var originalOpen = pageWindow.XMLHttpRequest.prototype.open; pageWindow.XMLHttpRequest.prototype.open = function (method, url) { rememberRequestTimedTextUrl(url); return originalOpen.apply(this, arguments); }; } } catch (xhrErr) { logger.error('xhr observer install failed', xhrErr); } } function rememberRequestTimedTextUrl(input) { try { if (!input) return; var url = typeof input === 'string' ? input : (input.url || String(input)); rememberNativeTimedTextUrl(url); } catch (err) { logger.debug('remember timedtext request failed', err); } } function collectNativeTimedTextHints() { try { if (!window.performance || typeof window.performance.getEntriesByType !== 'function') return; var entries = window.performance.getEntriesByType('resource') || []; var start = Math.max(0, entries.length - 80); var i; for (i = start; i < entries.length; i++) { rememberNativeTimedTextUrl(entries[i] && entries[i].name); } } catch (err) { logger.debug('performance timedtext scan failed', err); } } function rememberNativeTimedTextUrl(rawUrl) { if (!rawUrl || String(rawUrl).indexOf('/api/timedtext') === -1) return; var parsed; try { parsed = new URL(rawUrl, location.href); } catch (err) { return; } if (parsed.hostname !== 'www.youtube.com' && parsed.hostname !== 'youtube.com') return; var videoId = parsed.searchParams.get('v') || ''; if (!videoId) return; var params = extractNativeTimedTextParams(parsed); if (!params) return; resetNativeTimedTextHints(videoId); var hint = { lang: parsed.searchParams.get('lang') || '', params: params, source: 'native-request', updatedAt: Date.now() }; nativeTimedTextHints.last = hint; if (hint.lang) nativeTimedTextHints.byLang[hint.lang] = hint; } function resetNativeTimedTextHints(videoId) { if (nativeTimedTextHints.videoId === videoId) return; nativeTimedTextHints.videoId = videoId; nativeTimedTextHints.byLang = {}; nativeTimedTextHints.last = null; } function extractNativeTimedTextParams(url) { var params = {}; var hasHint = false; var i; for (i = 0; i < CONFIG.nativeTimedTextParamKeys.length; i++) { var key = CONFIG.nativeTimedTextParamKeys[i]; if (!url.searchParams.has(key)) continue; params[key] = url.searchParams.get(key); hasHint = true; } if (!hasHint || !params.pot) return null; return params; } function describeNativeTimedTextHint() { var hint = getNativeTimedTextHint(null); if (!hint || !hint.params) return null; var keys = []; var key; for (key in hint.params) keys.push(key); return { ageMs: Math.max(0, Date.now() - hint.updatedAt), keys: keys, lang: hint.lang || '', videoId: nativeTimedTextHints.videoId || '' }; } function getNativeTimedTextHint(track) { collectNativeTimedTextHints(); var currentVideoId = getVideoId(); if (currentVideoId && nativeTimedTextHints.videoId && nativeTimedTextHints.videoId !== currentVideoId) { resetNativeTimedTextHints(currentVideoId); } if (!track) return nativeTimedTextHints.last; var languageCode = track.languageCode || ''; if (languageCode && nativeTimedTextHints.byLang[languageCode]) return nativeTimedTextHints.byLang[languageCode]; var prefix = languageCode ? String(languageCode).split('-')[0] : ''; var key; if (prefix) { for (key in nativeTimedTextHints.byLang) { if (String(key || '').split('-')[0] === prefix) return nativeTimedTextHints.byLang[key]; } } return nativeTimedTextHints.last; } function waitForNativeTimedTextHint(track) { if (getNativeTimedTextHint(track)) return Promise.resolve(true); if (window.__ydsHarnessShimInstalled) return Promise.resolve(false); return waitFor(function () { return getNativeTimedTextHint(track); }, CONFIG.nativeTimedTextHintWaitMs, 120).then(function (hint) { return !!hint; }); } function getBrowserLikeUserAgent() { try { return navigator && navigator.userAgent ? navigator.userAgent : 'Mozilla/5.0'; } catch (err) { return 'Mozilla/5.0'; } } function buildInnertubeContext() { return { client: { clientName: 'WEB', clientVersion: getInnertubeClientVersion(), hl: document.documentElement && document.documentElement.lang ? document.documentElement.lang : 'zh-CN', visitorData: getInnertubeVisitorData() } }; } function buildInnertubeHeaders() { var headers = { 'Content-Type': 'application/json', 'X-YouTube-Client-Name': '1', 'X-YouTube-Client-Version': getInnertubeClientVersion() }; var visitorData = getInnertubeVisitorData(); if (visitorData) headers['X-Goog-Visitor-Id'] = visitorData; return headers; } function getVideoId() { return new URLSearchParams(location.search).get('v') || ''; } function getVideo() { return document.querySelector(CONFIG.selectors.rootVideo); } function getPlayer() { return document.querySelector(CONFIG.selectors.player); } function getCaptionContainer() { return document.querySelector(CONFIG.selectors.captionContainer); } function getObservationTarget() { return document.querySelector('#content') || document.querySelector('#page-manager') || document.body || document.documentElement; } function ensureHistoryHook() { if (window.__ydsHistoryHookInstalled) return; window.__ydsHistoryHookInstalled = true; function dispatchHistoryChange(source) { try { window.dispatchEvent(new CustomEvent(CONFIG.historyEventName, { detail: { href: location.href, source: source } })); } catch (err) { logger.error('history hook dispatch failed', err); } } function patchHistoryMethod(name) { if (!history || typeof history[name] !== 'function') return; var original = history[name]; history[name] = function () { var result = original.apply(this, arguments); window.setTimeout(function () { dispatchHistoryChange(name); }, 0); return result; }; } patchHistoryMethod('pushState'); patchHistoryMethod('replaceState'); } function ensureCaptionsEnabled() { var btn = document.querySelector(CONFIG.selectors.subtitlesButton); if (!btn) return; if (btn.getAttribute('aria-pressed') === 'false') btn.click(); } function clearNativeCaptionWindow() { var container = getCaptionContainer(); if (container) container.classList.remove('yds-native-mode'); var node = document.getElementById(CONFIG.ids.nativeWindow); if (node) node.remove(); } function ensureNativeCaptionWindow() { var container = getCaptionContainer(); var player = getPlayer(); if (!player) return null; if (container) container.classList.add('yds-native-mode'); var node = document.getElementById(CONFIG.ids.nativeWindow); if (!node) { node = document.createElement('div'); node.id = CONFIG.ids.nativeWindow; node.className = 'yds-native-window'; var lineA = document.createElement('div'); lineA.className = 'yds-native-line yds-native-line-a'; var lineB = document.createElement('div'); lineB.className = 'yds-native-line yds-native-line-b'; node.appendChild(lineA); node.appendChild(lineB); player.appendChild(node); } else if (node.parentNode !== player) { player.appendChild(node); } applySubtitleStyle(node); return node; } function applySubtitleStyle(node) { node = node || document.getElementById(CONFIG.ids.nativeWindow); if (!node) return; var lineA = node.querySelector('.yds-native-line-a'); var lineB = node.querySelector('.yds-native-line-b'); var nativeStyle = state.syncNativeStyle ? getNativeCaptionStyleHint() : null; var useNativeFont = nativeStyle && nativeStyle.fontFamily && normalizeFontFamily(state.fontFamily) === DEFAULTS.fontFamily; var useNativeSourceSize = nativeStyle && nativeStyle.fontSize && Number(state.sourceFontSize) === DEFAULTS.sourceFontSize; var useNativeTargetSize = nativeStyle && nativeStyle.fontSize && Number(state.targetFontSize) === DEFAULTS.targetFontSize; var useNativeSourceColor = nativeStyle && nativeStyle.color && normalizeColor(state.sourceColor, DEFAULTS.sourceColor) === DEFAULTS.sourceColor; var fontCss = useNativeFont ? nativeStyle.fontFamily : getFontCss(state.fontFamily); var sourceFontSize = useNativeSourceSize ? nativeStyle.fontSize : clampNumber(parseFloat(state.sourceFontSize), 16, 56, DEFAULTS.sourceFontSize) + 'px'; var targetFontSize = useNativeTargetSize ? nativeStyle.fontSize : clampNumber(parseFloat(state.targetFontSize), 16, 56, DEFAULTS.targetFontSize) + 'px'; node.style.bottom = getSubtitleBottomPercent() + '%'; if (lineA) { lineA.style.fontFamily = fontCss; lineA.style.fontSize = sourceFontSize; lineA.style.fontWeight = nativeStyle && nativeStyle.fontWeight ? nativeStyle.fontWeight : '600'; lineA.style.color = useNativeSourceColor ? nativeStyle.color : normalizeColor(state.sourceColor, DEFAULTS.sourceColor); applyNativeBackgroundStyle(lineA, nativeStyle); } if (lineB) { lineB.style.fontFamily = fontCss; lineB.style.fontSize = targetFontSize; lineB.style.fontWeight = nativeStyle && nativeStyle.fontWeight ? nativeStyle.fontWeight : '600'; lineB.style.color = nativeStyle && nativeStyle.color && state.displayMode === 'target' ? nativeStyle.color : normalizeColor(state.targetColor, DEFAULTS.targetColor); applyNativeBackgroundStyle(lineB, nativeStyle); lineB.style.marginTop = lineA && lineA.style.display !== 'none' && lineB.style.display !== 'none' ? clampNumber(parseFloat(state.lineGap), 0, 24, DEFAULTS.lineGap) + 'px' : '0'; } } function getSubtitleBottomPercent() { var base = clampNumber(parseFloat(state.bottomOffset), 2, 28, DEFAULTS.bottomOffset); var player = getPlayer(); var controls = player ? player.querySelector(CONFIG.selectors.playerControls) : null; if (!player || !controls || !player.clientHeight) return base; var style = window.getComputedStyle(controls); var controlsVisible = !player.classList.contains('ytp-autohide') && style.display !== 'none' && style.visibility !== 'hidden' && controls.offsetHeight > 0; if (!controlsVisible) return base; var controlsPercent = Math.ceil((controls.offsetHeight / player.clientHeight) * 100) + 3; return Math.max(base, Math.min(28, controlsPercent)); } function getNativeCaptionStyleHint() { var node = document.querySelector(CONFIG.selectors.nativeCaptionText); if (!node) return null; var style = window.getComputedStyle(node); var fontSize = parseFloat(style.fontSize); var hint = {}; if (fontSize >= 16 && fontSize <= 72) hint.fontSize = Math.round(fontSize) + 'px'; if (style.fontFamily) hint.fontFamily = style.fontFamily; if (style.fontWeight) hint.fontWeight = style.fontWeight; if (style.color && style.color !== 'rgba(0, 0, 0, 0)') hint.color = style.color; if (isVisibleBackgroundColor(style.backgroundColor)) hint.backgroundColor = style.backgroundColor; return hint; } function applyNativeBackgroundStyle(line, nativeStyle) { if (nativeStyle && nativeStyle.backgroundColor) { line.style.backgroundColor = nativeStyle.backgroundColor; line.style.borderRadius = '2px'; line.style.padding = '0 4px'; } else { line.style.backgroundColor = ''; line.style.borderRadius = ''; line.style.padding = ''; } } function isVisibleBackgroundColor(value) { var text = String(value || '').trim(); if (!text || text === 'transparent') return false; if (/rgba\([^)]*,\s*0\)$/i.test(text)) return false; return true; } function renderNativeCaption(textA, textB) { if (!textA && !textB) { clearNativeCaptionWindow(); return; } var node = ensureNativeCaptionWindow(); if (!node) return; var lineA = node.querySelector('.yds-native-line-a'); var lineB = node.querySelector('.yds-native-line-b'); if (!lineA || !lineB) return; lineA.textContent = textA || ''; lineB.textContent = textB || ''; lineA.style.display = textA ? 'block' : 'none'; lineB.style.display = textB ? 'block' : 'none'; applySubtitleStyle(node); } function hasRelevantMutation(mutations) { var selector = [ CONFIG.selectors.rootVideo, CONFIG.selectors.player, CONFIG.selectors.captionContainer, CONFIG.selectors.metadataTopRow, CONFIG.selectors.metadataActions, '#' + CONFIG.ids.uiSlot, '#' + CONFIG.ids.launcher, '#' + CONFIG.ids.panel ].join(','); var i; for (i = 0; i < mutations.length; i++) { if (mutationHasRelevantNode(mutations[i].addedNodes, selector)) return true; if (mutationHasRelevantNode(mutations[i].removedNodes, selector)) return true; } return false; } function mutationHasRelevantNode(nodeList, selector) { var i; for (i = 0; i < nodeList.length; i++) { var node = nodeList[i]; if (!node || node.nodeType !== 1) continue; if (matchesSelector(node, selector)) return true; if (typeof node.querySelector === 'function' && node.querySelector(selector)) return true; } return false; } function matchesSelector(node, selector) { var matcher = node.matches || node.msMatchesSelector || node.webkitMatchesSelector; return !!matcher && matcher.call(node, selector); } function isInteractiveTarget(target) { if (!target || typeof target.closest !== 'function') return false; return !!target.closest('button,input,textarea,select,a,label'); } function clampToViewport(value, size, max) { var safeMax = Math.max(0, (max || 0) - (size || 0)); if (value < 0) return 0; if (value > safeMax) return safeMax; return value; } function clampIndex(index, length) { if (!length) return 0; if (index < 0) return 0; if (index >= length) return length - 1; return index; } function buildTimedTextUrl(baseUrl, params) { var url = new URL(baseUrl, location.href); var key; for (key in params) { if (params[key] == null || params[key] === '') { url.searchParams.delete(key); } else { url.searchParams.set(key, params[key]); } } return url.toString(); } function findCueText(cues, time) { var left = 0; var right = cues.length - 1; while (left <= right) { var mid = (left + right) >> 1; var cue = cues[mid]; if (time < cue.start) { right = mid - 1; } else if (time > cue.end) { left = mid + 1; } else { return cue.text; } } return ''; } function chooseTrack(tracks, preferredIndex, targetLang) { if (!tracks.length) return { index: 0, track: null }; var index = clampIndex(preferredIndex, tracks.length); var track = getUsableTrack(tracks[index]) ? tracks[index] : null; if (!track) { index = findTrackIndex(tracks, function (item) { return getUsableTrack(item); }); track = index === -1 ? null : tracks[index]; } if (track && track.languageCode === targetLang) { var altIndex = findTrackIndex(tracks, function (item, itemIndex) { return itemIndex !== index && getUsableTrack(item) && item.languageCode !== targetLang; }); if (altIndex !== -1) { index = altIndex; track = tracks[altIndex]; } } return { index: index < 0 ? 0 : index, track: track }; } function findTrackIndex(tracks, predicate) { var i; for (i = 0; i < tracks.length; i++) { if (predicate(tracks[i], i)) return i; } return -1; } function findTrackByLanguage(tracks, languageCode, excludeIndex) { if (!languageCode) return null; var exactIndex = findTrackIndex(tracks, function (track, index) { return index !== excludeIndex && getUsableTrack(track) && track.languageCode === languageCode; }); if (exactIndex !== -1) return tracks[exactIndex]; var prefix = String(languageCode).split('-')[0]; var prefixIndex = findTrackIndex(tracks, function (track, index) { return index !== excludeIndex && getUsableTrack(track) && String(track.languageCode || '').split('-')[0] === prefix; }); return prefixIndex === -1 ? null : tracks[prefixIndex]; } function canUseDefaultTrackFallback(selectedTrack, fallbackTrack, fallbackIndex, selectedIndex) { if (fallbackIndex < 0 || fallbackIndex === selectedIndex || !getUsableTrack(fallbackTrack)) return false; if (!selectedTrack) return true; var selectedLang = selectedTrack.languageCode || ''; var fallbackLang = fallbackTrack.languageCode || ''; if (!selectedLang || !fallbackLang) return true; return String(selectedLang).split('-')[0] === String(fallbackLang).split('-')[0]; } function getUsableTrack(track) { return track && track.baseUrl ? track : null; } function getTrackName(track) { if (!track) return 'track'; return track.name && track.name.simpleText ? track.name.simpleText : (track.vssId || 'track'); } function formatTrackLabel(track, index) { return '#' + index + ' ' + getTrackName(track) + ' (' + (track.languageCode || '') + ')'; } function getPlayerResponse() { var pageWindow = getPageWindow(); var player = getPlayer(); try { if (player && typeof player.getPlayerResponse === 'function') { var direct = player.getPlayerResponse(); if (direct) return direct; } } catch (err) { logger.error('getPlayerResponse failed', err); } if (pageWindow.ytInitialPlayerResponse) return pageWindow.ytInitialPlayerResponse; return extractPlayerResponseFromHtml(document.documentElement ? document.documentElement.innerHTML : ''); } function getCaptionRenderer(playerResponse) { if (!playerResponse || !playerResponse.captions) return null; return playerResponse.captions.playerCaptionsTracklistRenderer || null; } function getCaptionTracks(playerResponse) { var renderer = getCaptionRenderer(playerResponse); return renderer && renderer.captionTracks ? renderer.captionTracks : []; } function getTranslationLanguages(playerResponse, tracks, sourceTrack) { var renderer = getCaptionRenderer(playerResponse); var raw = []; var i; if (renderer && renderer.translationLanguages) raw = raw.concat(renderer.translationLanguages); if (sourceTrack && sourceTrack.translationLanguages) raw = raw.concat(sourceTrack.translationLanguages); for (i = 0; i < (tracks || []).length; i++) { if (tracks[i] && tracks[i].translationLanguages) raw = raw.concat(tracks[i].translationLanguages); } return normalizeTranslationLanguages(raw, sourceTrack); } function normalizeTranslationLanguages(raw, sourceTrack) { var seen = {}; var result = []; var sourceLang = sourceTrack && sourceTrack.languageCode ? String(sourceTrack.languageCode) : ''; var i; for (i = 0; i < (raw || []).length; i++) { var language = raw[i] || {}; var code = String(language.languageCode || language.lang || language.value || '').trim(); if (!code || seen[code]) continue; if (sourceLang && code === sourceLang) continue; seen[code] = true; result.push({ languageCode: code, name: readTranslationLanguageName(language) || code }); } return result; } function readTranslationLanguageName(language) { if (!language) return ''; return readText(language.languageName) || readText(language.name) || readText(language.label) || String(language.displayName || language.title || '').trim(); } function readText(value) { if (!value) return ''; if (typeof value === 'string') return value.trim(); if (value.simpleText) return String(value.simpleText).trim(); if (value.runs && value.runs.length) { return value.runs.map(function (run) { return run && run.text ? run.text : ''; }).join('').trim(); } return ''; } function getDefaultCaptionTrackIndex(playerResponse) { var renderer = getCaptionRenderer(playerResponse); var audioTracks = renderer && renderer.audioTracks ? renderer.audioTracks : []; if (!audioTracks.length) return -1; var index = audioTracks[0] && typeof audioTracks[0].defaultCaptionTrackIndex === 'number' ? audioTracks[0].defaultCaptionTrackIndex : -1; return index; } function getBestCaptionData(videoId) { var playerResponse = getPlayerResponse(); var tracks = getCaptionTracks(playerResponse); if (tracks.length) { return Promise.resolve({ defaultTrackIndex: getDefaultCaptionTrackIndex(playerResponse), playerResponse: playerResponse, source: 'page', tracks: tracks }); } return fetchPlayerResponseFromYoutubei(videoId).then(function (remoteResponse) { return { defaultTrackIndex: getDefaultCaptionTrackIndex(remoteResponse), playerResponse: remoteResponse, source: 'youtubei', tracks: getCaptionTracks(remoteResponse) }; }).catch(function (err) { logger.error('youtubei player fallback failed', err); return { defaultTrackIndex: getDefaultCaptionTrackIndex(playerResponse), playerResponse: playerResponse, source: 'page-fallback', tracks: tracks }; }); } function getInnertubeClientVersion() { try { var pageWindow = getPageWindow(); if (pageWindow.ytcfg && typeof pageWindow.ytcfg.get === 'function') { return pageWindow.ytcfg.get('INNERTUBE_CLIENT_VERSION') || pageWindow.ytcfg.get('INNERTUBE_CONTEXT_CLIENT_VERSION') || '2.20250312.04.00'; } } catch (err) { logger.error('getInnertubeClientVersion failed', err); } return '2.20250312.04.00'; } function getInnertubeVisitorData() { try { var pageWindow = getPageWindow(); if (pageWindow.ytcfg && typeof pageWindow.ytcfg.get === 'function') { return pageWindow.ytcfg.get('VISITOR_DATA') || ''; } } catch (err) { logger.error('getInnertubeVisitorData failed', err); } return ''; } function fetchPlayerResponseFromYoutubei(videoId) { return postJson('https://www.youtube.com/youtubei/v1/player?prettyPrint=false', { context: buildInnertubeContext(), videoId: videoId }, buildInnertubeHeaders()); } function postJson(url, body, headers) { var payload = JSON.stringify(body); var pageFetch = getPageFetch(); if (!pageFetch) { return postJsonWithGM(url, payload, headers); } return pageFetch(url, { method: 'POST', body: payload, headers: headers, credentials: 'include', cache: 'no-store' }).then(function (res) { if (res.status === 429) throw rateLimitError(url, 'fetch'); if (!res.ok) throw makeHttpError(res.status, url, 'fetch'); return res.json(); }).catch(function (err) { if (err && err.status === 429) throw err; return postJsonWithGM(url, payload, headers); }); } function postJsonWithGM(url, payload, headers) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'POST', url: url, data: payload, headers: mergeHeaders(headers, { 'Referer': 'https://www.youtube.com/', 'User-Agent': getBrowserLikeUserAgent() }), onload: function (res) { if (res.status === 429) { reject(rateLimitError(url, 'gm')); return; } if (res.status < 200 || res.status >= 300) { reject(makeHttpError(res.status, url, 'gm')); return; } try { resolve(JSON.parse(res.responseText)); } catch (parseErr) { reject(parseErr); } }, onerror: function () { reject(makeHttpError('ERR', url, 'gm')); } }); }); } function extractPlayerResponseFromHtml(html) { var markers = ['ytInitialPlayerResponse = ', 'var ytInitialPlayerResponse = ']; var i; for (i = 0; i < markers.length; i++) { var marker = markers[i]; var markerIndex = html.indexOf(marker); if (markerIndex === -1) continue; var start = html.indexOf('{', markerIndex + marker.length); if (start === -1) continue; var depth = 0; var inString = false; var escaped = false; var quote = ''; var j; for (j = start; j < html.length; j++) { var ch = html.charAt(j); if (inString) { if (escaped) { escaped = false; } else if (ch === '\\') { escaped = true; } else if (ch === quote) { inString = false; } continue; } if (ch === '"' || ch === '\'') { inString = true; quote = ch; continue; } if (ch === '{') depth += 1; if (ch === '}') { depth -= 1; if (depth === 0) { try { return JSON.parse(html.slice(start, j + 1)); } catch (err) { logger.error('extractPlayerResponseFromHtml failed', err); return null; } } } } } return null; } function makeHttpError(status, url, via) { var err = new Error('HTTP ' + status); err.status = status; err.url = url; err.via = via; return err; } function rateLimitError(url, via) { var err = new Error('HTTP 429'); err.status = 429; err.url = url; err.via = via; err.rateLimited = true; return err; } function mergeHeaders(baseHeaders, extraHeaders) { var merged = {}; var key; baseHeaders = baseHeaders || {}; extraHeaders = extraHeaders || {}; for (key in baseHeaders) merged[key] = baseHeaders[key]; for (key in extraHeaders) merged[key] = extraHeaders[key]; return merged; } function httpGet(url) { return new Promise(function (resolve, reject) { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Referer': 'https://www.youtube.com/', 'User-Agent': getBrowserLikeUserAgent() }, onload: function (res) { if (res.status === 429) { reject(rateLimitError(url, 'gm')); return; } if (res.status < 200 || res.status >= 300) { reject(makeHttpError(res.status, url, 'gm')); return; } resolve(res.responseText); }, onerror: function () { reject(makeHttpError('ERR', url, 'gm')); } }); }); } function fetchText(url) { var pageFetch = getPageFetch(); if (!pageFetch) { return httpGet(url); } return pageFetch(url, { credentials: 'include', cache: 'no-store' }).then(function (res) { if (res.status === 429) throw rateLimitError(url, 'fetch'); if (!res.ok) throw makeHttpError(res.status, url, 'fetch'); return res.text(); }).catch(function (err) { if (err && err.status === 429) throw err; return httpGet(url); }); } function fetchBestPair(sourceTrack, targetTrack, targetLang) { clearFetchDiagnostics(); return fetchTrackCues(sourceTrack, null, 'source').then(function (sourceCues) { if (sourceCues.length) { return fetchTargetPair(sourceCues, targetTrack, sourceTrack, targetLang, ''); } appendFetchDiagnostic('target', 'skip-source-empty'); return fetchTranscriptFallbackPair(sourceTrack, targetTrack, targetLang).then(function (fallbackResult) { if (!fallbackResult.cuesA.length && !fallbackResult.cuesB.length) { return { cuesA: [], cuesB: [], fallback: fallbackResult.fallback || '' }; } if (!fallbackResult.cuesA.length || fallbackResult.cuesB.length) return fallbackResult; return fetchTargetPair(fallbackResult.cuesA, targetTrack, sourceTrack, targetLang, fallbackResult.fallback || ''); }); }); } function fetchTargetPair(sourceCues, targetTrack, sourceTrack, targetLang, fallback) { var track = targetTrack || sourceTrack; var language = targetTrack ? null : targetLang; if (!track || !track.baseUrl) { appendFetchDiagnostic('target', 'skip-no-target-track'); return Promise.resolve({ cuesA: sourceCues || [], cuesB: [], fallback: fallback || '' }); } return fetchTrackCues(track, language, 'target').then(function (targetCues) { return { cuesA: sourceCues || [], cuesB: targetCues || [], fallback: fallback || '' }; }).catch(function (err) { appendFetchDiagnostic('target', 'target-error-kept-source(' + formatError(err) + ')'); return { cuesA: sourceCues || [], cuesB: [], fallback: fallback || '' }; }); } function fetchTrackCues(track, targetLang, label) { var attempts = []; function summarizeUrl(candidate) { try { var parsed = new URL(candidate.url, location.href); var fmt = parsed.searchParams.get('fmt') || 'raw'; var lang = parsed.searchParams.get('lang') || ''; var tlang = parsed.searchParams.get('tlang') || ''; return fmt + ':' + lang + (tlang ? '->' + tlang : '') + (candidate.native ? ':native' : ''); } catch (err) { return 'unknown'; } } function summarizeText(text) { var compact = String(text || '').replace(/\s+/g, ' ').trim(); if (!compact) return 'empty'; return compact.slice(0, 48); } function tryCandidateSet(candidates, index) { if (index >= candidates.length) { setFetchDiagnostic(label, attempts.join(' | ') || 'no-attempt'); return Promise.resolve([]); } return fetchText(candidates[index].url).then(function (text) { var cues = parseCaptionPayload(text); attempts.push(summarizeUrl(candidates[index]) + ':ok(' + cues.length + ',' + summarizeText(text) + ')'); if (cues.length) { setFetchDiagnostic(label, attempts.join(' | ')); return cues; } return tryCandidateSet(candidates, index + 1); }).catch(function (err) { attempts.push(summarizeUrl(candidates[index]) + ':err(' + formatError(err) + ')'); if (err && err.status === 429) { setFetchDiagnostic(label, attempts.join(' | ')); throw err; } return tryCandidateSet(candidates, index + 1); }); } function tryNativeFallback(cause) { return waitForNativeTimedTextHint(track).then(function (hasHint) { var nativeCandidates = hasHint ? buildTimedTextCandidates(track, targetLang, true) : []; if (!nativeCandidates.length) { if (cause) throw cause; return []; } return tryCandidateSet(nativeCandidates, 0).then(function (cues) { if (cues.length || !cause) return cues; throw cause; }); }); } var candidates = buildTimedTextCandidates(track, targetLang, false); var hasNativeCandidates = candidates.some(function (candidate) { return candidate.native; }); return tryCandidateSet(candidates, 0).then(function (cues) { if (cues.length || hasNativeCandidates) return cues; return tryNativeFallback(null); }).catch(function (err) { if (err && err.status === 429 && !hasNativeCandidates) { return tryNativeFallback(err); } throw err; }); } function buildTimedTextCandidates(track, targetLang, nativeOnly) { var variants = [ { fmt: 'json3' }, { fmt: 'srv1' }, { fmt: 'srv3' }, { fmt: 'ttml' }, { fmt: 'vtt' }, {} ]; var candidates = []; var seen = {}; var hint = getNativeTimedTextHint(track); var i; if (hint && hint.params) { for (i = 0; i < variants.length; i++) { addCandidate(mergeObjects(hint.params, mergeTimedTextParams(targetLang, variants[i])), true); } } if (!nativeOnly) { for (i = 0; i < variants.length; i++) { addCandidate(mergeTimedTextParams(targetLang, variants[i]), false); } } return candidates; function addCandidate(params, native) { var url = buildTimedTextUrl(track.baseUrl, params); if (seen[url]) return; seen[url] = true; candidates.push({ native: native, url: url }); } } function mergeTimedTextParams(targetLang, extraParams) { var params = {}; var key; if (targetLang) params.tlang = targetLang; for (key in extraParams) params[key] = extraParams[key]; return params; } function mergeObjects(base, extra) { var merged = {}; var key; base = base || {}; extra = extra || {}; for (key in base) merged[key] = base[key]; for (key in extra) merged[key] = extra[key]; return merged; } function fetchTranscriptFallbackPair(sourceTrack, targetTrack, targetLang) { return fetchTranscriptCues(getVideoId()).then(function (fallbackCues) { if (fallbackCues.length) { appendFetchDiagnostic('source', 'transcript-api:ok(' + fallbackCues.length + ')'); return { cuesA: fallbackCues, cuesB: [], fallback: 'transcript-api' }; } appendFetchDiagnostic('source', 'transcript-api:empty'); return fetchTranscriptUiPair(sourceTrack, targetTrack, targetLang); }).catch(function (apiErr) { appendFetchDiagnostic('source', 'transcript-api:err(' + formatError(apiErr) + ')'); return fetchTranscriptUiPair(sourceTrack, targetTrack, targetLang); }).catch(function (uiErr) { appendFetchDiagnostic('source', 'transcript-ui:err(' + formatError(uiErr) + ')'); return { cuesA: [], cuesB: [], fallback: '' }; }); } function fetchTranscriptUiPair(sourceTrack, targetTrack, targetLang) { return withTranscriptPanel(function (panel) { var originalTitle = getSelectedTranscriptLanguageTitle(panel) || ''; return loadTranscriptUiPair(panel, sourceTrack, targetTrack, targetLang).then(function (result) { return restoreTranscriptLanguage(panel, originalTitle).then(function () { return result; }, function () { return result; }); }, function (err) { return restoreTranscriptLanguage(panel, originalTitle).then(function () { throw err; }, function () { throw err; }); }); }); } function withTranscriptPanel(task) { var panelInfo = null; return openTranscriptPanel().then(function (info) { panelInfo = info; return task(info.panel, info); }).finally(function () { if (!panelInfo) return; if (panelInfo.openedByScript) { closeTranscriptPanel(panelInfo.panel); } else { removeHiddenTranscriptPanelStyle(); } }); } function loadTranscriptUiPair(panel, sourceTrack, targetTrack, targetLang) { var sourceInfo = null; var targetInfo = null; return readTranscriptUiCues(panel, sourceTrack, sourceTrack ? sourceTrack.languageCode : '', true).then(function (value) { sourceInfo = value; appendFetchDiagnostic('source', 'transcript-ui:ok(' + value.cues.length + ',' + (value.title || 'current') + ')'); return readTranscriptUiCues(panel, targetTrack, targetTrack ? targetTrack.languageCode : targetLang, false).then(function (targetValue) { targetInfo = targetValue; if (targetValue.cues.length) { appendFetchDiagnostic('target', 'transcript-ui:ok(' + targetValue.cues.length + ',' + (targetValue.title || targetLang || 'target') + ')'); } else if (targetValue.title) { appendFetchDiagnostic('target', 'transcript-ui:empty(' + targetValue.title + ')'); } else if (targetLang) { appendFetchDiagnostic('target', 'transcript-ui:skip(' + targetLang + ')'); } return { cuesA: sourceInfo.cues, cuesB: targetInfo.cues, fallback: 'transcript-ui' }; }); }).catch(function (err) { throw err; }); } function readTranscriptUiCues(panel, track, languageCode, required) { var options = getTranscriptLanguageOptions(panel); var desiredTitle = resolveTranscriptLanguageTitle(options, track, languageCode); var currentTitle = getSelectedTranscriptLanguageTitle(panel); var hasLanguageOptions = !!options.length; if (!desiredTitle && !hasLanguageOptions) { if (required) { return waitFor(function () { var currentCues = extractTranscriptPanelCues(panel); return currentCues.length ? currentCues : null; }, 3000, 120).then(function (cues) { if (!cues || !cues.length) throw new Error('Transcript UI empty: current'); return { cues: cues, title: currentTitle || 'current' }; }); } return Promise.resolve({ cues: [], title: '' }); } if (!desiredTitle) { if (required) { if (!track && currentTitle) { desiredTitle = currentTitle; } else if (currentTitle && transcriptTitleMatches(currentTitle, track, languageCode)) { desiredTitle = currentTitle; } else { throw new Error('Transcript language not found: ' + (track ? getTrackName(track) : (languageCode || 'source'))); } } else { return Promise.resolve({ cues: [], title: '' }); } } return ensureTranscriptLanguage(panel, desiredTitle).then(function () { return waitFor(function () { var cues = extractTranscriptPanelCues(panel); return cues.length ? cues : null; }, 3000, 120); }).then(function (cues) { if (!cues || !cues.length) { if (required) throw new Error('Transcript UI empty: ' + desiredTitle); return { cues: [], title: desiredTitle }; } return { cues: cues, title: desiredTitle }; }); } function openTranscriptPanel() { var ready = getReadyTranscriptPanel(); if (ready) { return Promise.resolve({ panel: ready, openedByScript: false }); } applyHiddenTranscriptPanelStyle(); return tryOpenTranscriptPanelLoop(Date.now() + 12000).then(function (panel) { if (panel) { return { panel: panel, openedByScript: true }; } removeHiddenTranscriptPanelStyle(); throw new Error('Transcript button not found'); }); } function tryOpenTranscriptPanelLoop(deadline) { return tryOpenTranscriptPanelOnce().then(function (panel) { if (panel) return panel; if (Date.now() >= deadline) return null; return wait(250).then(function () { return tryOpenTranscriptPanelLoop(deadline); }); }); } function tryOpenTranscriptPanelOnce() { var panel = getReadyTranscriptPanel(); if (panel) return Promise.resolve(panel); return tryClickTranscriptAndWait(findTranscriptTrigger(), 3000, 120).then(function (directPanel) { if (directPanel) return directPanel; var menuButton = document.querySelector(CONFIG.selectors.transcriptMenuButton); if (!menuButton) return null; menuButton.click(); return waitFor(function () { return findTranscriptMenuItem(); }, 2000, 100).then(function (menuItem) { return tryClickTranscriptAndWait(menuItem, 3000, 120); }); }).then(function (menuPanel) { if (menuPanel) return menuPanel; return tryClickTranscriptAndWait(document.querySelector(CONFIG.selectors.transcriptDescriptionButton), 3000, 120); }); } function tryClickTranscriptAndWait(element, timeoutMs, intervalMs) { if (!element) return Promise.resolve(null); element.click(); return waitFor(function () { return getReadyTranscriptPanel(); }, timeoutMs || 3000, intervalMs || 120); } function getTranscriptPanel() { return document.querySelector(CONFIG.selectors.transcriptPanel); } function getReadyTranscriptPanel() { var panels = document.querySelectorAll(CONFIG.selectors.transcriptPanel); var i; for (i = 0; i < panels.length; i++) { if (hasTranscriptContent(panels[i])) return panels[i]; } return null; } function closeTranscriptPanel(panel) { var root = panel || getTranscriptPanel(); var selector = '#visibility-button ytd-button-renderer button, #visibility-button yt-button-shape button, #dismiss-button button, ytd-engagement-panel-title-header-renderer #dismiss-button button, ytd-engagement-panel-title-header-renderer #dismiss-button, yt-icon-button#dismiss-button button, yt-icon-button#dismiss-button'; var header = root ? root.querySelector('ytd-engagement-panel-title-header-renderer, #header') : null; var button = header ? header.querySelector(selector) : null; if (!button && root) button = root.querySelector(selector); if (!button) button = document.querySelector(selector); if (button) button.click(); deferHiddenTranscriptStyleRemoval(root); } function deferHiddenTranscriptStyleRemoval(panel) { var started = Date.now(); function tick() { if (isTranscriptPanelClosed(panel) || Date.now() - started >= 1800) { removeHiddenTranscriptPanelStyle(); return; } setTimeout(tick, 120); } tick(); } function isTranscriptPanelClosed(panel) { var currentPanel = panel || getTranscriptPanel(); if (!currentPanel || !document.contains(currentPanel) || currentPanel.hidden || currentPanel.getAttribute('aria-hidden') === 'true') return true; var style = window.getComputedStyle(currentPanel); return style.display === 'none' || style.visibility === 'hidden'; } function applyHiddenTranscriptPanelStyle() { if (document.getElementById(CONFIG.ids.hiddenTranscriptStyle)) return; var style = document.createElement('style'); style.id = CONFIG.ids.hiddenTranscriptStyle; style.textContent = '#panels ytd-engagement-panel-section-list-renderer[visibility=\"ENGAGEMENT_PANEL_VISIBILITY_EXPANDED\"]{position:fixed!important;opacity:0!important;pointer-events:none!important}'; (document.head || document.documentElement || document.body).appendChild(style); } function removeHiddenTranscriptPanelStyle() { var node = document.getElementById(CONFIG.ids.hiddenTranscriptStyle); if (node) node.remove(); } function hasTranscriptContent(panel) { if (!panel || panel.hidden || panel.getAttribute('aria-hidden') === 'true') return false; return !!panel.querySelector(CONFIG.selectors.transcriptRenderer + ', ' + CONFIG.selectors.transcriptSegment); } function findTranscriptTrigger() { var chipTrigger = findTranscriptChipButton(); if (chipTrigger) return chipTrigger; return findTranscriptMenuItem(); } function findTranscriptChipButton() { var items = document.querySelectorAll(CONFIG.selectors.transcriptChipButton + ', [aria-label], [title]'); var i; for (i = 0; i < items.length; i++) { if (matchTranscriptLabel(items[i])) return items[i]; } return null; } function findTranscriptMenuItem() { var items = document.querySelectorAll(CONFIG.selectors.transcriptMenuItems); var i; for (i = 0; i < items.length; i++) { if (matchTranscriptLabel(items[i])) return items[i]; } return null; } function matchTranscriptLabel(node) { if (!node) return false; var text = [node.getAttribute && node.getAttribute('aria-label'), node.getAttribute && node.getAttribute('title'), node.textContent].join(' '); return TRANSCRIPT_LABEL_PATTERN.test(String(text || '').trim()); } function describeTranscriptTrigger() { var trigger = findTranscriptTrigger(); if (!trigger) return 'none'; var parts = []; var tagName = trigger.tagName ? trigger.tagName.toLowerCase() : 'node'; parts.push(tagName); var ariaLabel = trigger.getAttribute ? trigger.getAttribute('aria-label') : ''; var title = trigger.getAttribute ? trigger.getAttribute('title') : ''; var text = String(trigger.textContent || '').replace(/\s+/g, ' ').trim(); var label = ariaLabel || title || text || ''; if (label) parts.push(label.slice(0, 48)); return parts.join(':'); } function getTranscriptRendererData(panel) { if (!panel) return null; var transcriptRenderer = panel.querySelector(CONFIG.selectors.transcriptRenderer); if (!transcriptRenderer) return null; if (transcriptRenderer.__data && transcriptRenderer.__data.data) return transcriptRenderer.__data.data; if (transcriptRenderer.data) return transcriptRenderer.data; if (transcriptRenderer.__dataHost && transcriptRenderer.__dataHost.__data) return transcriptRenderer.__dataHost.__data; return null; } function extractTranscriptPanelCues(panel) { var cues = extractTranscriptPanelCuesFromData(panel); if (cues.length) return cues; return extractTranscriptPanelCuesFromDom(panel); } function extractTranscriptPanelCuesFromData(panel) { var transcriptData = getTranscriptRendererData(panel); var segments = transcriptData && transcriptData.content && transcriptData.content.transcriptSearchPanelRenderer && transcriptData.content.transcriptSearchPanelRenderer.body && transcriptData.content.transcriptSearchPanelRenderer.body.transcriptSegmentListRenderer ? transcriptData.content.transcriptSearchPanelRenderer.body.transcriptSegmentListRenderer.initialSegments : null; var cues = []; var i; if (!segments || !segments.length) return cues; for (i = 0; i < segments.length; i++) { var item = segments[i] && segments[i].transcriptSegmentRenderer; if (!item) continue; var startMs = parseInt(item.startMs || '0', 10); var endMs = parseInt(item.endMs || '0', 10); var text = readRunsText(item.snippet && item.snippet.runs); if (!text) continue; cues.push({ start: startMs / 1000, end: (endMs || startMs + 5000) / 1000, text: text }); } normalizeCueEnds(cues); return cues; } function extractTranscriptPanelCuesFromDom(panel) { var renderers = panel.querySelectorAll(CONFIG.selectors.transcriptSegment); var cues = []; var i; for (i = 0; i < renderers.length; i++) { var renderer = renderers[i]; var textNode = renderer.querySelector(CONFIG.selectors.transcriptText); var text = textNode ? String(textNode.textContent || '').trim() : ''; var targetId = renderer.getAttribute('target-id'); var startMs = 0; var endMs = 0; var parts; if (!text) continue; if (!targetId && renderer.data && renderer.data.targetId) targetId = renderer.data.targetId; if (!targetId && renderer.__data && renderer.__data.data && renderer.__data.data.targetId) targetId = renderer.__data.data.targetId; if (renderer.tagName && renderer.tagName.toLowerCase() === 'transcript-segment-view-model') { startMs = parseTranscriptTimeToMs(renderer.querySelector(CONFIG.selectors.transcriptTime)); } else if (targetId) { parts = targetId.split('.'); startMs = parseInt(parts[parts.length - 2] || '0', 10); endMs = parseInt(parts[parts.length - 1] || '0', 10); } else { startMs = parseTranscriptTimeToMs(renderer.querySelector(CONFIG.selectors.transcriptTime)); } cues.push({ start: startMs / 1000, end: (endMs || startMs + 5000) / 1000, text: text.replace(/\s+/g, ' ').trim() }); } normalizeCueEnds(cues); return cues; } function normalizeCueEnds(cues) { var i; for (i = 0; i < cues.length; i++) { if (cues[i].end > cues[i].start) continue; cues[i].end = i + 1 < cues.length ? cues[i + 1].start : cues[i].start + 5; } } function parseTranscriptTimeToMs(node) { var text = node ? String(node.textContent || '').trim() : ''; if (!text) return 0; var parts = text.split(':'); var nums = []; var i; for (i = 0; i < parts.length; i++) nums.push(parseInt(parts[i] || '0', 10) || 0); if (nums.length === 3) return ((nums[0] * 3600) + (nums[1] * 60) + nums[2]) * 1000; if (nums.length === 2) return ((nums[0] * 60) + nums[1]) * 1000; return (nums[0] || 0) * 1000; } function getTranscriptLanguageOptions(panel) { var transcriptData = getTranscriptRendererData(panel); var subMenuItems = transcriptData && transcriptData.content && transcriptData.content.transcriptSearchPanelRenderer && transcriptData.content.transcriptSearchPanelRenderer.footer && transcriptData.content.transcriptSearchPanelRenderer.footer.transcriptFooterRenderer && transcriptData.content.transcriptSearchPanelRenderer.footer.transcriptFooterRenderer.languageMenu && transcriptData.content.transcriptSearchPanelRenderer.footer.transcriptFooterRenderer.languageMenu.sortFilterSubMenuRenderer ? transcriptData.content.transcriptSearchPanelRenderer.footer.transcriptFooterRenderer.languageMenu.sortFilterSubMenuRenderer.subMenuItems : null; var options = []; var i; if (!subMenuItems || !subMenuItems.length) return options; for (i = 0; i < subMenuItems.length; i++) { options.push({ title: subMenuItems[i].title || '', selected: !!subMenuItems[i].selected }); } return options; } function getSelectedTranscriptLanguageTitle(panel) { var options = getTranscriptLanguageOptions(panel); var i; for (i = 0; i < options.length; i++) { if (options[i].selected && options[i].title) return options[i].title; } var selectors = ['#label-text.yt-dropdown-menu', '[aria-selected=\"true\"]', '.iron-selected']; for (i = 0; i < selectors.length; i++) { var node = panel.querySelector(selectors[i]); if (node && String(node.textContent || '').trim()) return String(node.textContent || '').trim(); } return ''; } function ensureTranscriptLanguage(panel, title) { var current = getSelectedTranscriptLanguageTitle(panel); if (!title || normalizeLangLabel(current) === normalizeLangLabel(title)) { return Promise.resolve(false); } var dropdownButton = panel.querySelector(CONFIG.selectors.transcriptLanguageDropdown); if (!dropdownButton) { return waitFor(function () { return panel.querySelector(CONFIG.selectors.transcriptLanguageDropdown); }, 2000, 120).then(function (button) { if (!button) throw new Error('Transcript language selector not found'); return switchTranscriptLanguageWithButton(panel, button, title); }); } return switchTranscriptLanguageWithButton(panel, dropdownButton, title); } function switchTranscriptLanguageWithButton(panel, button, title) { button.click(); return wait(300).then(function () { var listboxes = document.querySelectorAll(CONFIG.selectors.transcriptVisibleListboxes); var i; var j; for (i = 0; i < listboxes.length; i++) { var items = listboxes[i].querySelectorAll('tp-yt-paper-item, yt-formatted-string'); for (j = 0; j < items.length; j++) { if (normalizeLangLabel(items[j].textContent) === normalizeLangLabel(title)) { var target = items[j].closest ? items[j].closest('tp-yt-paper-item') : null; (target || items[j]).click(); return wait(900).then(function () { return waitFor(function () { return normalizeLangLabel(getSelectedTranscriptLanguageTitle(panel)) === normalizeLangLabel(title) ? true : null; }, 2500, 120).then(function (matched) { if (!matched) throw new Error('Transcript language switch timed out: ' + title); return true; }); }); } } } document.body.click(); throw new Error('Transcript language option not found: ' + title); }); } function restoreTranscriptLanguage(panel, title) { if (!panel || !title) return Promise.resolve(); var current = getSelectedTranscriptLanguageTitle(panel); if (!current || normalizeLangLabel(current) === normalizeLangLabel(title)) return Promise.resolve(); return ensureTranscriptLanguage(panel, title).catch(function () {}); } function resolveTranscriptLanguageTitle(options, track, languageCode) { var aliases = getLanguageAliases(languageCode, track ? getTrackName(track) : ''); var i; var j; if (track) { var exactName = normalizeLangLabel(getTrackName(track)); for (i = 0; i < options.length; i++) { if (normalizeLangLabel(options[i].title) === exactName) return options[i].title; } } for (i = 0; i < aliases.length; i++) { var alias = normalizeLangLabel(aliases[i]); if (!alias) continue; for (j = 0; j < options.length; j++) { var optionTitle = normalizeLangLabel(options[j].title); if (optionTitle === alias || optionTitle.indexOf(alias) !== -1 || alias.indexOf(optionTitle) !== -1) return options[j].title; } } return ''; } function transcriptTitleMatches(title, track, languageCode) { return !!resolveTranscriptLanguageTitle([{ title: title, selected: true }], track, languageCode); } function getLanguageAliases(languageCode, displayName) { var aliases = []; var normalized = String(languageCode || '').toLowerCase(); var prefix = normalized.split('-')[0]; if (displayName) aliases.push(displayName); if (languageCode) aliases.push(languageCode); if (prefix && prefix !== normalized) aliases.push(prefix); if (normalized === 'zh-hant') { aliases.push('中文(繁體字)', '繁體中文', '繁体中文', '繁體字', '繁体', 'traditional chinese', 'traditional'); } else if (normalized === 'zh-hans') { aliases.push('中文(简体)', '中文(簡體)', '简体中文', '簡體中文', '简体', '簡體', 'simplified chinese', 'simplified'); } else if (prefix === 'zh') { aliases.push('中文', 'chinese'); } else if (prefix === 'en') { aliases.push('English', '英文', '英语', '英語'); } else if (prefix === 'ja') { aliases.push('Japanese', '日文', '日语', '日語', '日本語'); } else if (prefix === 'ko') { aliases.push('Korean', '韩文', '韓文', '韩语', '韓語', '한국어'); } else if (prefix === 'es') { aliases.push('Spanish', 'Español', '西班牙语', '西班牙語'); } return aliases; } function normalizeLangLabel(value) { return String(value || '').toLowerCase().replace(/\s+/g, '').replace(/[()()._-]/g, ''); } function wait(delayMs) { return new Promise(function (resolve) { setTimeout(resolve, delayMs); }); } function waitFor(getValue, timeoutMs, intervalMs) { var started = Date.now(); return new Promise(function (resolve) { function tick() { var value = null; try { value = getValue(); } catch (err) { value = null; } if (value) { resolve(value); return; } if (Date.now() - started >= timeoutMs) { resolve(null); return; } setTimeout(tick, intervalMs); } tick(); }); } function fetchTranscriptCues(videoId) { return fetchTranscriptResponse(videoId).then(function (response) { return parseTranscriptResponse(response); }); } function fetchTranscriptResponse(videoId) { return postJson('https://www.youtube.com/youtubei/v1/next?prettyPrint=false', { context: buildInnertubeContext(), videoId: videoId }, buildInnertubeHeaders()).then(function (nextResponse) { var endpoint = findNestedByKey(nextResponse, 'getTranscriptEndpoint'); if (!endpoint || !endpoint.params) { throw new Error('Transcript endpoint not found'); } return postJson('https://www.youtube.com/youtubei/v1/get_transcript?prettyPrint=false', { context: buildInnertubeContext(), params: endpoint.params }, buildInnertubeHeaders()); }); } function parseTranscriptResponse(response) { var listRenderer = findNestedByKey(response, 'transcriptSegmentListRenderer'); var segments = listRenderer && listRenderer.initialSegments ? listRenderer.initialSegments : []; var cues = []; var i; for (i = 0; i < segments.length; i++) { var item = segments[i] && segments[i].transcriptSegmentRenderer; if (!item) continue; var startMs = parseInt(item.startMs || '0', 10); var endMs = parseInt(item.endMs || '0', 10); var text = readRunsText(item.snippet && item.snippet.runs); if (!text) continue; cues.push({ start: startMs / 1000, end: (endMs || startMs) / 1000, text: text }); } return cues; } function readRunsText(runs) { if (!runs || !runs.length) return ''; var parts = []; var i; for (i = 0; i < runs.length; i++) { if (runs[i] && runs[i].text) parts.push(runs[i].text); } return parts.join('').replace(/\s+/g, ' ').trim(); } function findNestedByKey(value, key) { if (!value || typeof value !== 'object') return null; if (Object.prototype.hasOwnProperty.call(value, key)) return value[key]; var prop; for (prop in value) { if (!Object.prototype.hasOwnProperty.call(value, prop)) continue; var nested = findNestedByKey(value[prop], key); if (nested) return nested; } return null; } function parseCaptionPayload(text) { var body = String(text || '').trim(); if (!body) return []; if (body.charAt(0) === '{') return parseJson3(body); if (body.charAt(0) === '<') return parseXml(body); return parseVtt(body); } function parseVtt(text) { var cues = []; var lines = String(text || '').replace(/\r/g, '').split('\n'); var i = 0; function toSec(value) { var parts = value.split(':'); var nums = []; var k; for (k = 0; k < parts.length; k++) nums.push(parseFloat(parts[k])); if (nums.length === 3) return nums[0] * 3600 + nums[1] * 60 + nums[2]; if (nums.length === 2) return nums[0] * 60 + nums[1]; return nums[0] || 0; } while (i < lines.length) { var line = lines[i].trim(); if (!line) { i += 1; continue; } if (line.indexOf('WEBVTT') === 0) { i += 1; continue; } if (/^\d+$/.test(line)) { i += 1; line = (lines[i] || '').trim(); } if (line.indexOf('-->') === -1) { i += 1; continue; } var parts = line.split('-->'); var start = toSec(parts[0].trim().split(' ')[0].replace(',', '.')); var end = toSec(parts[1].trim().split(' ')[0].replace(',', '.')); i += 1; var textLines = []; while (i < lines.length && lines[i].trim() !== '') { textLines.push(lines[i].replace(/<[^>]+>/g, '').trim()); i += 1; } var cueText = textLines.join('\n').trim(); if (cueText) cues.push({ start: start, end: end, text: cueText }); } return cues; } function parseJson3(text) { var data = JSON.parse(text); var events = data && data.events ? data.events : []; var cues = []; var i; for (i = 0; i < events.length; i++) { var event = events[i]; if (!event || !event.segs || !event.segs.length) continue; var start = (event.tStartMs || 0) / 1000; var end = ((event.tStartMs || 0) + (event.dDurationMs || 0)) / 1000; var segs = []; var j; for (j = 0; j < event.segs.length; j++) segs.push(event.segs[j].utf8 || ''); var cueText = segs.join('').replace(/\n+/g, '\n').trim(); if (cueText) cues.push({ start: start, end: end, text: cueText }); } return cues; } function parseXml(text) { var xml = new DOMParser().parseFromString(text, 'text/xml'); if (xml.querySelector('parsererror')) throw new Error('XML parse error'); var nodes = xml.querySelectorAll('p, text'); var cues = []; var i; function readTime(node, nameA, nameB) { var raw = node.getAttribute(nameA); if (raw == null && nameB) raw = node.getAttribute(nameB); return raw == null ? 0 : parseFloat(raw); } for (i = 0; i < nodes.length; i++) { var node = nodes[i]; var start = readTime(node, 't', 'start'); var dur = readTime(node, 'd', 'dur'); if (node.tagName.toLowerCase() === 'p') { start = start / 1000; dur = dur / 1000; } var cueText = String(node.textContent || '').replace(/\s+/g, ' ').trim(); if (!cueText) continue; cues.push({ start: start, end: start + dur, text: cueText }); } return cues; } function formatError(err) { if (!err) return 'unknown'; if (typeof err === 'string') return err; var parts = []; if (err.message) parts.push(err.message); if (err.status != null) parts.push('status=' + err.status); if (err.via) parts.push('via=' + err.via); return parts.join(' | ') || String(err); } })();