// ==UserScript== // @name Youtube实时翻译脚本-基础班 // @namespace http://tampermonkey.net/ // @version 1.0 // @license MIT // @author wangwangit // @description YouTube实时翻译脚本,提供优化的流媒体和缓存功能,将英文字幕实时翻译成中文并转换为语音播放。 // @match *://*.youtube.com/* // @grant GM_xmlhttpRequest // @connect xxxx // @connect xxxx // @connect api.x.ai // @run-at document-end // @downloadURL none // ==/UserScript== (function() { 'use strict'; const CONFIG = { // 添加 AI 模型配置 AI_MODELS: { TYPE: 'OPENAI', // 默认使用 XAI XAI: { API_KEY: 'xxxx', API_URL: 'https://xxxx/v1/chat/completions', MODEL: 'grok-beta', STREAM: false // XAI 不支持流式 }, OPENAI: { API_KEY: 'xxxx', API_URL: 'https://xxxx/v1/chat/completions', MODEL: 'Qwen/Qwen2.5-7B-Instruct', STREAM: true // OpenAI 支持流式 } }, // TTS(文字转语音)相关配置 TTS: { TYPE: 'BROWSER', // 可选值: 'VITS' 或 'EDGE' VITS: { BASE_URL: 'https://xxxx/tts', VOICES: { 'char_model/原神/珊瑚宫心海/牌局的形势千变万化,想要获胜的话…有时候也必须兵行险着。.wav': '珊瑚宫心海' }, DEFAULT_VOICE: "char_model/原神/珊瑚宫心海/牌局的形势千变万化,想要获胜的话…有时候也必须兵行险着。.wav" }, BROWSER: { // 浏览器TTS模式下默认参数 语速 RATE: 1.0, // 语调 PITCH: 1.0, // 音量 VOLUME: 1.0, VOICE: null // 添加此属性来存储选中的语音 }, EDGE: { BASE_URL: 'https://xxxx', VOICES: { 'Female-zh-CN-XiaoxiaoNeural': '晓晓 (女声,温暖)', 'Female-zh-CN-XiaoyiNeural': '晓伊 (女声,活泼)', 'Male-zh-CN-YunjianNeural': '云健 (男声,激情)', 'Male-zh-CN-YunxiNeural': '云希 (男声,阳光)', 'Male-zh-CN-YunxiaNeural': '云夏 (男声,可爱)', 'Male-zh-CN-YunyangNeural': '云扬 (男声,专业)', 'Female-zh-CN-liaoning-XiaobeiNeural': '晓北 (女声,东北方言)', 'Female-zh-CN-shaanxi-XiaoniNeural': '晓妮 (女声,陕西方言)' }, DEFAULT_VOICE: 'Male-zh-CN-YunxiNeural', RATE: 0, VOLUME: 0 } }, // 缓存详细配置 CACHE: { AUDIO_SIZE: 500, // 音频缓存容量 TRANS_SIZE: 500, // 翻译缓存容量 } } // 2. 创建基础缓存类 class BaseCache { /** * @description: 构造函数,初始化缓存。 * @param {number} capacity - 缓存容量。 * @param {string} prefix - 缓存键前缀。 */ constructor(capacity, prefix) { this.cache = new LRUCache(capacity); this.prefix = prefix; } /** * @description: 生成缓存键。 * @param {string} text - 用于生成缓存键的文本。 * @param {number} startTime - 开始时间。 * @return {string} - 生成的缓存键。 */ generateCacheKey(startTime) { const uid = getUid(); const key = `${this.prefix}${uid}${startTime}`; // console.log('生成缓存键:', { // 前缀: this.prefix, // 开始时间: startTime, // 原始文本: text.slice(0, 30) + '...', // 缓存键: key // }); return key; } /** * @description: 将缓存保存到 localStorage。 * @param {string} storageKey - localStorage 键。 * @return {Promise} * @throws {Error} - 保存缓存失败时抛出异常。 */ async saveToStorage(storageKey) { try { const cacheData = {}; this.cache.cache.forEach((value, key) => { cacheData[key] = value; }); localStorage.setItem(storageKey, JSON.stringify(cacheData)); // console.log('cache', '缓存已保存:', { // 缓存条目数: Object.keys(cacheData).length, // 存储大小: JSON.stringify(cacheData).length + ' bytes' // }); } catch (error) { console.log('error', '保存缓存失败:', error); } } /** * @description: 从 localStorage 加载缓存。 * @param {string} storageKey - localStorage 键。 * @return {Promise} - 加载的缓存数据,如果未找到则返回 null。 * @throws {Error} - 加载缓存失败时抛出异常。 */ async loadFromStorage(storageKey) { try { console.log('loadFromStorage', '开始加载缓存:', storageKey); const cacheData = localStorage.getItem(storageKey); if (!cacheData) { console.log('warning', '未找到缓存数据'); return null; } const parsedCache = JSON.parse(cacheData); Object.entries(parsedCache).forEach(([key, value]) => { this.cache.put(key, value); }); // console.log('success', '已加载缓存:', { // 缓存条目数: this.cache.size, // 缓存容量: this.cache.capacity // }); } catch (error) { console.log('error', '加载缓存失败:', error); } } } // LRU缓存实现 class LRUCache { /** * @description: 构造函数,初始化LRU缓存。 * @param {number} capacity - 缓存容量。 */ constructor(capacity) { this.capacity = capacity; this.cache = new Map(); // 最大历史记录数 this.maxHistorySize = 10; } /** * @description: 获取缓存值。 * @param {string} key - 缓存键。 * @return {any} - 缓存值,如果未找到则返回 null。 */ get(key) { if (!this.cache.has(key)) return null; const value = this.cache.get(key); this.cache.delete(key); this.cache.set(key, value); // 更新访问时间 return value; } /** * @description: 设置缓存值。 * @param {string} key - 缓存键。 * @param {any} value - 缓存值。 * @return {void} */ put(key, value) { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.capacity) { // 移除最近最少使用的条目 this.cache.delete(this.cache.keys().next().value); } this.cache.set(key, value); } /** * @description: 检查缓存中是否存在键。 * @param {string} key - 缓存键。 * @return {boolean} - 如果存在则返回 true,否则返回 false。 */ has(key) { return this.cache.has(key); } /** * @description: 清空缓存。 * @return {void} */ clear() { this.cache.clear(); } } // 音频管理器 class AudioManager extends BaseCache { constructor() { super(CONFIG.CACHE.AUDIO_SIZE, 'audio' + getUid()); this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); this.db = null; this.dbName = 'YTTranslatorAudio'; this.storeName = 'audioBuffers'; this.initDB(); // 添加浏览器TTS初始化 this.synth = window.speechSynthesis; this.currentUtterance = null; } /** * @description: 停止当前音频播放。 * @return {void} */ async stopVideo() { if (CONFIG.TTS.TYPE === 'BROWSER' && this.currentUtterance) { this.synth.cancel(); this.currentUtterance = null; this.isPlaying = false; } else if (this.currentSource) { try { this.currentSource.stop(); this.currentSource.disconnect(); this.currentSource = null; this.isPlaying = false; console.log('停止当前音频播放'); } catch (error) { console.error('停止音频失败:', error); } } } /** * @description: 使用 Edge TTS 获取音频数据 * @param {string} text - 要转换为音频的文本 * @return {Promise} - 获取的 AudioBuffer */ async fetchAudioEdge(text) { try { console.log("声音类型", CONFIG.TTS.EDGE.DEFAULT_VOICE); // 第一步:发送初始请求获取 EVENT_ID const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://xxxx/call/textToSpeech', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ data: [ text, CONFIG.TTS.EDGE.DEFAULT_VOICE, CONFIG.TTS.EDGE.RATE, CONFIG.TTS.EDGE.VOLUME ] }), onload: response => resolve(response), onerror: error => reject(error) }); }); console.log('edge response', response); const data = JSON.parse(response.responseText); if (!data.event_id) { throw new Error('未获取到有效的 EVENT_ID'); } // 第二步:获取音频URL const audioUrl = await this.handleSSEResponse(data.event_id); console.log('audioUrl', audioUrl); // 第三步:下载并解码音频 return await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: audioUrl, responseType: 'arraybuffer', onload: async response => { try { console.log('response.response', response.response); if (response.status !== 200) { throw new Error(`音频下载失败: ${response.status}`); } const audioBuffer = await this.audioContext.decodeAudioData(response.response); resolve(audioBuffer); } catch (error) { reject(error); } }, onerror: reject }); }); } catch (error) { console.error('Edge TTS 获取音频失败:', error); throw error; } } /** * @description: 处理 SSE 响应 * @param {string} eventId - 事件ID * @return {Promise} - 音频URL */ async handleSSEResponse(eventId) { return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); xhr.open('GET', `${CONFIG.TTS.EDGE.BASE_URL}/call/textToSpeech/${eventId}`, true); xhr.setRequestHeader('Accept', 'text/event-stream'); xhr.setRequestHeader('Cache-Control', 'no-cache'); let buffer = ''; xhr.onreadystatechange = function() { if (xhr.readyState === 3) { let newData = xhr.responseText.substring(buffer.length); buffer = xhr.responseText; let lines = newData.split('\n'); lines.forEach(line => { if (line.startsWith('data:')) { try { const jsonData = JSON.parse(line.slice(5)); if (Array.isArray(jsonData) && jsonData[0]?.path) { xhr.abort(); const url = `${CONFIG.TTS.EDGE.BASE_URL}/file=${jsonData[0].path}`; resolve(url); } } catch (e) { console.log('解析SSE数据失败:', e); } } }); } }; xhr.onerror = reject; xhr.send(); // 30秒超时 setTimeout(() => { xhr.abort(); reject(new Error('SSE请求超时')); }, 300000); }); } /** * @description: 播放音频。 * @param {AudioBuffer} buffer - 要播放的 AudioBuffer。 * @param {number} startTime - 开始时间 (可选)。 * @return {Promise} - 播放完成的 Promise。 * @throws {Error} - 播放失败时抛出异常。 */ async playAudio(buffer) { if (CONFIG.TTS.TYPE === 'BROWSER') { return new Promise((resolve, reject) => { try { this.synth.cancel(); // 停止当前播放 console.log('浏览器TTS模式下直接返回翻译文本'); const utterance = new SpeechSynthesisUtterance(buffer); this.currentUtterance = utterance; // 设置语音参数 utterance.lang = 'zh-CN'; utterance.rate = CONFIG.TTS.BROWSER.RATE; utterance.pitch = CONFIG.TTS.BROWSER.PITCH; utterance.volume = CONFIG.TTS.BROWSER.VOLUME; // 设置选中的语音 if (CONFIG.TTS.BROWSER.VOICE) { const voices = speechSynthesis.getVoices(); const selectedVoice = voices.find(voice => voice.name === CONFIG.TTS.BROWSER.VOICE.name && voice.lang === CONFIG.TTS.BROWSER.VOICE.lang ); if (selectedVoice) { utterance.voice = selectedVoice; } } utterance.onend = () => { this.isPlaying = false; this.currentUtterance = null; resolve(); }; utterance.onerror = (error) => { this.isPlaying = false; this.currentUtterance = null; reject(error); }; this.isPlaying = true; this.synth.speak(utterance); } catch (error) { this.isPlaying = false; this.currentUtterance = null; reject(error); } }); } else { return new Promise((resolve, reject) => { try { //打印当前播放器状态 //console.log('当前播放器状态:', this.shouldPlay); // 检查是否应该播放 - 修改逻辑 // if (!this.shouldPlay) { // 改为检查 !this.shouldPlay // console.log('播放已停止,跳过音频播放'); // return resolve(); // 直接返回,不播放音频 // } // 停止当前播放 // if (this.currentSource) { // console.log('我要停止当前播放'); // this.stop(); // } // 创建新的音频源 const source = this.audioContext.createBufferSource(); source.buffer = buffer; source.connect(this.audioContext.destination); this.currentSource = source; this.isPlaying = true; // 监听播放完成 source.onended = () => { this.isPlaying = false; this.currentSource = null; resolve(); }; // 开始播放 source.start(0); } catch (error) { this.isPlaying = false; this.currentSource = null; reject(error); } }); } } // 初始化IndexedDB async initDB() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, 1); request.onerror = () => { console.error('打开数据库失败:', request.error); reject(request.error); }; request.onsuccess = () => { this.db = request.result; console.log('数据库连接成功'); resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains(this.storeName)) { db.createObjectStore(this.storeName); console.log('创建音频缓存存储空间'); } }; }); } /** * @description: 将 AudioBuffer 序列化为可存储的对象。 * @param {AudioBuffer} audioBuffer - 要序列化的 AudioBuffer。 * @return {object} - 序列化后的对象。 */ serializeAudioBuffer(audioBuffer) { const channelData = []; for (let i = 0; i < audioBuffer.numberOfChannels; i++) { channelData.push(Array.from(audioBuffer.getChannelData(i))); } return { channelData, sampleRate: audioBuffer.sampleRate, length: audioBuffer.length, duration: audioBuffer.duration, numberOfChannels: audioBuffer.numberOfChannels }; } /** * @description: 将序列化后的对象反序列化为 AudioBuffer。 * @param {object} data - 序列化后的对象。 * @return {Promise} - 反序列化后的 AudioBuffer。 */ async deserializeAudioBuffer(data) { const audioBuffer = this.audioContext.createBuffer( data.numberOfChannels, data.length, data.sampleRate ); for (let i = 0; i < data.numberOfChannels; i++) { const channelData = new Float32Array(data.channelData[i]); audioBuffer.copyToChannel(channelData, i); } return audioBuffer; } /** * @description: 将音频数据保存到 IndexedDB。 * @param {string} key - 缓存键。 * @param {AudioBuffer} audioBuffer - 要保存的 AudioBuffer。 * @return {Promise} - 保存完成的 Promise。 * @throws {Error} - 保存失败时抛出异常。 */ async saveToIndexedDB(key, audioBuffer) { if (!this.db) await this.initIndexedDB(); return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readwrite'); const store = transaction.objectStore(this.storeName); const serializedData = this.serializeAudioBuffer(audioBuffer); const request = store.put(serializedData, key); request.onsuccess = () => { console.log('音频数据已保存到 IndexedDB:', key); resolve(); }; request.onerror = () => { console.error('保存音频数据失败:', request.error); reject(request.error); }; }); } // 从 IndexedDB 加载 async loadFromIndexedDB(key) { if (!this.db) await this.initIndexedDB(); return new Promise((resolve, reject) => { const transaction = this.db.transaction([this.storeName], 'readonly'); const store = transaction.objectStore(this.storeName); const request = store.get(key); request.onsuccess = async () => { if (request.result) { try { const audioBuffer = await this.deserializeAudioBuffer(request.result); // console.log('从 IndexedDB 加载音频数据成功:', key); resolve(audioBuffer); } catch (error) { console.error('反序列化音频数据失败:', error); reject(error); } } else { resolve(null); } }; request.onerror = () => { console.error('加载音频数据失败:', request.error); reject(request.error); }; }); } // 获取音频 async getAudio(newSubtitles, startTime) { if (CONFIG.TTS.TYPE === 'BROWSER') { // 浏览器TTS模式下直接返回翻译文本 return newSubtitles.translation; } const cacheKey = this.generateCacheKey(startTime); // 检查缓存 try { const cached = await this.loadFromIndexedDB(cacheKey); if (cached) { console.log('使用缓存的音频:', cacheKey); return cached; } } catch (error) { console.error('读取音频缓存失败:', error); } // 获取新音频 try { const audioBuffer = await this.fetchAudioWithRetry(newSubtitles.translation, newSubtitles.duration); // 保存到缓存 await this.saveToIndexedDB(cacheKey, audioBuffer); return audioBuffer; } catch (error) { console.error('获取音频失败:', error); throw error; } } /** * @description: 使用重试机制获取音频。 * @param {string} text - 要转换为音频的文本。 * @param {number} duration - 预期音频持续时间。 * @return {Promise} - 获取的 AudioBuffer,如果失败则返回 null。 */ async fetchAudioWithRetry(text, duration) { console.log('开始获取音频:', { 文本: text, 持续时间: duration }); // 添加更细致的语速调整 const wordsCount = text.length; const avgCharDuration = 0.2; // 每个字符的平均时长 const expectedDuration = wordsCount * avgCharDuration; let speed_factor = duration ? expectedDuration / duration : 1.0; // 使用更平滑的映射函数 if (speed_factor < 0.8) { speed_factor = 0.8 + (speed_factor / 0.8) * 0.2; } else if (speed_factor > 1.2) { speed_factor = 1.2 - (1.2 / speed_factor) * 0.2; } // 添加音频时长验证 const buffer = await this.fetchAudio(text, speed_factor); return buffer; } /** * @description: 获取音频数据。 * @param {string} text - 要转换为音频的文本。 * @param {number} speed_factor - 语速因子。 * @return {Promise} - 获取的 AudioBuffer。 * @throws {Error} - 获取音频失败时抛出异常。 */ async fetchAudio(text, speed_factor = 1.0) { if (CONFIG.TTS.TYPE === 'EDGE') { return await this.fetchAudioEdge(text); } else { // 原有的 VITS 方法 const params = new URLSearchParams({ text: text, text_lang: "zh", ref_audio_path: CONFIG.TTS.VITS.DEFAULT_VOICE, prompt_lang: "zh", prompt_text: "牌局的形势千变万化,想要获胜的话…有时候也必须兵行险着。", top_k: "5", top_p: "1", temperature: "0.8", speed_factor: speed_factor, fragment_interval: "0.3" }); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `${CONFIG.TTS.VITS.BASE_URL}?${params.toString()}`, responseType: 'arraybuffer', headers: { 'Accept': '*/*', 'Origin': 'https://xxxx', 'Referer': 'https://xxxx' }, onload: async (response) => { try { if (response.status !== 200) { throw new Error(`HTTP Error: ${response.status}`); } const audioBuffer = await this.audioContext.decodeAudioData(response.response); resolve(audioBuffer); } catch (error) { reject(error); } }, onerror: reject }); }); } } // 批量预加载音频 async preloadAudioBatch(subtitles, concurrentLimit = 3) { // 创建任务数组 const tasks = subtitles.map(sub => ({ text: sub.translation, startTime: sub.startTime })); // 并发控制 const results = []; for (let i = 0; i < subtitles.length; i += concurrentLimit) { const batch = subtitles.slice(i, i + concurrentLimit); const promises = batch.map(task => this.getAudio(task, task.startTime) .catch(error => { console.error('音频加载失败:', error); return null; }) ); const batchResults = await Promise.all(promises); results.push(...batchResults); // 简单进度显示 console.log(`音频加载进度: ${i + batch.length}/${tasks.length}`); // 等待500毫秒 await new Promise(resolve => setTimeout(resolve, 500)); } return results; } } // 添加翻译管理器类 class TranslationManager extends BaseCache { constructor() { super(CONFIG.CACHE.TRANS_SIZE, 'trans' + getUid()); this.hasCache = false; // 添加缓存标志 this.currentModel = CONFIG.AI_MODELS.TYPE; this.newSubtitles = []; // 定期保存缓存 // setInterval(() => this.saveToStorage('ytTranslatorTransCache' + getUid()), 30000); this.loadFromStorage('ytTranslatorTransCache' + getUid()); } // 根据不同模型构建请求体 buildRequestBody(text, modelConfig) { const systemPrompt = `你是一位资深的Netflix字幕翻译专家,精通英汉翻译,对影视作品的文化内涵和语言特点有深刻理解。你的任务是将英文Netflix字幕翻译成自然流畅、符合中文表达习惯的中文字幕,并对字幕进行必要的合并和调整,以提升中文观众的观影体验。 **输入格式**: 每行字幕格式为: "时间戳@@@英文字幕" **输出格式**: 每行字幕格式为: "时间戳@@@合并后的英文字幕@@@合并后的中文翻译" **翻译流程**: 1. **上下文理解和字幕优化**: 分析连续最多3行的字幕及其上下文,进行逻辑推理,并对字幕进行必要的合并和调整。以下情况通常需要合并: * 同一人物的连续短句,构成一个完整的表达。 * 对前一句的补充说明或解释。 * 问答,将问题和回答合并成一句(如果合适)。 * 表达并列关系或因果关系的短句。 * **并非所有字幕都需要合并,应仔细斟酌,确保优化后的字幕更符合中文表达习惯,并提升观影体验。例如,不同人物的对话、场景切换、情绪转变等情况,通常不应合并。需要权衡合并后的流畅性和原文的节奏感。** 一些语气词或简短的感叹词,即使是同一人物所说,也可能需要单独保留,以更好地传达情感。 * **合并后的字幕,其对应的中文翻译理想情况下应控制在20-30个汉字之间。如果超过30个汉字,请尝试拆分成多句,并根据句意调整时间戳,确保每句中文翻译的长度在合理的范围内,以避免单行字幕过长影响观影体验。** 2. **拆分长语句** - 检查翻译文本长度,是否存在冗长语句,针对超过50字的语句重新拆分,以20字至30字最佳为最佳字幕长度 - 评估语言流畅性 - 检查语言风格是否与原文一致 - 检查字幕简洁性,指出翻译过于冗长的地方,翻译应接近原文长度 1. 根据 Netflix 字幕标准保持句子意义连贯 2. 保持各部分长度大致相等(每部分至少 3 个单词) 3. 在标点符号或连词等自然点处分割 3. **翻译**: 将优化后的英文翻译成符合Netflix翻译规范的中文。翻译时需注意以下几点: * 准确传达原文的语气、情感、文化背景和潜台词。 * 避免出现误译、漏译、错译等情况。 * 力求使译文简洁明了、自然流畅,符合中文观众的观影习惯。 * 考虑说话者的身份、语气、情绪以及当时的场景,使翻译更贴合剧情和人物设定。 * 准确处理俚语、习语、文化特定表达、语气词、情感表达等。 * 处理好人称代词、指代关系,确保对话的连贯性和人物语气的一致性。 **翻译要求**: 1. **翻译质量标准**: - 遵循Netflix字幕翻译规范 - 确保译文自然流畅,符合中文表达习惯 - 保持原作风格和语气特点 - 准确传达文化内涵和潜台词 2. **特殊处理要求**: - 准确处理俚语、习语和文化特定表达 - 保留必要的语气词和情感表达 - 适当本地化处理,使译文更贴近中文观众 - 处理好人称代词、指代关系 - 注意对话的连贯性和人物语气的一致性 3. **输出格式要求**: - 严格保持 "时间戳@@@中文翻译" 的格式 - 每条翻译独占一行 - 不要添加任何额外注释或说明 - 确保时间戳格式正确(保留3位小数) 4. **禁止事项**: - 不要添加原文或解释 - 不要加入个人评论 - 不要使用机械化的直译 - 不要出现过长语句,以每行20个汉字为一句最佳! 示例: 输入: 01.234@@@What are you doing? 01.876@@@I'm reading a book. 02.345@@@It's about a detective. 输出: 01.234@@@What are you doing?I'm reading a book.It's about a detective@@@你在做什么?我在读一本关于侦探的书。 输入: 03.456@@@The car exploded. 04.123@@@Run! 输出: 03.456@@@The car exploded.@@@汽车爆炸了! 04.123@@@Run!@@@快跑! 输入: 05.678@@@He's a real piece of work. (俚语) 06.345@@@You can say that again. (习语) 输出: 05.678@@@He's a real piece of work. @@@他真是个怪胎。 06.345@@@You can say that again. @@@你说得对极了。 # 反面示例(多句合并后中文过长,需要拆分): 01.234@@@He picked up the phone. 01.876@@@He dialed a number. 02.345@@@And he started talking. It was a long and complicated conversation. 错误输出: 01.234@@@He picked up the phone. He dialed a number. And he started talking. It was a long and complicated conversation.@@@他拿起电话,拨了个号码,然后开始说话。这是一段漫长而复杂的对话。 正确输出: 01.234@@@He picked up the phone. He dialed a number.@@@他拿起电话,拨了个号码。 01.876@@@And he started talking.@@@然后他开始说话。 02.345@@@It was a long and complicated conversation.@@@这是一段漫长而复杂的对话。 `; const baseBody = { messages: [ { role: "system", content: systemPrompt }, { role: "user", content: text } ], model: modelConfig.MODEL, temperature: 0.2 }; // 只在支持流式的模型中添加 stream 参数 if (modelConfig.STREAM) { baseBody.stream = true; }else{ baseBody.stream = false; } return baseBody; } // 从不同模型的响应中提取翻译文本 extractTranslation(data) { const modelConfig = CONFIG.AI_MODELS[this.currentModel]; if (modelConfig.STREAM) { // 流式响应格式 return data.choices[0]?.delta?.content || ''; } else { // 非流式响应格式 return data.choices[0]?.message?.content || ''; } } // 非流式翻译方法 async normalTranslation(text) { const modelConfig = CONFIG.AI_MODELS[this.currentModel]; if (!modelConfig) { throw new Error(`未找到模型配置: ${this.currentModel}`); } const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${modelConfig.API_KEY}` }; const requestBody = this.buildRequestBody(text, modelConfig); try { const response = await fetch(modelConfig.API_URL, { method: 'POST', headers: headers, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return this.extractTranslation(data); } catch (error) { console.error('非流式翻译失败:', error); throw error; } } // 新增流式翻译方法 async streamTranslation(text) { const modelConfig = CONFIG.AI_MODELS[this.currentModel]; if (!modelConfig) { throw new Error(`未找到模型配置: ${this.currentModel}`); } const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${modelConfig.API_KEY}` }; // 根据不同模型构建请求体 const requestBody = this.buildRequestBody(text, modelConfig); try { const response = await fetch(modelConfig.API_URL, { method: 'POST', headers: headers, body: JSON.stringify(requestBody) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const reader = response.body.getReader(); let decoder = new TextDecoder(); let buffer = ''; let translation = ''; while (true) { const {value, done} = await reader.read(); if (done) break; buffer += decoder.decode(value, {stream: true}); const lines = buffer.split('\n'); // 处理完整的行 for (let i = 0; i < lines.length - 1; i++) { const line = lines[i].trim(); if (!line || line === 'data: [DONE]') continue; if (line.startsWith('data: ')) { const data = JSON.parse(line.slice(5)); translation += this.extractTranslation(data); } } // 保留未完成的行 buffer = lines[lines.length - 1]; } return translation.trim(); } catch (error) { console.error('流式翻译失败:', error); throw error; } } /** * @description: 获取字幕总结 * @param {Array} subtitles - 字幕数组 * @return {Promise} - 总结文本 */ async getSummary(subtitles) { try { // 将所有字幕文本合并 const allText = subtitles .map(sub => `${sub.text}\n${sub.translation || ''}`) .join('\n'); const prompt = `请用中文总结以下视频内容的要点(不超过300字):\n\n${allText}`; const response = await fetch(this.API_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.API_KEY}` }, body: JSON.stringify({ messages: [ { role: "system", content: "你是一个专业的视频内容总结专家。请简明扼要地总结视频的主要内容,重点和关键信息。" }, { role: "user", content: prompt } ], model: "grok-beta", stream: false, temperature: 0.3 }) }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); return data.choices[0].message.content.trim(); } catch (error) { console.error('获取总结失败:', error); throw error; } } // 批量翻译字幕 async translateBatch(subtitles) { if (!subtitles || subtitles.length === 0) return []; // 获取字幕数量 const subLength = parseInt(localStorage.getItem('subLength' + getUid()) || '0'); console.log('字幕数量:', subLength); // 获取缓存中字幕数量 const cachedSubLength = this.cache.cache.size; console.log('缓存中字幕数量:', cachedSubLength); if(cachedSubLength <= subLength && cachedSubLength > 0){ // 打印缓存信息 console.log('✅ 使用现有缓存', this.cache.cache); return Array.from(this.cache.cache.values()).sort((a, b) => a.startTime - b.startTime); } try { // 将字幕转换为特定格式: 时间点@@@文本 const formattedSubtitles = subtitles.map(sub => `${sub.startTime.toFixed(3)}@@@${sub.text}` ).join('\n'); // console.log('开始批量翻译:', { // 字幕数量: subtitles.length, // 样本: formattedSubtitles // }); const translation = await this.fetchTranslation(formattedSubtitles); // 解析翻译结果 const translationLines = translation.split('\n').filter(line => line.trim()); console.log('翻译完成:', { 翻译结果数: translationLines.length, 样本: translationLines }); // 重置新字幕数组 this.newSubtitles = []; // 遍历翻译结果 for (let i = 0; i < translationLines.length; i++) { const line = translationLines[i]; const [timeStr, oldText, translatedText] = line.split('@@@'); if (!timeStr || !oldText || !translatedText) continue; const startTime = parseFloat(timeStr); // 查找这个时间点对应的原字幕 const originalSub = subtitles.find(s => Math.abs(s.startTime - startTime) < 0.1); if (!originalSub) continue; // 创建新的字幕条目 const newSubtitle = new SubtitleEntry(oldText, startTime, originalSub.duration); newSubtitle.translation = translatedText; // 查找下一个翻译行的时间点(如果存在) // if (i < translationLines.length - 1) { // const nextLine = translationLines[i + 1]; // const [nextTimeStr] = nextLine.split('@@@'); // const nextTime = parseFloat(nextTimeStr); // // 查找两个时间点之间的所有原文字幕 // const intermediateSubtitles = subtitles.filter(sub => // sub.startTime > startTime && // sub.startTime < nextTime // ); // // 如果存在中间字幕,合并原文 // if (intermediateSubtitles.length > 0) { // newSubtitle.text = [originalSub.text, ...intermediateSubtitles.map(sub => sub.text)].join(' '); // // 更新持续时间为最后一个字幕的结束时间 // const lastSub = intermediateSubtitles[intermediateSubtitles.length - 1]; // newSubtitle.duration = (lastSub.startTime + lastSub.duration) - startTime; // } // } this.newSubtitles.push(newSubtitle); } // 按时间排序 this.newSubtitles.sort((a, b) => a.startTime - b.startTime); // 调整持续时间,确保不会重叠 for (let i = 0; i < this.newSubtitles.length - 1; i++) { const currentSub = this.newSubtitles[i]; const nextSub = this.newSubtitles[i + 1]; if (currentSub.startTime + currentSub.duration > nextSub.startTime) { currentSub.duration = nextSub.startTime - currentSub.startTime; } } console.log('字幕重构完成:', { 原字幕数: subtitles.length, 新字幕数: this.newSubtitles.length, 样本: this.newSubtitles.slice(0, 3).map(sub => ({ 时间: sub.startTime, 持续: sub.duration, 原文: sub.text, 译文: sub.translation })) }); // 将翻译结果保存到缓存 this.newSubtitles.forEach(sub => { this.cache.put(this.generateCacheKey(sub.startTime), sub); }); // 在storage中保存缓存,记录当前字幕数量 localStorage.setItem('subLength' + getUid(), this.newSubtitles.length); // 设置缓存标志 this.hasCache = true; // 返回重构后的字幕数组 return this.newSubtitles; } catch (error) { console.error('批量翻译失败:', error); throw error; } } // 调用翻译API async fetchTranslation(text) { console.log('开始翻译:', { 文本长度: text.length, 使用模型: this.currentModel, 是否流式: CONFIG.AI_MODELS[this.currentModel].STREAM }); const MAX_LENGTH = 10000; // 设置单次翻译的最大字符数 const MIN_SEGMENT_SIZE = 3000; // 最小分段大小 const DELAY_BETWEEN_REQUESTS = 5000; // 请求间隔5秒 // 如果文本长度在限制范围内,直接翻译 if (text.length <= MAX_LENGTH) { return CONFIG.AI_MODELS[this.currentModel].STREAM ? await this.streamTranslation(text) : await this.normalTranslation(text); } try { // 将文本按换行符分割成行 const lines = text.split('\n'); const segments = []; let currentSegment = []; let currentLength = 0; // 智能分段 for (const line of lines) { if (currentLength + line.length > MAX_LENGTH || (currentLength > MIN_SEGMENT_SIZE && line.includes('@@@'))) { if (currentSegment.length > 0) { segments.push(currentSegment.join('\n')); currentSegment = []; currentLength = 0; } } currentSegment.push(line); currentLength += line.length; } // 添加最后一段 if (currentSegment.length > 0) { segments.push(currentSegment.join('\n')); } console.log('文本分段完成:', { 总行数: lines.length, 分段数: segments.length, 各段长度: segments.map(s => s.length) }); // 串行处理所有分段,每次请求之间添加延时 const translations = []; for (let i = 0; i < segments.length; i++) { // 如果不是第一个请求,等待指定时间 if (i > 0) { console.log(`等待 ${DELAY_BETWEEN_REQUESTS/1000} 秒后继续下一个请求...`); await new Promise(resolve => setTimeout(resolve, DELAY_BETWEEN_REQUESTS)); } console.log(`开始处理第 ${i + 1}/${segments.length} 段`); const translation = await (CONFIG.AI_MODELS[this.currentModel].STREAM ? this.streamTranslation(segments[i]) : this.normalTranslation(segments[i])); translations.push(translation); console.log(`第 ${i + 1} 段翻译完成`); } // 合并结果 const combinedTranslation = translations.join('\n'); console.log('所有分段翻译完成,合并后行数:', combinedTranslation.split('\n').length); return combinedTranslation; } catch (error) { console.error('分段翻译失败:', error); throw error; } } } // 添加视频控制器类 class VideoController { constructor() { this.player = PlayerManager.getInstance().player; this.videoElement = PlayerManager.getInstance().videoElement; this.subtitleManager = new SubtitleManager(); this.isPlaying = false; // 打印变量信息 console.log("VideoController: " ,this.player, this.videoElement, this.subtitleManager) } // 播放视频 playVideo() { if (this.player && typeof this.player.playVideo === 'function') { this.player.playVideo(); this.isPlaying = true; console.log('视频开始播放'); } else if (this.videoElement) { this.videoElement.play(); this.isPlaying = true; console.log('视频开始播放(HTML5)'); } } // 暂停视频 pauseVideo() { if (this.player && typeof this.player.pauseVideo === 'function') { this.player.pauseVideo(); this.isPlaying = false; console.log('视频已暂停'); } else if (this.videoElement) { this.videoElement.pause(); this.isPlaying = false; console.log('视频已暂停(HTML5)'); } } // 获取当前播放时间 getCurrentTime() { if (this.player && typeof this.player.getCurrentTime === 'function') { return this.player.getCurrentTime(); } else if (this.videoElement) { return this.videoElement.currentTime; } return 0; } // 获取视频状态 getPlayerState() { if (this.player && typeof this.player.getPlayerState === 'function') { return this.player.getPlayerState(); } else if (this.videoElement) { return this.videoElement.paused ? 2 : 1; // 1:播放中 2:暂停 } return -1; } } // 主控制器 class YouTubeTranslator { constructor() { this.playerManager = PlayerManager.getInstance(); this.subtitleManager = new SubtitleManager(); this.translationManager = new TranslationManager(); this.audioManager = new AudioManager(); this.currentVideoId = this.getVideoId(); this.player = this.playerManager.player; this.isPlaying = false; //console.log("播放器管理器: " ,this.playerManager.player) this.uiManager = null; // 添加 uiManager 属性 // 上一条播放的字幕时间戳 this.lastPlayedSubtitleTime = 0; } async generateSummary() { try { if (!this.subtitleManager.subtitles.length) { throw new Error('没有可用的字幕'); } return await this.translationManager.getSummary(this.subtitleManager.subtitles); } catch (error) { console.error('生成总结失败:', error); throw error; } } // 添加设置 UI 管理器的方法 setUIManager(uiManager) { this.uiManager = uiManager; } startPeriodicCheck() { if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } this.checkInterval = setInterval(async () => { if (!this.isActive) { clearInterval(this.checkInterval); this.checkInterval = null; return; } //console.log('检查播放状态...'); try { // 如果当前正在播放音频,跳过这次检查 if (this.isPlayingAudio) { return; } const currentTime = this.player.getCurrentTime(); // 快3秒 // 获取当前时间并加3秒提前量 let checkTime = currentTime + 2; //console.log('当前播放时间:', currentTime); // 获取当前时间点的字幕 const currentSubtitle = this.subtitleManager.findSubtitleAtTime(checkTime); // 如果当前时间点没有字幕,跳过 if (!currentSubtitle) return; if(currentSubtitle.startTime <= this.lastPlayedSubtitleTime){ return; } // 检查是否已经播放过这个字幕 if (this.lastPlayedSubtitleTime === currentSubtitle.startTime) { return; } // 生成缓存键 const cacheKey = this.audioManager.generateCacheKey( currentSubtitle.startTime ); // 更新UI显示最近的字幕 if (this.uiManager) { this.uiManager.updateSubtitleDisplay(currentSubtitle); } this.lastPlayedSubtitleTime = currentSubtitle.startTime; if (CONFIG.TTS.TYPE === 'BROWSER') { // 设置播放状态 this.isPlayingAudio = true; // console.log('浏览器TTS模式',CONFIG.TTS.BROWSER.VOICE,currentSubtitle); // 播放音频 try{ await this.audioManager.playAudio(currentSubtitle.translation); } finally { // 确保播放完成后重置状态 this.isPlayingAudio = false; } }else{ // 从缓存获取音频 const cachedAudio = await this.audioManager.loadFromIndexedDB(cacheKey); if (cachedAudio) { // 再次检查状态,防止在加载音频过程中状态发生变化 if (this.isPlayingAudio || !this.isActive) { return; } console.log('找到缓存音频,准备播放:', { 时间点: currentSubtitle.startTime, 原文: currentSubtitle.text, 译文: currentSubtitle.translation }); // 设置播放状态 this.isPlayingAudio = true; try { // 播放音频 await this.audioManager.playAudio(cachedAudio); // 记录已播放的字幕时间戳 this.lastPlayedSubtitleTime = currentSubtitle.startTime; } finally { // 确保播放完成后重置状态 this.isPlayingAudio = false; } } } } catch (error) { console.error('定期检查出错:', error); this.isPlayingAudio = false; } }, 1000); // 每秒检查一次 } // 在 startTranslator 方法中添加调用 async startTranslator() { try { this.isActive = true; console.log('开始启动翻译器...'); // 开始定时检查任务 this.startPeriodicCheck(); console.log('翻译器启动完成'); this.uiManager.updateStatus('开始播放', 'success'); } catch (error) { console.error('启动失败:', error); this.uiManager.updateStatus(`启动失败: ${error.message}`, 'error'); this.isActive = false; } } // 在 stopTranslator 方法中添加清理 stopTranslator() { console.log('停止翻译器...'); this.isPlayingAudio = false; // 重置播放状态 // 清除定时检查 if (this.checkInterval) { clearInterval(this.checkInterval); this.checkInterval = null; } } // 添加翻译所有字幕的方法 async translateAllSubtitles() { try { console.log('开始翻译所有字幕...'); const subtitles = this.subtitleManager.subtitles; const newSubtitles = await this.translationManager.translateBatch(subtitles); console.log('所有字幕翻译完成,字幕数:', newSubtitles.length); // 开始预加载音频 console.log('开始预加载音频...'); await this.audioManager.preloadAudioBatch(newSubtitles); // 更新字幕管理器中的字幕数组 this.subtitleManager.subtitles = newSubtitles; console.log('所有字幕翻译和音频加载完成'); return true; } catch (error) { console.error('翻译字幕失败:', error); throw error; } } async loadSubtitles() { if (!this.currentVideoId) { throw new Error('未找到视频ID'); } try { const hasSubtitles = await this.subtitleManager.loadSubtitles(this.currentVideoId); if (!hasSubtitles) { throw new Error('未找到字幕'); } return true; } catch (error) { console.error('加载字幕失败:', error); throw error; } } getVideoId() { try { // 检查是否在YouTube账户页面 if (window.location.href.includes('accounts.youtube.com')) { return null; } // 方法1: 从URL获取 const url = window.location.href; console.log("当前页面URL:", url); if (url.includes('youtube.com')) { // 标准观看页面 if (url.includes('/watch')) { const urlParams = new URLSearchParams(window.location.search); const videoId = urlParams.get('v'); if (videoId) { console.log("从URL参数获取到视频ID:", videoId); return videoId; } } // 短视频格式 if (url.includes('/shorts/')) { const matches = url.match(/\/shorts\/([a-zA-Z0-9_-]{11})/); if (matches && matches[1]) { console.log("从shorts URL获取到视频ID:", matches[1]); return matches[1]; } } } // 方法2: 从视频元素获取 const videoElement = document.querySelector('video'); if (videoElement) { // 从视频源获取 const videoSrc = videoElement.src; if (videoSrc) { const videoIdMatch = videoSrc.match(/\/([a-zA-Z0-9_-]{11})/); if (videoIdMatch && videoIdMatch[1]) { console.log("从视频源获取到视频ID:", videoIdMatch[1]); return videoIdMatch[1]; } } // 从播放器容器获取 const playerContainer = document.getElementById('movie_player') || document.querySelector('.html5-video-player'); if (playerContainer) { const dataVideoId = playerContainer.getAttribute('video-id') || playerContainer.getAttribute('data-video-id'); if (dataVideoId) { console.log("从播放器容器获取到视频ID:", dataVideoId); return dataVideoId; } } } // 方法3: 从页面元数据获取 const ytdPlayerConfig = document.querySelector('ytd-player'); if (ytdPlayerConfig) { const videoData = ytdPlayerConfig.getAttribute('video-id'); if (videoData) { console.log("从ytd-player获取到视频ID:", videoData); return videoData; } } // 方法4: 从页面脚本数据获取 const scripts = document.getElementsByTagName('script'); for (const script of scripts) { const content = script.textContent; if (content && content.includes('"videoId"')) { const match = content.match(/"videoId":\s*"([a-zA-Z0-9_-]{11})"/); if (match && match[1]) { console.log("从页面脚本获取到视频ID:", match[1]); return match[1]; } } } // 如果所有方法都失败,等待页面加载完成后重试 if (document.readyState !== 'complete') { console.log("页面未完全加载,返回null"); return null; } throw new Error('未在当前页面找到有效的YouTube视频'); } catch (error) { console.error('获取视频ID失败:', error); return null; } } } // 添加播放器管理类(单例模式) class PlayerManager { constructor() { // 如果已经存在实例,直接返回 if (PlayerManager.instance) { return PlayerManager.instance; } this._player = null; this._videoElement = null; this._initialized = false; PlayerManager.instance = this; } // 获取实例的静态方法 static getInstance() { if (!PlayerManager.instance) { PlayerManager.instance = new PlayerManager(); } return PlayerManager.instance; } // 初始化播放器 async initialize() { if (this._initialized) { return this._player; } try { await this.waitForYouTubePlayer(); this._initialized = true; console.log('播放器管理器初始化成功'); return this._player; } catch (error) { console.error('播放器管理器初始化失败:', error); throw error; } } // 等待YouTube播放器加载 async waitForYouTubePlayer() { return new Promise((resolve, reject) => { let attempts = 0; const maxAttempts = 20; const interval = setInterval(() => { const player = document.querySelector('#movie_player'); const videoElement = document.querySelector('video'); if (player && typeof player.getCurrentTime === 'function') { clearInterval(interval); this._player = player; this._videoElement = videoElement; console.log('成功获取YouTube播放器'); resolve(player); } else if (++attempts >= maxAttempts) { clearInterval(interval); reject(new Error('无法获取YouTube播放器')); } }, 500); }); } // 获取播放器实例 get player() { return this._player; } // 获取video元素 get videoElement() { return this._videoElement; } // 检查播放器是否已初始化 get isInitialized() { return this._initialized; } } // 字幕条目类 class SubtitleEntry { constructor(text, startTime, duration) { this.text = text; this.startTime = startTime; this.duration = duration; this.translation = null; this.audioBuffer = null; } } // 字幕管理器类 class SubtitleManager { constructor() { this.subtitles = []; this.currentIndex = 0; } /** * @description: 加载字幕。 * @param {string} videoId - 视频 ID。 * @return {Promise} - 是否成功加载字幕。 * @throws {Error} - 加载字幕失败时抛出异常。 */ async loadSubtitles(videoId) { try { // 获取页面HTML内容 const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`); const html = await response.text(); // 使用正则表达式匹配字幕URL const timedTextMatch = html.match(/https:\/\/www\.youtube\.com\/api\/timedtext\?[^"]+/); if (!timedTextMatch) { throw new Error('未找到字幕URL'); } // 构建字幕URL const url = new URL(timedTextMatch[0].replace(/\\u0026/g, '&')); url.searchParams.set('lang', 'en'); // 设置为英文字幕 const subtitleUrl = url.toString(); console.log('获取字幕:', subtitleUrl); const subtitleResponse = await fetch(subtitleUrl); const subtitleXML = await subtitleResponse.text(); // console.log('字幕XML:', subtitleXML); // 添加日志输出 // 解析字幕 const textRegex = /]*>([\s\S]*?)<\/text>/g; this.subtitles = []; let match; while ((match = textRegex.exec(subtitleXML)) !== null) { const text = match[1] .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/'/g, "'") .replace(/"/g, '"') .replace(/\n/g, ' ') .trim(); if (text) { // 只添加非空文本 // 获取开始时间和持续时间 const startMatch = match[0].match(/start="([^"]+)"/); const durMatch = match[0].match(/dur="([^"]+)"/); const startTime = startMatch ? parseFloat(startMatch[1]) : 0; const duration = durMatch ? parseFloat(durMatch[1]) : 0; this.subtitles.push(new SubtitleEntry(text, startTime, duration)); } } // 解析完字幕后进行排序 this.subtitles.sort((a, b) => a.startTime - b.startTime); console.log(`成功加载 ${this.subtitles.length} 条字幕`); return this.subtitles.length > 0; } catch (error) { console.error('获取字幕时出错:', error); throw error; } } /** * @description: 获取指定时间范围内的字幕。 * @param {number} startTime - 开始时间。 * @param {number} endTime - 结束时间。 * @return {Array} - 指定时间范围内的字幕数组。 */ getSubtitlesInRange(startTime, endTime) { return this.subtitles.filter(sub => sub.startTime >= startTime && sub.startTime <= endTime ); } /** * @description: 查找指定时间点对应的字幕。 * @param {number} time - 时间点。 * @return {SubtitleEntry|null} - 找到的字幕,如果未找到则返回 null。 */ findSubtitleAtTime(time) { try { // 获取所有字幕的时间点 const timePoints = this.subtitles.map(sub => ({ time: sub.startTime, subtitle: sub })); // 按时间排序 timePoints.sort((a, b) => a.time - b.time); // 找到小于等于当前时间的最后一条字幕 let targetSubtitle = null; for (let i = timePoints.length - 1; i >= 0; i--) { if (timePoints[i].time <= time) { targetSubtitle = timePoints[i].subtitle; break; } } if (targetSubtitle) { // console.log('找到目标字幕:', { // 当前时间: time, // 字幕: { // 文本: targetSubtitle.text, // 开始时间: targetSubtitle.startTime, // 持续时间: targetSubtitle.duration // } // }); return targetSubtitle; } // 如果没有找到小于等于当前时间的字幕,返回第一条字幕 if (timePoints.length > 0 && time < timePoints[0].time) { const firstSubtitle = timePoints[0].subtitle; console.log('返回第一条字幕:', { 当前时间: time, 字幕: { 文本: firstSubtitle.text, 开始时间: firstSubtitle.startTime, 持续时间: firstSubtitle.duration } }); return firstSubtitle; } console.log('未找到合适的字幕:', { 当前时间: time, 字幕总数: this.subtitles.length }); return null; } catch (error) { console.error('查找字幕时出错:', error); return null; } } } // UI管理器 class UIManager { constructor(videoController,translator) { this.container = null; this.statusDisplay = null; this.startButton = null; this.pauseButton = null; this.loadSubtitlesButton = null; this.isCollapsed = false; this.videoController = videoController; this.translator = translator; this.lastDisplayedSubtitleId = null; // 添加追踪变量 this.createUI(); this.attachEventListeners(); } createUI() { // 创建主容器 this.container = document.createElement('div'); this.container.style.cssText = ` position: fixed; top: 20px; right: 20px; width: 390px; background: rgba(33, 33, 33, 0.9); border-radius: 8px; padding: 15px; color: #fff; font-family: Arial, sans-serif; z-index: 9999; transition: all 0.3s ease; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); `; // 创建顶部栏 const topBar = this.createTopBar(); this.container.appendChild(topBar); // 创建主内容容器 this.mainContent = document.createElement('div'); this.mainContent.style.cssText = ` transition: all 0.3s ease; `; // 创建控制按钮 const controls = this.createControls(); this.mainContent.appendChild(controls); // 创建TTS面板 - 在这里添加 this.createTTSPanel(); // 创建状态显示区域 this.createStatusDisplay(); this.mainContent.appendChild(this.statusDisplay); // 创建并添加总结面板 this.createSummaryPanel(); this.container.appendChild(this.mainContent); document.body.appendChild(this.container); // 使面板可拖动 this.makeDraggable(topBar); } createTTSPanel() { const ttsPanel = document.createElement('div'); ttsPanel.style.cssText = ` margin-top: 15px; padding: 15px; background: rgba(33, 150, 243, 0.1); border-radius: 8px; border-left: 4px solid #2196F3; `; // TTS类型选择 const typeContainer = document.createElement('div'); typeContainer.style.cssText = ` margin-bottom: 12px; display: flex; align-items: center; `; const typeLabel = document.createElement('label'); typeLabel.textContent = 'TTS引擎: '; typeLabel.style.cssText = ` color: #fff; margin-right: 10px; font-size: 14px; font-weight: 500; `; const typeSelect = document.createElement('select'); typeSelect.style.cssText = ` padding: 8px 12px; border-radius: 4px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(33, 150, 243, 0.3); font-size: 14px; cursor: pointer; outline: none; transition: all 0.3s ease; `; ['EDGE', 'VITS','BROWSER'].forEach(type => { const option = document.createElement('option'); option.value = type; option.textContent = type; if (CONFIG.TTS.TYPE === type) { option.selected = true; } typeSelect.appendChild(option); }); // 声音选择 const voiceContainer = document.createElement('div'); voiceContainer.style.cssText = ` margin-top: 12px; display: flex; align-items: center; `; const voiceLabel = document.createElement('label'); voiceLabel.textContent = '声音: '; voiceLabel.style.cssText = ` color: #fff; margin-right: 10px; font-size: 14px; font-weight: 500; `; const voiceSelect = document.createElement('select'); voiceSelect.style.cssText = ` padding: 8px 12px; border-radius: 4px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(33, 150, 243, 0.3); font-size: 14px; cursor: pointer; outline: none; transition: all 0.3s ease; width: 200px; `; // 更新声音选项的函数 const updateVoiceOptions = () => { // 清空现有选项 while (voiceSelect.firstChild) { voiceSelect.removeChild(voiceSelect.firstChild); } if (typeSelect.value === 'EDGE') { Object.entries(CONFIG.TTS.EDGE.VOICES).forEach(([id, name]) => { const option = document.createElement('option'); option.value = id; option.textContent = name; if (id === CONFIG.TTS.EDGE.DEFAULT_VOICE) { option.selected = true; } voiceSelect.appendChild(option); }); } if (CONFIG.TTS.TYPE === 'VITS') { const option = document.createElement('option'); option.value = CONFIG.TTS.VITS.DEFAULT_VOICE; option.textContent = '珊瑚宫心海'; option.selected = true; voiceSelect.appendChild(option); } if (CONFIG.TTS.TYPE === 'BROWSER') { // 浏览器 TTS 模式下获取系统语音列表 const populateVoiceList = () => { const voices = speechSynthesis.getVoices(); // 过滤只包含 Chinese 的语音 const chineseVoices = voices.filter(voice => voice.lang.toLowerCase().includes('zh-cn') ); if (chineseVoices.length === 0) { // 如果没有找到中文语音,添加提示选项 const option = document.createElement('option'); option.textContent = '未找到中文语音'; option.disabled = true; voiceSelect.appendChild(option); } else { chineseVoices.forEach(voice => { const option = document.createElement('option'); option.textContent = `${voice.name} (${voice.lang})`; if (voice.default) { option.textContent += ' — DEFAULT'; } option.setAttribute('data-lang', voice.lang); option.setAttribute('data-name', voice.name); voiceSelect.appendChild(option); }); // 如果有已保存的语音设置,选中对应选项 if (CONFIG.TTS.BROWSER.VOICE) { const savedVoice = Array.from(voiceSelect.options).find(option => option.getAttribute('data-name') === CONFIG.TTS.BROWSER.VOICE.name && option.getAttribute('data-lang') === CONFIG.TTS.BROWSER.VOICE.lang ); if (savedVoice) { savedVoice.selected = true; } } } // 调试输出 console.log('可用的中文语音:', chineseVoices.map(v => ({ name: v.name, lang: v.lang, default: v.default }))); }; // 初始填充语音列表 populateVoiceList(); // 监听语音列表变化 if (typeof speechSynthesis !== 'undefined' && speechSynthesis.onvoiceschanged !== undefined) { speechSynthesis.onvoiceschanged = populateVoiceList; } } }; // 初始化声音选项 updateVoiceOptions(); // 添加事件监听器 typeSelect.addEventListener('change', () => { CONFIG.TTS.TYPE = typeSelect.value; updateVoiceOptions(); }); voiceSelect.addEventListener('change', (e) => { const selectedOption = e.target.selectedOptions[0]; if (typeSelect.value === 'BROWSER') { // 保存选中的浏览器语音信息 CONFIG.TTS.BROWSER.VOICE = { name: selectedOption.getAttribute('data-name'), lang: selectedOption.getAttribute('data-lang') }; } else if (typeSelect.value === 'EDGE') { CONFIG.TTS.EDGE.DEFAULT_VOICE = selectedOption.value; } else { CONFIG.TTS.VITS.DEFAULT_VOICE = selectedOption.value; } }); // 组装面板 typeContainer.appendChild(typeLabel); typeContainer.appendChild(typeSelect); voiceContainer.appendChild(voiceLabel); voiceContainer.appendChild(voiceSelect); ttsPanel.appendChild(typeContainer); ttsPanel.appendChild(voiceContainer); // 添加到主内容区域 if (this.mainContent) { this.mainContent.appendChild(ttsPanel); } // 创建 AI 模型选择面板(移到这里,只创建一次) this.createAIModelPanel(); } // 分离 AI 模型面板创建为独立方法 createAIModelPanel() { const aiModelPanel = document.createElement('div'); aiModelPanel.style.cssText = ` margin-top: 15px; padding: 15px; background: rgba(33, 150, 243, 0.1); border-radius: 8px; border-left: 4px solid #2196F3; `; const modelContainer = document.createElement('div'); modelContainer.style.cssText = ` display: flex; align-items: center; margin-bottom: 12px; `; const modelLabel = document.createElement('label'); modelLabel.textContent = 'AI 模型: '; modelLabel.style.cssText = ` color: #fff; margin-right: 10px; font-size: 14px; font-weight: 500; `; const modelSelect = document.createElement('select'); modelSelect.style.cssText = ` padding: 8px 12px; border-radius: 4px; background: rgba(255, 255, 255, 0.9); color: #333; border: 1px solid rgba(33, 150, 243, 0.3); font-size: 14px; cursor: pointer; outline: none; transition: all 0.3s ease; width: 200px; `; // 添加可用的 AI 模型选项 Object.keys(CONFIG.AI_MODELS).forEach(model => { if (model !== 'TYPE') { const option = document.createElement('option'); option.value = model; option.textContent = `${model} (${CONFIG.AI_MODELS[model].MODEL})`; if (CONFIG.AI_MODELS.TYPE === model) { option.selected = true; } modelSelect.appendChild(option); } }); // 添加事件监听器 modelSelect.addEventListener('change', () => { CONFIG.AI_MODELS.TYPE = modelSelect.value; this.translator.translationManager.currentModel = modelSelect.value; this.updateStatus(`已切换至 ${modelSelect.value} 模型`, 'info'); }); // 添加悬停效果 modelSelect.addEventListener('mouseover', () => { modelSelect.style.borderColor = 'rgba(33, 150, 243, 0.6)'; modelSelect.style.boxShadow = '0 0 5px rgba(33, 150, 243, 0.3)'; }); modelSelect.addEventListener('mouseout', () => { modelSelect.style.borderColor = 'rgba(33, 150, 243, 0.3)'; modelSelect.style.boxShadow = 'none'; }); modelContainer.appendChild(modelLabel); modelContainer.appendChild(modelSelect); aiModelPanel.appendChild(modelContainer); // 添加到主内容区域 if (this.mainContent) { this.mainContent.appendChild(aiModelPanel); } } createTopBar() { const topBar = document.createElement('div'); topBar.style.cssText = ` display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; cursor: move; padding: 5px; `; // 标题 const title = document.createElement('div'); title.textContent = 'YouTube 实时翻译'; title.style.cssText = ` font-weight: bold; font-size: 14px; `; // 按钮容器 const buttonContainer = document.createElement('div'); buttonContainer.style.cssText = ` display: flex; gap: 8px; `; // 折叠按钮 this.toggleButton = document.createElement('button'); this.toggleButton.textContent = '↑'; this.toggleButton.style.cssText = ` background: none; border: none; color: #fff; cursor: pointer; padding: 2px 6px; font-size: 14px; border-radius: 4px; transition: background 0.2s; `; this.toggleButton.addEventListener('click', () => this.toggleCollapse()); buttonContainer.appendChild(this.toggleButton); topBar.appendChild(title); topBar.appendChild(buttonContainer); return topBar; } createControls() { const controls = document.createElement('div'); controls.style.cssText = ` display: flex; gap: 10px; margin-bottom: 15px; `; // 加载字幕按钮 this.loadSubtitlesButton = this.createButton('加载字幕', '#2196F3'); // 开始按钮 this.startButton = this.createButton('开始播放', '#4CAF50'); this.startButton.disabled = true; this.startButton.style.opacity = '0.5'; this.startButton.style.cursor = 'not-allowed'; // 暂停按钮 this.pauseButton = this.createButton('停止播放', '#FF5722'); this.pauseButton.style.display = 'block'; // 新增总结按钮 this.summaryButton = this.createButton('生成总结', '#9C27B0'); this.summaryButton.style.display = 'block'; // 添加这一行 controls.appendChild(this.loadSubtitlesButton); controls.appendChild(this.startButton); controls.appendChild(this.pauseButton); controls.appendChild(this.summaryButton); return controls; } createSummaryPanel() { this.summaryPanel = document.createElement('div'); this.summaryPanel.style.cssText = ` margin-top: 15px; padding: 15px; background: rgba(156, 39, 176, 0.1); border-radius: 8px; border-left: 4px solid #9C27B0; display: none; transition: all 0.3s ease; `; const title = document.createElement('div'); title.textContent = '视频内容总结'; title.style.cssText = ` font-weight: bold; margin-bottom: 10px; color: #9C27B0; font-size: 14px; display: flex; justify-content: space-between; align-items: center; `; // 添加复制按钮 const copyButton = document.createElement('button'); copyButton.textContent = '复制'; copyButton.style.cssText = ` background: #9C27B0; color: white; border: none; border-radius: 4px; padding: 4px 8px; font-size: 12px; cursor: pointer; transition: all 0.2s ease; `; copyButton.addEventListener('mouseover', () => { copyButton.style.background = '#7B1FA2'; }); copyButton.addEventListener('mouseout', () => { copyButton.style.background = '#9C27B0'; }); copyButton.addEventListener('click', () => { navigator.clipboard.writeText(this.summaryContent.textContent) .then(() => { copyButton.textContent = '已复制'; setTimeout(() => { copyButton.textContent = '复制'; }, 2000); }) .catch(err => console.error('复制失败:', err)); }); title.appendChild(copyButton); this.summaryContent = document.createElement('div'); this.summaryContent.style.cssText = ` font-size: 14px; line-height: 1.6; color: #fff; white-space: pre-wrap; margin-top: 10px; max-height: 400px; overflow-y: auto; padding-right: 10px; `; // 添加滚动条样式 this.summaryContent.style.cssText += ` scrollbar-width: thin; scrollbar-color: #9C27B0 rgba(156, 39, 176, 0.1); `; this.summaryPanel.appendChild(title); this.summaryPanel.appendChild(this.summaryContent); this.mainContent.appendChild(this.summaryPanel); } // 添加字幕显示方法 updateSubtitleDisplay(subtitle) { // 生成字幕唯一ID (使用时间戳和文本组合) const subtitleId = `${subtitle.startTime}-${subtitle.text}`; // 检查是否已经显示过这条字幕 if (this.lastDisplayedSubtitleId === subtitleId) { return; // 如果是相同字幕,直接返回 } const entry = document.createElement('div'); entry.style.cssText = ` margin: 10px 0; padding: 12px; background: rgba(255, 255, 255, 0.1); border-radius: 8px; border-left: 4px solid #4CAF50; transition: all 0.3s ease; `; // 添加鼠标悬停效果 entry.addEventListener('mouseover', () => { entry.style.background = 'rgba(255, 255, 255, 0.15)'; entry.style.transform = 'translateX(5px)'; }); entry.addEventListener('mouseout', () => { entry.style.background = 'rgba(255, 255, 255, 0.1)'; entry.style.transform = 'translateX(0)'; }); // 显示时间信息 const timeInfo = document.createElement('div'); timeInfo.style.cssText = ` color: #888; font-size: 11px; margin-bottom: 8px; font-family: monospace; `; timeInfo.textContent = `⏱ ${subtitle.startTime.toFixed(2)}s - ${(subtitle.startTime + subtitle.duration).toFixed(2)}s`; entry.appendChild(timeInfo); // 显示原文 const originalText = document.createElement('div'); originalText.style.cssText = ` color: #bbb; margin: 6px 0; font-size: 13px; line-height: 1.4; padding-left: 20px; position: relative; `; // 创建图标元素 const originalIcon = document.createElement('span'); originalIcon.style.cssText = ` position: absolute; left: 0; `; originalIcon.textContent = '💢'; // 创建文本元素 const originalTextContent = document.createElement('span'); originalTextContent.textContent = subtitle.text; originalText.appendChild(originalIcon); originalText.appendChild(originalTextContent); entry.appendChild(originalText); // 显示译文 if (subtitle.translation) { const translatedText = document.createElement('div'); translatedText.style.cssText = ` color: #fff; margin: 6px 0; font-size: 14px; line-height: 1.4; font-weight: 500; padding-left: 20px; position: relative; `; // 创建译文图标元素 const translatedIcon = document.createElement('span'); translatedIcon.style.cssText = ` position: absolute; left: 0; `; translatedIcon.textContent = '🤖'; // 创建译文文本元素 const translatedTextContent = document.createElement('span'); translatedTextContent.textContent = subtitle.translation; translatedText.appendChild(translatedIcon); translatedText.appendChild(translatedTextContent); entry.appendChild(translatedText); } // 更新最后显示的字幕ID this.lastDisplayedSubtitleId = subtitleId; this.statusDisplay.appendChild(entry); this.statusDisplay.scrollTop = this.statusDisplay.scrollHeight; } // 添加事件监听器 attachEventListeners() { // 加载字幕按钮事件 this.loadSubtitlesButton.addEventListener('click', async () => { this.loadSubtitlesButton.disabled = true; this.loadSubtitlesButton.textContent = '正在加载字幕...'; try { // 加载字幕 await this.translator.loadSubtitles(); this.updateStatus(`已加载 ${this.translator.subtitleManager.subtitles.length} 条字幕`, 'success'); // 开始翻译 this.updateStatus('正在翻译字幕...', 'info'); await this.translator.translateAllSubtitles(); this.updateStatus('字幕翻译完成', 'success'); // 更新UI状态 this.loadSubtitlesButton.style.display = 'none'; this.summaryButton.style.display = 'block'; this.startButton.disabled = false; this.startButton.style.opacity = '1'; this.startButton.style.cursor = 'pointer'; // 显示翻译样本 // const allSubtitles = this.translator.subtitleManager.subtitles; // if (allSubtitles) { // allSubtitles.forEach(sub => { // this.updateSubtitleDisplay(sub); // }); // } } catch (error) { this.loadSubtitlesButton.disabled = false; this.loadSubtitlesButton.textContent = '重试加载字幕'; this.updateStatus(`加载字幕失败: ${error.message}`, 'error'); } }); // 开始播放按钮事件 this.startButton.addEventListener('click', async () => { try { this.startButton.style.display = 'none'; this.pauseButton.style.display = 'block'; this.translator.startTranslator(); this.videoController.playVideo(); //this.updateStatus('开始播放', 'success'); } catch (error) { this.updateStatus(`播放失败: ${error.message}`, 'error'); this.startButton.style.display = 'block'; this.pauseButton.style.display = 'none'; } }); // 暂停按钮事件 this.pauseButton.addEventListener('click', () => { this.pauseButton.style.display = 'none'; this.startButton.style.display = 'block'; this.videoController.pauseVideo(); this.updateStatus('播放已暂停', 'info'); }); // 总结按钮事件 this.summaryButton.addEventListener('click', async () => { try { this.summaryButton.disabled = true; this.summaryButton.textContent = '正在生成总结...'; this.updateStatus('正在生成视频内容总结...', 'info'); const summary = await this.translator.generateSummary(); this.summaryContent.textContent = summary; this.summaryPanel.style.display = 'block'; this.updateStatus('总结生成完成', 'success'); } catch (error) { this.updateStatus(`生成总结失败: ${error.message}`, 'error'); } finally { this.summaryButton.disabled = false; this.summaryButton.textContent = '生成总结'; } }); } createButton(text, color) { const button = document.createElement('button'); button.textContent = text; button.style.cssText = ` padding: 10px 20px; border: none; border-radius: 8px; background: ${color}; color: white; cursor: pointer; font-size: 14px; flex: 1; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); `; button.addEventListener('mouseover', () => { button.style.transform = 'translateY(-2px)'; button.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.3)'; }); button.addEventListener('mouseout', () => { button.style.transform = 'translateY(0)'; button.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)'; }); return button; } createStatusDisplay() { this.statusDisplay = document.createElement('div'); this.statusDisplay.style.cssText = ` margin-top: 15px; max-height: 450px; max-width: 400px; overflow-y: auto; padding: 10px; background: rgba(0, 0, 0, 0.2); border-radius: 8px; font-size: 14px; line-height: 1.5; `; } toggleCollapse() { this.isCollapsed = !this.isCollapsed; if (this.isCollapsed) { this.mainContent.style.display = 'none'; this.container.style.width = '200px'; this.toggleButton.textContent = '↓'; } else { this.mainContent.style.display = 'block'; this.container.style.width = '300px'; this.toggleButton.textContent = '↑'; } } makeDraggable(dragHandle) { let isDragging = false; let currentX; let currentY; let initialX; let initialY; let xOffset = 0; let yOffset = 0; dragHandle.addEventListener('mousedown', (e) => { initialX = e.clientX - xOffset; initialY = e.clientY - yOffset; if (e.target === dragHandle) { isDragging = true; } }); document.addEventListener('mousemove', (e) => { if (isDragging) { e.preventDefault(); currentX = e.clientX - initialX; currentY = e.clientY - initialY; xOffset = currentX; yOffset = currentY; const maxX = window.innerWidth - this.container.offsetWidth; const maxY = window.innerHeight - this.container.offsetHeight; xOffset = Math.min(Math.max(0, xOffset), maxX); yOffset = Math.min(Math.max(0, yOffset), maxY); this.container.style.transform = `translate(${xOffset}px, ${yOffset}px)`; } }); document.addEventListener('mouseup', () => { initialX = currentX; initialY = currentY; isDragging = false; }); } updateStatus(message, type = 'info') { const entry = document.createElement('div'); entry.style.cssText = ` margin-bottom: 8px; padding: 4px 8px; border-radius: 4px; font-size: 13px; ${type === 'error' ? 'background: rgba(244, 67, 54, 0.2); color: #ff8a80;' : ''} `; entry.textContent = `${type === 'error' ? '❌ ' : ''}${message}`; this.statusDisplay.appendChild(entry); this.statusDisplay.scrollTop = this.statusDisplay.scrollHeight; } } // 初始化应用 async function initializeApp() { // 检查是否在YouTube账户页面 if (window.location.href.includes('accounts.youtube.com')) { console.log('在账户页面,跳过初始化'); return; } const playerManager = PlayerManager.getInstance(); await playerManager.initialize(); // 启动前10秒内每秒检查一次播放状态 const player = playerManager.player; //console.log("播放器信息: " ,player) // 创建视频控制器 const videoController = new VideoController(); // 创建翻译器 const translator = new YouTubeTranslator(); // 创建UI管理器 const ui = new UIManager(videoController,translator); // 设置 UI 管理器 translator.setUIManager(ui); // 获取视频ID const videoId = translator.getVideoId(); if (videoId) { console.log('成功获取视频ID: ', videoId); let checkCount = 0; // 启动前10秒内每秒检查一次播放状态 const checkInterval = setInterval(() => { if (checkCount >= 5) { clearInterval(checkInterval); return; } if (player && typeof player.getPlayerState === 'function' && player.getPlayerState() === 1) { player.pauseVideo(); console.log('视频已自动暂停'); } checkCount++; }, 1000); translator.initialize().catch(error => { console.error('初始化失败:', error); }); } else if (retryCount < maxRetries) { console.log(`未获取到视频ID,${retryInterval/1000}秒后重试 (${retryCount + 1}/${maxRetries})`); retryCount++; setTimeout(tryInitialize, retryInterval); } else { console.log('达到最大重试次数,初始化失败'); } } // 页面加载完成后启动应用 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeApp); } else { initializeApp(); } function getUid() { try { // 检查是否在YouTube账户页面 if (window.location.href.includes('accounts.youtube.com')) { return null; } // 方法1: 从URL获取 const url = window.location.href; //console.log("当前页面URL:", url); if (url.includes('youtube.com')) { // 标准观看页面 if (url.includes('/watch')) { const urlParams = new URLSearchParams(window.location.search); const videoId = urlParams.get('v'); if (videoId) { // console.log("从URL参数获取到视频ID:", videoId); return videoId; } } // 短视频格式 if (url.includes('/shorts/')) { const matches = url.match(/\/shorts\/([a-zA-Z0-9_-]{11})/); if (matches && matches[1]) { console.log("从shorts URL获取到视频ID:", matches[1]); return matches[1]; } } } // 方法2: 从视频元素获取 const videoElement = document.querySelector('video'); if (videoElement) { // 从视频源获取 const videoSrc = videoElement.src; if (videoSrc) { const videoIdMatch = videoSrc.match(/\/([a-zA-Z0-9_-]{11})/); if (videoIdMatch && videoIdMatch[1]) { console.log("从视频源获取到视频ID:", videoIdMatch[1]); return videoIdMatch[1]; } } // 从播放器容器获取 const playerContainer = document.getElementById('movie_player') || document.querySelector('.html5-video-player'); if (playerContainer) { const dataVideoId = playerContainer.getAttribute('video-id') || playerContainer.getAttribute('data-video-id'); if (dataVideoId) { console.log("从播放器容器获取到视频ID:", dataVideoId); return dataVideoId; } } } // 方法3: 从页面元数据获取 const ytdPlayerConfig = document.querySelector('ytd-player'); if (ytdPlayerConfig) { const videoData = ytdPlayerConfig.getAttribute('video-id'); if (videoData) { console.log("从ytd-player获取到视频ID:", videoData); return videoData; } } // 方法4: 从页面脚本数据获取 const scripts = document.getElementsByTagName('script'); for (const script of scripts) { const content = script.textContent; if (content && content.includes('"videoId"')) { const match = content.match(/"videoId":\s*"([a-zA-Z0-9_-]{11})"/); if (match && match[1]) { console.log("从页面脚本获取到视频ID:", match[1]); return match[1]; } } } // 如果所有方法都失败,等待页面加载完成后重试 if (document.readyState !== 'complete') { console.log("页面未完全加载,返回null"); return null; } throw new Error('未在当前页面找到有效的YouTube视频'); } catch (error) { console.error('获取视频ID失败:', error); return null; } } })();