// ==UserScript==
// @name YouTube字幕文本转语音TTS(适用于沉浸式翻译)
// @namespace http://tampermonkey.net/
// @version 1.9
// @description 将YouTube上的沉浸式翻译中文字幕转换为语音播放,支持更改音色和调整语音速度
// @author Sean2333
// @match https://www.youtube.com/*
// @grant none
// @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 = true; // 是否跟随视频倍速
let customSpeed = 1.0; // 自定义倍速值
let isSpeechEnabled = true; // 控制语音播放的开关状态
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);
};
// 添加超时重试机制
setTimeout(() => {
if (voices.length === 0) {
voices = synth.getVoices();
if (voices.length > 0) {
console.log('通过重试加载到语音列表,共', voices.length, '个语音');
resolve(voices);
}
}
}, 1000);
}
});
}
function createVoiceSelectUI() {
const container = document.createElement('div');
container.className = 'voice-select-container';
Object.assign(container.style, {
position: 'fixed',
top: '10px',
right: '10px',
background: 'rgba(255, 255, 255, 0.9)',
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.9)';
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 = '−';
Object.assign(toggleButton.style, {
border: 'none',
background: 'none',
cursor: 'pointer',
fontSize: '16px',
padding: '0 5px'
});
// 内容区域
const content = document.createElement('div');
// 创建语音开关控制区域
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 = '启用语音播放';
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;
if (!isSpeechEnabled) {
// 如果关闭语音,取消当前播放并恢复视频播放
if (synth.speaking) {
synth.cancel();
}
if (isWaitingToSpeak) {
const video = document.querySelector('video');
if (video && video.paused) {
video.play();
}
isWaitingToSpeak = false;
}
pendingText = null;
}
console.log('语音播放已' + (isSpeechEnabled ? '启用' : '禁用'));
};
// 组装语音开关UI
speechToggleDiv.appendChild(speechToggleCheckbox);
speechToggleDiv.appendChild(speechToggleLabel);
// 将语音开关添加到内容区域最上方
content.insertBefore(speechToggleDiv, content.firstChild);
// 音色选择区域
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 speedControl = document.createElement('div');
Object.assign(speedControl.style, {
marginTop: '10px',
borderTop: '1px solid #eee',
paddingTop: '10px'
});
// 跟随视频倍速选项
const followSpeedDiv = document.createElement('div');
Object.assign(followSpeedDiv.style, {
marginBottom: '8px'
});
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');
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;
console.log('语音速度模式:', followVideoSpeed ? '跟随视频' : '自定义');
};
customSpeedSelect.onchange = function() {
customSpeed = parseFloat(this.value);
console.log('自定义语音速度设置为:', customSpeed);
};
testButton.onclick = (e) => {
e.stopPropagation();
if (selectedVoice) {
speakText('这是一个测试语音', false);
}
};
// 初始化状态
customSpeedSelect.disabled = followVideoSpeed;
// 组装UI
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(speedControl);
container.appendChild(titleBar);
container.appendChild(content);
document.body.appendChild(container);
// 折叠/展开功能
let isCollapsed = false;
toggleButton.onclick = (e) => {
e.stopPropagation();
isCollapsed = !isCollapsed;
// 保存当前位置的 right 值
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;
}
// 保持 right 值不变
container.style.right = currentRight;
toggleButton.textContent = 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) {
isDragging = false;
const container = document.querySelector('.voice-select-container');
if (container) {
container.style.transition = 'all 0.2s';
}
}
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);
// 转换为 right 值
container.style.right = `${window.innerWidth - newX - container.offsetWidth}px`;
container.style.top = `${newY}px`;
// 清除 left 属性
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);
});
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];
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('新字幕出现,但当前语音未完成');
pendingText = text;
if (video && !video.paused) {
video.pause();
isWaitingToSpeak = true;
console.log('视频已暂停,等待当前语音完成');
}
return;
}
// console.log('准备朗读文本:', text);
if (synth.speaking) {
console.log('正在停止当前语音播放');
synth.cancel();
}
if (text) {
const utterance = new SpeechSynthesisUtterance(text);
utterance.lang = 'zh-CN';
if (selectedVoice) {
utterance.voice = selectedVoice;
}
// 设置语速
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 (isWaitingToSpeak && video && video.paused) {
isWaitingToSpeak = false;
video.play();
console.log('所有语音播放完成,视频继续播放');
}
};
utterance.onerror = () => {
console.error('语音播放出错');
if (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) {
// 直接查找所有 target-cue 类的元素
const targetCaptions = immersiveCaptionWindow.shadowRoot.querySelectorAll('.target-cue');
let captionText = '';
targetCaptions.forEach(span => {
captionText += span.textContent + ' ';
});
captionText = captionText.trim();
return captionText;
}
return '';
}
function setupCaptionObserver() {
function waitForCaptionContainer() {
const immersiveCaptionWindow = document.querySelector('#immersive-translate-caption-window');
if (immersiveCaptionWindow && immersiveCaptionWindow.shadowRoot) {
// 改为监听 shadowRoot 下的第一层 div
const rootContainer = immersiveCaptionWindow.shadowRoot.querySelector('div');
if (rootContainer) {
console.log('找到字幕根容器,开始监听变化');
const observer = new MutationObserver(() => {
// 获取当前文本
const currentText = getCaptionText();
if (currentText && currentText !== lastCaptionText) {
lastCaptionText = currentText;
speakText(currentText, true);
}
});
const config = {
childList: true,
subtree: true,
characterData: true // 添加对文本内容变化的监听
};
observer.observe(rootContainer, config);
const initialText = getCaptionText();
if (initialText) {
lastCaptionText = initialText;
speakText(initialText, true);
}
} else {
setTimeout(waitForCaptionContainer, 1000);
}
} else {
setTimeout(waitForCaptionContainer, 1000);
}
}
waitForCaptionContainer();
}
function cleanup() {
document.removeEventListener('mousedown', dragStart);
document.removeEventListener('mousemove', drag);
document.removeEventListener('mouseup', dragEnd);
document.removeEventListener('mouseleave', dragEnd);
}
window.addEventListener('load', function() {
console.log('页面加载完成,开始初始化脚本');
setTimeout(() => {
selectVoice();
setupCaptionObserver();
}, 1000);
});
window.addEventListener('unload', cleanup);
// 修改窗口大小变化处理
window.addEventListener('resize', function() {
const container = document.querySelector('.voice-select-container');
if (container) {
const rect = container.getBoundingClientRect();
const maxY = window.innerHeight - container.offsetHeight;
// 保持右侧距离不变,只调整 top 值
let newY = Math.min(Math.max(0, rect.top), maxY);
container.style.top = `${newY}px`;
}
});
})();