// ==UserScript==
// @name YouTube字幕文本转语音TTS(适用于沉浸式翻译)
// @namespace http://tampermonkey.net/
// @version 1.11
// @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 pendingText = 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 shortcuts = {
toggleSpeech: 'Alt+T', // 开关TTS功能
};
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() {
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;
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;
}
pendingText = 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, {
marginLeft: '5px',
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'
});
const voiceLabel = document.createElement('div');
voiceLabel.textContent = '选择音色:';
Object.assign(voiceLabel.style, {
marginBottom: '5px'
});
const select = document.createElement('select');
Object.assign(select.style, {
width: '100%',
padding: '5px',
marginBottom: '5px',
borderRadius: '3px'
});
const testButton = document.createElement('button');
testButton.textContent = '测试音色';
Object.assign(testButton.style, {
padding: '5px 10px',
borderRadius: '3px',
cursor: 'pointer',
width: '100%'
});
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);
};
testButton.onclick = (e) => {
e.stopPropagation();
if (selectedVoice) {
speakText('这是一个测试语音', false);
}
};
customSpeedSelect.disabled = followVideoSpeed;
titleBar.appendChild(title);
titleBar.appendChild(toggleButton);
voiceDiv.appendChild(voiceLabel);
voiceDiv.appendChild(select);
voiceDiv.appendChild(testButton);
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;
while (select.firstChild) {
select.removeChild(select.firstChild);
}
const chineseVoices = voices.filter(voice =>
voice.lang.includes('zh') || voice.name.toLowerCase().includes('chinese')
);
chineseVoices.forEach((voice, index) => {
const option = document.createElement('option');
option.value = index;
option.textContent = `${voice.name} (${voice.lang})`;
select.appendChild(option);
});
if (selectedVoiceName) {
selectedVoice = chineseVoices.find(voice => voice.name === selectedVoiceName);
}
if (!selectedVoice) {
selectedVoice = chineseVoices.find(voice =>
voice.name === 'Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)'
) || chineseVoices[0];
}
const selectedIndex = chineseVoices.indexOf(selectedVoice);
if (selectedIndex >= 0) {
select.selectedIndex = selectedIndex;
}
select.onchange = function() {
selectedVoice = chineseVoices[this.value];
selectedVoiceName = selectedVoice.name;
GM_setValue('selectedVoiceName', selectedVoiceName);
console.log('已切换语音到:', selectedVoice.name);
};
console.log('可用的中文语音数量:', chineseVoices.length);
if (chineseVoices.length > 0) {
console.log('第一个可用的中文语音:', chineseVoices[0].name);
}
});
}
function speakText(text, isNewCaption = false) {
if (!isSpeechEnabled) {
return;
}
const video = document.querySelector('video');
if (isNewCaption && synth.speaking) {
console.log('新字幕出现,但当前语音未完成');
if (autoVideoPause) {
pendingText = text;
if (video && !video.paused) {
video.pause();
isWaitingToSpeak = true;
console.log('视频已暂停,等待当前语音完成');
}
} else {
// 不自动暂停时,直接取消当前语音播放新的
synth.cancel();
pendingText = null;
isWaitingToSpeak = false;
}
return;
}
if (synth.speaking) {
console.log('正在停止当前语音播放');
synth.cancel();
}
if (text) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
if (selectedVoice) {
utterance.voice = selectedVoice;
}
utterance.volume = speechVolume;
if (followVideoSpeed && video) {
utterance.rate = video.playbackRate;
console.log('使用视频倍速:', utterance.rate);
} else {
utterance.rate = customSpeed;
console.log('使用自定义倍速:', utterance.rate);
}
utterance.onend = () => {
console.log('当前语音播放完成');
if (pendingText) {
console.log('播放等待的文本');
const nextText = pendingText;
pendingText = null;
speakText(nextText);
}
else if (autoVideoPause && isWaitingToSpeak && video && video.paused) {
isWaitingToSpeak = false;
video.play();
console.log('所有语音播放完成,视频继续播放');
}
};
utterance.onerror = () => {
console.error('语音播放出错');
if (autoVideoPause && isWaitingToSpeak && video && video.paused) {
isWaitingToSpeak = false;
video.play();
console.log('语音播放出错,视频继续播放');
}
pendingText = null;
};
synth.speak(utterance);
console.log('开始朗读');
} else {
console.log('文本为空,跳过朗读');
}
}
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 = '';
pendingText = 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(`未找到字幕容器,${retryCount + 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);
})();