// ==UserScript==
// @name YouTube同声传译:字幕文本转语音TTS(适用于沉浸式翻译)
// @namespace http://tampermonkey.net/
// @version 1.12.2
// @description 将YouTube上的沉浸式翻译双语字幕转换为语音播放,支持更改音色和调整语音速度,支持多语言
// @author Sean2333
// @match https://www.youtube.com/*
// @grant GM_setValue
// @grant GM_getValue
// @license MIT
// ==/UserScript==
(function () {
'use strict';
let lastCaptionText = '';
const synth = window.speechSynthesis;
let selectedVoice = null;
let pendingUtterance = null;
let isWaitingToSpeak = false;
let voiceSelectUI = null;
let isDragging = false;
let startX;
let startY;
let followVideoSpeed = GM_getValue('followVideoSpeed', true);
let customSpeed = GM_getValue('customSpeed', 1.0);
let isSpeechEnabled = GM_getValue('isSpeechEnabled', true);
let speechVolume = GM_getValue('speechVolume', 1.0);
let isCollapsed = GM_getValue('isCollapsed', false);
let selectedVoiceName = GM_getValue('selectedVoiceName', null);
let windowPosX = GM_getValue('windowPosX', null);
let windowPosY = GM_getValue('windowPosY', null);
let autoVideoPause = GM_getValue('autoVideoPause', true);
let currentObserver = null;
let currentVideoId = null;
let videoObserver = null;
let originalPushState = null;
let originalReplaceState = null;
let timeoutIds = [];
let currentUtterance = null;
function setupShortcuts() {
document.addEventListener('keydown', (e) => {
if (e.altKey && e.key.toLowerCase() === 't') { // 添加 toLowerCase() 以兼容大小写
const speechToggleCheckbox = document.querySelector('#speechToggleCheckbox');
if (speechToggleCheckbox) {
speechToggleCheckbox.click();
console.log('触发TTS开关快捷键');
} else {
console.log('未找到TTS开关元素');
}
}
});
}
function loadVoices() {
return new Promise(function (resolve) {
let voices = synth.getVoices();
if (voices.length !== 0) {
console.log('成功加载语音列表,共', voices.length, '个语音');
resolve(voices);
} else {
console.log('等待语音列表加载...');
synth.onvoiceschanged = function () {
voices = synth.getVoices();
console.log('语音列表加载完成,共', voices.length, '个语音');
resolve(voices);
};
const timeoutId = setTimeout(() => {
voices = synth.getVoices();
if (voices.length > 0) {
console.log('通过重试加载到语音列表,共', voices.length, '个语音');
resolve(voices);
}
}, 1000);
timeoutIds.push(timeoutId);
}
});
}
function createVoiceSelectUI() {
function updateDropdownState(isOpen) {
select.style.display = isOpen ? 'block' : 'none';
dropdownArrow.textContent = isOpen ? '▲' : '▼';
}
const container = document.createElement('div');
container.className = 'voice-select-container';
Object.assign(container.style, {
position: 'fixed',
top: windowPosY || '10px',
right: windowPosX || '10px',
width: '260px',
background: 'rgba(255, 255, 255, 0.75)',
padding: '10px',
border: '1px solid rgba(221, 221, 221, 0.8)',
borderRadius: '5px',
zIndex: '9999',
boxShadow: '0 2px 5px rgba(0, 0, 0, 0.15)',
userSelect: 'none',
transition: 'all 0.2s'
});
container.addEventListener('mouseenter', () => {
container.style.background = 'rgba(255, 255, 255, 0.95)';
container.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.2)';
});
container.addEventListener('mouseleave', () => {
container.style.background = 'rgba(255, 255, 255, 0.75)';
container.style.boxShadow = '0 2px 5px rgba(0, 0, 0, 0.15)';
});
const titleBar = document.createElement('div');
titleBar.className = 'title-bar';
Object.assign(titleBar.style, {
padding: '5px',
marginBottom: '10px',
borderBottom: '1px solid #eee',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'move'
});
const title = document.createElement('span');
title.textContent = '字幕语音设置';
const toggleButton = document.createElement('button');
toggleButton.textContent = isCollapsed ? '+' : '−';
Object.assign(toggleButton.style, {
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '16px',
padding: '0 5px'
});
const content = document.createElement('div');
if (isCollapsed) {
content.style.display = 'none';
}
// 语音开关
const speechToggleDiv = document.createElement('div');
Object.assign(speechToggleDiv.style, {
marginBottom: '10px',
borderBottom: '1px solid #eee',
paddingBottom: '10px'
});
const speechToggleCheckbox = document.createElement('input');
speechToggleCheckbox.type = 'checkbox';
speechToggleCheckbox.checked = isSpeechEnabled;
speechToggleCheckbox.id = 'speechToggleCheckbox';
const speechToggleLabel = document.createElement('label');
speechToggleLabel.textContent = '启用语音播放(Alt+T)';
speechToggleLabel.htmlFor = 'speechToggleCheckbox';
Object.assign(speechToggleLabel.style, {
marginLeft: '5px'
});
speechToggleCheckbox.onchange = function () {
isSpeechEnabled = this.checked;
select.disabled = !isSpeechEnabled;
testButton.disabled = !isSpeechEnabled;
followSpeedCheckbox.disabled = !isSpeechEnabled;
customSpeedSelect.disabled = !isSpeechEnabled || followVideoSpeed;
volumeSlider.disabled = !isSpeechEnabled;
autoVideoPauseCheckbox.disabled = !isSpeechEnabled;
searchInput.disabled = !isSpeechEnabled;
GM_setValue('isSpeechEnabled', isSpeechEnabled);
if (!isSpeechEnabled) {
if (synth.speaking) {
synth.cancel();
}
if (isWaitingToSpeak) {
const video = document.querySelector('video');
if (video && video.paused) {
video.play();
}
isWaitingToSpeak = false;
}
pendingUtterance = null;
disconnectObservers();
} else {
setupCaptionObserver();
setupNavigationListeners();
}
console.log('语音播放已' + (isSpeechEnabled ? '启用' : '禁用'));
};
speechToggleDiv.appendChild(speechToggleCheckbox);
speechToggleDiv.appendChild(speechToggleLabel);
content.insertBefore(speechToggleDiv, content.firstChild);
// 自动暂停视频开关
const autoVideoPauseDiv = document.createElement('div');
Object.assign(autoVideoPauseDiv.style, {
marginBottom: '10px',
borderBottom: '1px solid #eee',
paddingBottom: '10px',
display: 'flex',
alignItems: 'center',
gap: '5px'
});
const autoVideoPauseCheckbox = document.createElement('input');
autoVideoPauseCheckbox.type = 'checkbox';
autoVideoPauseCheckbox.checked = autoVideoPause;
autoVideoPauseCheckbox.id = 'autoVideoPauseCheckbox';
const autoVideoPauseLabel = document.createElement('label');
autoVideoPauseLabel.textContent = '自动暂停视频,以完整播放语音(推荐开启)';
autoVideoPauseLabel.htmlFor = 'autoVideoPauseCheckbox';
Object.assign(autoVideoPauseLabel.style, {
flex: '1'
});
const helpIcon = document.createElement('span');
helpIcon.textContent = '?';
Object.assign(helpIcon.style, {
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
width: '14px',
height: '14px',
borderRadius: '50%',
backgroundColor: '#e0e0e0',
color: '#666',
fontSize: '10px',
cursor: 'help',
marginLeft: '2px'
});
const tooltip = document.createElement('div');
tooltip.textContent = '开启后,当新字幕出现时,如果上一条语音还未播放完,会自动暂停视频等待语音播放完成。这样可以确保每条字幕都被完整朗读。由于文字转语音存在一定延迟,建议开启此选项以获得最佳体验。';
Object.assign(tooltip.style, {
position: 'fixed',
display: 'none',
backgroundColor: 'rgba(0, 0, 0, 0.8)',
color: 'white',
padding: '8px 12px',
borderRadius: '4px',
fontSize: '12px',
width: '220px',
zIndex: '10000',
pointerEvents: 'none',
lineHeight: '1.5',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
});
helpIcon.appendChild(tooltip);
helpIcon.addEventListener('mousemove', (e) => {
tooltip.style.display = 'block';
const gap = 10;
let left = e.clientX + gap;
let top = e.clientY + gap;
if (left + tooltip.offsetWidth > window.innerWidth) {
left = e.clientX - tooltip.offsetWidth - gap;
}
if (top + tooltip.offsetHeight > window.innerHeight) {
top = e.clientY - tooltip.offsetHeight - gap;
}
tooltip.style.left = left + 'px';
tooltip.style.top = top + 'px';
});
helpIcon.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
});
const labelWrapper = document.createElement('div');
Object.assign(labelWrapper.style, {
display: 'flex',
alignItems: 'center',
flex: '1'
});
labelWrapper.appendChild(autoVideoPauseLabel);
labelWrapper.appendChild(helpIcon);
autoVideoPauseCheckbox.onchange = function () {
autoVideoPause = this.checked;
GM_setValue('autoVideoPause', autoVideoPause);
console.log('自动暂停视频已' + (autoVideoPause ? '启用' : '禁用'));
};
autoVideoPauseDiv.appendChild(autoVideoPauseCheckbox);
autoVideoPauseDiv.appendChild(labelWrapper);
content.insertBefore(autoVideoPauseDiv, content.firstChild.nextSibling);
// 音色选择
const voiceDiv = document.createElement('div');
Object.assign(voiceDiv.style, {
marginBottom: '10px',
position: 'relative'
});
const voiceLabel = document.createElement('div');
voiceLabel.textContent = '切换音色(支持多语言,与字幕语言匹配即可):';
Object.assign(voiceLabel.style, {
marginBottom: '5px'
});
const dropdownContainer = document.createElement('div');
Object.assign(dropdownContainer.style, {
position: 'relative',
width: '100%'
});
const inputContainer = document.createElement('div');
Object.assign(inputContainer.style, {
position: 'relative',
width: '100%'
});
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.placeholder = '搜索或选择音色...';
Object.assign(searchInput.style, {
width: '100%',
padding: '5px 25px 5px 8px',
marginBottom: '5px',
borderRadius: '3px',
boxSizing: 'border-box'
});
const dropdownArrow = document.createElement('span');
dropdownArrow.textContent = '▼';
Object.assign(dropdownArrow.style, {
position: 'absolute',
right: '8px',
top: '50%',
transform: 'translateY(-50%)',
color: '#666',
fontSize: '12px',
cursor: 'pointer',
padding: '5px'
});
dropdownArrow.addEventListener('click', (e) => {
e.stopPropagation();
if (!isSpeechEnabled) {
return;
}
const isOpen = select.style.display === 'none';
updateDropdownState(isOpen);
});
const select = document.createElement('ul');
Object.assign(select.style, {
position: 'absolute',
width: '100%',
maxHeight: '200px',
overflowY: 'auto',
border: '1px solid #ccc',
borderRadius: '3px',
backgroundColor: 'white',
zIndex: '10000',
listStyle: 'none',
padding: '0',
margin: '0',
display: 'none',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
});
searchInput.addEventListener('click', (e) => {
e.stopPropagation();
updateDropdownState(true);
});
document.addEventListener('click', () => {
updateDropdownState(false);
});
select.addEventListener('click', (e) => {
e.stopPropagation();
const clickedOption = e.target;
if (clickedOption.tagName === 'LI') {
const voiceIndex = parseInt(clickedOption.dataset.value);
if (!isNaN(voiceIndex)) {
// 获取当前可用的语音列表
const voices = window.speechSynthesis.getVoices();
selectedVoice = voices[voiceIndex];
selectedVoiceName = selectedVoice.name;
searchInput.value = clickedOption.textContent;
GM_setValue('selectedVoiceName', selectedVoiceName);
updateDropdownState(false);
}
}
});
searchInput.oninput = function () {
const searchTerm = this.value.toLowerCase();
Array.from(select.children).forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(searchTerm) ? 'block' : 'none';
});
updateDropdownState(true);
};
// 测试音色按钮
const testButton = document.createElement('button');
testButton.textContent = '测试音色';
Object.assign(testButton.style, {
padding: '5px 10px',
borderRadius: '3px',
cursor: 'pointer',
width: '100%',
marginTop: '5px'
});
// 音量控制
const volumeControl = document.createElement('div');
Object.assign(volumeControl.style, {
marginTop: '10px',
borderTop: '1px solid #eee',
paddingTop: '10px'
});
const volumeLabel = document.createElement('div');
volumeLabel.textContent = '音量控制:';
Object.assign(volumeLabel.style, {
marginBottom: '5px'
});
const volumeSlider = document.createElement('input');
volumeSlider.type = 'range';
volumeSlider.min = '0';
volumeSlider.max = '1';
volumeSlider.step = '0.1';
volumeSlider.value = speechVolume;
Object.assign(volumeSlider.style, {
width: '100%',
margin: '5px 0',
});
const volumeValue = document.createElement('span');
volumeValue.textContent = `${Math.round(speechVolume * 100)}%`;
Object.assign(volumeValue.style, {
fontSize: '12px',
color: '#666',
marginLeft: '5px'
});
volumeSlider.onchange = function () {
speechVolume = parseFloat(this.value);
volumeValue.textContent = `${Math.round(speechVolume * 100)}%`;
GM_setValue('speechVolume', speechVolume);
console.log('音量已设置为:', speechVolume);
};
volumeSlider.oninput = function () {
volumeValue.textContent = `${Math.round(this.value * 100)}%`;
};
volumeControl.appendChild(volumeLabel);
volumeControl.appendChild(volumeSlider);
volumeControl.appendChild(volumeValue);
// 语音速度控制
const speedControl = document.createElement('div');
Object.assign(speedControl.style, {
marginTop: '10px',
borderTop: '1px solid #eee',
paddingTop: '10px',
display: 'flex',
alignItems: 'center',
gap: '10px'
});
const followSpeedDiv = document.createElement('div');
Object.assign(followSpeedDiv.style, {
flex: '1'
});
const followSpeedCheckbox = document.createElement('input');
followSpeedCheckbox.type = 'checkbox';
followSpeedCheckbox.checked = followVideoSpeed;
followSpeedCheckbox.id = 'followSpeedCheckbox';
const followSpeedLabel = document.createElement('label');
followSpeedLabel.textContent = '跟随视频倍速';
followSpeedLabel.htmlFor = 'followSpeedCheckbox';
Object.assign(followSpeedLabel.style, {
marginLeft: '5px'
});
const customSpeedDiv = document.createElement('div');
Object.assign(customSpeedDiv.style, {
flex: '1'
});
const customSpeedLabel = document.createElement('div');
customSpeedLabel.textContent = '自定义倍速:';
Object.assign(customSpeedLabel.style, {
marginBottom: '5px'
});
const customSpeedSelect = document.createElement('select');
const speedOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0];
speedOptions.forEach(speed => {
const option = document.createElement('option');
option.value = speed;
option.textContent = `${speed}x`;
if (speed === customSpeed) option.selected = true;
customSpeedSelect.appendChild(option);
});
Object.assign(customSpeedSelect.style, {
width: '100%',
padding: '5px',
borderRadius: '3px'
});
followSpeedCheckbox.onchange = function () {
followVideoSpeed = this.checked;
customSpeedSelect.disabled = this.checked;
GM_setValue('followVideoSpeed', followVideoSpeed);
console.log('语音速度模式:', followVideoSpeed ? '跟随视频' : '自定义');
};
customSpeedSelect.onchange = function () {
customSpeed = parseFloat(this.value);
GM_setValue('customSpeed', customSpeed);
console.log('自定义语音速度设置为:', customSpeed);
};
const testPhrases = {
'zh': '这是一个中文测试语音',
'zh-CN': '这是一个中文测试语音',
'zh-TW': '這是一個中文測試語音',
'zh-HK': '這是一個中文測試語音',
'en': 'This is a test voice in English',
'ja': 'これは日本語のテスト音声です',
'ko': '이것은 한국어 테스트 음성입니다',
'fr': 'Ceci est un test vocal en français',
'de': 'Dies ist eine Testsprache auf Deutsch',
'es': 'Esta es una voz de prueba en español',
'it': 'Questa è una voce di prova in italiano',
'ru': 'Это тестовый голос на русском языке',
'pt': 'Esta é uma voz de teste em português',
'default': 'This is a test voice' // 默认测试文本
};
testButton.onclick = (e) => {
e.stopPropagation();
if (selectedVoice) {
// 获取语音的基础语言代码(例如 'zh-CN' 转为 'zh')
const baseLang = selectedVoice.lang.split('-')[0];
const fullLang = selectedVoice.lang;
// 按优先级选择测试文本:完整语言代码 > 基础语言代码 > 默认文本
const testText = testPhrases[fullLang] || testPhrases[baseLang] || testPhrases['default'];
console.log(`使用测试文本(${selectedVoice.lang}): ${testText}`);
speakText(testText, false);
}
};
customSpeedSelect.disabled = followVideoSpeed;
titleBar.appendChild(title);
titleBar.appendChild(toggleButton);
inputContainer.appendChild(searchInput);
inputContainer.appendChild(dropdownArrow);
dropdownContainer.appendChild(inputContainer);
dropdownContainer.appendChild(select);
dropdownContainer.appendChild(testButton);
voiceDiv.appendChild(voiceLabel);
voiceDiv.appendChild(dropdownContainer);
followSpeedDiv.appendChild(followSpeedCheckbox);
followSpeedDiv.appendChild(followSpeedLabel);
customSpeedDiv.appendChild(customSpeedLabel);
customSpeedDiv.appendChild(customSpeedSelect);
speedControl.appendChild(followSpeedDiv);
speedControl.appendChild(customSpeedDiv);
content.appendChild(voiceDiv);
content.appendChild(volumeControl);
content.appendChild(speedControl);
container.appendChild(titleBar);
container.appendChild(content);
if (isCollapsed) {
container.style.width = 'auto';
container.style.minWidth = '100px';
}
document.body.appendChild(container);
toggleButton.onclick = (e) => {
e.stopPropagation();
isCollapsed = !isCollapsed;
const currentRight = container.style.right;
if (isCollapsed) {
container.dataset.expandedWidth = container.offsetWidth + 'px';
content.style.display = 'none';
container.style.width = 'auto';
container.style.minWidth = '100px';
} else {
content.style.display = 'block';
container.style.width = container.dataset.expandedWidth;
}
container.style.right = currentRight;
toggleButton.textContent = isCollapsed ? '+' : '−';
GM_setValue('isCollapsed', isCollapsed);
};
document.addEventListener('mousedown', dragStart);
document.addEventListener('mousemove', drag);
document.addEventListener('mouseup', dragEnd);
document.addEventListener('mouseleave', dragEnd);
return { container, select, content };
}
function dragStart(e) {
if (e.target.closest('.title-bar')) {
isDragging = true;
const container = e.target.closest('.voice-select-container');
const rect = container.getBoundingClientRect();
startX = e.clientX - rect.left;
startY = e.clientY - rect.top;
container.style.transition = 'none';
}
}
function dragEnd(e) {
if (isDragging) {
isDragging = false;
const container = document.querySelector('.voice-select-container');
if (container) {
container.style.transition = 'all 0.2s';
const rect = container.getBoundingClientRect();
windowPosX = `${window.innerWidth - rect.right}px`;
windowPosY = `${rect.top}px`;
GM_setValue('windowPosX', windowPosX);
GM_setValue('windowPosY', windowPosY);
console.log('保存浮窗位置:', windowPosX, windowPosY);
}
}
}
function drag(e) {
if (isDragging) {
e.preventDefault();
const container = document.querySelector('.voice-select-container');
if (container) {
let newX = e.clientX - startX;
let newY = e.clientY - startY;
const maxX = window.innerWidth - container.offsetWidth;
const maxY = window.innerHeight - container.offsetHeight;
newX = Math.min(Math.max(0, newX), maxX);
newY = Math.min(Math.max(0, newY), maxY);
container.style.right = `${window.innerWidth - newX - container.offsetWidth}px`;
container.style.top = `${newY}px`;
container.style.left = '';
}
}
}
function selectVoice() {
loadVoices().then(function (voices) {
if (!voiceSelectUI) {
voiceSelectUI = createVoiceSelectUI();
}
const select = voiceSelectUI.select;
const searchInput = voiceSelectUI.container.querySelector('input[type="text"]');
while (select.firstChild) {
select.removeChild(select.firstChild);
}
voices.forEach((voice, index) => {
const option = document.createElement('li');
option.dataset.value = index;
option.textContent = `${voice.name} (${voice.lang})`;
Object.assign(option.style, {
padding: '8px 10px',
cursor: 'pointer',
borderBottom: '1px solid #eee'
});
option.addEventListener('mouseover', () => {
option.style.backgroundColor = '#f0f0f0';
});
option.addEventListener('mouseout', () => {
option.style.backgroundColor = '';
});
option.addEventListener('click', () => {
selectedVoice = voices[index];
selectedVoiceName = selectedVoice.name;
searchInput.value = option.textContent;
GM_setValue('selectedVoiceName', selectedVoiceName);
select.style.display = 'none';
console.log('已切换语音到:', selectedVoice.name);
});
select.appendChild(option);
});
// 添加默认选中值设置:
if (selectedVoice) {
searchInput.value = `${selectedVoice.name} (${selectedVoice.lang})`;
}
if (!selectedVoice) {
selectedVoice = voices.find(voice =>
voice.name === selectedVoiceName
) || voices.find(voice =>
voice.name === 'Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)'
) || voices.find(voice => voice.lang.includes('zh')) || voices[0];
}
const selectedIndex = voices.indexOf(selectedVoice);
if (selectedIndex >= 0) {
searchInput.value = `${selectedVoice.name} (${selectedVoice.lang})`;
}
});
}
function speakText(text, isNewCaption = false) {
if (!isSpeechEnabled || !text) {
return;
}
const video = document.querySelector('video');
// 准备新的语音合成实例
const utterance = new SpeechSynthesisUtterance(text);
if (selectedVoice) {
utterance.voice = selectedVoice;
utterance.lang = selectedVoice.lang;
}
utterance.volume = speechVolume;
if (followVideoSpeed && video) {
utterance.rate = video.playbackRate;
} else {
utterance.rate = customSpeed;
}
// 设置语音事件处理
utterance.onstart = () => {
currentUtterance = utterance;
console.log('开始播放语音:', text);
};
utterance.onend = () => {
console.log('语音播放完成');
// 只有当前播放的语音完成时才清除currentUtterance
if (currentUtterance === utterance) {
currentUtterance = null;
}
if (pendingUtterance) {
console.log('播放准备好的语音');
const nextUtterance = pendingUtterance;
pendingUtterance = null;
// 确保下一句话开始播放
synth.speak(nextUtterance);
} else if (autoVideoPause && isWaitingToSpeak && video && video.paused) {
isWaitingToSpeak = false;
video.play();
console.log('所有语音播放完成,视频继续播放');
}
};
utterance.onerror = (event) => {
console.error('语音播放出错:', event);
// 只有当前播放的语音出错时才清除currentUtterance
if (currentUtterance === utterance) {
currentUtterance = null;
}
if (autoVideoPause && isWaitingToSpeak && video && video.paused) {
isWaitingToSpeak = false;
video.play();
}
// 如果出错的是待播放的语音,也需要清除
if (pendingUtterance === utterance) {
pendingUtterance = null;
}
};
if (synth.speaking) {
// 当前有语音在播放,将新语音存为待播放
console.log('当前正在播放语音,新语音准备完成:', text);
// 如果已经有待播放的语音,先取消它
if (pendingUtterance) {
console.log('更新待播放语音');
// 可以选择是否保留之前的待播放语音
// synth.cancel(); // 取消之前的待播放语音
}
if (autoVideoPause && !isWaitingToSpeak) {
// 只在这时暂停视频
if (video && !video.paused) {
video.pause();
isWaitingToSpeak = true;
console.log('新语音准备完成,视频暂停等待当前语音完成');
}
}
// 更新待播放的语音
pendingUtterance = utterance;
} else {
// 没有语音在播放,直接开始播放
console.log('直接播放语音');
synth.speak(utterance);
}
}
function getCaptionText() {
const immersiveCaptionWindow = document.querySelector('#immersive-translate-caption-window');
if (immersiveCaptionWindow && immersiveCaptionWindow.shadowRoot) {
const targetCaptions = immersiveCaptionWindow.shadowRoot.querySelectorAll('.target-cue');
let captionText = '';
targetCaptions.forEach(span => {
captionText += span.textContent + ' ';
});
captionText = captionText.trim();
return captionText;
}
return '';
}
function setupCaptionObserver() {
if (!isSpeechEnabled) {
return;
}
let retryCount = 0;
const maxRetries = 10;
function waitForCaptionContainer() {
if (!isSpeechEnabled) {
return;
}
const immersiveCaptionWindow = document.querySelector('#immersive-translate-caption-window');
if (immersiveCaptionWindow && immersiveCaptionWindow.shadowRoot) {
const rootContainer = immersiveCaptionWindow.shadowRoot.querySelector('div');
if (rootContainer) {
console.log('找到字幕根容器,开始监听变化');
if (currentObserver) {
currentObserver.disconnect();
console.log('断开旧的字幕观察者连接');
}
lastCaptionText = '';
pendingUtterance = null;
if (synth.speaking) {
synth.cancel();
console.log('取消当前正在播放的语音');
}
isWaitingToSpeak = false;
currentObserver = new MutationObserver(() => {
const currentText = getCaptionText();
if (currentText && currentText !== lastCaptionText) {
lastCaptionText = currentText;
speakText(currentText, true);
}
});
const config = {
childList: true,
subtree: true,
characterData: true
};
currentObserver.observe(rootContainer, config);
console.log('新的字幕观察者设置完成');
const initialText = getCaptionText();
if (initialText) {
lastCaptionText = initialText;
speakText(initialText, true);
}
} else {
if (retryCount < maxRetries) {
console.log('未找到字幕容器,1秒后重试');
retryCount++;
const timeoutId = setTimeout(waitForCaptionContainer, 1000);
timeoutIds.push(timeoutId);
} else {
console.log('达到最大重试次数,放弃寻找字幕容器');
}
}
} else {
if (retryCount < maxRetries) {
console.log('等待字幕窗口加载,1秒后重试');
retryCount++;
const timeoutId = setTimeout(waitForCaptionContainer, 1000);
timeoutIds.push(timeoutId);
} else {
console.log('达到最大重试次数,放弃寻找字幕窗口');
}
}
}
waitForCaptionContainer();
}
function checkForVideoChange() {
if (!isSpeechEnabled) {
return;
}
const videoId = new URLSearchParams(window.location.search).get('v');
if (videoId && videoId !== currentVideoId) {
console.log('检测到视频切换,从', currentVideoId, '切换到', videoId);
currentVideoId = videoId;
if (currentObserver) {
currentObserver.disconnect();
console.log('断开旧的字幕观察者连接');
}
if (synth.speaking) {
synth.cancel();
console.log('取消当前正在播放的语音');
}
let retryCount = 0;
const maxRetries = 10;
function trySetupObserver() {
if (!isSpeechEnabled) {
return;
}
if (retryCount >= maxRetries) {
console.log('达到最大重试次数,放弃设置字幕监听');
return;
}
const immersiveCaptionWindow = document.querySelector('#immersive-translate-caption-window');
if (immersiveCaptionWindow && immersiveCaptionWindow.shadowRoot) {
console.log('找到字幕容器,开始设置监听');
setupCaptionObserver();
} else {
console.log(`未找到字幕容器,1秒后重试`);
retryCount++;
const timeoutId = setTimeout(trySetupObserver, 1000);
timeoutIds.push(timeoutId);
}
}
const timeoutId = setTimeout(trySetupObserver, 1500);
timeoutIds.push(timeoutId);
}
}
function setupNavigationListeners() {
if (!isSpeechEnabled) {
return;
}
videoObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
checkForVideoChange();
}
}
});
function observeVideoPlayer() {
const playerContainer = document.querySelector('#player-container');
if (playerContainer) {
videoObserver.observe(playerContainer, {
childList: true,
subtree: true
});
}
}
observeVideoPlayer();
originalPushState = history.pushState;
history.pushState = function () {
originalPushState.apply(history, arguments);
checkForVideoChange();
};
originalReplaceState = history.replaceState;
history.replaceState = function () {
originalReplaceState.apply(history, arguments);
checkForVideoChange();
};
window.addEventListener('hashchange', checkForVideoChange);
window.addEventListener('popstate', checkForVideoChange);
window.addEventListener('yt-navigate-start', onNavigateStart);
window.addEventListener('yt-navigate-finish', onNavigateFinish);
}
function onNavigateStart() {
if (isSpeechEnabled) {
console.log('YouTube导航开始');
checkForVideoChange();
}
}
function onNavigateFinish() {
if (isSpeechEnabled) {
console.log('YouTube导航完成');
checkForVideoChange();
}
}
function disconnectObservers() {
if (currentObserver) {
currentObserver.disconnect();
currentObserver = null;
console.log('已断开字幕观察者');
}
if (videoObserver) {
videoObserver.disconnect();
videoObserver = null;
console.log('已断开视频观察者');
}
window.removeEventListener('hashchange', checkForVideoChange);
window.removeEventListener('popstate', checkForVideoChange);
window.removeEventListener('yt-navigate-start', onNavigateStart);
window.removeEventListener('yt-navigate-finish', onNavigateFinish);
if (originalPushState) {
history.pushState = originalPushState;
originalPushState = null;
}
if (originalReplaceState) {
history.replaceState = originalReplaceState;
originalReplaceState = null;
}
timeoutIds.forEach(id => clearTimeout(id));
timeoutIds = [];
}
function cleanup() {
document.removeEventListener('mousedown', dragStart);
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', dragEnd);
document.removeEventListener('mouseleave', dragEnd);
window.removeEventListener('resize', onWindowResize);
disconnectObservers();
if (synth.speaking) {
synth.cancel();
}
}
function onWindowResize() {
const container = document.querySelector('.voice-select-container');
if (container) {
const rect = container.getBoundingClientRect();
const maxY = window.innerHeight - container.offsetHeight;
let newY = Math.min(Math.max(0, rect.top), maxY);
container.style.top = `${newY}px`;
}
}
window.addEventListener('load', function () {
console.log('页面加载完成,开始初始化脚本');
setTimeout(() => {
selectVoice();
setupShortcuts();
if (isSpeechEnabled) {
setupCaptionObserver();
setupNavigationListeners();
currentVideoId = new URLSearchParams(window.location.search).get('v');
console.log('初始视频ID:', currentVideoId);
}
}, 1000);
});
window.addEventListener('unload', cleanup);
window.addEventListener('resize', onWindowResize);
})();