XavierTTS - 字幕同声传译

通过上传SRT文件,使用Web Speech API为视频添加同声传译语音,并可选择音色。

目前為 2025-05-01 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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; // 用户是否已与页面交互

    // --- 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() {
        availableVoices = synth.getVoices().filter(voice => DEFAULT_VOICE_FILTER(voice.name));
        if (availableVoices.length > 0) {
            voicesLoaded = true;
            populateVoiceSelect();
            // 尝试加载上次选择的音色
            const savedVoiceName = GM_getValue('selectedVoiceName');
            if (savedVoiceName) {
                const savedVoice = availableVoices.find(v => v.name === savedVoiceName);
                if (savedVoice) {
                    selectedVoice = savedVoice;
                    voiceSelect.value = savedVoiceName;
                }
            }
            if (!selectedVoice && availableVoices.length > 0) {
                selectedVoice = availableVoices[0]; // 默认选择第一个
                voiceSelect.value = selectedVoice.name;
            }
            console.log("音色加载完成:", availableVoices);
            if (voiceLoadInterval) {
                clearInterval(voiceLoadInterval);
                voiceLoadInterval = null;
            }
        } else {
            console.log("等待音色加载...");
        }
    }

    /**
     * 填充音色选择下拉框
     */
    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; // 标记为已播放

            // --- 处理等待状态 ---
            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("选择的音色无效");
        }
    }

    /**
     * 处理视频时间更新
     */
    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 voiceSelectLabel = document.createElement('label');
        voiceSelect = document.createElement('select');
        voiceSelect.addEventListener('change', handleVoiceChange);
        voiceSelectLabel.appendChild(document.createTextNode(' 音色: '));
        voiceSelectLabel.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(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 音色加载 ---
        // getVoices() 可能是异步的,需要监听 voiceschanged 事件或轮询
        if (synth.onvoiceschanged !== undefined) {
            synth.onvoiceschanged = loadVoices;
        }
        // 作为备用方案或初始加载尝试
        loadVoices();
        // 如果初始加载未成功,设置轮询
        if (!voicesLoaded) {
           voiceLoadInterval = setInterval(() => {
                loadVoices();
                if (voicesLoaded && voiceLoadInterval) {
                    clearInterval(voiceLoadInterval);
                    voiceLoadInterval = null;
                }
            }, 500); // 每 500ms 检查一次
             // 设置超时停止轮询
            setTimeout(() => {
                if (voiceLoadInterval) {
                    clearInterval(voiceLoadInterval);
                    voiceLoadInterval = null;
                    if (!voicesLoaded) {
                        console.warn("音色加载超时。");
                        alert("无法加载语音合成音色,请检查浏览器支持或刷新页面重试。");
                    }
                }
            }, 10000); // 10秒超时
        }


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

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

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

})();