XavierTTS - 字幕同声传译

通过上传SRT文件,使用Web Speech API为视频添加同声传译语音,并可选择音色。欢迎大家使用并提出宝贵意见。

// ==UserScript==
// @name         XavierTTS - 字幕同声传译
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  通过上传SRT文件,使用Web Speech API为视频添加同声传译语音,并可选择音色。欢迎大家使用并提出宝贵意见。
// @author       Xavier
// @match        *://*/*
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @license      CC BY-NC-ND
// ==/UserScript==

(function() {
    'use strict';

    // --- 配置 ---
    const PRELOAD_COUNT = 10; // 预加载的字幕数量
    const PRELOAD_TRIGGER_INDEX = 5; // 触发下一次预加载的索引(相对于当前批次)
    const DEFAULT_VOICE_FILTER = name => name.startsWith('Microsoft'); // 默认音色过滤器

    // --- 全局变量 ---
    let subtitles = []; // 解析后的字幕数组 { id, startTime, endTime, text, playbackRate }
    let currentSubtitleIndex = -1;
    let voiceCache = {}; // 语音缓存 { subtitleId: SpeechSynthesisUtterance }
    let isShowingSubtitles = false;
    let selectedVoice = null;
    let availableVoices = [];
    let videoElement = null;
    let lastSpokenIndex = -1; // 上次播放语音的索引
    let isWaitingForAudio = false; // 是否因音频播放冲突而暂停视频等待
    let nextSubtitleIndexToPlay = -1; // 等待音频结束后需要播放的字幕索引
    let blockSeekedAutoPlay = false; // 临时阻止 seeked 事件自动播放视频
    let hasUserInteracted = false; // 用户是否已与页面交互
    let voicesInitialized = false; // 音色列表是否已初始化

    // --- DOM 元素 ---
    let container = null;
    let uploadButton = null;
    let showSubtitleCheckbox = null;
    let voiceSelect = null;
    let subtitleDisplay = null;

    // --- Web Speech API ---
    const synth = window.speechSynthesis;
    let voicesLoaded = false;
    let voiceLoadInterval = null;

    // --- 工具函数 ---
    /**
     * 解析SRT文件内容
     * @param {string} srtContent SRT文件文本内容
     * @returns {Array} 解析后的字幕对象数组
     */
    function parseSRT(srtContent) {
        const lines = srtContent.trim().replace(/\r/g, '').split('\n\n'); // 按空行分割字幕块
        const subtitles = [];
        let idCounter = 1;

        for (const block of lines) {
            const blockLines = block.trim().split('\n');
            if (blockLines.length < 2) continue; // 跳过无效块

            // 第一行通常是序号,我们忽略它,直接解析时间码
            let timeLineIndex = -1;
            for(let i = 0; i < blockLines.length; i++) {
                if (blockLines[i].includes('-->')) {
                    timeLineIndex = i;
                    break;
                }
            }

            if (timeLineIndex === -1) continue; // 块中未找到时间码行

            const timeMatch = blockLines[timeLineIndex].match(/(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})/);
            if (!timeMatch) continue; // 时间码格式不匹配

            try {
                const startTime = timeStringToMs(timeMatch[1]);
                const endTime = timeStringToMs(timeMatch[2]);

                // 时间码行之后的所有行都是文本
                const text = blockLines.slice(timeLineIndex + 1).join('\n').trim();

                if (text) { // 确保有文本内容
                    subtitles.push({
                        id: idCounter++,
                        startTime: startTime,
                        endTime: endTime,
                        text: text,
                        playbackRate: 1.0 // 初始化播放速率为 1.0
                    });
                }
            } catch (e) {
                console.error(`Error parsing SRT block: \n${block}\n`, e);
            }
        }

        return subtitles;
    }

    /**
     * 将时间字符串 HH:MM:SS,ms 转换为毫秒
     * @param {string} timeString 时间字符串
     * @returns {number} 毫秒数
     */
    function timeStringToMs(timeString) {
        const parts = timeString.split(/[:,]/);
        const hours = parseInt(parts[0], 10);
        const minutes = parseInt(parts[1], 10);
        const seconds = parseInt(parts[2], 10);
        const milliseconds = parseInt(parts[3], 10);
        return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;
    }

    /**
     * 查找当前时间对应的字幕索引
     * @param {number} currentTime 当前视频时间 (ms)
     * @returns {number} 字幕索引,未找到则返回 -1
     */
    function findSubtitleIndex(currentTime) {
        for (let i = 0; i < subtitles.length; i++) {
            if (currentTime >= subtitles[i].startTime && currentTime <= subtitles[i].endTime) {
                return i;
            }
        }
        return -1;
    }

    /**
     * 重置状态
     */
    function resetState() {
        subtitles = [];
        currentSubtitleIndex = -1;
        voiceCache = {};
        isShowingSubtitles = showSubtitleCheckbox.checked;
        selectedVoice = availableVoices.find(v => v.name === voiceSelect.value) || null;
        lastSpokenIndex = -1;
        isWaitingForAudio = false; // 重置等待状态
        nextSubtitleIndexToPlay = -1; // 重置待播放索引
        blockSeekedAutoPlay = false; // 重置 seeked 阻止标志
        hasUserInteracted = false; // 重置用户交互标志
        synth.cancel(); // 取消所有待播放的语音
        // (移除字幕对象 playbackRate 的重置)
        // subtitles.forEach(sub => sub.playbackRate = 1.0); // 不再需要 playbackRate
        if (subtitleDisplay) {
            subtitleDisplay.textContent = '';
        }
        if (videoElement) {
            // 确保视频速率和音量恢复正常(如果之前被修改过)
            if (videoElement.playbackRate !== 1.0) {
                videoElement.playbackRate = 1.0;
            }
            if (videoElement.muted) {
                videoElement.muted = false;
            }
            // 不需要在这里暂停或重置进度,因为这是通用重置,不是文件上传触发的
        }
        console.log("状态已重置");
    }

    // --- 语音合成相关 ---

    /**
     * 获取并过滤可用音色
     */
    function loadVoices() {
        // 如果已初始化,则不再执行
        if (voicesInitialized) {
            console.log("音色已初始化,跳过加载。");
            return;
        }

        availableVoices = synth.getVoices().filter(voice => DEFAULT_VOICE_FILTER(voice.name));
        if (availableVoices.length > 0) {
            voicesLoaded = true; // 标记基础语音已加载
            populateVoiceSelect(); // 填充下拉列表

            let defaultVoiceSet = false;

            // 1. 尝试加载上次选择的音色
            const savedVoiceName = GM_getValue('selectedVoiceName');
            if (savedVoiceName) {
                const savedVoice = availableVoices.find(v => v.name === savedVoiceName);
                if (savedVoice) {
                    selectedVoice = savedVoice;
                    voiceSelect.value = savedVoiceName;
                    defaultVoiceSet = true;
                    console.log("加载已保存音色:", selectedVoice.name);
                }
            }

            // 2. 如果未设置默认音色,尝试查找第一个中文音色
            if (!defaultVoiceSet) {
                const chineseVoice = availableVoices.find(v => v.lang.toLowerCase().startsWith('zh'));
                if (chineseVoice) {
                    selectedVoice = chineseVoice;
                    voiceSelect.value = selectedVoice.name;
                    defaultVoiceSet = true;
                    console.log("默认选择中文音色:", selectedVoice.name);
                }
            }

            // 3. 如果仍未设置默认音色(无保存、无中文),选择列表第一个
            if (!defaultVoiceSet && availableVoices.length > 0) {
                selectedVoice = availableVoices[0];
                voiceSelect.value = selectedVoice.name;
                defaultVoiceSet = true;
                console.log("默认选择第一个可用音色:", selectedVoice.name);
            }

            console.log("音色列表处理完成:", availableVoices);
            voicesInitialized = true; // 标记为已初始化

            // (移除 voiceLoadInterval 相关逻辑)
            // if (voiceLoadInterval) {
            //     clearInterval(voiceLoadInterval);
            //     voiceLoadInterval = null;
            // }
        } else {
            // 初始加载时可能为空,需要等待 voiceschanged 事件
            console.log("首次尝试加载音色列表为空,等待 voiceschanged 事件...");
            // 添加一次性的 voiceschanged 监听器
            synth.addEventListener('voiceschanged', function handleVoicesChangedOnce() {
                 console.log("voiceschanged 事件触发,尝试重新加载音色...");
                 synth.removeEventListener('voiceschanged', handleVoicesChangedOnce); // 移除监听器
                 loadVoices(); // 再次调用 loadVoices
            }, { once: true }); // 使用 once 选项确保只触发一次
        }
    }

    /**
     * 填充音色选择下拉框
     */
    function populateVoiceSelect() {
        voiceSelect.innerHTML = ''; // 清空现有选项
        availableVoices.forEach(voice => {
            const option = document.createElement('option');
            option.value = voice.name;
            option.textContent = `${voice.name} (${voice.lang})`;
            voiceSelect.appendChild(option);
        });
        // 恢复选择
        if (selectedVoice) {
            voiceSelect.value = selectedVoice.name;
        }
    }

    /**
     * 预加载指定范围的字幕语音
     * @param {number} startIndex 开始索引
     * @param {number} count 加载数量
     */
    function preloadVoices(startIndex, count) {
        if (!selectedVoice) {
            console.warn("尚未选择音色,无法预加载语音");
            return;
        }
        const endIndex = Math.min(startIndex + count, subtitles.length);
        console.log(`开始预加载语音: 索引 ${startIndex} 到 ${endIndex - 1}`);
        for (let i = startIndex; i < endIndex; i++) {
            const sub = subtitles[i];
            if (!voiceCache[sub.id]) { // 仅当缓存中不存在时才创建
                const utterance = new SpeechSynthesisUtterance(sub.text);
                utterance.voice = selectedVoice;
                utterance.lang = selectedVoice.lang;
                // 注意:此时不调用 synth.speak(),只是创建对象
                voiceCache[sub.id] = utterance;
                // console.log(`已创建 Utterance 缓存: ${sub.id}`);
            }
        }
        console.log(`预加载完成: 索引 ${startIndex} 到 ${endIndex - 1}`);
    }

    /**
     * 播放指定字幕的语音
     * @param {number} index 字幕索引
     */
    function playVoice(index) {
        // 基本检查: 无视频、无效索引、已播放、正在播放其他语音、或视频已暂停
        if (!videoElement || index < 0 || index >= subtitles.length || index === lastSpokenIndex || synth.speaking || videoElement.paused) {
            // console.log(`跳过播放: index=${index}, lastSpoken=${lastSpokenIndex}, speaking=${synth.speaking}, paused=${videoElement?.paused}`);
            return;
        }

        const sub = subtitles[index];
        let utterance = voiceCache[sub.id];

        // 缓存未命中处理
        if (!utterance) {
            console.warn(`缓存未命中,尝试即时创建语音: ${sub.id}`);
            if (!selectedVoice) {
                 console.error("无选定音色,无法创建语音");
                 return;
            }
            utterance = new SpeechSynthesisUtterance(sub.text);
            utterance.voice = selectedVoice;
            utterance.lang = selectedVoice.lang;
            voiceCache[sub.id] = utterance; // 加入缓存
        }

        // --- 播放前准备 ---
        const subtitleDuration = sub.endTime - sub.startTime;
        let estimatedVoiceDuration = utterance._actualDuration; // 优先使用缓存的实际时长

        // 如果没有实际时长,则估算
        if (!estimatedVoiceDuration) {
            estimatedVoiceDuration = estimateSpeechDuration(utterance);
            // console.log(`估算语音时长: ${sub.id} -> ${estimatedVoiceDuration}ms`);
        }

        console.log(`准备播放: ${sub.id} (${index}) - "${sub.text.substring(0, 20)}..."`);
        console.log(`字幕时长: ${subtitleDuration}ms, 语音时长: ${estimatedVoiceDuration}ms (${utterance._actualDuration ? '实际' : '估算'})`);

        // --- 事件处理 ---
        let voiceStartTime = 0; // 用于计算实际时长

        // 清理旧监听器(重要,防止重复添加)
        utterance.onstart = null;
        utterance.onend = null;
        utterance.onerror = null;

        utterance.onstart = () => {
            console.log(`语音开始播放: ${sub.id}`);
            voiceStartTime = performance.now();
            // (移除视频速率调整逻辑)
            // 确保视频音量未被静音 (如果之前被静音过)
            if (videoElement && videoElement.muted) {
                 console.log("确保视频未静音");
                 videoElement.muted = false;
            }
             // 确保视频播放速率为 1.0 (如果之前被修改过)
            if (videoElement && videoElement.playbackRate !== 1.0) {
                console.log("确保视频播放速率为 1.0");
                videoElement.playbackRate = 1.0;
            }
        };

        utterance.onend = () => {
            const voiceEndTime = performance.now();
            const actualVoiceDuration = voiceEndTime - voiceStartTime;

            if (voiceStartTime > 0) { // 确保 onstart 被触发过
                utterance._actualDuration = actualVoiceDuration; // 缓存实际时长
                console.log(`语音播放结束: ${sub.id}, 实际时长: ${actualVoiceDuration.toFixed(0)}ms`);

                // (移除 sub.playbackRate 计算和存储)
            } else {
                console.log(`语音播放结束 (onstart 未触发?): ${sub.id}`);
            }

            // (移除累积时间偏移更新逻辑)

            lastSpokenIndex = index; // 标记为已播放

            // --- 触发下一批预加载 ---
            // 当播放到当前批次的第 PRELOAD_TRIGGER_INDEX 条时 (索引从0开始)
            // 并且下一批的起始索引没有超出字幕总数
            const nextBatchStartIndex = Math.floor(index / PRELOAD_COUNT) * PRELOAD_COUNT + PRELOAD_COUNT;
            if (index % PRELOAD_COUNT === PRELOAD_TRIGGER_INDEX && nextBatchStartIndex < subtitles.length) {
                 console.log(`播放到索引 ${index},触发预加载下一批: 从 ${nextBatchStartIndex} 开始`);
                 preloadVoices(nextBatchStartIndex, PRELOAD_COUNT);
            }

            // --- 处理等待状态 ---
            if (isWaitingForAudio) {
                console.log(`音频 ${index} 播放完毕,恢复视频并准备播放下一条 ${nextSubtitleIndexToPlay}`);
                isWaitingForAudio = false;
                const nextIndex = nextSubtitleIndexToPlay;
                nextSubtitleIndexToPlay = -1; // 重置

                // 确保视频存在且仍处于暂停状态(防止用户在等待时手动播放)
                if (videoElement && videoElement.paused) {
                    videoElement.play(); // 恢复视频播放
                }

                // 延迟一小段时间再播放下一条语音,给视频一点缓冲时间
                // 否则可能 video.play() 还没生效,就被 playVoice 里的 paused 检查挡住
                setTimeout(() => {
                    if (nextIndex !== -1) {
                         playVoice(nextIndex); // 播放之前被暂缓的字幕语音
                    }
                }, 50); // 50ms 延迟,可以根据需要调整

            }

            // (移除恢复播放状态逻辑)

            // --- 移除预加载逻辑 ---
            // 检查是否需要触发下一批预加载... (移除)
        };

        utterance.onerror = (event) => {
            console.error(`语音合成错误: ${sub.id}`, event.error);
            utterance._actualDuration = undefined; // 清除可能不准的缓存
            // --- 处理等待状态 (错误情况) ---
            if (isWaitingForAudio) {
                 console.warn(`语音 ${index} 播放出错,但仍在等待状态。尝试恢复视频...`);
                 isWaitingForAudio = false;
                 nextSubtitleIndexToPlay = -1; // 清除待播放索引
                 if (videoElement && videoElement.paused) {
                     videoElement.play();
                 }
            }
            lastSpokenIndex = index; // 即使错误也标记,防止卡住
        };

        // (移除音频播放速率设置逻辑)
        utterance.rate = 1.0; // 确保速率始终为 1.0


        // 播放语音
        try {
            synth.cancel(); // 在播放新语音前,取消任何正在播放或排队的语音
            synth.speak(utterance);
        } catch (error) {
            console.error(`synth.speak 错误: ${sub.id}`, error);
             // --- 处理等待状态 (catch 块) ---
            if (isWaitingForAudio) {
                 console.warn(`调用 speak 时出错,但仍在等待状态。尝试恢复视频...`);
                 isWaitingForAudio = false;
                 nextSubtitleIndexToPlay = -1; // 清除待播放索引
                 if (videoElement && videoElement.paused) {
                     videoElement.play();
                 }
            }
            lastSpokenIndex = index; // 即使错误也标记
        }
    }

    /**
     * 估算语音时长 (这是一个非常粗略的估算)
     * @param {SpeechSynthesisUtterance} utterance
     * @returns {number} 毫秒
     */
    function estimateSpeechDuration(utterance) {
        // 估算值,可以根据经验调整
        // 英文大约 15 chars/sec -> 67ms/char
        // 中文大约 4-5 chars/sec -> 200-250ms/char
        // 这里取一个折中偏快的值,避免不必要的减速
        const msPerChar = utterance.lang.startsWith('zh') ? 180 : 75;
        const minDuration = 500; // 至少给 500ms
        const estimated = Math.max(minDuration, utterance.text.length * msPerChar);
        // console.log(`估算时长 (${utterance.lang}): ${utterance.text.length} chars * ${msPerChar}ms/char -> ${estimated.toFixed(0)}ms`);
        return estimated;
    }

    // --- 事件处理 ---

    /**
     * 处理文件上传
     * @param {Event} event
     */
    function handleFileUpload(event) {
        const file = event.target.files[0];
        if (!file) {
            return;
        }

        resetState(); // 重置状态

        const reader = new FileReader();
        reader.onload = (e) => {
            try {
                subtitles = parseSRT(e.target.result);
                console.log(`SRT 文件解析成功,共 ${subtitles.length} 条字幕`);
                if (subtitles.length > 0) {
                    console.log("字幕解析成功,开始查找视频元素并准备播放...");
                    findVideoElement(); // 查找视频元素

                    // 确保 videoElement 已找到后再操作
                    const checkVideoInterval = setInterval(() => {
                        if (videoElement) {
                            clearInterval(checkVideoInterval);
                            console.log("视频元素已找到,暂停并重置进度。视频将保持暂停,等待用户手动播放。");
                            // 标记用户已交互
                            hasUserInteracted = true;
                            videoElement.pause();
                            // 设置标志位,阻止 seeked 事件自动播放
                            blockSeekedAutoPlay = true;
                            videoElement.currentTime = 0;
                            // 稍后重置标志位
                            setTimeout(() => { blockSeekedAutoPlay = false; }, 100); // 延迟时间应足够 seeked 事件触发

                            // 初始预加载第一批语音
                            preloadVoices(0, PRELOAD_COUNT);
                        } else {
                            console.log("仍在等待视频元素...");
                            // 可以添加超时逻辑
                        }
                    }, 200); // 每 200ms 检查一次

                } else {
                    alert("解析成功,但未发现有效字幕条目。");
                }
            } catch (error) {
                console.error("解析 SRT 文件失败:", error);
                alert(`解析 SRT 文件失败: ${error.message}`);
                resetState();
            }
        };
        reader.onerror = (e) => {
            console.error("读取文件失败:", e);
            alert("读取文件失败");
            resetState();
        };
        reader.readAsText(file);

        // 清空文件选择,以便可以再次选择同一个文件
        event.target.value = null;
    }

    /**
     * 处理显示字幕复选框变化
     */
    function handleShowSubtitleChange() {
        isShowingSubtitles = showSubtitleCheckbox.checked;
        if (subtitleDisplay) {
            subtitleDisplay.style.display = isShowingSubtitles ? 'block' : 'none';
            if (!isShowingSubtitles) {
                subtitleDisplay.textContent = ''; // 清空内容
            }
        }
        console.log(`显示字幕: ${isShowingSubtitles}`);
    }

    /**
     * 处理音色选择变化
     */
    function handleVoiceChange() {
        const selectedName = voiceSelect.value;
        selectedVoice = availableVoices.find(v => v.name === selectedName) || null;
        if (selectedVoice) {
            GM_setValue('selectedVoiceName', selectedVoice.name); // 保存选择
            console.log(`音色已选择: ${selectedVoice.name}`);
            // 如果已有字幕,需要重新预加载语音
            if (subtitles.length > 0) {
                console.log("音色已更改,重新预加载语音...");
                voiceCache = {}; // 清空旧缓存
                lastSpokenIndex = -1; // 重置播放状态
                synth.cancel(); // 取消当前语音
                // 音色更改后,重新预加载第一批语音
                preloadVoices(0, PRELOAD_COUNT);
            }
        } else {
            console.warn("选择的音色无效");
        }
    }

    /**
     * 处理音色搜索框输入
     * @param {Event} event
     */
    function handleVoiceSearch(event) {
        const searchTerm = event.target.value.toLowerCase().trim();
        const options = voiceSelect.options;
        let firstVisibleOption = null;

        for (let i = 0; i < options.length; i++) {
            const option = options[i];
            const optionText = option.textContent.toLowerCase();
            const matches = optionText.includes(searchTerm);
            option.style.display = matches ? '' : 'none';
            if (matches && !firstVisibleOption) {
                firstVisibleOption = option;
            }
        }

        // 可选:如果当前选中的选项被隐藏了,自动选中第一个可见的选项
        // if (voiceSelect.selectedOptions.length > 0 && voiceSelect.selectedOptions[0].style.display === 'none' && firstVisibleOption) {
        //     voiceSelect.value = firstVisibleOption.value;
        //     // 注意:这里不应该触发 handleVoiceChange,只是更新显示
        // }
        // 简单起见,暂时不自动切换选中项
    }


    /**
     * 处理视频时间更新
     */
    function handleTimeUpdate() {
        // 基本检查: 无视频、无字幕、无音色
        // 注意:即使视频暂停,也可能需要处理音频结束后的逻辑,所以不在这里检查 videoElement.paused
        if (!videoElement || subtitles.length === 0 || !selectedVoice) {
            return;
        }

        // 如果正在等待音频结束,则不处理时间更新 (除非视频被外部暂停了)
        if (isWaitingForAudio && !videoElement.paused) {
            return;
        }
        // 如果是因为等待而暂停,但被外部播放了,取消等待状态
        if (isWaitingForAudio && videoElement.paused === false) {
             console.log("视频在等待期间被外部播放,取消等待状态");
             isWaitingForAudio = false;
             nextSubtitleIndexToPlay = -1;
        }

        const currentTimeMs = videoElement.currentTime * 1000;
        // (移除 adjustedSearchTime 计算)
        const newSubtitleIndex = findSubtitleIndex(currentTimeMs); // 使用原始时间
        // console.log(`TimeUpdate: Current=${currentTimeMs.toFixed(0)}, Index=${newSubtitleIndex}`);


        // 更新字幕显示 (仅在内容变化时更新 DOM)
        if (isShowingSubtitles && subtitleDisplay) {
            const currentText = newSubtitleIndex !== -1 ? subtitles[newSubtitleIndex].text : '';
            if (subtitleDisplay.textContent !== currentText) {
                 subtitleDisplay.textContent = currentText;
            }
        }

        // --- 语音播放逻辑 ---
        if (newSubtitleIndex !== -1) {
            // 找到了当前时间对应的字幕
            // 检查是否是新的、尚未播放过的字幕
            if (newSubtitleIndex > lastSpokenIndex) {
                // 检查是否有语音正在播放 (冲突检测)
                if (synth.speaking) {
                    // 如果正在播放语音,并且视频没有暂停,则暂停视频等待
                    if (!videoElement.paused) {
                        console.log(`语音播放冲突: 正在播放 ${lastSpokenIndex}, 需要播放 ${newSubtitleIndex}。暂停视频等待...`);
                        videoElement.pause();
                        isWaitingForAudio = true;
                        nextSubtitleIndexToPlay = newSubtitleIndex;
                    } else {
                         // 如果视频已经暂停了(可能是用户暂停的),则不强制播放,但记录下需要播放的索引
                         console.log(`语音播放冲突,但视频已暂停。记录待播放索引 ${newSubtitleIndex}`);
                         nextSubtitleIndexToPlay = newSubtitleIndex; // 记录,但不设置 isWaitingForAudio
                    }
                } else {
                    // 没有语音在播放,直接播放新的字幕语音
                    playVoice(newSubtitleIndex);
                }
            }
            // (移除旧注释)
        } else {
            // 当前时间没有对应字幕

            // 如果有正在播放的语音,且当前时间已经超出了该语音对应的字幕范围,则停止它
            // (适用于用户快进跳过字幕的情况)
            // 增加一个检查,确保 lastSpokenIndex 是有效的
            if (synth.speaking && lastSpokenIndex >= 0 && lastSpokenIndex < subtitles.length && currentTimeMs > subtitles[lastSpokenIndex].endTime + 200) { // 加一点缓冲时间
                console.log(`用户可能已跳过字幕 ${lastSpokenIndex},停止当前语音`);
                synth.cancel();
                // lastSpokenIndex 保持不变或根据需要重置,这里保持不变可能更好
            }
        }

        // 更新当前字幕索引(无论是否播放语音)
        // 只有在索引实际改变时才更新,避免不必要的赋值
        if (currentSubtitleIndex !== newSubtitleIndex) {
             currentSubtitleIndex = newSubtitleIndex;
        }
    }

    /**
     * 处理视频跳转完成事件 (seeked)
     */
    function handleSeeked() {
        if (!videoElement) return;
        console.log(`视频跳转完成 (seeked) 到: ${videoElement.currentTime.toFixed(3)}s`);
        // (移除 accumulatedTimeOffset 重置)
        // 取消当前可能正在播放或排队的语音
        synth.cancel();
        // 重置上次播放索引,允许立即播放跳转后的字幕语音
        lastSpokenIndex = -1;
        // 重置等待状态
        isWaitingForAudio = false;
        nextSubtitleIndexToPlay = -1;
        // 确保视频是播放状态(如果跳转前是暂停的,跳转后应该恢复播放)
        // 增加检查,防止在文件上传重置时自动播放,并确保用户已交互
        if (videoElement && videoElement.paused && !blockSeekedAutoPlay && hasUserInteracted) {
             console.log("Seeked 事件:恢复播放 (用户已交互)");
             videoElement.play().catch(e => console.error("恢复播放失败:", e)); // 添加 catch 以防万一
        } else if (blockSeekedAutoPlay) {
             console.log("Seeked 事件:因 blockSeekedAutoPlay 标志阻止自动播放");
        } else if (!hasUserInteracted) {
             console.log("Seeked 事件:用户尚未交互,不自动播放");
        }
        // 立即触发一次时间更新处理,以显示正确的字幕并准备播放语音
        handleTimeUpdate();
    }

    /**
     * 查找页面上的视频元素
     */
    function findVideoElement() {
        // 尝试常见的 video 标签
        videoElement = document.querySelector('video');
        if (videoElement) {
            console.log("找到视频元素:", videoElement);
            // 移除旧监听器(如果存在)
            videoElement.removeEventListener('timeupdate', handleTimeUpdate);
            videoElement.removeEventListener('seeked', handleSeeked); // 移除旧的 seeked 监听器
            // 添加新监听器
            videoElement.addEventListener('timeupdate', handleTimeUpdate);
            videoElement.addEventListener('seeked', handleSeeked); // 添加 seeked 监听器

            // --- 初始化 UI 位置到视频底部居中 ---
            if (container) {
                try {
                    const videoRect = videoElement.getBoundingClientRect();
                    const containerRect = container.getBoundingClientRect();
                    const marginBottom = 15; // 距离视频底部的边距 (px)

                    // 计算目标位置 (使用 fixed 定位,相对于视口)
                    let targetTop = videoRect.top + videoRect.height - containerRect.height - marginBottom;
                    let targetLeft = videoRect.left + (videoRect.width / 2) - (containerRect.width / 2);

                    // 简单的边界检查,防止 UI 完全移出屏幕可视区域
                    targetTop = Math.max(5, Math.min(targetTop, window.innerHeight - containerRect.height - 5));
                    targetLeft = Math.max(5, Math.min(targetLeft, window.innerWidth - containerRect.width - 5));

                    console.log(`初始化 UI 位置到视频底部居中: top=${targetTop.toFixed(0)}px, left=${targetLeft.toFixed(0)}px`);

                    // 应用样式
                    container.style.position = 'fixed'; // 确保是 fixed 定位
                    container.style.top = `${targetTop}px`;
                    container.style.left = `${targetLeft}px`;
                    container.style.bottom = 'auto'; // 清除 bottom
                    container.style.right = 'auto'; // 清除 right
                    container.style.transform = 'none'; // 清除 transform (之前用于居中)
                } catch (e) {
                    console.error("设置 UI 初始位置时出错:", e);
                    // 出错时回退到默认位置或不进行操作
                }
            }
            // --- UI 位置初始化结束 ---

        } else {
            console.warn("未找到 <video> 元素,播放同步功能将不可用。");
            // 可以添加更复杂的逻辑来查找特定网站的播放器
        }
    }

    // --- 初始化 ---

    /**
     * 创建并添加 UI 元素
     */
    function createUI() {
        container = document.createElement('div');
        container.id = 'substream-tts-controls';

        // --- 添加拖动功能 ---
        let isDragging = false;
        let offsetX, offsetY;

        container.style.cursor = 'move'; // 添加拖动光标

        container.addEventListener('mousedown', (e) => {
            // 确保只在容器本身上按下鼠标左键时触发拖动
            if (e.target === container && e.button === 0) {
                isDragging = true;
                const rect = container.getBoundingClientRect();
                offsetX = e.clientX - rect.left;
                offsetY = e.clientY - rect.top;

                // 确保使用 top/left 定位
                container.style.bottom = 'auto';
                container.style.top = `${rect.top}px`;
                container.style.left = `${rect.left}px`;

                document.addEventListener('mousemove', handleMouseMove);
                document.addEventListener('mouseup', handleMouseUp);
                e.preventDefault(); // 防止拖动时选中文本
            }
        });

        function handleMouseMove(e) {
            if (!isDragging) return;
            const newTop = e.clientY - offsetY;
            const newLeft = e.clientX - offsetX;
            container.style.top = `${newTop}px`;
            container.style.left = `${newLeft}px`;
        }

        function handleMouseUp() {
            if (isDragging) {
                isDragging = false;
                document.removeEventListener('mousemove', handleMouseMove);
                document.removeEventListener('mouseup', handleMouseUp);
            }
        }
        // --- 拖动功能结束 ---


        // 文件上传按钮
        const fileInput = document.createElement('input');
        fileInput.type = 'file';
        fileInput.accept = '.srt';
        fileInput.style.display = 'none'; // 隐藏原生输入框
        fileInput.addEventListener('change', handleFileUpload);

        uploadButton = document.createElement('button');
        uploadButton.textContent = '上传 SRT';
        uploadButton.addEventListener('click', () => fileInput.click()); // 点击按钮触发文件选择

        // 显示字幕复选框
        const showSubtitleLabel = document.createElement('label');
        showSubtitleCheckbox = document.createElement('input');
        showSubtitleCheckbox.type = 'checkbox';
        showSubtitleCheckbox.checked = isShowingSubtitles;
        showSubtitleCheckbox.addEventListener('change', handleShowSubtitleChange);
        showSubtitleLabel.appendChild(showSubtitleCheckbox);
        showSubtitleLabel.appendChild(document.createTextNode(' 显示字幕'));

        // --- 音色选择(带搜索) ---
        const voiceSearchContainer = document.createElement('div');
        voiceSearchContainer.style.marginLeft = '15px'; // 与其他控件对齐
        voiceSearchContainer.style.display = 'inline-block'; // 使其水平排列

        const voiceSearchLabel = document.createElement('label');
        voiceSearchLabel.appendChild(document.createTextNode(' 音色: '));

        const voiceSearchInput = document.createElement('input');
        voiceSearchInput.type = 'text';
        voiceSearchInput.placeholder = '搜索音色...';
        voiceSearchInput.style.marginLeft = '5px';
        voiceSearchInput.style.padding = '4px'; // 调整内边距
        voiceSearchInput.style.border = '1px solid #ccc';
        voiceSearchInput.style.borderRadius = '3px';
        voiceSearchInput.addEventListener('input', handleVoiceSearch);

        voiceSelect = document.createElement('select');
        voiceSelect.style.marginLeft = '5px';
        voiceSelect.style.maxWidth = '200px'; // 限制最大宽度
        voiceSelect.addEventListener('change', handleVoiceChange);

        voiceSearchContainer.appendChild(voiceSearchLabel);
        voiceSearchContainer.appendChild(voiceSearchInput);
        voiceSearchContainer.appendChild(voiceSelect);
        // --- 音色选择结束 ---


        // 字幕显示区域
        subtitleDisplay = document.createElement('div');
        subtitleDisplay.id = 'substream-tts-display';
        subtitleDisplay.style.display = isShowingSubtitles ? 'block' : 'none';

        // 添加到容器
        container.appendChild(uploadButton);
        container.appendChild(showSubtitleLabel);
        // container.appendChild(voiceSelectLabel); // 改为添加搜索容器
        container.appendChild(voiceSearchContainer);
        container.appendChild(subtitleDisplay); // 将字幕显示区域添加到容器内部

        // 添加到页面
        document.body.appendChild(container);
        // document.body.appendChild(subtitleDisplay); // 不再单独添加

        // 添加样式
        GM_addStyle(`
            #substream-tts-controls {
                position: fixed;
                /* bottom: 10px; */ /* 改用 top/left 定位 */
                top: calc(100vh - 80px); /* 初始大致位置,拖动后会更新 */
                left: 10px;
                background-color: rgba(0, 0, 0, 0.7);
                color: white;
                cursor: move; /* 添加拖动光标 */
                padding: 10px;
                border-radius: 5px;
                z-index: 9999;
                font-family: sans-serif;
                font-size: 14px;
            }
            #substream-tts-controls label {
                margin-left: 15px;
                cursor: pointer;
            }
            #substream-tts-controls button, #substream-tts-controls select {
                margin-left: 5px;
                padding: 5px;
                border: 1px solid #ccc;
                border-radius: 3px;
            }
            #substream-tts-display {
                /* position: fixed; */ /* 不再需要 fixed 定位 */
                position: absolute; /* 相对于父容器 (controls) 定位 */
                bottom: 100%; /* 定位到父容器顶部 */
                left: 0; /* 与父容器左侧对齐 */
                width: 100%; /* 宽度与父容器一致 */
                /* max-width: 800px; */ /* 最大宽度可能需要调整或移除 */
                margin-bottom: 5px; /* 在字幕和控制面板之间添加一点间距 */
                background-color: rgba(0, 0, 0, 0.8);
                color: white;
                padding: 10px; /* 调整内边距 */
                border-radius: 5px;
                /* z-index: 9998; */ /* 不再需要 z-index */
                font-size: 16px; /* 调整字体大小 */
                text-align: center;
                pointer-events: none; /* 允许点击穿透 */
                box-sizing: border-box; /* 确保 padding 不会影响总宽度 */
            }
        `);
    }

    /**
     * 初始化脚本
     */
    function init() {
        console.log("SubStream TTS 初始化...");
        createUI();

        // --- 处理 Web Speech API 音色加载 ---
        // 尝试加载音色
        // loadVoices 函数内部会处理 voiceschanged 事件(如果需要)
        loadVoices();

        // (移除 onvoiceschanged 和 setInterval 逻辑)
        // if (synth.onvoiceschanged !== undefined) { ... }
        // if (!voicesLoaded) { ... }


        // 初始尝试查找视频元素
        findVideoElement();
        // 也可以设置一个延时或MutationObserver来更可靠地查找动态加载的视频
        setTimeout(findVideoElement, 3000); // 3秒后再次尝试

        console.log("SubStream TTS 初始化完成.");
    }

    // --- 脚本入口 ---
    // 延迟执行,等待页面加载
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();