// ==UserScript==
// @name ChatGPT朗读速度控制器
// @description 通过油猴菜单控制ChatGPT朗读速度,支持0.01-100x速度调节
// @author schweigen
// @version 1.0
// @namespace ChatGPT.SpeedController.Simple
// @icon https://www.google.com/s2/favicons?sz=64&domain=chatgpt.com
// @match https://chatgpt.com
// @match https://chatgpt.com/?model=*
// @match https://chatgpt.com/?temporary-chat=*
// @match https://chatgpt.com/c/*
// @match https://chatgpt.com/g/*
// @match https://chatgpt.com/share/*
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.registerMenuCommand
// @grant GM.unregisterMenuCommand
// @run-at document-start
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// 配置常量
const MIN_SPEED = 0.01;
const MAX_SPEED = 100;
// 全局变量
let currentSpeed = 1;
let playingAudio = new Set();
let menuCommands = [];
// 加载保存的速度设置
async function loadSettings() {
const saved = await GM.getValue('playbackSpeed', 1);
currentSpeed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, saved));
console.log(`ChatGPT速度控制器:当前速度 ${currentSpeed}x`);
}
// 保存速度设置
async function saveSettings() {
await GM.setValue('playbackSpeed', currentSpeed);
console.log(`ChatGPT速度控制器:速度已保存为 ${currentSpeed}x`);
}
// 设置音频播放速度
function setAudioSpeed(speed) {
// 设置所有当前播放的音频速度
playingAudio.forEach(audio => {
if (audio && !audio.paused) {
audio.playbackRate = speed;
audio.preservesPitch = true;
audio.mozPreservesPitch = true;
audio.webkitPreservesPitch = true;
}
});
}
// 监听新的音频元素
function setupAudioListener() {
// 监听播放事件
document.addEventListener('play', (e) => {
const audio = e.target;
if (audio instanceof HTMLAudioElement) {
audio.playbackRate = currentSpeed;
audio.preservesPitch = true;
audio.mozPreservesPitch = true;
audio.webkitPreservesPitch = true;
playingAudio.add(audio);
// 音频结束时从集合中移除
const cleanup = () => playingAudio.delete(audio);
audio.addEventListener('pause', cleanup, {once: true});
audio.addEventListener('ended', cleanup, {once: true});
}
}, true);
// 监听速度变化事件,防止被重置
document.addEventListener('ratechange', (e) => {
const audio = e.target;
if (audio instanceof HTMLAudioElement && Math.abs(audio.playbackRate - currentSpeed) > 0.01) {
audio.playbackRate = currentSpeed;
}
}, true);
}
// 更改速度
async function changeSpeed(newSpeed) {
currentSpeed = Math.max(MIN_SPEED, Math.min(MAX_SPEED, newSpeed));
setAudioSpeed(currentSpeed);
await saveSettings();
updateMenus();
// 显示通知
showNotification(`朗读速度已设置为 ${currentSpeed}x`);
}
// 显示通知 (移除旧的通知函数,只在changeSpeed中使用右上角显示)
function showNotification(message) {
// 创建右上角通知
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #4CAF50;
color: white;
padding: 12px 18px;
border-radius: 8px;
z-index: 10000;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
border: 2px solid #45a049;
`;
notification.textContent = message;
document.body.appendChild(notification);
// 3秒后自动消失
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 3000);
}
// 注册菜单命令
function registerMenus() {
// 清除旧菜单
menuCommands.forEach(id => GM.unregisterMenuCommand(id));
menuCommands = [];
// 设置面板
menuCommands.push(GM.registerMenuCommand('⚙️ 打开速度设置', () => {
showSettingsPanel();
}));
// 查看当前速度
menuCommands.push(GM.registerMenuCommand('🎵 查看当前速度', () => {
showSpeedDisplay();
}));
}
// 显示右上角速度提示框
function showSpeedDisplay() {
const speedBox = document.createElement('div');
speedBox.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 12px 18px;
border-radius: 8px;
z-index: 10000;
font-size: 16px;
font-weight: bold;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
border: 2px solid #666;
`;
speedBox.textContent = `当前速度: ${currentSpeed}x`;
document.body.appendChild(speedBox);
// 3秒后自动消失
setTimeout(() => {
if (speedBox.parentNode) {
speedBox.parentNode.removeChild(speedBox);
}
}, 3000);
}
// 显示设置面板
function showSettingsPanel() {
// 如果已经存在设置面板,先移除
const existingPanel = document.getElementById('speed-settings-panel');
if (existingPanel) {
existingPanel.remove();
}
// 创建背景遮罩
const overlay = document.createElement('div');
overlay.id = 'speed-settings-panel';
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
z-index: 10000;
display: flex;
justify-content: center;
align-items: center;
animation: fadeIn 0.2s ease-out;
`;
// 添加动画样式
const style = document.createElement('style');
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from { transform: scale(0.9) translateY(-20px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
`;
document.head.appendChild(style);
// 创建设置面板
const panel = document.createElement('div');
panel.style.cssText = `
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
padding: 24px;
box-shadow: 0 20px 40px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.1);
max-width: 320px;
width: 90%;
animation: slideIn 0.3s ease-out;
position: relative;
overflow: hidden;
`;
// 添加装饰性背景
const bgDecor = document.createElement('div');
bgDecor.style.cssText = `
position: absolute;
top: -50%;
right: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
pointer-events: none;
`;
panel.appendChild(bgDecor);
const content = document.createElement('div');
content.style.cssText = `
position: relative;
z-index: 1;
`;
content.innerHTML = `
<div style="text-align: center; margin-bottom: 20px;">
<div style="
display: inline-block;
width: 48px;
height: 48px;
background: rgba(255,255,255,0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
backdrop-filter: blur(10px);
">
<span style="font-size: 24px;">🎵</span>
</div>
<h3 style="margin: 0; color: white; font-size: 18px; font-weight: 600; text-shadow: 0 1px 2px rgba(0,0,0,0.3);">朗读速度设置</h3>
</div>
<div style="margin-bottom: 18px;">
<label style="display: block; margin-bottom: 8px; color: rgba(255,255,255,0.9); font-size: 14px; font-weight: 500;">
当前速度: <span style="color: #fff; font-weight: 600;">${currentSpeed}x</span>
</label>
<div style="position: relative;">
<input type="range" id="speedSlider" min="0.25" max="5" step="0.25" value="${Math.min(currentSpeed, 5)}"
style="
width: 100%;
margin: 8px 0;
-webkit-appearance: none;
appearance: none;
height: 6px;
background: rgba(255,255,255,0.3);
border-radius: 3px;
outline: none;
">
<style>
#speedSlider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
#speedSlider::-moz-range-thumb {
width: 18px;
height: 18px;
background: white;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
</style>
</div>
<div style="display: flex; justify-content: space-between; font-size: 11px; color: rgba(255,255,255,0.7); margin-top: 4px;">
<span>0.25x</span>
<span>5x</span>
</div>
</div>
<div style="margin-bottom: 20px;">
<label style="display: block; margin-bottom: 8px; color: rgba(255,255,255,0.9); font-size: 14px; font-weight: 500;">
精确值 (${MIN_SPEED} - ${MAX_SPEED})
</label>
<input type="number" id="speedInput" min="${MIN_SPEED}" max="${MAX_SPEED}" step="0.01" value="${currentSpeed}"
style="
width: 100%;
padding: 10px 12px;
border: none;
border-radius: 8px;
font-size: 14px;
background: rgba(255,255,255,0.95);
color: #333;
backdrop-filter: blur(10px);
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
transition: all 0.2s ease;
"
onfocus="this.style.background='rgba(255,255,255,1)'; this.style.transform='translateY(-1px)'"
onblur="this.style.background='rgba(255,255,255,0.95)'; this.style.transform='translateY(0)'">
</div>
<div style="display: flex; gap: 10px;">
<button id="applyBtn" style="
padding: 12px 20px;
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
color: white;
cursor: pointer;
flex: 1;
font-size: 14px;
font-weight: 600;
box-shadow: 0 4px 12px rgba(79, 172, 254, 0.4);
transition: all 0.2s ease;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
"
onmouseover="this.style.transform='translateY(-2px)'; this.style.boxShadow='0 6px 16px rgba(79, 172, 254, 0.5)'"
onmouseout="this.style.transform='translateY(0)'; this.style.boxShadow='0 4px 12px rgba(79, 172, 254, 0.4)'"
>✓ 应用</button>
<button id="cancelBtn" style="
padding: 12px 20px;
border: 1px solid rgba(255,255,255,0.3);
border-radius: 8px;
background: rgba(255,255,255,0.1);
color: white;
cursor: pointer;
flex: 1;
font-size: 14px;
font-weight: 500;
backdrop-filter: blur(10px);
transition: all 0.2s ease;
"
onmouseover="this.style.background='rgba(255,255,255,0.2)'; this.style.transform='translateY(-1px)'"
onmouseout="this.style.background='rgba(255,255,255,0.1)'; this.style.transform='translateY(0)'"
>✕ 取消</button>
</div>
`;
panel.appendChild(content);
overlay.appendChild(panel);
document.body.appendChild(overlay);
// 获取元素
const speedSlider = panel.querySelector('#speedSlider');
const speedInput = panel.querySelector('#speedInput');
const applyBtn = panel.querySelector('#applyBtn');
const cancelBtn = panel.querySelector('#cancelBtn');
// 滑块和输入框同步
speedSlider.addEventListener('input', () => {
speedInput.value = speedSlider.value;
});
speedInput.addEventListener('input', () => {
const value = parseFloat(speedInput.value);
if (value >= 0.25 && value <= 5) {
speedSlider.value = value;
}
});
// 应用按钮
applyBtn.addEventListener('click', () => {
const newSpeed = parseFloat(speedInput.value);
if (newSpeed >= MIN_SPEED && newSpeed <= MAX_SPEED) {
changeSpeed(newSpeed);
overlay.remove();
} else {
alert(`请输入有效的速度值 (${MIN_SPEED}-${MAX_SPEED})`);
}
});
// 取消按钮和背景点击
cancelBtn.addEventListener('click', () => overlay.remove());
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.remove();
});
// ESC键关闭
const escHandler = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
}
// 更新菜单显示 (简化版不需要动态更新)
function updateMenus() {
// 菜单项是静态的,不需要更新
}
// 初始化
async function init() {
await loadSettings();
setupAudioListener();
registerMenus();
// 监听页面变化,确保音频监听器始终有效
const observer = new MutationObserver((mutations) => {
const hasAudio = mutations.some(mutation =>
Array.from(mutation.addedNodes).some(node =>
node.nodeName === 'AUDIO' ||
(node.querySelector && node.querySelector('audio'))
)
);
if (hasAudio) {
setAudioSpeed(currentSpeed);
}
});
if (document.body) {
observer.observe(document.body, {childList: true, subtree: true});
}
console.log('ChatGPT朗读速度控制器已启动');
}
// 等待DOM加载完成
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, {once: true});
} else {
init();
}
})();