YouTube bilingual subtitles

YouTube双语字幕

// ==UserScript==
// @name         YouTube bilingual subtitles
// @namespace    http://tampermonkey.net/
// @version      1.0.0
// @description  YouTube双语字幕
// @author       hex0x13h
// @match        https://www.youtube.com/watch*
// @match        https://youtube.com/watch*
// @match        https://m.youtube.com/watch*
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-idle
// @connect      translate.googleapis.com
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  // ---------------- 配置 ----------------
  const config = {
    targetLang: GM_getValue('targetLang', 'zh-cn'),
    originalLang: 'auto',
    showOriginal: GM_getValue('showOriginal', true),
    fontSize: GM_getValue('fontSize', 16),
    position: GM_getValue('subtitlePosition', 'bottom'),
    hideNativeCC: GM_getValue('hideNativeCC', false), // 仅视觉隐藏原生字幕
  };

  const languages = {
    'zh-cn': '中文(简体)',
    'zh-tw': '中文(繁体)',
    en: 'English',
    ja: '日本語',
    ko: '한국어',
    fr: 'Français',
    de: 'Deutsch',
    es: 'Español',
    ru: 'Русский',
    pt: 'Português',
    it: 'Italiano',
    ar: 'العربية',
    hi: 'हिन्दी',
    th: 'ไทย',
    vi: 'Tiếng Việt',
  };

  // ---------------- 状态 ----------------
  let subtitleContainer = null;
  let controlPanel = null;
  let statusElement = null;

  let playerObserver = null;
  let captionObserver = null;
  let pollIntervalId = null;
  let resizeObs = null;

  let currentSubtitle = '';
  let isInitialized = false;
  let currentUrl = location.href;
  let playerRoot = null;

  // ---------------- 时间工具 ----------------
  const now = () => (performance && performance.now ? performance.now() : Date.now());
  function debounce(fn, delay) {
    let t;
    return function (...args) {
      clearTimeout(t);
      t = setTimeout(() => fn.apply(this, args), delay);
    };
  }
  function throttle(fn, minInterval) {
    let last = 0;
    let pending = null;
    return function (...args) {
      const ts = now();
      if (ts - last >= minInterval) {
        last = ts;
        fn.apply(this, args);
      } else {
        pending && clearTimeout(pending);
        pending = setTimeout(() => {
          last = now();
          fn.apply(this, args);
        }, minInterval - (ts - last));
      }
    };
  }

  // ==================== 高效翻译子系统(核心升级) ====================

  // 句子切分(尽量以标点断开,保留顺序)
  const SENTENCE_SPLIT_RE = /([。.。\.!?!?;;]+)/g;
  function splitSentences(text) {
    if (!text) return [];
    const parts = [];
    let buf = '';
    text.split(SENTENCE_SPLIT_RE).forEach((chunk, i, arr) => {
      buf += chunk;
      if (SENTENCE_SPLIT_RE.test(chunk) || i === arr.length - 1) {
        const s = buf.trim();
        if (s) parts.push(s);
        buf = '';
      }
    });
    return parts.length ? parts : [text.trim()];
  }

  // 文本清洗与归一化
  function clean(s) {
    return (s || '')
      .replace(/\s+/g, ' ')
      .replace(/\s+([,.;:!?,。;:!?])/g, '$1')
      .replace(/([\u4e00-\u9fa5])\s+([\u4e00-\u9fa5])/g, '$1$2')
      .trim();
  }
  function normalize(s) {
    return (s || '')
      .replace(/\s+/g, ' ')
      .replace(/[。.。]/g, '.')
      .replace(/\s+([,.;:!?])/g, '$1')
      .trim()
      .toLowerCase();
  }

  // 句子级 LRU 缓存
  class LRU {
    constructor(limit = 500) { this.limit = limit; this.map = new Map(); }
    get(k) {
      if (!this.map.has(k)) return undefined;
      const v = this.map.get(k);
      this.map.delete(k); this.map.set(k, v);
      return v;
    }
    set(k, v) {
      if (this.map.has(k)) this.map.delete(k);
      this.map.set(k, v);
      if (this.map.size > this.limit) {
        const first = this.map.keys().next().value;
        this.map.delete(first);
      }
    }
    clear(){ this.map.clear(); }
  }
  const sentenceCache = new LRU(500);

  // 并发队列(外发请求限流)
  const MAX_CONCURRENCY = 2; // 可按网络稳定度调成 1~3
  let active = 0;
  const queue = [];
  function enqueue(task) {
    return new Promise((resolve, reject) => {
      queue.push({ task, resolve, reject });
      pump();
    });
  }
  function pump() {
    while (active < MAX_CONCURRENCY && queue.length) {
      const { task, resolve, reject } = queue.shift();
      active++;
      task().then(resolve, reject).finally(() => { active--; pump(); });
    }
  }

  // 单次请求:多行文本合并为一条,返回按行切分
  function requestTranslate(lines, targetLang) {
    const text = lines.join('\n');
    const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`;
    return enqueue(() => new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout: 6000,
        onload: (resp) => {
          try {
            const data = JSON.parse(resp.responseText);
            const full = (data && data[0]) ? data[0].map(v => v[0]).join('') : text;
            resolve(full.split('\n').map(clean));
          } catch (e) {
            console.error('翻译解析失败:', e);
            resolve(lines);
          }
        },
        onerror: () => resolve(lines),
        ontimeout: () => resolve(lines),
      });
    }));
  }

  // 主翻译:句子缓存 + 批量请求
  async function translateText(text, targetLang = config.targetLang) {
    if (!text || !text.trim()) return '';
    const sentences = splitSentences(text);

    const need = [];
    const indexOfNeed = [];
    const result = new Array(sentences.length);

    sentences.forEach((s, i) => {
      const key = `${s}__${targetLang}`;
      const hit = sentenceCache.get(key);
      if (hit !== undefined) {
        result[i] = hit;
      } else {
        need.push(s);
        indexOfNeed.push(i);
      }
    });

    // 分批:控制每批长度,减少 414/限流
    const batches = [];
    if (need.length) {
      const MAX_BATCH_CHARS = 1500;
      let batch = [];
      let len = 0;
      for (const s of need) {
        if (len + s.length + 1 > MAX_BATCH_CHARS && batch.length) {
          batches.push(batch);
          batch = [s]; len = s.length + 1;
        } else { batch.push(s); len += s.length + 1; }
      }
      if (batch.length) batches.push(batch);
    }

    const translatedBatches = await Promise.all(batches.map(b => requestTranslate(b, targetLang)));

    // 写回
    let cursor = 0;
    translatedBatches.forEach(arr => {
      arr.forEach(t => {
        const idx = indexOfNeed[cursor++];
        const origin = sentences[idx];
        const key = `${origin}__${targetLang}`;
        sentenceCache.set(key, t);
        result[idx] = t;
      });
    });

    const merged = clean(result.join(' '));
    return merged || text;
  }

  // ==================== 0 延迟显示(先原文,后替换) ====================
  const LOW_LATENCY_MODE = true;  // 需要关闭可改为 false
  let lastInstantText = '';

  function showOriginalInstant(text) {
    if (!LOW_LATENCY_MODE) return;
    if (!subtitleContainer) return;
    const t = (text || '').trim();
    if (!t || t === lastInstantText) return;
    // 若已显示双语字幕,且内容不变,则跳过
    if (currentSubtitle && t === currentSubtitle) return;

    subtitleContainer.textContent = '';
    const originalDiv = document.createElement('div');
    originalDiv.style.color = '#e0e0e0';
    originalDiv.style.fontSize = '0.9em';
    originalDiv.style.opacity = '0.85';
    originalDiv.textContent = t;
    subtitleContainer.appendChild(originalDiv);

    subtitleContainer.style.display = 'block';
    lastInstantText = t;
  }

  // ---------------- DOM 创建(保持不变) ----------------
  function createElement(tag, styles = {}, textContent = '') {
    const el = document.createElement(tag);
    Object.assign(el.style, styles);
    if (textContent) el.textContent = textContent;
    return el;
  }

  // 播放器根节点
  function getPlayerRoot() {
    return document.querySelector('#movie_player') ||
           document.querySelector('.html5-video-player') ||
           document.body; // 兜底
  }

  // 控制面板(样式/结构不变)
  function createControlPanel() {
    const old = document.getElementById('bilingual-subtitle-panel');
    if (old) old.remove();
    const oldTab = document.getElementById('bilingual-reopen-tab');
    if (oldTab) oldTab.remove();

    controlPanel = createElement('div', {
      position: 'fixed',
      top: '70px',
      right: '20px',
      background: 'rgba(0,0,0,0.95)',
      color: '#fff',
      padding: '20px',
      borderRadius: '12px',
      zIndex: '2147483647',
      minWidth: '300px',
      maxWidth: '350px',
      boxShadow: '0 8px 32px rgba(0,0,0,0.5)',
      border: '2px solid #ff0000',
      fontFamily: 'Segoe UI, Arial, sans-serif',
      fontSize: '14px',
      transform: GM_getValue('panelHidden', false) ? 'translateX(280px)' : 'translateX(0px)',
      transition: 'transform .25s ease',
    });
    controlPanel.id = 'bilingual-subtitle-panel';

    const header = createElement('div', { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '15px' });
    const title = createElement('h3', { margin: '0', fontSize: '16px', fontWeight: 'bold' }, '🎬 双语字幕');
    const toggleBtn = createElement('button', {
      background: '#ff4757', border: 'none', color: '#fff', fontSize: '16px',
      cursor: 'pointer', padding: '5px 10px', borderRadius: '4px', fontWeight: 'bold'
    }, GM_getValue('panelHidden', false) ? '+' : '−');
    toggleBtn.id = 'toggle-panel';
    header.appendChild(title);
    header.appendChild(toggleBtn);

    const content = createElement('div', { display: GM_getValue('panelHidden', false) ? 'none' : 'block' });
    content.id = 'panel-content';

    // 语言
    const langGroup = createElement('div', { marginBottom: '15px' });
    langGroup.appendChild(createElement('label', { display: 'block', marginBottom: '5px', fontSize: '12px', color: '#ccc' }, '翻译语言:'));
    const langSelect = createElement('select', {
      width: '100%',
      padding: '8px',
      border: '1px solid #444',
      borderRadius: '6px',
      background: '#222',
      color: 'white',
      fontSize: '12px',
    });
    langSelect.id = 'target-lang';
    Object.entries(languages).forEach(([code, name]) => {
      const opt = createElement('option', {}, name);
      opt.value = code;
      if (code === config.targetLang) opt.selected = true;
      langSelect.appendChild(opt);
    });
    langGroup.appendChild(langSelect);

    // 显示原文
    const originalGroup = createElement('div', { marginBottom: '15px' });
    const originalLabel = createElement('label', { display: 'flex', alignItems: 'center', fontSize: '12px', color: '#ccc', cursor: 'pointer' });
    const originalCheckbox = createElement('input');
    originalCheckbox.type = 'checkbox';
    originalCheckbox.id = 'show-original';
    originalCheckbox.checked = config.showOriginal;
    originalCheckbox.style.marginRight = '8px';
    originalLabel.appendChild(originalCheckbox);
    originalLabel.appendChild(createElement('span', {}, '显示原始字幕'));
    originalGroup.appendChild(originalLabel);

    // 隐藏原生字幕(仅视觉)
    const hideNativeGroup = createElement('div', { marginBottom: '15px' });
    const hideNativeLabel = createElement('label', { display: 'flex', alignItems: 'center', fontSize: '12px', color: '#ccc', cursor: 'pointer' });
    const hideNativeCb = createElement('input');
    hideNativeCb.type = 'checkbox';
    hideNativeCb.id = 'hide-native-cc';
    hideNativeCb.checked = config.hideNativeCC;
    hideNativeCb.style.marginRight = '8px';
    hideNativeLabel.appendChild(hideNativeCb);
    hideNativeLabel.appendChild(createElement('span', {}, '隐藏原生字幕(仅视觉隐藏)'));
    hideNativeGroup.appendChild(hideNativeLabel);

    // 字体
    const fontGroup = createElement('div', { marginBottom: '15px' });
    fontGroup.appendChild(createElement('label', { display: 'block', marginBottom: '5px', fontSize: '12px', color: '#ccc' }, '字体大小:'));
    const fontSlider = createElement('input');
    fontSlider.type = 'range';
    fontSlider.id = 'font-size';
    fontSlider.min = '12';
    fontSlider.max = '24';
    fontSlider.value = config.fontSize;
    fontSlider.style.width = '100%';
    const fontValue = createElement('span', { fontSize: '11px', color: '#999' }, config.fontSize + 'px');
    fontValue.id = 'font-size-value';
    fontGroup.appendChild(fontSlider);
    fontGroup.appendChild(fontValue);

    // 位置
    const posGroup = createElement('div', { marginBottom: '15px' });
    posGroup.appendChild(createElement('label', { display: 'block', marginBottom: '5px', fontSize: '12px', color: '#ccc' }, '字幕位置:'));
    const posSelect = createElement('select', {
      width: '100%',
      padding: '8px',
      border: '1px solid #444',
      borderRadius: '6px',
      background: '#222',
      color: 'white',
      fontSize: '12px',
    });
    posSelect.id = 'subtitle-position';
    [
      { value: 'bottom', text: '底部' },
      { value: 'top', text: '顶部' },
    ].forEach((p) => {
      const o = createElement('option', {}, p.text);
      o.value = p.value;
      if (p.value === config.position) o.selected = true;
      posSelect.appendChild(o);
    });

    // 状态
    const statusGroup = createElement('div', { marginBottom: '15px' });
    const statusText = createElement('div', { fontSize: '11px', color: '#999', textAlign: 'center' }, '状态: ');
    statusElement = createElement('span', { color: '#4fc3f7' }, '等待字幕...');
    statusElement.id = 'status-text';
    statusText.appendChild(statusElement);
    statusGroup.appendChild(statusText);

    // 按钮
    const clearBtn = createElement(
      'button',
      { width: '100%', padding: '8px', background: '#ff4757', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px', marginBottom: '10px' },
      '清除翻译缓存'
    );
    clearBtn.id = 'clear-cache';
    const testBtn = createElement(
      'button',
      { width: '100%', padding: '8px', background: '#2ed573', color: 'white', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '12px' },
      '测试翻译'
    );
    testBtn.id = 'test-translation';

    // 组装
    content.appendChild(langGroup);
    content.appendChild(originalGroup);
    content.appendChild(hideNativeGroup);
    content.appendChild(fontGroup);
    content.appendChild(posGroup);
    content.appendChild(statusGroup);
    content.appendChild(clearBtn);
    content.appendChild(testBtn);

    controlPanel.appendChild(header);
    controlPanel.appendChild(content);
    document.body.appendChild(controlPanel);

    // 抽拉手(保持)
    const reopenTab = createElement('div', {
      position: 'fixed',
      top: '120px',
      right: '0px',
      width: '28px',
      height: '96px',
      background: '#ff4757',
      color: '#fff',
      borderTopLeftRadius: '8px',
      borderBottomLeftRadius: '8px',
      display: GM_getValue('panelHidden', false) ? 'flex' : 'none',
      alignItems: 'center',
      justifyContent: 'center',
      cursor: 'pointer',
      zIndex: '2147483647',
      boxShadow: '0 4px 12px rgba(0,0,0,.4)',
      userSelect: 'none',
      fontWeight: 'bold'
    }, '≡');
    reopenTab.title = '点击展开双语字幕面板(Alt+Shift+B 也可切换)';
    reopenTab.id = 'bilingual-reopen-tab';
    document.body.appendChild(reopenTab);

    const showPanel = () => {
      const c = document.getElementById('panel-content');
      if (!c) return;
      c.style.display = 'block';
      controlPanel.style.transform = 'translateX(0px)';
      toggleBtn.textContent = '−';
      GM_setValue('panelHidden', false);
      const tab = document.getElementById('bilingual-reopen-tab');
      if (tab) tab.style.display = 'none';
    };

    const hidePanel = () => {
      const c = document.getElementById('panel-content');
      if (!c) return;
      c.style.display = 'none';
      controlPanel.style.transform = 'translateX(280px)';
      toggleBtn.textContent = '+';
      GM_setValue('panelHidden', true);
      const tab = document.getElementById('bilingual-reopen-tab');
      if (tab) tab.style.display = 'flex';
    };

    toggleBtn.addEventListener('click', (e) => {
      e.preventDefault();
      const isHidden = content.style.display === 'none';
      isHidden ? showPanel() : hidePanel();
    });
    reopenTab.addEventListener('click', (e) => {
      e.preventDefault();
      showPanel();
    });

    function ensurePanelOnScreen() {
      const rect = controlPanel.getBoundingClientRect();
      const vw = Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
      const vh = Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
      if (rect.left >= vw || rect.right <= 0 || rect.top >= vh || rect.bottom <= 0) {
        showPanel();
        controlPanel.style.right = '20px';
        controlPanel.style.top = '70px';
      }
    }
    ensurePanelOnScreen();
    window.addEventListener('resize', ensurePanelOnScreen);

    // 其它控件事件(保持)
    langSelect.addEventListener('change', (e) => {
      config.targetLang = e.target.value;
      GM_setValue('targetLang', config.targetLang);
      sentenceCache.clear();
      updateStatus('语言已更改');
    });

    originalCheckbox.addEventListener('change', (e) => {
      config.showOriginal = e.target.checked;
      GM_setValue('showOriginal', config.showOriginal);
      updateSubtitleDisplay();
    });

    hideNativeCb.addEventListener('change', (e) => {
      config.hideNativeCC = e.target.checked;
      GM_setValue('hideNativeCC', config.hideNativeCC);
      applyHideNativeCC(config.hideNativeCC);
    });

    fontSlider.addEventListener('input', (e) => {
      config.fontSize = parseInt(e.target.value, 10);
      fontValue.textContent = config.fontSize + 'px';
      GM_setValue('fontSize', config.fontSize);
      updateSubtitleDisplay();
    });

    posSelect.addEventListener('change', (e) => {
      config.position = e.target.value;
      GM_setValue('subtitlePosition', config.position);
      updateSubtitleDisplay();
    });

    clearBtn.addEventListener('click', (e) => {
      e.preventDefault();
      sentenceCache.clear();
      updateStatus('缓存已清除');
    });

    testBtn.addEventListener('click', async (e) => {
      e.preventDefault();
      updateStatus('测试中...');
      const result = await translateText('Hello World', config.targetLang);
      updateStatus(`测试成功: ${result}`);
      showBilingualSubtitle('This is a test subtitle for the bilingual subtitle tool.');
      setTimeout(hideSubtitle, 2500);
    });
  }

  function updateStatus(message) {
    if (statusElement) {
      statusElement.textContent = message;
      statusElement.style.color = '#4fc3f7';
    }
  }

  // 字幕容器(样式保持)
  function createSubtitleContainer() {
    const old = document.getElementById('bilingual-subtitles');
    if (old) old.remove();

    playerRoot = getPlayerRoot();

    subtitleContainer = createElement('div', {
      position: 'absolute',
      left: '50%',
      transform: 'translateX(-50%)',
      background: 'rgba(0, 0, 0, 0.9)',
      color: 'white',
      padding: '10px 16px',
      borderRadius: '8px',
      fontFamily: 'Arial, sans-serif',
      textAlign: 'center',
      zIndex: '2147483646',
      maxWidth: '86%',
      lineHeight: '1.35',
      boxShadow: '0 4px 12px rgba(0, 0, 0, 0.5)',
      display: 'none',
      border: '1px solid rgba(255, 255, 255, 0.2)',
    });
    subtitleContainer.id = 'bilingual-subtitles';

    (playerRoot || document.body).appendChild(subtitleContainer);

    if (resizeObs) resizeObs.disconnect();
    resizeObs = new ResizeObserver(() => updateSubtitleDisplay());
    resizeObs.observe(playerRoot || document.body);

    updateSubtitleDisplay();
  }

  function updateSubtitleDisplay() {
    if (!subtitleContainer) return;

    subtitleContainer.style.fontSize = config.fontSize + 'px';

    const SAFE_OFFSET = 14; // 与原生字幕的安全间距
    if (config.position === 'top') {
      subtitleContainer.style.top = '8%';
      subtitleContainer.style.bottom = 'auto';
    } else {
      subtitleContainer.style.bottom = `calc(12% + ${SAFE_OFFSET}px)`;
      subtitleContainer.style.top = 'auto';
    }
  }

  // 视觉隐藏/恢复原生字幕(不影响抓取)
  function applyHideNativeCC(hide) {
    const cc = document.querySelector('.ytp-caption-window-container');
    if (!cc) return;
    cc.style.opacity = hide ? '0' : '';
    cc.style.pointerEvents = hide ? 'none' : '';
  }

  // 从原生字幕容器抓取文本(不依赖 opacity,隐藏时也能抓到)
  function getSubtitleText() {
    const container = document.querySelector('.ytp-caption-window-container');
    if (!container || container.offsetParent === null) return null;

    const segments = container.querySelectorAll('.ytp-caption-segment');
    let text = '';
    segments.forEach((seg) => {
      const style = window.getComputedStyle(seg);
      if (style && style.display !== 'none' && style.visibility !== 'hidden') {
        const t = seg.textContent || '';
        if (t.trim()) text += (text ? ' ' : '') + t.trim();
      }
    });

    return text || null;
  }

  // 显示双语字幕:整体替换字幕容器内容,保持同步出现
  async function showBilingualSubtitle(originalText) {
    if (!subtitleContainer || !originalText) return;
    originalText = originalText.trim();
    if (originalText === currentSubtitle) return;

    currentSubtitle = originalText;
    updateStatus('翻译中...');

    try {
      const translatedTextRaw = await translateText(currentSubtitle, config.targetLang);
      const translatedText = (translatedTextRaw || '').trim();

      const same = normalize(translatedText) === normalize(currentSubtitle);

      subtitleContainer.textContent = '';

      if (!same) {
        const translatedDiv = createElement('div', { color: '#4fc3f7', marginBottom: config.showOriginal ? '5px' : '0', fontWeight: 'bold' }, translatedText);
        subtitleContainer.appendChild(translatedDiv);
        if (config.showOriginal) {
          const originalDiv = createElement('div', { color: '#e0e0e0', fontSize: '0.9em', opacity: '0.85' }, currentSubtitle);
          subtitleContainer.appendChild(originalDiv);
        }
        updateStatus('字幕已显示');
      } else {
        const onlyDiv = createElement('div', { color: '#e0e0e0', fontWeight: 'bold' }, currentSubtitle);
        subtitleContainer.appendChild(onlyDiv);
        updateStatus('同文无需翻译');
      }

      subtitleContainer.style.display = 'block';
      lastInstantText = same ? currentSubtitle : translatedText;
    } catch (e) {
      console.error('显示字幕失败:', e);
      updateStatus('翻译失败(已保留原文)');
      // 保留即时原文,不清空容器避免闪烁
    }
  }

  // 隐藏字幕,整体隐藏并清空
  function hideSubtitle() {
    if (subtitleContainer) {
      subtitleContainer.style.display = 'none';
      subtitleContainer.textContent = '';
      currentSubtitle = '';
      lastInstantText = '';
      updateStatus('等待字幕...');
    }
  }

  // =============== 监听逻辑(修复同步问题) ===============
  const debouncedCheck = debounce(() => {
    const t = getSubtitleText();
    if (t && t !== currentSubtitle) {
      throttledApply(t);
    } else if (!t && currentSubtitle) {
      hideSubtitle();
    }
  }, 220);

  const throttledApply = throttle((t) => showBilingualSubtitle(t), 200);

  function observeCaptions() {
    disconnectCaptionObserver();

    const container = document.querySelector('.ytp-caption-window-container');
    if (!container) return;

    captionObserver = new MutationObserver(() => {
      const raw = getSubtitleText();
      if (raw) showOriginalInstant(raw);
      debouncedCheck();
    });
    captionObserver.observe(container, {
      childList: true,
      subtree: true,
      characterData: true,
      attributes: true,
      attributeFilter: ['class', 'style'],
    });

    if (pollIntervalId) clearInterval(pollIntervalId);
    pollIntervalId = setInterval(() => {
      const raw = getSubtitleText();
      if (raw) showOriginalInstant(raw);
      debouncedCheck();
    }, 800);
  }

  function observePlayer() {
    disconnectPlayerObserver();

    const player = getPlayerRoot();
    playerObserver = new MutationObserver(() => {
      if (document.querySelector('.ytp-caption-window-container')) {
        observeCaptions();
      }
    });
    playerObserver.observe(player || document.body, { childList: true, subtree: true });
  }

  function disconnectCaptionObserver() {
    if (captionObserver) {
      captionObserver.disconnect();
      captionObserver = null;
    }
    if (pollIntervalId) {
      clearInterval(pollIntervalId);
      pollIntervalId = null;
    }
  }

  function disconnectPlayerObserver() {
    if (playerObserver) {
      playerObserver.disconnect();
      playerObserver = null;
    }
  }

  // ---------------- 初始化 & 清理 ----------------
  function cleanup() {
    disconnectCaptionObserver();
    disconnectPlayerObserver();
    if (resizeObs) { resizeObs.disconnect(); resizeObs = null; }
    const oldPanel = document.getElementById('bilingual-subtitle-panel'); if (oldPanel) oldPanel.remove();
    const oldTab = document.getElementById('bilingual-reopen-tab'); if (oldTab) oldTab.remove();
    const oldSubs = document.getElementById('bilingual-subtitles'); if (oldSubs) oldSubs.remove();
    currentSubtitle = '';
    lastInstantText = '';
  }

  function forceInit() {
    if (isInitialized) return;
    try {
      cleanup();
      createControlPanel();
      createSubtitleContainer();
      observePlayer();
      observeCaptions();
      applyHideNativeCC(config.hideNativeCC);

      isInitialized = true;
      updateStatus('工具已就绪');
      console.log('双语字幕工具初始化完成!');
    } catch (e) {
      console.error('初始化失败:', e);
      setTimeout(() => { isInitialized = false; forceInit(); }, 2000);
    }
  }

  // 页面加载
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', forceInit);
  } else {
    forceInit();
  }

  // URL 变化(单页应用)
  setInterval(() => {
    if (location.href !== currentUrl) {
      currentUrl = location.href;
      console.log('页面URL变化,重新初始化...');
      isInitialized = false;
      forceInit();
    }
  }, 800);

  // 全局快捷键:Alt + Shift + B 切换显示/隐藏面板
  window.addEventListener('keydown', (e) => {
    if (e.altKey && e.shiftKey && (e.key.toLowerCase && e.key.toLowerCase() === 'b')) {
      const panel = document.getElementById('bilingual-subtitle-panel');
      const content = document.getElementById('panel-content');
      const tab = document.getElementById('bilingual-reopen-tab');
      if (!panel || !content) return;
      const hidden = content.style.display === 'none';
      if (hidden) {
        content.style.display = 'block';
        panel.style.transform = 'translateX(0px)';
        GM_setValue('panelHidden', false);
        if (tab) tab.style.display = 'none';
      } else {
        content.style.display = 'none';
        panel.style.transform = 'translateX(280px)';
        GM_setValue('panelHidden', true);
        if (tab) tab.style.display = 'flex';
      }
    }
  });
})();