simpleocr / youtube_sub.js
sonygod's picture
修复压缩包
20bd56a
// 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 = `
<div class="word-definition-popup">
<div class="markdown"></div>
</div>
`;
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 = `
<div style="color: red">
Error: ${error.message}<br>
Please check console for details.
</div>
`;
// 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);
}
})();
//