YouTube Local Subtitles - Native CC Subtitle Injection

从本地加载 SRT/VTT 字幕,并将其注入为 YouTube 原生 CC 字幕轨道,不创建独立字幕层,也不隐藏原生字幕层。

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         YouTube Local Subtitles - Native CC Subtitle Injection
// @name:en      YouTube Local Subtitles - Native CC Subtitle Injection
// @name:zh-CN   YouTube 本地字幕 - 注入原生CC字幕
// @name:zh-TW   YouTube 本地字幕 - 注入原生CC字幕
// @namespace    https://github.com/FaQxD233/YouTube-Local-Subtitle-Loader-for-Tampermonkey
// @version      1.2.1
// @description  从本地加载 SRT/VTT 字幕,并将其注入为 YouTube 原生 CC 字幕轨道,不创建独立字幕层,也不隐藏原生字幕层。
// @description:en  Load local SRT/VTT subtitles and inject them as a YouTube native CC track without creating a separate subtitle layer or hiding the native subtitle layer.
// @description:zh-CN  从本地加载 SRT/VTT 字幕,并将其注入为 YouTube 原生 CC 字幕轨道,不创建独立字幕层,也不隐藏原生字幕层。
// @description:zh-TW  載入本地 SRT/VTT 字幕,並將其注入為 YouTube 原生 CC 字幕軌道,不建立獨立字幕層,也不隱藏原生字幕層。
// @match        https://www.youtube.com/watch*
// @match        https://www.youtube.com/*&v=*
// @run-at       document-start
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';

  const TRACK_VSS_ID = '.yt-local-subtitle';
  const TRACK_LANG = 'yt-local';
  const MENU_CLASS = 'tm-local-native-cc-menuitem';
  const STATE_KEY = '__ytLocalNativeCcState';

  const MESSAGES = {
    zh: {
      trackName: '本地字幕',
      uploadLabel: '上传本地字幕',
      notLoaded: '未加载',
      countSuffix: '条',
      loadedLog: '[本地字幕] 已作为 YouTube 原生 CC 轨道注入:',
      countLog: '条目数:',
      emptyWarn: '[本地字幕] 没有解析到有效字幕:',
      debugPrefix: '[本地字幕:debug]',
    },
    en: {
      trackName: 'Local subtitles',
      uploadLabel: 'Upload local subtitles',
      notLoaded: 'Not loaded',
      countSuffix: 'cues',
      loadedLog: '[Local subtitles] Injected as a YouTube native CC track:',
      countLog: 'cues:',
      emptyWarn: '[Local subtitles] No valid subtitles parsed:',
      debugPrefix: '[Local subtitles:debug]',
    },
  };

  const state = window[STATE_KEY] || {
    cues: [],
    filename: '',
    version: 0,
    track: null,
    retryTimer: null,
    fileInput: null,
    settingsObserverAttached: false,
    initialResponsePatched: false,
    xhrPatched: false,
    initTimer: null,
    adBypassTimer: null,
  };
  window[STATE_KEY] = state;

  function onReady(fn) {
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', fn, { once: true });
    } else {
      fn();
    }
  }

  function init() {
    ensureLocalTrack();
    ensureFileInput();
    patchLocalTimedtextXhr();
    patchInitialPlayerResponseSetter();
    injectKnownPlayerResponses();
    setupSettingsObserver();
    setupAdBypass();
  }

  function setupAdBypass() {
    if (state.adBypassTimer) return;

    state.adBypassTimer = setInterval(() => {
      const player = getPlayer();
      if (!player || !player.classList || !player.classList.contains('ad-showing')) return;

      clickSkipAdButton(player);
      fastForwardCurrentAd();
    }, 250);
  }

  function clickSkipAdButton(player) {
    const skipButton = player.querySelector(
      '.ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-skip-ad-button, button[class*="skip"]'
    );
    if (skipButton && !skipButton.disabled) skipButton.click();
  }

  function fastForwardCurrentAd() {
    const video = document.querySelector('video.html5-main-video');
    if (!video || !Number.isFinite(video.duration) || video.duration <= 0) return;

    try { video.playbackRate = Math.max(video.playbackRate || 1, 16); } catch (err) {}

    try {
      if (video.duration - video.currentTime > 0.5) {
        video.currentTime = Math.max(0, video.duration - 0.1);
      }
    } catch (err) {}
  }

  function ensureLocalTrack() {
    if (!state.track || isLegacyLocalTrack(state.track)) state.track = createLocalCaptionTrack();
    if ('captionDataUrl' in state) delete state.captionDataUrl;
  }

  function isLegacyLocalTrack(track) {
    return !!(track && typeof track.baseUrl === 'string' && track.baseUrl.startsWith('data:'));
  }

  function ensureFileInput() {
    if (state.fileInput && document.body && document.body.contains(state.fileInput)) return;
    if (!document.body) return;

    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.srt,.vtt,.txt';
    input.style.display = 'none';
    input.addEventListener('change', handleFileChange);
    document.body.appendChild(input);
    state.fileInput = input;
  }

  function handleFileChange(event) {
    const file = event.target.files && event.target.files[0];
    event.target.value = '';
    if (!file) return;

    const reader = new FileReader();
    reader.onload = function (ev) {
      const text = ev.target && ev.target.result ? String(ev.target.result) : '';
      loadLocalSubtitles(text, file.name);
    };
    reader.readAsText(file, 'utf-8');
  }

  function loadLocalSubtitles(text, filename) {
    const cues = /^\s*WEBVTT/i.test(text) ? parseVtt(text) : parseSrt(text);
    cues.sort((a, b) => a.start - b.start);

    state.cues = cues;
    state.filename = filename || msg('trackName');
    state.version += 1;
    ensureLocalTrack();
    state.track = createLocalCaptionTrack();

    injectKnownPlayerResponses();
    refreshNativeCaptionTracklist();
    updateMenuStatus();

    if (cues.length) {
      selectLocalTrackWithRetry(4);
      console.log(msg('loadedLog'), filename, msg('countLog'), cues.length);
    } else {
      console.warn(msg('emptyWarn'), filename);
    }
  }

  function createLocalCaptionTrack() {
    const displayName = getTrackDisplayName();
    return {
      baseUrl: getLocalCaptionBaseUrl(),
      name: { simpleText: displayName, runs: [{ text: displayName }] },
      vssId: TRACK_VSS_ID,
      languageCode: TRACK_LANG,
      languageName: { simpleText: displayName, runs: [{ text: displayName }] },
      isTranslatable: false,
      isDefault: false,
      trackName: msg('trackName'),
      displayName,
      rtl: false,
    };
  }

  function createCaptionsBaseUrl() {
    const videoId = getVideoId();
    const baseUrl = new URL('/api/timedtext', location.origin);
    baseUrl.searchParams.set('v', videoId || 'local');
    baseUrl.searchParams.set('type', 'track');
    return baseUrl.toString();
  }

  function getLocalCaptionBaseUrl() {
    const videoId = getVideoId();
    const url = new URL('/api/timedtext', location.origin);
    url.searchParams.set('v', videoId || 'local');
    url.searchParams.set('lang', TRACK_LANG);
    url.searchParams.set('name', msg('trackName'));
    url.searchParams.set('fmt', 'json3');
    url.searchParams.set('local_subtitle', '1');
    url.searchParams.set('version', String(state.version));
    return url.toString();
  }

  function patchLocalTimedtextXhr() {
    if (state.xhrPatched || typeof XMLHttpRequest === 'undefined') return;

    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
      this._tmLocalSubtitleUrl = isLocalTimedtextUrl(url) ? String(url) : '';
      return originalOpen.apply(this, arguments);
    };

    XMLHttpRequest.prototype.send = function patchedSend() {
      if (!this._tmLocalSubtitleUrl) return originalSend.apply(this, arguments);

      const xhr = this;
      const body = makeLocalTimedtextResponse(xhr._tmLocalSubtitleUrl);
      defineGetter(xhr, 'readyState', 4);
      defineGetter(xhr, 'status', 200);
      defineGetter(xhr, 'statusText', 'OK');
      defineGetter(xhr, 'responseURL', xhr._tmLocalSubtitleUrl);
      defineGetter(xhr, 'responseText', body);
      defineGetter(xhr, 'response', body);

      setTimeout(() => {
        fireXhrEvent(xhr, 'readystatechange');
        fireXhrEvent(xhr, 'load');
        fireXhrEvent(xhr, 'loadend');
      }, 0);
      return undefined;
    };

    state.xhrPatched = true;
  }

  function isLocalTimedtextUrl(rawUrl) {
    if (!rawUrl) return false;
    try {
      const url = new URL(String(rawUrl), location.origin);
      return url.origin === location.origin && url.pathname === '/api/timedtext' && url.searchParams.get('local_subtitle') === '1';
    } catch (err) {
      return false;
    }
  }

  function makeLocalTimedtextResponse(rawUrl) {
    let fmt = 'json3';
    try {
      fmt = (new URL(String(rawUrl), location.origin).searchParams.get('fmt') || 'json3').toLowerCase();
    } catch (err) {}

    if (fmt === 'srv3' || fmt === 'ttml' || fmt === 'xml') return toYouTubeTranscriptXml(state.cues);
    if (fmt === 'vtt' || fmt === 'webvtt') return toWebVtt(state.cues);
    return JSON.stringify(toYouTubeJson3(state.cues));
  }

  function defineGetter(obj, key, value) {
    try {
      Object.defineProperty(obj, key, { configurable: true, get: () => value });
    } catch (err) {}
  }

  function fireXhrEvent(xhr, type) {
    const event = new Event(type);
    const handler = xhr['on' + type];
    if (typeof handler === 'function') handler.call(xhr, event);
    xhr.dispatchEvent(event);
  }

  function getTrackDisplayName() {
    const trackName = msg('trackName');
    return state.filename ? `${trackName} (${state.filename})` : trackName;
  }

  function msg(key) {
    const lang = detectUiLanguage();
    return (MESSAGES[lang] && MESSAGES[lang][key]) || MESSAGES.en[key] || key;
  }

  function detectUiLanguage() {
    const candidates = [
      document.documentElement && document.documentElement.lang,
      document.querySelector('html[lang]') && document.querySelector('html[lang]').getAttribute('lang'),
      navigator.language,
    ];

    return candidates.some((lang) => /^zh/i.test(String(lang || ''))) ? 'zh' : 'en';
  }

  function getVideoId() {
    try {
      return new URL(location.href).searchParams.get('v') || '';
    } catch (err) {
      return '';
    }
  }

  function patchInitialPlayerResponseSetter() {
    if (state.initialResponsePatched) return;

    let currentValue = injectIntoPossiblePlayerResponse(window.ytInitialPlayerResponse);
    try {
      Object.defineProperty(window, 'ytInitialPlayerResponse', {
        configurable: true,
        get: () => currentValue,
        set: (value) => {
          currentValue = injectIntoPossiblePlayerResponse(value);
        },
      });
      state.initialResponsePatched = true;
    } catch (err) {}
  }

  function injectKnownPlayerResponses() {
    injectIntoPossiblePlayerResponse(window.ytInitialPlayerResponse);

    const player = getPlayer();
    if (player && typeof player.getPlayerResponse === 'function') {
      injectIntoPossiblePlayerResponse(player.getPlayerResponse());
    }

    try {
      const ytcfg = window.ytcfg && window.ytcfg.data_;
      if (ytcfg && ytcfg.PLAYER_VARS && ytcfg.PLAYER_VARS.raw_player_response) {
        injectIntoPossiblePlayerResponse(ytcfg.PLAYER_VARS.raw_player_response);
      }
    } catch (err) {}
  }

  function injectIntoPossiblePlayerResponse(value) {
    if (!value || typeof value !== 'object' || !state.track) return value;

    if (value.videoDetails || value.streamingData || value.playabilityStatus || value.captions) {
      ensureCaptionsContainer(value);
      injectTracklistRenderer(value.captions.playerCaptionsTracklistRenderer);
    }

    if (value.playerResponse) {
      ensureCaptionsContainer(value.playerResponse);
      injectTracklistRenderer(value.playerResponse.captions.playerCaptionsTracklistRenderer);
    }

    return value;
  }

  function ensureCaptionsContainer(playerResponse) {
    if (!playerResponse.captions) playerResponse.captions = {};
    if (!playerResponse.captions.playerCaptionsRenderer) {
      playerResponse.captions.playerCaptionsRenderer = { baseUrl: createCaptionsBaseUrl() };
    }
    if (!playerResponse.captions.playerCaptionsTracklistRenderer) {
      playerResponse.captions.playerCaptionsTracklistRenderer = {};
    }
  }

  function injectTracklistRenderer(renderer) {
    if (!renderer || !state.track) return;
    if (!Array.isArray(renderer.captionTracks)) renderer.captionTracks = [];

    const nextTrack = createLocalCaptionTrack();
    state.track = nextTrack;

    const existingIndex = renderer.captionTracks.findIndex((track) => track && track.vssId === TRACK_VSS_ID);
    if (existingIndex >= 0) {
      renderer.captionTracks[existingIndex] = nextTrack;
    } else {
      renderer.captionTracks.push(nextTrack);
    }

    const trackIndex = renderer.captionTracks.findIndex((track) => track && track.vssId === TRACK_VSS_ID);
    ensureAudioTrackReferences(renderer, trackIndex);

    if (!Array.isArray(renderer.translationLanguages)) renderer.translationLanguages = [];
    if (!renderer.defaultAudioTrackIndex) renderer.defaultAudioTrackIndex = 0;
  }

  function ensureAudioTrackReferences(renderer, trackIndex) {
    if (trackIndex < 0) return;
    if (!Array.isArray(renderer.audioTracks) || !renderer.audioTracks.length) {
      renderer.audioTracks = [{ captionTrackIndices: [trackIndex], defaultCaptionTrackIndex: trackIndex }];
      return;
    }

    renderer.audioTracks.forEach((audioTrack) => {
      if (!Array.isArray(audioTrack.captionTrackIndices)) audioTrack.captionTrackIndices = [];
      if (!audioTrack.captionTrackIndices.includes(trackIndex)) audioTrack.captionTrackIndices.push(trackIndex);
      if (audioTrack.defaultCaptionTrackIndex == null && state.cues.length) audioTrack.defaultCaptionTrackIndex = trackIndex;
    });
  }

  function refreshNativeCaptionTracklist() {
    const player = getPlayer();
    if (!player || !state.track) return;

    try { player.loadModule && player.loadModule('captions'); } catch (err) {}

    try {
      const response = typeof player.getPlayerResponse === 'function' ? player.getPlayerResponse() : null;
      injectIntoPossiblePlayerResponse(response);
    } catch (err) {}

    try {
      if (typeof player.getOption === 'function' && typeof player.setOption === 'function') {
        const tracklist = player.getOption('captions', 'tracklist') || { captionTracks: [] };
        injectTracklistRenderer(tracklist);
        player.setOption('captions', 'tracklist', tracklist);
        debugLog('tracklist refreshed', tracklist.captionTracks && tracklist.captionTracks.length);
      }
    } catch (err) {}
  }

  function selectLocalTrackWithRetry(remaining) {
    if (state.retryTimer) {
      clearTimeout(state.retryTimer);
      state.retryTimer = null;
    }

    refreshNativeCaptionTracklist();
    forceCaptionButtonOn();
    clearNativeCaptionTrack();

    setTimeout(() => {
      selectLocalTrack();
      pokeVideoClock();

      if (remaining > 0) {
        state.retryTimer = setTimeout(() => selectLocalTrackWithRetry(remaining - 1), 500);
      }
    }, 80);
  }

  function clearNativeCaptionTrack() {
    const player = getPlayer();
    if (!player) return;

    try {
      if (typeof player.setOption === 'function') {
        player.setOption('captions', 'track', null);
        player.setOption('captions', 'track', {});
      }
    } catch (err) {}

    try {
      if (typeof player.setSubtitlesTrack === 'function') player.setSubtitlesTrack(null);
    } catch (err) {}
  }

  function selectLocalTrack() {
    const player = getPlayer();
    if (!player || !state.track) return;

    const trackForApi = {
      languageCode: TRACK_LANG,
      vssId: TRACK_VSS_ID,
      trackName: msg('trackName'),
      displayName: getTrackDisplayName(),
    };

    try {
      if (typeof player.setOption === 'function') {
        player.setOption('captions', 'captionsInitialState', 'CAPTIONS_INITIAL_STATE_ON');
        player.setOption('captions', 'enabled', true);
        player.setOption('captions', 'track', trackForApi);
        player.setOption('captions', 'track', state.track);
        debugLog('track selected', state.track);
      }
    } catch (err) {}

    try {
      if (typeof player.setSubtitlesTrack === 'function') player.setSubtitlesTrack(state.track);
    } catch (err) {}

  }

  function forceCaptionButtonOn() {
    const button = document.querySelector('.ytp-subtitles-button');
    if (button && button.getAttribute('aria-pressed') !== 'true') button.click();
  }

  function pokeVideoClock() {
    const video = document.querySelector('video.html5-main-video');
    if (!video) return;

    try { video.dispatchEvent(new Event('timeupdate')); } catch (err) {}

    if (!Number.isFinite(video.duration) || video.duration <= 0 || video.seeking) return;

    try {
      const nextTime = Math.min(video.duration - 0.05, video.currentTime + 0.001);
      if (nextTime > 0 && Math.abs(nextTime - video.currentTime) > 0.0001) {
        video.currentTime = nextTime;
      }
    } catch (err) {}
  }

  function getPlayer() {
    return document.querySelector('#movie_player') || document.querySelector('.html5-video-player');
  }

  function debugLog() {
    console.log(msg('debugPrefix'), ...arguments);
  }

  function setupSettingsObserver() {
    const player = getPlayer();
    if (!player) return;
    const settingsMenu = player.querySelector('.ytp-settings-menu');
    if (!settingsMenu || settingsMenu._tmLocalNativeCcObserverAttached) return;

    const observer = new MutationObserver(() => injectUploadMenuItem(settingsMenu));
    observer.observe(settingsMenu, { childList: true, subtree: true });
    settingsMenu._tmLocalNativeCcObserverAttached = true;
    injectUploadMenuItem(settingsMenu);
  }

  function injectUploadMenuItem(settingsMenu) {
    const rootMenu = Array.from(settingsMenu.querySelectorAll('.ytp-panel-menu')).find((menu) => {
      const panel = menu.closest('.ytp-panel');
      return panel && !panel.querySelector('.ytp-panel-header');
    });
    if (!rootMenu || rootMenu.querySelector(`.${MENU_CLASS}`)) return;

    const item = document.createElement('div');
    item.className = `ytp-menuitem ${MENU_CLASS}`;

    const label = document.createElement('div');
    label.className = 'ytp-menuitem-label';
    label.textContent = msg('uploadLabel');

    const content = document.createElement('div');
    content.className = 'ytp-menuitem-content';
    content.textContent = getMenuStatusText();

    item.appendChild(label);
    item.appendChild(content);
    item.addEventListener('click', (event) => {
      event.preventDefault();
      event.stopPropagation();
      ensureFileInput();
      state.fileInput && state.fileInput.click();
    });

    rootMenu.appendChild(item);
  }

  function updateMenuStatus() {
    document.querySelectorAll(`.${MENU_CLASS} .ytp-menuitem-label`).forEach((el) => {
      el.textContent = msg('uploadLabel');
    });

    document.querySelectorAll(`.${MENU_CLASS} .ytp-menuitem-content`).forEach((el) => {
      el.textContent = getMenuStatusText();
    });
  }

  function getMenuStatusText() {
    if (!state.cues.length) return msg('notLoaded');
    return `${state.cues.length} ${msg('countSuffix')}`;
  }

  function parseSrt(text) {
    const lines = text.replace(/\r/g, '').split('\n');
    const entries = [];
    let i = 0;

    while (i < lines.length) {
      let line = lines[i].trim();
      if (!line) {
        i += 1;
        continue;
      }

      if (/^\d+$/.test(line)) {
        i += 1;
        line = (lines[i] || '').trim();
      }

      const timeMatch = line.match(/(.+?)\s*-->\s*(.+)/);
      if (!timeMatch) {
        i += 1;
        continue;
      }

      const start = parseTime(timeMatch[1]);
      const end = parseTime(timeMatch[2]);
      i += 1;

      const textLines = [];
      while (i < lines.length && lines[i].trim() !== '') {
        textLines.push(cleanCueText(lines[i]));
        i += 1;
      }

      if (end > start && textLines.length) entries.push({ start, end, text: textLines.join('\n') });
    }

    return entries;
  }

  function parseVtt(text) {
    const lines = text.replace(/\r/g, '').split('\n');
    const entries = [];
    let i = 0;

    while (i < lines.length) {
      const line = lines[i].trim();
      if (!line || /^WEBVTT/i.test(line) || line.startsWith('NOTE')) i += 1;
      else break;
    }

    while (i < lines.length) {
      let line = lines[i].trim();
      if (!line) {
        i += 1;
        continue;
      }

      if (!line.includes('-->')) {
        i += 1;
        line = (lines[i] || '').trim();
      }

      const timeMatch = line.match(/(.+?)\s*-->\s*(.+)/);
      if (!timeMatch) {
        i += 1;
        continue;
      }

      const start = parseTime(timeMatch[1]);
      const end = parseTime(timeMatch[2]);
      i += 1;

      const textLines = [];
      while (i < lines.length && lines[i].trim() !== '') {
        textLines.push(cleanCueText(lines[i]));
        i += 1;
      }

      if (end > start && textLines.length) entries.push({ start, end, text: textLines.join('\n') });
    }

    return entries;
  }

  function parseTime(raw) {
    let text = String(raw || '').trim().split(/[ \t]/)[0].replace(',', '.');
    const parts = text.split(':');
    let h = 0;
    let m = 0;
    let s = 0;

    if (parts.length === 3) {
      h = parseInt(parts[0], 10) || 0;
      m = parseInt(parts[1], 10) || 0;
      s = parseFloat(parts[2]) || 0;
    } else if (parts.length === 2) {
      m = parseInt(parts[0], 10) || 0;
      s = parseFloat(parts[1]) || 0;
    } else {
      s = parseFloat(parts[0]) || 0;
    }

    return h * 3600 + m * 60 + s;
  }

  function cleanCueText(text) {
    return String(text)
      .replace(/<\/?(?:font|b|i|u|c|v|ruby|rt|lang)[^>]*>/gi, '')
      .replace(/\{\\[^}]+}/g, '')
      .trimEnd();
  }

  function toYouTubeJson3(cues) {
    return {
      wireMagic: 'pb3',
      pens: [],
      wsWinStyles: [],
      wpWinPositions: [],
      events: cues.map((cue) => ({
        tStartMs: Math.max(0, Math.round(cue.start * 1000)),
        dDurationMs: Math.max(1, Math.round((cue.end - cue.start) * 1000)),
        segs: textToJson3Segs(cue.text),
      })),
    };
  }

  function toYouTubeTranscriptXml(cues) {
    return '<transcript>' + cues.map((cue) => {
      const start = formatXmlNumber(cue.start);
      const dur = formatXmlNumber(Math.max(0.001, cue.end - cue.start));
      return `<text start="${start}" dur="${dur}">${escapeXml(cue.text)}</text>`;
    }).join('') + '</transcript>';
  }

  function formatXmlNumber(value) {
    return Number(value || 0).toFixed(3).replace(/\.0+$/, '').replace(/(\.\d*?)0+$/, '$1');
  }

  function escapeXml(value) {
    return String(value || '')
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;')
      .replace(/\n/g, '&#10;');
  }

  function textToJson3Segs(text) {
    const lines = String(text || '').split('\n');
    const segs = [];
    lines.forEach((line, index) => {
      if (index > 0) segs.push({ utf8: '\n' });
      segs.push({ utf8: line });
    });
    return segs.length ? segs : [{ utf8: '' }];
  }

  function toWebVtt(cues) {
    return 'WEBVTT\n\n' + cues.map((cue, index) => {
      return `${index + 1}\n${formatVttTime(cue.start)} --> ${formatVttTime(cue.end)}\n${cue.text}`;
    }).join('\n\n') + '\n';
  }

  function formatVttTime(seconds) {
    const safe = Math.max(0, seconds || 0);
    const h = Math.floor(safe / 3600);
    const m = Math.floor((safe % 3600) / 60);
    const s = Math.floor(safe % 60);
    const ms = Math.round((safe - Math.floor(safe)) * 1000);
    return `${pad2(h)}:${pad2(m)}:${pad2(s)}.${String(ms).padStart(3, '0')}`;
  }

  function pad2(value) {
    return String(value).padStart(2, '0');
  }

  patchLocalTimedtextXhr();
  patchInitialPlayerResponseSetter();
  injectKnownPlayerResponses();
  setupAdBypass();

  onReady(init);
  window.addEventListener('yt-navigate-start', () => {
    state.cues = [];
    state.filename = '';
    state.track = createLocalCaptionTrack();
    updateMenuStatus();
  }, true);
  window.addEventListener('yt-navigate-finish', () => setTimeout(init, 800), true);
  state.initTimer = state.initTimer || setInterval(init, 3000);
})();