|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
GM_addStyle(` |
|
|
.subtitle-manager { |
|
|
z-index: 9999999 !important; |
|
|
} |
|
|
`); |
|
|
(function () { |
|
|
'use strict'; |
|
|
|
|
|
|
|
|
if (window.subtitleManagerInstance) { |
|
|
window.subtitleManagerInstance.cleanup(); |
|
|
} |
|
|
const WORD_CACHE_EXPIRY = 7 * 24 * 60 * 60 * 1000; |
|
|
const WORD_CACHE_KEY = 'youtube_subtitle_word_cache'; |
|
|
|
|
|
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; |
|
|
} |
|
|
`; |
|
|
|
|
|
|
|
|
const popupHTML = ` |
|
|
<div class="word-definition-popup"> |
|
|
<div class="markdown"></div> |
|
|
</div> |
|
|
`; |
|
|
const CHUNK_SIZE = 200; |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
extractUniqueWords() { |
|
|
|
|
|
const words = this.subtitles.english |
|
|
.flatMap(sub => sub.words |
|
|
.map(w => w.text.toLowerCase()) |
|
|
.filter(text => |
|
|
|
|
|
/^[a-z]+$/.test(text) && |
|
|
text.length > 1 |
|
|
) |
|
|
); |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
while (container.firstChild) { |
|
|
container.removeChild(container.firstChild); |
|
|
} |
|
|
|
|
|
const uniqueWords = this.extractUniqueWords(); |
|
|
const existingWords = this.loadNewWordsCache(); |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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; |
|
|
}; |
|
|
|
|
|
|
|
|
exportDiv.appendChild(createExportButton('Export All Cached Words', () => { |
|
|
const words = [...this.loadNewWordsCache()].sort(); |
|
|
this.downloadFile('cached_words.txt', words.join('\n')); |
|
|
})); |
|
|
|
|
|
|
|
|
exportDiv.appendChild(createExportButton('Export Video Words', () => { |
|
|
const words = this.extractUniqueWords(); |
|
|
this.downloadFile('video_words.txt', words.join('\n')); |
|
|
})); |
|
|
|
|
|
|
|
|
exportDiv.appendChild(createExportButton('Export EN SRT', () => { |
|
|
const srt = this.convertToSRT(this.subtitles.english); |
|
|
this.downloadFile('english.srt', srt); |
|
|
})); |
|
|
|
|
|
|
|
|
exportDiv.appendChild(createExportButton('Export CN SRT', () => { |
|
|
const srt = this.convertToSRT(this.subtitles.chinese); |
|
|
this.downloadFile('chinese.srt', srt); |
|
|
})); |
|
|
|
|
|
|
|
|
exportDiv.appendChild(createExportButton('Export Mixed SRT', () => { |
|
|
const srt = this.convertToMixedSRT(this.subtitles.english, this.subtitles.chinese); |
|
|
this.downloadFile('mixed.srt', srt); |
|
|
})); |
|
|
|
|
|
|
|
|
exportDiv.appendChild(createExportButton('Export Words by Letter (ZIP)', async () => { |
|
|
try { |
|
|
|
|
|
console.log('Starting ZIP export...'); |
|
|
const words = [...this.loadNewWordsCache()].sort(); |
|
|
|
|
|
|
|
|
const files = {}; |
|
|
words.forEach(word => { |
|
|
const letter = word[0].toLowerCase(); |
|
|
if (/[a-z]/.test(letter)) { |
|
|
if (!files[letter]) files[letter] = []; |
|
|
files[letter].push(word); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
const zipObj = {}; |
|
|
Object.entries(files).forEach(([letter, words]) => { |
|
|
const content = words.join('\n'); |
|
|
zipObj[`${letter}.txt`] = new TextEncoder().encode(content); |
|
|
}); |
|
|
|
|
|
|
|
|
const zipData = fflate.zipSync(zipObj, { |
|
|
level: 6, |
|
|
mem: 8 |
|
|
}); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
})); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const grouped = uniqueWords.reduce((acc, word) => { |
|
|
const firstLetter = word[0].toUpperCase(); |
|
|
if (!acc[firstLetter]) acc[firstLetter] = []; |
|
|
acc[firstLetter].push(word); |
|
|
return acc; |
|
|
}, {}); |
|
|
|
|
|
|
|
|
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); |
|
|
}); |
|
|
|
|
|
|
|
|
this.updateNewWordsCache(uniqueWords); |
|
|
|
|
|
|
|
|
const freshCache = this.loadNewWordsCache(); |
|
|
const totalCachedWords = freshCache.size; |
|
|
|
|
|
|
|
|
summaryDiv.textContent = `Unique: ${uniqueWords.length} | Total: ${totalCachedWords}`; |
|
|
container.insertBefore(exportDiv, container.firstChild.nextSibling); |
|
|
} |
|
|
|
|
|
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() { |
|
|
|
|
|
if (this.container) { |
|
|
this.container.remove(); |
|
|
} |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
const player = document.querySelector('.html5-video-player'); |
|
|
if (player?.parentNode) { |
|
|
player.parentNode.insertBefore(this.container, player.nextSibling); |
|
|
} |
|
|
} else { |
|
|
|
|
|
document.body.appendChild(this.container); |
|
|
} |
|
|
} |
|
|
|
|
|
checkCaptionSources(player) { |
|
|
|
|
|
const tracks = player.getElementsByTagName('track'); |
|
|
if (tracks.length) { |
|
|
debug.log('Found caption tracks:', tracks.length); |
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
const captionButton = document.querySelector('.ytp-subtitles-button'); |
|
|
if (captionButton) { |
|
|
debug.log('Caption button state:', captionButton.getAttribute('aria-pressed')); |
|
|
return true; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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...'); |
|
|
|
|
|
|
|
|
await this.waitForYouTubeAPI(); |
|
|
|
|
|
|
|
|
const player = document.querySelector('.html5-video-player'); |
|
|
debug.log('Player found:', player); |
|
|
debug.log('Player methods:', Object.getOwnPropertyNames(player.__proto__)); |
|
|
|
|
|
|
|
|
try { |
|
|
|
|
|
if (player.getSubtitlesTrackList) { |
|
|
const tracks = await player.getSubtitlesTrackList(); |
|
|
debug.log('Tracks from API:', tracks); |
|
|
return tracks; |
|
|
} |
|
|
|
|
|
|
|
|
if (player.getPlayerResponse) { |
|
|
const response = await player.getPlayerResponse(); |
|
|
debug.log('Player response:', response); |
|
|
if (response.captions) { |
|
|
return response.captions.playerCaptionsTracklistRenderer.captionTracks; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
const url = new URL(track.baseUrl); |
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
const subtitles = data.events |
|
|
.filter(event => event.segs && event.tStartMs !== undefined) |
|
|
.map(event => ({ |
|
|
startTime: event.tStartMs / 1000, |
|
|
endTime: (event.tStartMs + event.dDurationMs) / 1000, |
|
|
|
|
|
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(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 []; |
|
|
} |
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
this.container = document.createElement('div'); |
|
|
this.container.className = 'subtitle-manager'; |
|
|
|
|
|
|
|
|
const toggleButton = document.createElement('button'); |
|
|
toggleButton.className = 'subtitle-toggle'; |
|
|
toggleButton.textContent = '≡'; |
|
|
|
|
|
const themeToggle = document.createElement('button'); |
|
|
themeToggle.className = 'subtitle-toggle theme-toggle'; |
|
|
themeToggle.style.top = '50px'; |
|
|
themeToggle.textContent = '☀'; |
|
|
themeToggle.addEventListener('click', () => this.toggleTheme()); |
|
|
|
|
|
const tabs = document.createElement('div'); |
|
|
tabs.className = 'subtitle-tabs'; |
|
|
|
|
|
|
|
|
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); |
|
|
}); |
|
|
|
|
|
|
|
|
const content = document.createElement('div'); |
|
|
content.className = 'subtitle-content'; |
|
|
|
|
|
|
|
|
this.container.appendChild(toggleButton); |
|
|
this.container.appendChild(themeToggle); |
|
|
this.container.appendChild(tabs); |
|
|
this.container.appendChild(content); |
|
|
|
|
|
|
|
|
if (document.body) { |
|
|
document.body.appendChild(this.container); |
|
|
} else { |
|
|
window.addEventListener('DOMContentLoaded', () => { |
|
|
document.body.appendChild(this.container); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
toggleButton.addEventListener('click', () => this.toggleCollapse()); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
} catch (error) { |
|
|
debug.error('Error setting up UI:', error); |
|
|
} |
|
|
} |
|
|
|
|
|
toggleCollapse() { |
|
|
this.isCollapsed = !this.isCollapsed; |
|
|
this.container.classList.toggle('collapsed', this.isCollapsed); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getWordTranslation(word) { |
|
|
|
|
|
const cache = JSON.parse(localStorage.getItem(WORD_CACHE_KEY) || '{}'); |
|
|
const now = Date.now(); |
|
|
const cachedResult = cache[word]; |
|
|
|
|
|
|
|
|
if (cachedResult && now - cachedResult.timestamp < WORD_CACHE_EXPIRY) { |
|
|
console.log('Cache hit:', word); |
|
|
return cachedResult.data; |
|
|
} |
|
|
|
|
|
|
|
|
Object.keys(cache).forEach(key => { |
|
|
if (now - cache[key].timestamp > WORD_CACHE_EXPIRY) { |
|
|
delete cache[key]; |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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')) |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
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; |
|
|
}; |
|
|
|
|
|
|
|
|
displaySingleLanguage(container, subtitles) { |
|
|
subtitles.forEach((sub, index) => { |
|
|
if (sub.words && sub.words.length > 0) { |
|
|
const line = document.createElement('div'); |
|
|
line.className = 'subtitle-line'; |
|
|
|
|
|
|
|
|
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(); |
|
|
console.log(`Prefix button ${text} clicked for line ${index}`); |
|
|
}; |
|
|
prefixControls.appendChild(btn); |
|
|
}); |
|
|
line.appendChild(prefixControls); |
|
|
|
|
|
|
|
|
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'; |
|
|
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) { |
|
|
|
|
|
popup = document.createElement('div'); |
|
|
popup.className = 'word-definition-popup'; |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const markdownDiv = document.createElement('div'); |
|
|
markdownDiv.className = 'markdown'; |
|
|
popup.appendChild(markdownDiv); |
|
|
|
|
|
|
|
|
document.body.appendChild(popup); |
|
|
|
|
|
|
|
|
document.addEventListener('click', () => { |
|
|
popup.classList.remove('show'); |
|
|
}); |
|
|
} |
|
|
|
|
|
let speakerBtn = popup.querySelector('.speaker-btn'); |
|
|
if (speakerBtn) { |
|
|
speakerBtn.remove(); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
let voices = speechSynthesis.getVoices(); |
|
|
if (!voices.length) { |
|
|
await new Promise(resolve => { |
|
|
speechSynthesis.onvoiceschanged = () => { |
|
|
voices = speechSynthesis.getVoices(); |
|
|
resolve(); |
|
|
}; |
|
|
}); |
|
|
} |
|
|
|
|
|
console.log('Available voices:', voices.map(v => ({ |
|
|
name: v.name, |
|
|
lang: v.lang, |
|
|
isFemale: v.name.toLowerCase().includes('female') |
|
|
}))); |
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
speechSynthesis.cancel(); |
|
|
|
|
|
|
|
|
speechSynthesis.speak(utterance); |
|
|
this.speakerClickCount++; |
|
|
} catch (error) { |
|
|
console.error('TTS failed:', error); |
|
|
} |
|
|
}; |
|
|
|
|
|
|
|
|
popup.insertBefore(speakerBtn, popup.firstChild); |
|
|
|
|
|
|
|
|
let markdownContainer = popup.querySelector('.markdown'); |
|
|
if (!markdownContainer) { |
|
|
markdownContainer = document.createElement('div'); |
|
|
markdownContainer.className = 'markdown'; |
|
|
popup.appendChild(markdownContainer); |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const response = await this.getWordTranslation(wordText); |
|
|
console.log('API Response:', response); |
|
|
|
|
|
const content = response?.data?.response; |
|
|
if (!content) { |
|
|
|
|
|
|
|
|
if (response?.status && response?.error) { |
|
|
throw new Error(`API Error: ${response.status} - ${response.error}`); |
|
|
} else { |
|
|
throw new Error('No content in API response'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const markdownContent = marked.parse(content); |
|
|
if (markdownContent) { |
|
|
|
|
|
while (markdownContainer.firstChild) { |
|
|
markdownContainer.removeChild(markdownContainer.firstChild); |
|
|
} |
|
|
|
|
|
|
|
|
const temp = document.createElement('div'); |
|
|
|
|
|
temp.textContent = markdownContent; |
|
|
|
|
|
|
|
|
|
|
|
const sanitizedHtml = DOMPurify.sanitize(markdownContent, { |
|
|
RETURN_TRUSTED_TYPE: true |
|
|
}); |
|
|
|
|
|
try { |
|
|
temp.innerHTML = sanitizedHtml; |
|
|
} catch (e) { |
|
|
console.error('DOMPurify error:', e); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
const errorHtml = ` |
|
|
<div style="color: red"> |
|
|
Error: ${error.message}<br> |
|
|
Please check console for details. |
|
|
</div> |
|
|
`; |
|
|
|
|
|
|
|
|
const sanitizedError = DOMPurify.sanitize(errorHtml, { |
|
|
RETURN_TRUSTED_TYPE: true |
|
|
}); |
|
|
|
|
|
|
|
|
const errorContainer = document.createElement('div'); |
|
|
errorContainer.className = 'markdown'; |
|
|
|
|
|
try { |
|
|
errorContainer.innerHTML = sanitizedError; |
|
|
|
|
|
|
|
|
const markdownElement = popup.querySelector('.markdown'); |
|
|
while (markdownElement.firstChild) { |
|
|
markdownElement.removeChild(markdownElement.firstChild); |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'; |
|
|
|
|
|
|
|
|
const textContainer = document.createElement('div'); |
|
|
textContainer.className = 'mixed-text-container'; |
|
|
|
|
|
|
|
|
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 = '翻译中...'; |
|
|
} |
|
|
|
|
|
|
|
|
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); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
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; |
|
|
let lastActiveIndex = -1; |
|
|
|
|
|
const findActiveSubtitle = (time, subtitles) => { |
|
|
|
|
|
const active = []; |
|
|
|
|
|
for (let i = 0; i < subtitles.length; i++) { |
|
|
const sub = subtitles[i]; |
|
|
|
|
|
if (time >= (sub.startTime - TRANSITION_BUFFER) && |
|
|
time <= (sub.endTime + TRANSITION_BUFFER)) { |
|
|
active.push({ index: i, sub }); |
|
|
} |
|
|
|
|
|
if (sub.startTime > time + 1) break; |
|
|
} |
|
|
|
|
|
if (active.length === 0) return -1; |
|
|
|
|
|
|
|
|
if (active.length > 1) { |
|
|
|
|
|
const justStarted = active.find(({ sub }) => |
|
|
Math.abs(time - sub.startTime) < TRANSITION_BUFFER); |
|
|
if (justStarted) return justStarted.index; |
|
|
|
|
|
|
|
|
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')); |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
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) { |
|
|
|
|
|
const activeLine = this.container.querySelector('.subtitle-line.active'); |
|
|
if (activeLine) { |
|
|
highlightWords(currentTime, activeLine); |
|
|
} |
|
|
rafId = requestAnimationFrame(() => handleTimeUpdate(video)); |
|
|
return; |
|
|
} |
|
|
|
|
|
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 { |
|
|
|
|
|
if (player.getPlayerResponse) { |
|
|
const response = player.getPlayerResponse(); |
|
|
if (response?.captions?.playerCaptionsTracklistRenderer?.captionTracks) { |
|
|
return response.captions.playerCaptionsTracklistRenderer.captionTracks; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
return Array.from(player.getElementsByTagName('track')); |
|
|
|
|
|
} catch (error) { |
|
|
debug.error('Error getting caption tracks:', error); |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
async translateInChunks(subtitleData) { |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const videoUrl = window.location.href; |
|
|
const cacheKey = `translations_${CHUNK_SIZE}_${videoUrl}`; |
|
|
const CACHE_EXPIRY = 365 * 24 * 60 * 60 * 1000; |
|
|
|
|
|
const chunks = []; |
|
|
let translations = []; |
|
|
const DELAY_BETWEEN_CHUNKS = 1000; |
|
|
let lastChunkIndex = 0; |
|
|
const totalChunks = Math.ceil(subtitleData.length / CHUNK_SIZE); |
|
|
|
|
|
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) { |
|
|
|
|
|
debug.log('Using complete cached translations'); |
|
|
return cachedTranslations; |
|
|
} else { |
|
|
|
|
|
debug.log(`Resuming from chunk ${lastProcessedChunk + 1}`); |
|
|
translations = [...cachedTranslations]; |
|
|
lastChunkIndex = lastProcessedChunk + 1; |
|
|
} |
|
|
} else { |
|
|
localStorage.removeItem(cacheKey); |
|
|
} |
|
|
} |
|
|
} catch (e) { |
|
|
debug.error('Cache read error:', e); |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
|
|
|
|
|
|
|
|
|
for (let i = lastChunkIndex * CHUNK_SIZE; i < subtitleData.length; i += CHUNK_SIZE) { |
|
|
chunks.push(subtitleData.slice(i, i + CHUNK_SIZE)); |
|
|
} |
|
|
|
|
|
|
|
|
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, |
|
|
total: totalChunks |
|
|
})); |
|
|
debug.log(`Saved progress: ${currentChunkIndex + 1}/${totalChunks}`); |
|
|
|
|
|
this.updateSubtitleDisplay(); |
|
|
} catch (e) { |
|
|
debug.error('Cache write error:', e); |
|
|
} |
|
|
|
|
|
|
|
|
} catch (error) { |
|
|
debug.error(`Chunk ${i + 1} translation failed:`, error); |
|
|
|
|
|
translations.push(...chunks[i].map(sub => ({ |
|
|
text: `[翻译失败] ${sub.text}`, |
|
|
time: sub.time |
|
|
}))); |
|
|
} |
|
|
} |
|
|
|
|
|
return translations; |
|
|
} |
|
|
|
|
|
async initializeSubtitles() { |
|
|
debug.log('Starting subtitle initialization'); |
|
|
|
|
|
|
|
|
const hasCaptions = await this.waitForSubtitles(); |
|
|
if (!hasCaptions) { |
|
|
debug.error('No captions available'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
const tracks = await this.getCaptionTracks(); |
|
|
if (!tracks?.length) { |
|
|
debug.error('No caption tracks found'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 (this.subtitles.english.length > 0 && !this.subtitles.chinese.length) { |
|
|
|
|
|
const videoUrl = window.location.href; |
|
|
|
|
|
|
|
|
const cacheKey = `translations_${CHUNK_SIZE}_${videoUrl}`; |
|
|
|
|
|
|
|
|
|
|
|
try { |
|
|
const cached = localStorage.getItem(cacheKey); |
|
|
if (cached) { |
|
|
const { translations, timestamp, lastProcessedChunk, total } = JSON.parse(cached); |
|
|
const CACHE_EXPIRY = 365 * 24 * 60 * 60 * 1000; |
|
|
|
|
|
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); |
|
|
} |
|
|
|
|
|
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', '翻译失败'); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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(() => { |
|
|
|
|
|
while (content.firstChild) { |
|
|
content.removeChild(content.firstChild); |
|
|
} |
|
|
|
|
|
|
|
|
if (!this.subtitles.english.length && !this.subtitles.chinese.length) { |
|
|
content.appendChild(this.createMessageElement('No subtitles available')); |
|
|
content.style.opacity = '1'; |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
} |
|
|
|
|
|
|
|
|
content.style.opacity = '1'; |
|
|
}, 300); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
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(); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
async function initializeManager() { |
|
|
debug.log('=== Starting initialization ==='); |
|
|
debug.log('Current URL:', window.location.href); |
|
|
debug.log('Document ready state:', document.readyState); |
|
|
|
|
|
|
|
|
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); |
|
|
} |
|
|
} |
|
|
|
|
|
let lastUrl = location.href; |
|
|
const observer = new MutationObserver(() => { |
|
|
if (location.href !== lastUrl) { |
|
|
lastUrl = location.href; |
|
|
initializeManager(); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
observer.observe(document.body, { |
|
|
subtree: true, |
|
|
childList: true |
|
|
}); |
|
|
|
|
|
|
|
|
if (document.readyState === 'loading') { |
|
|
document.addEventListener('DOMContentLoaded', initializeManager); |
|
|
} else { |
|
|
setTimeout(initializeManager, 2000); |
|
|
} |
|
|
})(); |
|
|
|