- // ==UserScript==
- // @name 🎬 YouTube AI 翻译助手 Pro - 实时翻译+AI配音
- // @namespace http://tampermonkey.net/
- // @version 1.0.2
- // @license MIT
- // @author wangwangit
- // @description 🚀 强大的 YouTube 视频翻译工具 | ✨ 实时英译中 | 🎯 智能AI翻译 | 🔊 自然语音朗读 | 📝 内容智能总结 | 💫 支持多种AI模型和语音引擎 | 🎨 优雅界面设计 | 让观看YouTube视频更轻松愉快!
- // @match *://*.youtube.com/*
- // @grant GM_xmlhttpRequest
- // @connect xxxx
- // @connect xxxx
- // @connect api.x.ai
- // @run-at document-end
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
-
- // 1. 首先声明全局配置变量
- let CONFIG;
-
-
-
-
- // 配置管理器
- class ConfigManager {
- static CONFIG_KEY = 'youtube_config';
-
- static getDefaultConfig() {
- return {
- AI_MODELS: {
- TYPE: 'OPENAI',
- XAI: {
- API_KEY: '你的密钥',
- API_URL: '你的api地址,注意,要携带/v1/chat/completions',
- MODEL: 'grok-beta',
- STREAM: false
- },
- OPENAI: {
- API_KEY: '你的密钥',
- API_URL: '你的api地址,注意,要携带/v1/chat/completions',
- MODEL: '你想要使用的模型名称',
- STREAM: true
- }
- },
- TTS: {
- TYPE: 'BROWSER',
- VITS: {
- BASE_URL: 'xxxx',
- DEFAULT_VOICE: "char_model/原神/珊瑚宫心海/牌局的形势千变万化,想要获胜的话…有时候也必须兵行险着。.wav"
- },
- BROWSER: {
- RATE: 1.0,
- PITCH: 1.0,
- VOLUME: 1.0,
- VOICE: null
- }
- },
- CACHE: {
- AUDIO_SIZE: 500,
- TRANS_SIZE: 500
- }
- };
- }
-
- static saveConfig(config) {
- try {
- const configString = JSON.stringify(config);
- localStorage.setItem('youtubeTranslatorConfig', configString);
- console.log('配置已保存:', config);
- } catch (error) {
- console.error('保存配置失败:', error);
- }
- }
-
- static loadConfig() {
- try {
- const savedConfig = localStorage.getItem('youtubeTranslatorConfig');
- if (savedConfig) {
- const parsedConfig = JSON.parse(savedConfig);
- // 合并保存的配置和默认配置
- CONFIG = {...this.getDefaultConfig(), ...parsedConfig};
- console.log('已加载保存的配置:', CONFIG);
- }
- return CONFIG;
- } catch (error) {
- console.error('加载配置失败:', error);
- return CONFIG;
- }
- }
- }
-
- // 初始化默认配置
- CONFIG = ConfigManager.getDefaultConfig();
- // 加载保存的配置
- CONFIG = ConfigManager.loadConfig();
- // 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<void>}
- * @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|object>} - 加载的缓存数据,如果未找到则返回 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: 处理 SSE 响应
- * @param {string} eventId - 事件ID
- * @return {Promise<string>} - 音频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<void>} - 播放完成的 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>} - 反序列化后的 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<void>} - 保存完成的 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>} - 获取的 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>} - 获取的 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. **翻译要求**:
- - **准确传达**原文的语气、情感、文化背景和潜台词。
- - **译文自然流畅**,符合中文表达习惯。
- - **妥善处理**俚语、习语、文化特定表达、语气词、情感表达等。
- - **保持对话连贯性**,处理好人称代词和指代关系,确保人物语气一致。
- - 避免误译、漏译、错译。
-
- 3. **输出规范**:
- - **格式**:"时间戳@@@合并后的英文字幕@@@合并后的中文翻译"
- - **每条字幕独立一行**,不添加任何额外注释或说明。
- - **时间戳格式正确**,保留3位小数。
-
- **示例**:
-
- *正面示例*:
-
- 输入:
- 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<SubtitleEntry>} subtitles - 字幕数组
- * @return {Promise<string>} - 总结文本
- */
- 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,
- 具体模型: CONFIG.AI_MODELS[this.currentModel].MODEL
- });
-
- 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() {
- // 加载配置
- window.CONFIG = ConfigManager.loadConfig();
- 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;
-
- }
-
-
- /**
- * @description: 处理配置更新
- * @param {string} key - 配置键
- * @param {any} value - 新的配置值
- */
- onConfigUpdate(key, value) {
- console.log('翻译器收到配置更新:', {
- 配置项: key,
- 新值: value
- });
-
- // 如果是模型相关的配置更新
- if (key.startsWith('AI_MODELS')) {
- // 更新翻译管理器的当前模型
- if (key === 'AI_MODELS.TYPE') {
- this.translationManager.currentModel = value;
- console.log('切换翻译模型:', {
- 新模型: value,
- 模型名称: CONFIG.AI_MODELS[value].MODEL,
- 流式响应: CONFIG.AI_MODELS[value].STREAM
- });
- }
- }
-
- // 如果是TTS相关的配置更新
- if (key.startsWith('TTS')) {
- // 可以在这里添加TTS配置更新的处理逻辑
- console.log('TTS配置已更新');
- }
- }
-
- 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<boolean>} - 是否成功加载字幕。
- * @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 = /<text[^>]*>([\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<SubtitleEntry>} - 指定时间范围内的字幕数组。
- */
- 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.createConfigPanel();
- 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);
-
- // 创建状态显示区域
- this.createStatusDisplay();
- this.mainContent.appendChild(this.statusDisplay);
-
- // 创建TTS面板
- this.createTTSPanel();
-
- // 创建并添加总结面板
- this.createSummaryPanel();
-
- this.container.appendChild(this.mainContent);
- document.body.appendChild(this.container);
-
- // 创建配置面板
- this.createConfigPanel();
-
- // 使面板可拖动
- 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;
- `;
-
- ['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;
- `;
-
- // 添加配置按钮
- const configButton = document.createElement('button');
- configButton.textContent = '⚙️';
- configButton.style.cssText = `
- background: none;
- border: none;
- color: #fff;
- cursor: pointer;
- padding: 2px 6px;
- font-size: 14px;
- border-radius: 4px;
- transition: background 0.2s;
- margin-right: 8px;
- `;
-
- configButton.addEventListener('click', () => this.toggleConfigPanel());
-
- this.toggleButton.addEventListener('click', () => this.toggleCollapse());
-
- buttonContainer.appendChild(configButton);
- buttonContainer.appendChild(this.toggleButton);
- topBar.appendChild(title);
- topBar.appendChild(buttonContainer);
-
- return topBar;
- }
-
-
- createConfigPanel() {
- this.configPanel = document.createElement('div');
- this.configPanel.style.cssText = `
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- width: 400px;
- background: rgba(33, 33, 33, 0.95);
- border-radius: 12px;
- padding: 20px;
- color: #fff;
- display: none;
- z-index: 10000;
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
- `;
-
- // 添加标题和关闭按钮
- const header = document.createElement('div');
- header.style.cssText = `
- display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 20px;
- padding-bottom: 10px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
- `;
-
- const title = document.createElement('h3');
- title.textContent = '配置设置';
- title.style.margin = '0';
-
- const closeButton = document.createElement('button');
- closeButton.textContent = '×';
- closeButton.style.cssText = `
- background: none;
- border: none;
- color: #fff;
- font-size: 20px;
- cursor: pointer;
- padding: 0 5px;
- `;
- closeButton.addEventListener('click', () => this.toggleConfigPanel());
-
- header.appendChild(title);
- header.appendChild(closeButton);
- this.configPanel.appendChild(header);
-
- // 创建配置选项
- const configSections = [
- {
- title: 'AI 模型设置',
- settings: [
- {
- type: 'select',
- label: '模型类型',
- key: 'AI_MODELS.TYPE',
- options: ['OPENAI'],
- value: CONFIG.AI_MODELS.TYPE
- },
- {
- type: 'text',
- label: 'API密钥',
- key: 'AI_MODELS.OPENAI.API_KEY',
- value: CONFIG.AI_MODELS.OPENAI.API_KEY
- },
- {
- type: 'text',
- label: 'API地址',
- key: 'AI_MODELS.OPENAI.API_URL',
- value: CONFIG.AI_MODELS.OPENAI.API_URL
- },
- {
- type: 'text',
- label: '模型名称',
- key: 'AI_MODELS.OPENAI.MODEL',
- value: CONFIG.AI_MODELS.OPENAI.MODEL
- },
- {
- type: 'select',
- label: '流式响应',
- key: 'AI_MODELS.OPENAI.STREAM',
- options: ['true', 'false'],
- value: CONFIG.AI_MODELS.OPENAI.STREAM.toString()
- }
- ]
- }
- // {
- // title: 'TTS 设置',
- // settings: [
- // {
- // type: 'select',
- // label: 'TTS引擎',
- // key: 'TTS.TYPE',
- // options: ['EDGE', 'VITS', 'BROWSER'],
- // value: CONFIG.TTS.TYPE
- // },
- // {
- // type: 'select',
- // label: 'EDGE声音',
- // key: 'TTS.EDGE.DEFAULT_VOICE',
- // options: Object.keys(CONFIG.TTS.EDGE.VOICES),
- // value: CONFIG.TTS.EDGE.DEFAULT_VOICE,
- // dependsOn: {
- // key: 'TTS.TYPE',
- // value: 'EDGE'
- // }
- // },
- // {
- // type: 'select',
- // label: 'VITS声音',
- // key: 'TTS.VITS.DEFAULT_VOICE',
- // options: ['珊瑚宫心海'], // 可以根据实际声音列表扩展
- // value: CONFIG.TTS.VITS.DEFAULT_VOICE,
- // dependsOn: {
- // key: 'TTS.TYPE',
- // value: 'VITS'
- // }
- // },
- // {
- // type: 'range',
- // label: '语速',
- // key: 'TTS.BROWSER.RATE',
- // min: 0.5,
- // max: 2,
- // step: 0.1,
- // value: CONFIG.TTS.BROWSER.RATE,
- // dependsOn: {
- // key: 'TTS.TYPE',
- // value: 'BROWSER'
- // }
- // },
- // {
- // type: 'range',
- // label: '音量',
- // key: 'TTS.BROWSER.VOLUME',
- // min: 0,
- // max: 1,
- // step: 0.1,
- // value: CONFIG.TTS.BROWSER.VOLUME,
- // dependsOn: {
- // key: 'TTS.TYPE',
- // value: 'BROWSER'
- // }
- // },
- // {
- // type: 'range',
- // label: '音调',
- // key: 'TTS.BROWSER.PITCH',
- // min: 0.5,
- // max: 2,
- // step: 0.1,
- // value: CONFIG.TTS.BROWSER.PITCH,
- // dependsOn: {
- // key: 'TTS.TYPE',
- // value: 'BROWSER'
- // }
- // }
- // ]
- // },
- // {
- // title: '缓存设置',
- // settings: [
- // {
- // type: 'number',
- // label: '音频缓存大小',
- // key: 'CACHE.AUDIO_SIZE',
- // min: 100,
- // max: 1000,
- // value: CONFIG.CACHE.AUDIO_SIZE
- // },
- // {
- // type: 'number',
- // label: '翻译缓存大小',
- // key: 'CACHE.TRANS_SIZE',
- // min: 100,
- // max: 1000,
- // value: CONFIG.CACHE.TRANS_SIZE
- // }
- // ]
- // }
- ];
-
- configSections.forEach(section => {
- const sectionEl = this.createConfigSection(section);
- this.configPanel.appendChild(sectionEl);
- });
-
- document.body.appendChild(this.configPanel);
- }
-
-
- // 在 updateConfig 方法中添加模型切换的处理
- updateConfig(key, value) {
- // 将点分隔的键转换为嵌套对象访问
- const keys = key.split('.');
- let current = CONFIG;
- for (let i = 0; i < keys.length - 1; i++) {
- current = current[keys[i]];
- }
-
- // 特殊处理布尔值
- if (value === 'true') value = true;
- if (value === 'false') value = false;
-
- current[keys[keys.length - 1]] = value;
- // 触发配置更新事件
- document.dispatchEvent(new CustomEvent('configUpdate', {
- detail: { key, value }
- }));
-
- // 保存配置
- ConfigManager.saveConfig(CONFIG);
-
- // 打印模型相关的配置变更
- if (key.startsWith('AI_MODELS')) {
- console.log('AI模型配置已更新:', {
- 配置项: key,
- 新值: value,
- 当前模型类型: CONFIG.AI_MODELS.TYPE,
- 模型名称: CONFIG.AI_MODELS[CONFIG.AI_MODELS.TYPE].MODEL,
- 流式响应: CONFIG.AI_MODELS[CONFIG.AI_MODELS.TYPE].STREAM
- });
- }
-
- // 通知更新 - 添加错误处理
- if (this.translator && typeof this.translator.onConfigUpdate === 'function') {
- this.translator.onConfigUpdate(key, value);
- } else {
- console.warn('翻译器未初始化或不支持配置更新');
- }
- }
-
-
- createConfigSection(section) {
- const sectionEl = document.createElement('div');
- sectionEl.style.marginBottom = '20px';
-
- const title = document.createElement('h4');
- title.textContent = section.title;
- title.style.marginBottom = '10px';
- sectionEl.appendChild(title);
-
- section.settings.forEach(setting => {
- const settingEl = this.createConfigSetting(setting);
- sectionEl.appendChild(settingEl);
- });
-
- return sectionEl;
- }
-
- createConfigSetting(setting) {
- const container = document.createElement('div');
- container.style.cssText = `
- margin-bottom: 15px;
- display: flex;
- align-items: center;
- gap: 10px;
- `;
-
- const label = document.createElement('label');
- label.textContent = setting.label;
- label.style.cssText = `
- width: 120px;
- color: #fff;
- font-size: 14px;
- `;
-
- // 添加依赖关系处理
- if (setting.dependsOn) {
- const updateVisibility = () => {
- const dependencyValue = this.getConfigValue(setting.dependsOn.key);
- container.style.display = dependencyValue === setting.dependsOn.value ? 'flex' : 'none';
- };
-
- // 监听依赖项的变化
- document.addEventListener('configUpdate', (e) => {
- if (e.detail.key === setting.dependsOn.key) {
- updateVisibility();
- }
- });
-
- // 初始化可见性
- updateVisibility();
- }
-
- let input;
- switch (setting.type) {
- case 'select':
- input = document.createElement('select');
- setting.options.forEach(option => {
- const opt = document.createElement('option');
- opt.value = option;
- opt.textContent = option;
- opt.selected = option === setting.value;
- // 设置选项样式
- opt.style.cssText = `
- background: #2f2f2f;
- color: #fff;
- padding: 8px;
- `;
- input.appendChild(opt);
- });
- // 为select元素添加特殊样式
- input.style.cssText = `
- padding: 8px 12px;
- border-radius: 4px;
- background: #2f2f2f;
- color: #fff;
- border: 1px solid #4CAF50;
- font-size: 14px;
- cursor: pointer;
- outline: none;
- width: 200px;
- transition: all 0.3s ease;
- appearance: none;
- -webkit-appearance: none;
- -moz-appearance: none;
- background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
- background-repeat: no-repeat;
- background-position: right 8px center;
- background-size: 16px;
- padding-right: 32px;
- `;
- break;
-
- case 'text':
- input = document.createElement('input');
- input.type = 'text';
- input.value = setting.value;
- input.style.cssText = `
- padding: 8px 12px;
- border-radius: 4px;
- background: #2f2f2f;
- color: #fff;
- border: 1px solid #4CAF50;
- font-size: 14px;
- width: 200px;
- outline: none;
- transition: all 0.3s ease;
- `;
- break;
-
- case 'number':
- input = document.createElement('input');
- input.type = 'number';
- input.min = setting.min;
- input.max = setting.max;
- input.value = setting.value;
- input.style.cssText = `
- padding: 8px 12px;
- border-radius: 4px;
- background: #2f2f2f;
- color: #fff;
- border: 1px solid #4CAF50;
- font-size: 14px;
- width: 200px;
- outline: none;
- transition: all 0.3s ease;
- `;
- break;
-
- case 'range':
- input = document.createElement('input');
- input.type = 'range';
- input.min = setting.min;
- input.max = setting.max;
- input.step = setting.step;
- input.value = setting.value;
- input.style.cssText = `
- width: 200px;
- height: 4px;
- border-radius: 2px;
- background: #4CAF50;
- outline: none;
- opacity: 0.7;
- transition: all 0.3s ease;
- -webkit-appearance: none;
- `;
- break;
- }
-
- // 添加悬停效果
- if (setting.type !== 'range') {
- input.addEventListener('mouseover', () => {
- input.style.borderColor = '#66BB6A';
- input.style.boxShadow = '0 0 5px rgba(76, 175, 80, 0.3)';
- });
-
- input.addEventListener('mouseout', () => {
- input.style.borderColor = '#4CAF50';
- input.style.boxShadow = 'none';
- });
-
- input.addEventListener('focus', () => {
- input.style.borderColor = '#66BB6A';
- input.style.boxShadow = '0 0 5px rgba(76, 175, 80, 0.3)';
- });
-
- input.addEventListener('blur', () => {
- input.style.borderColor = '#4CAF50';
- input.style.boxShadow = 'none';
- });
- }
-
- // 为range类型添加特殊样式
- if (setting.type === 'range') {
- input.addEventListener('mouseover', () => {
- input.style.opacity = '1';
- });
-
- input.addEventListener('mouseout', () => {
- input.style.opacity = '0.7';
- });
-
- // 添加滑块样式
- const styleSheet = document.createElement('style');
- styleSheet.textContent = `
- input[type=range]::-webkit-slider-thumb {
- -webkit-appearance: none;
- appearance: none;
- width: 16px;
- height: 16px;
- border-radius: 50%;
- background: #fff;
- cursor: pointer;
- transition: all 0.3s ease;
- }
- input[type=range]::-webkit-slider-thumb:hover {
- background: #e0e0e0;
- transform: scale(1.1);
- }
- `;
- document.head.appendChild(styleSheet);
- }
-
- input.addEventListener('change', (e) => {
- let value = e.target.value;
- if (setting.type === 'number' || setting.type === 'range') {
- value = parseFloat(value);
- }
- this.updateConfig(setting.key, value);
- });
-
- container.appendChild(label);
- container.appendChild(input);
-
- return container;
- }
-
- toggleConfigPanel() {
- if (!this.configPanel) {
- this.createConfigPanel();
- }
- const isVisible = this.configPanel.style.display === 'block';
- this.configPanel.style.display = isVisible ? 'none' : 'block';
- }
-
-
- // 添加获取配置值的辅助方法
- getConfigValue(key) {
- const keys = key.split('.');
- let value = CONFIG;
- for (const k of keys) {
- value = value[k];
- }
- return value;
- }
- 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;
- }
- }
-
- })();