// Update userscript headers // ==UserScript== // @name YouTube Subtitle Manager // @namespace http://tampermonkey.net/ // @version 1.1 // @description Enhanced YouTube subtitle manager with bilingual support // @author Your name // @match https://www.youtube.com/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant GM_download // @connect * // @connect sonygod-flash.hf.space // @require https://cdn.jsdelivr.net/npm/marked/marked.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.0.6/purify.min.js // @require https://cdn.jsdelivr.net/npm/fflate/umd/index.js // @run-at document-end // ==/UserScript== // Critical z-index style - must be outside IIFE GM_addStyle(` .subtitle-manager { z-index: 9999999 !important; } `); (function () { 'use strict'; // Singleton check if (window.subtitleManagerInstance) { window.subtitleManagerInstance.cleanup(); } const WORD_CACHE_EXPIRY = 7 * 24 * 60 * 60 * 1000; // 7 days in ms const WORD_CACHE_KEY = 'youtube_subtitle_word_cache'; // Add after WORD_CACHE_KEY constant: const NEW_WORDS_CACHE_KEY = 'youtube_subtitle_newword'; // 增强的样式定义,添加过渡效果 const styles = ` .subtitle-manager { position: fixed; right: 0; top: 60px; width: 450px; height: calc(100vh - 60px); background: rgba(33, 33, 33, 0.95); color: white; z-index: 9999; font-family: Arial, sans-serif; display: flex; flex-direction: column; transition: transform 0.3s ease; } .subtitle-manager.collapsed { transform: translateX(420px); } .subtitle-toggle { position: absolute; left: -30px; top: 10px; width: 30px; height: 30px; background: rgba(33, 33, 33, 0.95); border: none; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; border-radius: 4px 0 0 4px; } .subtitle-tabs { display: flex; border-bottom: 1px solid #444; } .subtitle-tab { flex: 1; padding: 12px; text-align: center; cursor: pointer; background: transparent; border: none; color: white; transition: background-color 0.2s ease; font-size: 14px; } .subtitle-tab.active { background: #444; font-weight: bold; } .subtitle-content { flex: 1; overflow-y: auto; padding: 15px; transition: opacity 0.3s ease; } .subtitle-line { padding: 12px 15px; margin: 4px 0; cursor: pointer; border-radius: 4px; transition: background-color 0.2s ease, transform 0.1s ease; position: relative; white-space: pre-wrap; word-wrap: break-word; line-height: 1.4; width: 410px; font-size: 14px; } .subtitle-word { display: inline; padding: 0 1px; border-radius: 2px; transition: all 0.15s ease; opacity: 0.7; } .subtitle-word.active { background: rgba(255, 255, 255, 0.3); color: #fff; opacity: 1; } .mixed-line { display: flex; flex-direction: column; gap: 12px; padding: 15px; } .en-text { color: #fff; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; } .zh-text { color: #aaa; font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; border-top: 1px solid rgba(255,255,255,0.1); margin-top: 8px; padding-top: 8px; } .timestamp { position: absolute; right: 12px; top: 8px; font-size: 11px; color: #888; opacity: 0; transition: opacity 0.2s ease; background: rgba(0,0,0,0.5); padding: 2px 6px; border-radius: 3px; } .subtitle-line:hover .timestamp { opacity: 1; } .subtitle-content::-webkit-scrollbar { width: 8px; } .subtitle-content::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.1); } .subtitle-content::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.3); border-radius: 4px; } .subtitle-content::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.4); } .subtitle-word { display: inline-block; padding: 0 1px; border-radius: 2px; transition: all 0.15s ease; opacity: 0.7; } .subtitle-word.active { background: rgba(255, 255, 255, 0.3); color: #fff; opacity: 1; } .subtitle-manager[data-theme="light"] { background: rgba(255, 255, 255, 0.95); color: #0066cc; } .subtitle-manager[data-theme="light"] .subtitle-word { color: #0066cc; opacity: 0.8; } .subtitle-manager[data-theme="light"] .subtitle-word.active { background: rgba(255, 255, 0, 0.3); color: #000; opacity: 1; } .subtitle-manager[data-theme="light"] .subtitle-tab { color: #0066cc; border-bottom: 1px solid #ddd; } .subtitle-manager[data-theme="light"] .subtitle-tab.active { background: #e8e8e8; } .theme-toggle { position: absolute; right: 10px; top: 10px; padding: 4px 8px; background: transparent; border: 1px solid currentColor; color: inherit; cursor: pointer; border-radius: 4px; font-size: 12px; } .subtitle-toggle.theme-toggle { left: -30px; width: 30px; height: 30px; background: rgba(33, 33, 33, 0.95); border: none; color: white; cursor: pointer; display: flex; align-items: center; justify-content: center; border-radius: 4px 0 0 4px; } .subtitle-manager[data-theme="light"] .timestamp { background: rgba(220, 220, 220, 0.9); color: #333; } .subtitle-manager[data-theme="light"] .subtitle-line .timestamp { opacity: 0; transition: opacity 0.2s ease; } .subtitle-manager[data-theme="light"] .subtitle-line:hover .timestamp { opacity: 1; } .word-btn { background: none; border: none; padding: 1px 2px; margin: 0; cursor: pointer; color: inherit; font: inherit; } .word-btn:hover { background: rgba(255, 255, 255, 0.1); border-radius: 2px; outline: 1px solid rgba(255, 255, 255, 0.2); } .line-controls { display: flex; gap: 2px; // Reduce from 4px align-items: center; margin: 0 4px; // Reduce from 8px } .line-btn { width: 20px; // Reduce from 24px height: 20px; // Reduce from 24px font-size: 10px; // Reduce from 12px } .line-btn:hover { opacity: 1; background: rgba(255, 255, 255, 0.1); } .subtitle-line { display: flex; align-items: center; } .subtitle-text { flex: 1; padding: 0 8px; } .subtitle-manager[data-theme="light"] .word-btn:hover { background: rgba(0, 0, 0, 0.05); outline: 1px solid rgba(0, 0, 0, 0.1); } // Keep active highlight styles .subtitle-word.active { background: rgba(255, 255, 255, 0.3); color: #fff; opacity: 1; border-radius: 2px; } .subtitle-manager[data-theme="light"] .subtitle-word.active { background: rgba(255, 255, 0, 0.3); color: #000; opacity: 1; } .word-definition-popup { position: fixed; right: -400px; top: 50%; transform: translateY(-50%); width: 380px; background: rgba(33, 33, 33, 0.95); border-radius: 8px; padding: 20px; color: white; transition: right 0.3s ease; z-index: 10000; max-height: 80vh; overflow-y: auto; font-size: 15px; line-height: 1.6; } .word-definition-popup.show { right: 460px; } .word-definition-popup .markdown { padding-top: 50px; font-size: 15px; line-height: 1.6; } .word-definition-popup .markdown p { margin: 12px 0; } .word-definition-popup .markdown strong { color: #4CAF50; font-weight: 600; } .word-definition-popup .markdown em { color: #FFC107; font-style: normal; } .word-definition-popup[data-theme="light"] { background: rgba(255, 255, 255, 0.95); color: #333; } .word-definition-popup[data-theme="light"] .speaker-btn { background: rgba(0, 0, 0, 0.1); color: #333; } .mixed-text-container { display: flex; flex-direction: column; gap: 8px; flex: 1; padding: 0 8px; } .mixed-line { display: flex; align-items: flex-start; padding: 15px; gap: 8px; } .zh-text { color: #fff; font-size: 14px; line-height: 1.5; } .en-text { color: #aaa; font-size: 14px; line-height: 1.5; border-top: 1px solid rgba(255,255,255,0.1); padding-top: 8px; } @media (max-width: 768px), (orientation: portrait) { .subtitle-manager { position: relative; right: auto; top: auto; width: 100%; height: 50vh; margin-top: 10px; margin-top: 400px; /* Move down below video */ background: rgba(33, 33, 33, 0.98); /* Slightly more opaque for mobile */ } .subtitle-manager.collapsed { transform: translateY(calc(100% - 40px)); margin-top: 400px; /* Keep margin when collapsed */ } .subtitle-toggle { top: -30px; left: 10px; transform: rotate(90deg); } .theme-toggle { top: -30px; right: 10px; } .subtitle-line { width: calc(100% - 30px); max-width: none; } } .word-definition-popup .speaker-btn { position: absolute; top: 15px; right: 15px; width: 36px; height: 36px; background: rgba(255, 255, 255, 0.1); border: none; border-radius: 50%; color: white; cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; transition: all 0.2s ease; } .word-definition-popup .speaker-btn:hover { background: rgba(255, 255, 255, 0.1); transform: scale(1.1); } .word-definition-popup[data-theme="light"] .speaker-btn { color: #333; } `; // Add popup HTML const popupHTML = `
`; const CHUNK_SIZE = 200; // Apply main styles GM_addStyle(styles); // 添加调试日志函数 const debug = { log: (...args) => { console.log('%c[Subtitle Manager]', 'color: #4CAF50', ...args); }, error: (...args) => { console.error('%c[Subtitle Manager]', 'color: #f44336', ...args); }, warn: (...args) => { console.warn('%c[Subtitle Manager]', 'color: #ff9800', ...args); } }; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); // 注入样式的函数 function injectStyles(styles) { try { const styleSheet = document.createElement('style'); styleSheet.textContent = styles; document.head.appendChild(styleSheet); debug.log('Styles injected successfully'); } catch (error) { debug.error('Failed to inject styles:', error); } } class SubtitleManager { constructor() { this.currentTab = 'english'; this.subtitles = { english: [], chinese: [], }; this.container = null; this.currentTime = 0; this.isCollapsed = false; this.isMobile = window.innerWidth <= 768 || window.matchMedia('(orientation: portrait)').matches; this.setupUI(); this.setupEventListeners(); window.subtitleManagerInstance = this; this.speakerClickCount = 0; // Add counter } extractUniqueWords() { // Get all English words from subtitles const words = this.subtitles.english .flatMap(sub => sub.words .map(w => w.text.toLowerCase()) .filter(text => // Only include valid English words /^[a-z]+$/.test(text) && text.length > 1 ) ); // Remove duplicates and sort return [...new Set(words)].sort(); } loadNewWordsCache() { try { const cached = localStorage.getItem(NEW_WORDS_CACHE_KEY); return cached ? new Set(JSON.parse(cached)) : new Set(); } catch (e) { debug.error('Failed to load words cache:', e); return new Set(); } } updateNewWordsCache(words) { try { const existingWords = this.loadNewWordsCache(); const updatedWords = [...existingWords, ...words]; localStorage.setItem(NEW_WORDS_CACHE_KEY, JSON.stringify([...new Set(updatedWords)])); } catch (e) { debug.error('Failed to update words cache:', e); } } convertToSRT(subtitles) { return subtitles.map((sub, index) => { const start = this.formatSRTTime(sub.startTime); const end = this.formatSRTTime(sub.endTime); const text = sub.words.map(w => w.text).join(' '); return `${index + 1}\n${start} --> ${end}\n${text}\n`; }).join('\n'); } convertToMixedSRT(enSubs, cnSubs) { return enSubs.map((enSub, index) => { const start = this.formatSRTTime(enSub.startTime); const end = this.formatSRTTime(enSub.endTime); const enText = enSub.words.map(w => w.text).join(' '); const cnText = cnSubs[index]?.words.map(w => w.text).join('') || ''; return `${index + 1}\n${start} --> ${end}\n${enText}\n${cnText}\n`; }).join('\n'); } formatSRTTime(seconds) { const pad = n => n.toString().padStart(2, '0'); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const ms = Math.floor((seconds % 1) * 1000); return `${pad(hours)}:${pad(minutes)}:${pad(secs)},${pad(ms)}`; } displayWordSummary(container) { // Clear existing content safely while (container.firstChild) { container.removeChild(container.firstChild); } const uniqueWords = this.extractUniqueWords(); const existingWords = this.loadNewWordsCache(); // Add summary header const summaryDiv = document.createElement('div'); summaryDiv.style.cssText = ` font-size: 18px; padding: 15px; margin-bottom: 20px; border-bottom: 1px solid #ccc; text-align: center; font-weight: bold; color: #4CAF50; `; container.appendChild(summaryDiv); //////////////////////////////////////////////// // Add export buttons container const exportDiv = document.createElement('div'); exportDiv.style.cssText = ` display: flex; gap: 10px; justify-content: center; padding: 10px; margin-bottom: 20px; flex-wrap: wrap; `; const createExportButton = (text, onClick) => { const btn = document.createElement('button'); btn.style.cssText = ` padding: 8px 16px; background: #4CAF50; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; `; btn.textContent = text; btn.onclick = onClick; return btn; }; // Export cached words exportDiv.appendChild(createExportButton('Export All Cached Words', () => { const words = [...this.loadNewWordsCache()].sort(); this.downloadFile('cached_words.txt', words.join('\n')); })); // Export current video words exportDiv.appendChild(createExportButton('Export Video Words', () => { const words = this.extractUniqueWords(); this.downloadFile('video_words.txt', words.join('\n')); })); // Export English SRT exportDiv.appendChild(createExportButton('Export EN SRT', () => { const srt = this.convertToSRT(this.subtitles.english); this.downloadFile('english.srt', srt); })); // Export Chinese SRT exportDiv.appendChild(createExportButton('Export CN SRT', () => { const srt = this.convertToSRT(this.subtitles.chinese); this.downloadFile('chinese.srt', srt); })); // Export Mixed SRT exportDiv.appendChild(createExportButton('Export Mixed SRT', () => { const srt = this.convertToMixedSRT(this.subtitles.english, this.subtitles.chinese); this.downloadFile('mixed.srt', srt); })); // Add export button after existing exports exportDiv.appendChild(createExportButton('Export Words by Letter (ZIP)', async () => { try { console.log('Starting ZIP export...'); const words = [...this.loadNewWordsCache()].sort(); // Group words by letter const files = {}; words.forEach(word => { const letter = word[0].toLowerCase(); if (/[a-z]/.test(letter)) { if (!files[letter]) files[letter] = []; files[letter].push(word); } }); // Prepare files for ZIP const zipObj = {}; Object.entries(files).forEach(([letter, words]) => { const content = words.join('\n'); zipObj[`${letter}.txt`] = new TextEncoder().encode(content); }); // Create ZIP using fflate const zipData = fflate.zipSync(zipObj, { level: 6, mem: 8 }); // Convert to blob and download const blob = new Blob([zipData], { type: 'application/zip' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = 'words.zip'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); console.log('ZIP download completed'); } catch (error) { console.error('ZIP generation failed:', error); alert('Failed to create ZIP file: ' + error.message); } })); ////////////////////////////////////////////////////////////////////////////////// // Group words by first letter const grouped = uniqueWords.reduce((acc, word) => { const firstLetter = word[0].toUpperCase(); if (!acc[firstLetter]) acc[firstLetter] = []; acc[firstLetter].push(word); return acc; }, {}); // Create letter sections with enhanced styling Object.entries(grouped) .sort(([a], [b]) => a.localeCompare(b)) .forEach(([letter, words]) => { const section = document.createElement('div'); section.className = 'word-section'; section.style.margin = '0 15px 25px 15px'; const heading = document.createElement('h3'); heading.style.cssText = ` color: #4CAF50; margin: 15px 0; font-size: 24px; font-weight: bold; `; heading.textContent = letter; section.appendChild(heading); const grid = document.createElement('div'); grid.className = 'word-grid'; grid.style.cssText = ` display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; padding: 10px; `; words.forEach(word => { const btn = document.createElement('button'); btn.className = `word-btn subtitle-word ${existingWords.has(word) ? 'known' : 'new'}`; btn.style.cssText = ` text-align: left; padding: 8px 12px; font-size: 16px; border-radius: 4px; transition: all 0.2s; cursor: pointer; ${existingWords.has(word) ? 'opacity: 0.5;' : ''} `; btn.dataset.word = word; btn.textContent = word; btn.onclick = (e) => { e.stopPropagation(); if (word) { this.showWordDefinition(word); } }; grid.appendChild(btn); }); section.appendChild(grid); container.appendChild(section); }); // Add new words to cache this.updateNewWordsCache(uniqueWords); // Get fresh count from cache after update const freshCache = this.loadNewWordsCache(); const totalCachedWords = freshCache.size; // Update summary text with fresh counts summaryDiv.textContent = `Unique: ${uniqueWords.length} | Total: ${totalCachedWords}`; container.insertBefore(exportDiv, container.firstChild.nextSibling); } // Add helper methods to class downloadFile(filename, content) { try { console.log('downloadFile started:', filename, content instanceof Blob); const url = content instanceof Blob ? URL.createObjectURL(content) : URL.createObjectURL(new Blob([content], { type: 'text/plain;charset=utf-8' })); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); console.log('Download completed'); } catch (error) { console.error('Download failed:', error); alert('Failed to download file: ' + error.message); } } cleanup() { // Remove existing instance if (this.container) { this.container.remove(); } // Clear any event listeners window.removeEventListener('resize', this.resizeHandler); } setupResizeHandler() { this.resizeHandler = () => { const isMobile = window.innerWidth <= 768 || window.matchMedia('(orientation: portrait)').matches; if (this.isMobile !== isMobile) { this.isMobile = isMobile; this.updateLayout(); } }; window.addEventListener('resize', this.resizeHandler); } updateLayout() { if (this.isMobile) { // Insert after video player const player = document.querySelector('.html5-video-player'); if (player?.parentNode) { player.parentNode.insertBefore(this.container, player.nextSibling); } } else { // Move back to body for desktop view document.body.appendChild(this.container); } } checkCaptionSources(player) { // Method 1: Direct caption track const tracks = player.getElementsByTagName('track'); if (tracks.length) { debug.log('Found caption tracks:', tracks.length); return true; } // Method 2: YouTube caption button const captionButton = document.querySelector('.ytp-subtitles-button'); if (captionButton) { debug.log('Caption button state:', captionButton.getAttribute('aria-pressed')); return true; } // Method 3: YouTube caption data if (player.getPlayerResponse) { const response = player.getPlayerResponse(); debug.log('Player response:', response); if (response?.captions?.playerCaptionsTracklistRenderer?.captionTracks) { return true; } } return false; } async waitForSubtitles() { debug.log('Waiting for subtitles...'); return new Promise(resolve => { const checkSubs = () => { const player = document.querySelector('.html5-video-player'); if (!player) { debug.log('No player found, retrying...'); requestAnimationFrame(checkSubs); return; } // Check multiple caption sources const hasCaptions = this.checkCaptionSources(player); if (hasCaptions) { debug.log('Captions found!'); resolve(true); } else { debug.log('No captions yet, retrying...'); requestAnimationFrame(checkSubs); } }; checkSubs(); }); } async waitForYouTubeAPI() { debug.log('Waiting for YouTube API initialization...'); return new Promise(resolve => { const check = () => { const player = document.querySelector('.html5-video-player'); debug.log('Checking for player:', player); if (player) { debug.log('Player API methods:', Object.keys(player)); } if (window.ytcfg && player) { debug.log('YouTube API ready'); resolve(); } else { requestAnimationFrame(check); } }; check(); }); } async getVideoTracks() { debug.log('Getting video tracks...'); // Wait for API await this.waitForYouTubeAPI(); // Get player with detailed logging const player = document.querySelector('.html5-video-player'); debug.log('Player found:', player); debug.log('Player methods:', Object.getOwnPropertyNames(player.__proto__)); // Try different methods to get captions try { // Method 1: Direct API if (player.getSubtitlesTrackList) { const tracks = await player.getSubtitlesTrackList(); debug.log('Tracks from API:', tracks); return tracks; } // Method 2: Get from player config if (player.getPlayerResponse) { const response = await player.getPlayerResponse(); debug.log('Player response:', response); if (response.captions) { return response.captions.playerCaptionsTracklistRenderer.captionTracks; } } // Method 3: Get from DOM const captionButton = document.querySelector('.ytp-subtitles-button'); debug.log('Caption button found:', captionButton); if (captionButton) { captionButton.click(); await new Promise(r => setTimeout(r, 1000)); const menu = document.querySelector('.ytp-caption-window-container'); debug.log('Caption menu found:', menu); if (menu) { const tracks = Array.from(menu.querySelectorAll('.ytp-caption-track')) .map(track => ({ languageCode: track.dataset.code, baseUrl: track.dataset.url })); debug.log('Tracks from DOM:', tracks); return tracks; } } } catch (error) { debug.error('Error getting tracks:', error); } debug.warn('No tracks found using any method'); return []; } async loadSubtitles(track) { const MAX_RETRIES = 3; let retries = 0; while (retries < MAX_RETRIES) { try { // Use baseUrl instead of src const url = new URL(track.baseUrl); // Add required params for JSON format url.searchParams.set('fmt', 'json3'); const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`HTTP error: ${response.status}`); } const data = await response.json(); debug.log('Subtitle data:', data); if (!data?.events) { throw new Error('Invalid subtitle data format'); } //debugger; const subtitles = data.events .filter(event => event.segs && event.tStartMs !== undefined) .map(event => ({ startTime: event.tStartMs / 1000, endTime: (event.tStartMs + event.dDurationMs) / 1000, // Filter out empty segments words: event.segs .filter(seg => seg.utf8 && seg.utf8.trim()) .map(seg => ({ text: seg.utf8, startTime: seg.tOffsetMs ? (event.tStartMs + seg.tOffsetMs) / 1000 : event.tStartMs / 1000, endTime: (seg.tOffsetMs ? event.tStartMs + seg.tOffsetMs + (seg.dDurationMs || 0) : event.tStartMs + event.dDurationMs) / 1000 })) })) // Filter out subtitles with no words .filter(sub => sub.words && sub.words.length > 0); debug.log(`Loaded ${subtitles.length} subtitles`); return subtitles; } catch (error) { debug.error(`Subtitle load attempt ${retries + 1} failed:`, error); retries++; if (retries === MAX_RETRIES) { debug.error('Failed to load subtitles after all retries'); return []; } // Exponential backoff await new Promise(r => setTimeout(r, 1000 * Math.pow(2, retries))); } } return []; } parseVTT(vttText) { const lines = vttText.trim().split('\n'); const subtitles = []; let currentSubtitle = null; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); // 跳过 WEBVTT 头部 if (line === 'WEBVTT' || line === '') continue; // 解析时间戳 const timeMatch = line.match(/(\d{2}):(\d{2}):(\d{2})\.(\d{3}) --> (\d{2}):(\d{2}):(\d{2})\.(\d{3})/); if (timeMatch) { if (currentSubtitle) { subtitles.push(currentSubtitle); } currentSubtitle = { startTime: this.parseTime(timeMatch.slice(1, 5)), endTime: this.parseTime(timeMatch.slice(5, 9)), text: '' }; continue; } // 收集字幕文本 if (currentSubtitle && line) { currentSubtitle.text += (currentSubtitle.text ? '\n' : '') + line; } } // 添加最后一条字幕 if (currentSubtitle) { subtitles.push(currentSubtitle); } return subtitles; } parseTime(timeParts) { return parseInt(timeParts[0]) * 3600 + parseInt(timeParts[1]) * 60 + parseInt(timeParts[2]) + parseInt(timeParts[3]) / 1000; } formatTime(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } async waitForElement(selector, timeout = 10000) { debug.log(`Waiting for element: ${selector}`); const element = document.querySelector(selector); if (element) return element; return new Promise(resolve => { const observer = new MutationObserver(() => { const element = document.querySelector(selector); if (element) { observer.disconnect(); resolve(element); } }); observer.observe(document.body, { childList: true, subtree: true }); }); } toggleTheme() { this.currentTheme = this.currentTheme === 'dark' ? 'light' : 'dark'; this.container.dataset.theme = this.currentTheme; } updateTabText(tabId, text) { const tab = this.container.querySelector(`button[data-tab="${tabId}"]`); if (tab) { tab.textContent = text; } } setupUI() { debug.log('Setting up UI'); try { // Create container with trusted content this.container = document.createElement('div'); this.container.className = 'subtitle-manager'; // Create toggle button const toggleButton = document.createElement('button'); toggleButton.className = 'subtitle-toggle'; toggleButton.textContent = '≡'; // Use textContent instead of innerHTML const themeToggle = document.createElement('button'); themeToggle.className = 'subtitle-toggle theme-toggle'; themeToggle.style.top = '50px'; // Position below toggle button themeToggle.textContent = '☀'; themeToggle.addEventListener('click', () => this.toggleTheme()); // Create tabs container const tabs = document.createElement('div'); tabs.className = 'subtitle-tabs'; // Create tab buttons safely const tabsData = [ { id: 'english', text: 'English' }, { id: 'chinese', text: '中文' }, { id: 'mixed', text: 'Mixed' }, { id: 'summary', text: 'Words' } ]; tabsData.forEach(tab => { const button = document.createElement('button'); button.className = 'subtitle-tab'; button.dataset.tab = tab.id; button.textContent = tab.text; if (tab.id === 'english') button.classList.add('active'); tabs.appendChild(button); }); // Create content container const content = document.createElement('div'); content.className = 'subtitle-content'; // Append elements this.container.appendChild(toggleButton); this.container.appendChild(themeToggle); this.container.appendChild(tabs); this.container.appendChild(content); // Wait for document.body to be available if (document.body) { document.body.appendChild(this.container); } else { window.addEventListener('DOMContentLoaded', () => { document.body.appendChild(this.container); }); } // Add toggle functionality toggleButton.addEventListener('click', () => this.toggleCollapse()); // Add theme toggle button } catch (error) { debug.error('Error setting up UI:', error); } } toggleCollapse() { this.isCollapsed = !this.isCollapsed; this.container.classList.toggle('collapsed', this.isCollapsed); } // Get word translation with cache async getWordTranslation(word) { // Get cache const cache = JSON.parse(localStorage.getItem(WORD_CACHE_KEY) || '{}'); const now = Date.now(); const cachedResult = cache[word]; // Check valid cache if (cachedResult && now - cachedResult.timestamp < WORD_CACHE_EXPIRY) { console.log('Cache hit:', word); return cachedResult.data; } // Clear expired entries Object.keys(cache).forEach(key => { if (now - cache[key].timestamp > WORD_CACHE_EXPIRY) { delete cache[key]; } }); // API call const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://sonygod-flash.hf.space/translate/${encodeURIComponent(word.trim())}`, headers: { 'Accept': 'application/json', 'Origin': 'https://www.youtube.com' }, onload: function (response) { if (response.status >= 200 && response.status < 300) { try { resolve(JSON.parse(response.responseText)); } catch (e) { reject(new Error('Invalid JSON response')); } } else { reject(new Error(`HTTP error! status: ${response.status}`)); } }, onerror: error => reject(new Error('Network request failed: ' + error.error)), ontimeout: () => reject(new Error('Request timed out')) }); }); // Cache successful response if (response?.data?.response) { cache[word] = { timestamp: now, data: response }; try { localStorage.setItem(WORD_CACHE_KEY, JSON.stringify(cache)); } catch (e) { console.error('Cache write failed:', e); } } return response; }; // Update displaySingleLanguage method displaySingleLanguage(container, subtitles) { subtitles.forEach((sub, index) => { if (sub.words && sub.words.length > 0) { const line = document.createElement('div'); line.className = 'subtitle-line'; // Add prefix buttons const prefixControls = document.createElement('div'); prefixControls.className = 'line-controls'; ['↑', '★'].forEach(text => { const btn = document.createElement('button'); btn.className = 'line-btn'; btn.textContent = text; btn.onclick = (e) => { e.stopPropagation(); // Prevent line click console.log(`Prefix button ${text} clicked for line ${index}`); }; prefixControls.appendChild(btn); }); line.appendChild(prefixControls); // Add subtitle text container const textContainer = document.createElement('div'); textContainer.className = 'subtitle-text'; sub.words.forEach((word, i) => { if (word.text && word.text.trim()) { const wordBtn = document.createElement('button'); wordBtn.className = 'word-btn subtitle-word'; // Add subtitle-word class for highlighting wordBtn.textContent = word.text.trim(); wordBtn.dataset.start = word.startTime; wordBtn.dataset.end = word.endTime; wordBtn.onclick = async (e) => { e.stopPropagation(); console.log(`You click "${word.text.trim()}" word`); const wordText = word.text.trim(); let popup = document.querySelector('.word-definition-popup'); if (!popup) { // Create popup container popup = document.createElement('div'); popup.className = 'word-definition-popup'; // Create markdown container const markdownDiv = document.createElement('div'); markdownDiv.className = 'markdown'; popup.appendChild(markdownDiv); // Add popup to document document.body.appendChild(popup); // Add click outside listener document.addEventListener('click', () => { popup.classList.remove('show'); }); } // Update speaker button for current word let speakerBtn = popup.querySelector('.speaker-btn'); if (speakerBtn) { speakerBtn.remove(); } // Create new speaker button with current word speakerBtn = document.createElement('button'); speakerBtn.className = 'speaker-btn'; speakerBtn.textContent = '🔊'; speakerBtn.onclick = async (e) => { e.stopPropagation(); try { const utterance = new SpeechSynthesisUtterance(wordText); utterance.lang = 'en-US'; utterance.rate = 0.9; // Get voices and select based on click count let voices = speechSynthesis.getVoices(); if (!voices.length) { await new Promise(resolve => { speechSynthesis.onvoiceschanged = () => { voices = speechSynthesis.getVoices(); resolve(); }; }); } // Log available voices console.log('Available voices:', voices.map(v => ({ name: v.name, lang: v.lang, isFemale: v.name.toLowerCase().includes('female') }))); // Select voice based on click count const isFemaleTurn = this.speakerClickCount % 2 === 1; const voice = voices.find(v => v.lang.includes('en') && v.name.toLowerCase().includes('female') === isFemaleTurn ); if (voice) { console.log('Selected voice:', voice.name); utterance.voice = voice; } else { console.warn('No matching voice found, using default'); } // Cancel any ongoing speech speechSynthesis.cancel(); // Speak the word speechSynthesis.speak(utterance); this.speakerClickCount++; } catch (error) { console.error('TTS failed:', error); } }; // Insert new speaker button at the start popup.insertBefore(speakerBtn, popup.firstChild); // Ensure markdown container exists let markdownContainer = popup.querySelector('.markdown'); if (!markdownContainer) { markdownContainer = document.createElement('div'); markdownContainer.className = 'markdown'; popup.appendChild(markdownContainer); } try { // Use GM_xmlhttpRequest with updated config // const response = await new Promise((resolve, reject) => { // GM_xmlhttpRequest({ // method: 'POST', // url: 'https://sonygod-flash.hf.space/ask', // headers: { // 'Content-Type': 'application/json', // 'Accept': 'application/json', // 'Origin': 'https://www.youtube.com' // }, // data: JSON.stringify({ // prompt: `中文翻译 "${word.text.trim()}"并详细介绍,100字以内,包括对应的英文近义词,英文反义词`, // model: 'GEMINI' // }), // onload: function (response) { // // Handle different status codes // if (response.status === 405) { // reject(new Error('API endpoint does not accept POST method. Try GET instead.')); // } else if (response.status >= 200 && response.status < 300) { // try { // resolve(JSON.parse(response.responseText)); // } catch (e) { // reject(new Error('Invalid JSON response')); // } // } else { // reject(new Error(`HTTP error! status: ${response.status}`)); // } // }, // onerror: function (error) { // reject(new Error('Network request failed: ' + error.error)); // }, // ontimeout: function () { // reject(new Error('Request timed out')); // } // }); // }); const response = await this.getWordTranslation(wordText); console.log('API Response:', response); const content = response?.data?.response; if (!content) { // Check for error response if (response?.status && response?.error) { throw new Error(`API Error: ${response.status} - ${response.error}`); } else { throw new Error('No content in API response'); } } // Create text node instead of using innerHTML const markdownContent = marked.parse(content); if (markdownContent) { // Clear existing content while (markdownContainer.firstChild) { markdownContainer.removeChild(markdownContainer.firstChild); } // Create temporary container const temp = document.createElement('div'); temp.textContent = markdownContent; // Sanitize the HTML content using DOMPurify const sanitizedHtml = DOMPurify.sanitize(markdownContent, { RETURN_TRUSTED_TYPE: true }); try { temp.innerHTML = sanitizedHtml; } catch (e) { console.error('DOMPurify error:', e); } // Safely append text content const fragment = document.createDocumentFragment(); fragment.appendChild(temp); markdownContainer.appendChild(fragment); popup.classList.add('show'); } else { throw new Error('Failed to parse markdown content'); } } catch (error) { console.error('API call failed:', error); // Create error message HTML const errorHtml = `
Error: ${error.message}
Please check console for details.
`; // Sanitize error message const sanitizedError = DOMPurify.sanitize(errorHtml, { RETURN_TRUSTED_TYPE: true }); // Create temporary container const errorContainer = document.createElement('div'); errorContainer.className = 'markdown'; try { errorContainer.innerHTML = sanitizedError; // Clear existing content const markdownElement = popup.querySelector('.markdown'); while (markdownElement.firstChild) { markdownElement.removeChild(markdownElement.firstChild); } // Append sanitized error message markdownElement.appendChild(errorContainer); popup.classList.add('show'); } catch (e) { console.error('Error displaying message:', e); } } }; textContainer.appendChild(wordBtn); if (i < sub.words.length - 1) { textContainer.appendChild(document.createTextNode(' ')); } } }); line.appendChild(textContainer); // // Add suffix buttons // const suffixControls = document.createElement('div'); // suffixControls.className = 'line-controls'; // ['✎', '↓'].forEach(text => { // const btn = document.createElement('button'); // btn.className = 'line-btn'; // btn.textContent = text; // btn.onclick = (e) => { // e.stopPropagation(); // Prevent line click // console.log(`Suffix button ${text} clicked for line ${index}`); // }; // suffixControls.appendChild(btn); // }); // line.appendChild(suffixControls); // Add timestamp const timestamp = document.createElement('span'); timestamp.className = 'timestamp'; timestamp.textContent = this.formatTime(sub.startTime); line.appendChild(timestamp); line.dataset.time = sub.startTime; line.dataset.index = index; container.appendChild(line); } }); } displayMixed(container) { const maxLength = Math.max( this.subtitles.english?.length || 0, this.subtitles.chinese?.length || 0 ); for (let i = 0; i < maxLength; i++) { const line = document.createElement('div'); line.className = 'subtitle-line mixed-line'; // Text container const textContainer = document.createElement('div'); textContainer.className = 'mixed-text-container'; // Chinese text with word buttons const zhText = document.createElement('div'); zhText.className = 'zh-text'; if (this.subtitles.chinese[i]?.words) { this.subtitles.chinese[i].words.forEach((word, idx) => { const wordBtn = document.createElement('button'); wordBtn.className = 'word-btn subtitle-word'; wordBtn.textContent = word.text; wordBtn.dataset.start = word.startTime; wordBtn.dataset.end = word.endTime; zhText.appendChild(wordBtn); if (idx < this.subtitles.chinese[i].words.length - 1) { zhText.appendChild(document.createTextNode(' ')); } }); } else { zhText.textContent = '翻译中...'; } // English text with word buttons const enText = document.createElement('div'); enText.className = 'en-text'; if (this.subtitles.english[i]?.words) { this.subtitles.english[i].words.forEach((word, idx) => { const wordBtn = document.createElement('button'); wordBtn.className = 'word-btn subtitle-word'; wordBtn.textContent = word.text; wordBtn.dataset.start = word.startTime; wordBtn.dataset.end = word.endTime; enText.appendChild(wordBtn); if (idx < this.subtitles.english[i].words.length - 1) { enText.appendChild(document.createTextNode(' ')); } }); } textContainer.appendChild(zhText); textContainer.appendChild(enText); line.appendChild(textContainer); // Add timestamp const timestamp = document.createElement('span'); timestamp.className = 'timestamp'; timestamp.textContent = this.subtitles.english[i] ? this.formatTime(this.subtitles.english[i].startTime) : ''; line.appendChild(timestamp); if (this.subtitles.english[i]) { line.dataset.time = this.subtitles.english[i].startTime; line.dataset.index = i; } container.appendChild(line); } } setupEventListeners() { // 标签切换 this.container.addEventListener('click', (e) => { if (e.target.classList.contains('subtitle-tab')) { const tabs = this.container.querySelectorAll('.subtitle-tab'); tabs.forEach(tab => tab.classList.remove('active')); e.target.classList.add('active'); this.currentTab = e.target.dataset.tab; this.updateSubtitleDisplay(); } }); // 字幕点击跳转 this.container.addEventListener('click', (e) => { const line = e.target.closest('.subtitle-line'); if (line && line.dataset.time) { const video = document.querySelector('video'); if (video) { video.currentTime = parseFloat(line.dataset.time); // 添加点击反馈 line.style.transform = 'scale(0.98)'; setTimeout(() => { line.style.transform = ''; }, 100); } } }); // Tab 键切换字幕 document.addEventListener('keydown', (e) => { if (e.key === 'Tab') { e.preventDefault(); const tabs = ['english', 'chinese', 'mixed']; const currentIndex = tabs.indexOf(this.currentTab); const nextIndex = (currentIndex + 1) % tabs.length; this.currentTab = tabs[nextIndex]; const tabButtons = this.container.querySelectorAll('.subtitle-tab'); tabButtons.forEach(tab => { tab.classList.toggle('active', tab.dataset.tab === this.currentTab); }); this.updateSubtitleDisplay(); } }); // 视频时间更新 this.setupVideoTimeUpdate(); } setupVideoTimeUpdate() { let rafId = null; const updateThreshold = 16; let lastUpdate = 0; const TRANSITION_BUFFER = 0.1; // 100ms buffer let lastActiveIndex = -1; const findActiveSubtitle = (time, subtitles) => { // Find all potentially active subtitles const active = []; for (let i = 0; i < subtitles.length; i++) { const sub = subtitles[i]; // Add buffer for smoother transitions if (time >= (sub.startTime - TRANSITION_BUFFER) && time <= (sub.endTime + TRANSITION_BUFFER)) { active.push({ index: i, sub }); } // Early exit if we're past the current time if (sub.startTime > time + 1) break; } if (active.length === 0) return -1; // If multiple subtitles are active, choose the most relevant one if (active.length > 1) { // Prefer subtitle that has just started const justStarted = active.find(({ sub }) => Math.abs(time - sub.startTime) < TRANSITION_BUFFER); if (justStarted) return justStarted.index; // Otherwise use the one with the closest start time return active.reduce((prev, curr) => { const prevDiff = Math.abs(time - prev.sub.startTime); const currDiff = Math.abs(time - curr.sub.startTime); return currDiff < prevDiff ? curr : prev; }).index; } return active[0].index; }; const clearAllHighlights = () => { const words = this.container.querySelectorAll('.subtitle-word.active'); words.forEach(word => word.classList.remove('active')); }; const highlightWords = (currentTime, activeLine) => { if (!activeLine) return; const words = Array.from(activeLine.querySelectorAll('.subtitle-word')); // Sort words by start time words.sort((a, b) => parseFloat(a.dataset.start) - parseFloat(b.dataset.start) ); let hasActiveWord = false; words.forEach((word, index) => { const start = parseFloat(word.dataset.start); const end = parseFloat(word.dataset.end); const isActive = currentTime >= start && currentTime <= end; if (isActive) { hasActiveWord = true; } // Also highlight previous words when current word is active word.classList.toggle('active', hasActiveWord ? currentTime >= start || index < words.findIndex(w => currentTime >= parseFloat(w.dataset.start) && currentTime <= parseFloat(w.dataset.end) ) : false ); }); }; const handleTimeUpdate = (video) => { const now = performance.now(); if (now - lastUpdate < updateThreshold) { rafId = requestAnimationFrame(() => handleTimeUpdate(video)); return; } lastUpdate = now; const currentTime = video.currentTime; const subtitles = this.currentTab === 'chinese' ? this.subtitles.chinese : this.subtitles.english; const newIndex = findActiveSubtitle(currentTime, subtitles); if (newIndex === lastActiveIndex) { // Still highlight words even if subtitle hasn't changed const activeLine = this.container.querySelector('.subtitle-line.active'); if (activeLine) { highlightWords(currentTime, activeLine); } rafId = requestAnimationFrame(() => handleTimeUpdate(video)); return; } // Clear all highlights when switching lines clearAllHighlights(); const lines = this.container.querySelectorAll('.subtitle-line'); lines.forEach(line => { const lineIndex = parseInt(line.dataset.index); const isActive = lineIndex === newIndex; line.classList.toggle('active', isActive); if (isActive && subtitles[lineIndex]) { highlightWords(currentTime, line); if (lineIndex !== lastActiveIndex) { line.scrollIntoView({ block: 'center', behavior: 'smooth' }); } } }); lastActiveIndex = newIndex; rafId = requestAnimationFrame(() => handleTimeUpdate(video)); }; const setupVideoListener = async () => { const video = await this.waitForElement('video'); if (video) { handleTimeUpdate(video); video.addEventListener('pause', () => { cancelAnimationFrame(rafId); }); video.addEventListener('play', () => { lastUpdate = 0; handleTimeUpdate(video); }); } }; setupVideoListener(); } async getCaptionTracks() { const player = document.querySelector('.html5-video-player'); if (!player) return null; try { // Try YouTube's API first if (player.getPlayerResponse) { const response = player.getPlayerResponse(); if (response?.captions?.playerCaptionsTracklistRenderer?.captionTracks) { return response.captions.playerCaptionsTracklistRenderer.captionTracks; } } // Fallback to DOM tracks return Array.from(player.getElementsByTagName('track')); } catch (error) { debug.error('Error getting caption tracks:', error); return null; } } async translateInChunks(subtitleData) { // Get video URL as cache key const videoUrl = window.location.href; const cacheKey = `translations_${CHUNK_SIZE}_${videoUrl}`; const CACHE_EXPIRY = 365 * 24 * 60 * 60 * 1000; // 7 days const chunks = []; let translations = []; const DELAY_BETWEEN_CHUNKS = 1000; // 1 second delay let lastChunkIndex = 0; const totalChunks = Math.ceil(subtitleData.length / CHUNK_SIZE); // Try to get cached progress try { const cached = localStorage.getItem(cacheKey); if (cached) { const { translations: cachedTranslations, timestamp, lastProcessedChunk } = JSON.parse(cached); if (Date.now() - timestamp < CACHE_EXPIRY) { if (lastProcessedChunk === totalChunks - 1) { // Translation was complete debug.log('Using complete cached translations'); return cachedTranslations; } else { // Resume from last successful chunk debug.log(`Resuming from chunk ${lastProcessedChunk + 1}`); translations = [...cachedTranslations]; lastChunkIndex = lastProcessedChunk + 1; } } else { localStorage.removeItem(cacheKey); } } } catch (e) { debug.error('Cache read error:', e); } // Initialize Chinese subtitles with placeholders this.subtitles.chinese = this.subtitles.english.map((sub, index) => ({ startTime: sub.startTime, endTime: sub.endTime, words: [{ text: translations[index]?.text || '翻译中...', startTime: sub.startTime, endTime: sub.endTime }] })); // Update display immediately this.updateSubtitleDisplay(); // Split remaining data into chunks for (let i = lastChunkIndex * CHUNK_SIZE; i < subtitleData.length; i += CHUNK_SIZE) { chunks.push(subtitleData.slice(i, i + CHUNK_SIZE)); } // Process each chunk for (let i = 0; i < chunks.length; i++) { const currentChunkIndex = lastChunkIndex + i; this.updateTabText('chinese', `翻译中...(${currentChunkIndex + 1}/${totalChunks})`); if (i > 0) { await sleep(DELAY_BETWEEN_CHUNKS); } try { const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: 'https://sonygod-flash.hf.space/ask', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, data: JSON.stringify({ prompt: `请将以下英文字幕翻译成中文(第${i + 1}组,共${chunks.length}组): 1. 必须保持JSON格式返回 2. 必须一一对应翻译,不能合并或拆分句子 3. 必须保持原时间戳 4. translations数组长度必须为${chunks[i].length} ${JSON.stringify(chunks[i], null, 2)} 返回格式: { "translations": [ { "text": "中文翻译", "time": 原时间戳 } ] }`, model: 'GEMINI' }), onload: response => { if (response.status === 200) { resolve(JSON.parse(response.responseText)); } else { reject(new Error(`HTTP error: ${response.status}`)); } }, onerror: error => reject(error) }); }); const content = response.data.response; const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*(?:```|\.{3})/); if (!jsonMatch) { console.log('Response:', content); throw new Error(`Chunk ${i + 1}: No JSON content found`); } const parsedResponse = JSON.parse(jsonMatch[1]); translations.push(...parsedResponse.translations); // 立即更新中文字幕 this.subtitles.chinese = this.subtitles.english.map((sub, index) => ({ startTime: sub.startTime, endTime: sub.endTime, words: [{ text: translations[index]?.text || '翻译中...', startTime: sub.startTime, endTime: sub.endTime }] })); // 先更新字幕显示 this.updateSubtitleDisplay(); try { localStorage.setItem(cacheKey, JSON.stringify({ translations, timestamp: Date.now(), lastProcessedChunk: currentChunkIndex, // Save current chunk index total: totalChunks })); debug.log(`Saved progress: ${currentChunkIndex + 1}/${totalChunks}`); // Refresh display this.updateSubtitleDisplay(); } catch (e) { debug.error('Cache write error:', e); } } catch (error) { debug.error(`Chunk ${i + 1} translation failed:`, error); // Add empty translations for failed chunk translations.push(...chunks[i].map(sub => ({ text: `[翻译失败] ${sub.text}`, time: sub.time }))); } } return translations; } async initializeSubtitles() { debug.log('Starting subtitle initialization'); // 1. Wait for subtitles const hasCaptions = await this.waitForSubtitles(); if (!hasCaptions) { debug.error('No captions available'); return; } // 2. Get tracks const tracks = await this.getCaptionTracks(); if (!tracks?.length) { debug.error('No caption tracks found'); return; } //enter debug mode //debugger; // 3. Load subtitle data for (const track of tracks) { if (track.languageCode.includes('en')) { this.subtitles.english = await this.loadSubtitles(track); } if (track.languageCode.includes('zh')) { this.subtitles.chinese = await this.loadSubtitles(track); } } this.updateSubtitleDisplay(); // If no Chinese subtitles, get translation if (this.subtitles.english.length > 0 && !this.subtitles.chinese.length) { const videoUrl = window.location.href; const cacheKey = `translations_${CHUNK_SIZE}_${videoUrl}`; // Try cache first try { const cached = localStorage.getItem(cacheKey); if (cached) { const { translations, timestamp, lastProcessedChunk, total } = JSON.parse(cached); const CACHE_EXPIRY = 365 * 24 * 60 * 60 * 1000; // 365 days if (Date.now() - timestamp < CACHE_EXPIRY) { if (lastProcessedChunk === total - 1) { debug.log('Using cached translations'); this.subtitles.chinese = this.subtitles.english.map((sub, index) => ({ startTime: sub.startTime, endTime: sub.endTime, words: [{ text: translations[index]?.text || '', startTime: sub.startTime, endTime: sub.endTime }] })); this.updateTabText('chinese', '中文'); return; } else { debug.log(`Cached translation incomplete (${lastProcessedChunk + 1}/${total}), resuming...`); } } else { localStorage.removeItem(cacheKey); } } } catch (e) { debug.error('Cache read error:', e); } // Format English subtitles as JSON const subtitleData = this.subtitles.english.map(sub => ({ text: sub.words.map(word => word.text).join(' '), time: sub.startTime })); try { const translations = await this.translateInChunks(subtitleData); this.subtitles.chinese = this.subtitles.english.map((sub, index) => ({ startTime: sub.startTime, endTime: sub.endTime, words: [{ text: translations[index]?.text || '', startTime: sub.startTime, endTime: sub.endTime }] })); this.updateTabText('chinese', '中文'); } catch (error) { debug.error('Translation failed:', error); this.updateTabText('chinese', '翻译失败'); } } // 4. Display subtitles this.updateSubtitleDisplay(); } createMessageElement(text) { const div = document.createElement('div'); div.className = 'subtitle-line'; div.textContent = text; return div; } updateSubtitleDisplay() { const content = this.container.querySelector('.subtitle-content'); content.style.opacity = '0'; setTimeout(() => { // Clear content safely while (content.firstChild) { content.removeChild(content.firstChild); } // Check for empty subtitles if (!this.subtitles.english.length && !this.subtitles.chinese.length) { content.appendChild(this.createMessageElement('No subtitles available')); content.style.opacity = '1'; return; } // Display based on current tab switch (this.currentTab) { case 'english': if (this.subtitles.english.length) { this.displaySingleLanguage(content, this.subtitles.english); } else { content.appendChild(this.createMessageElement('English subtitles not available')); } break; case 'chinese': if (this.subtitles.chinese.length) { this.displaySingleLanguage(content, this.subtitles.chinese); } else { content.appendChild(this.createMessageElement('中文字幕不可用')); } break; case 'mixed': if (this.subtitles.english.length || this.subtitles.chinese.length) { this.displayMixed(content); } else { content.appendChild(this.createMessageElement('No subtitles available for mixed mode')); } break; case 'summary': this.displayWordSummary(content); break; } // Fade in content.style.opacity = '1'; }, 300); } } // Modified initialization with proper waiting async function waitForYouTubeReady() { debug.log('Waiting for YouTube player...'); return new Promise((resolve) => { const check = () => { const player = document.querySelector('.html5-video-player'); const video = document.querySelector('video'); if (player && video && !player.classList.contains('uninitialized')) { debug.log('YouTube player ready'); resolve(); } else { requestAnimationFrame(check); } }; check(); }); } // Add init logging async function initializeManager() { debug.log('=== Starting initialization ==='); debug.log('Current URL:', window.location.href); debug.log('Document ready state:', document.readyState); // Cleanup existing instance if (window.subtitleManagerInstance) { window.subtitleManagerInstance.cleanup(); } if (!window.location.pathname.includes('/watch')) { debug.log('Not a video page, skipping'); return; } try { await waitForYouTubeReady(); debug.log('YouTube ready, creating manager'); const manager = new SubtitleManager(); debug.log('Manager created, initializing subtitles'); await manager.initializeSubtitles(); debug.log('=== Initialization complete ==='); } catch (error) { debug.error('Initialization failed:', error); } } // Modified URL change detection let lastUrl = location.href; const observer = new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; initializeManager(); } }); // Start observing observer.observe(document.body, { subtree: true, childList: true }); // Initialize when document is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initializeManager); } else { setTimeout(initializeManager, 2000); } })(); //