// ==UserScript== // @name 在线听书助手 🎧 // @namespace https://github.com/ebook-tts // @version 4.0.0 // @description 在线阅读时朗读网页内容,支持系统音色+多路网络音色(有道/搜狗/Google TTS)、中文音色名、语速/音调/音量调节、悬浮球设置面板 // @author WorkBuddy // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect dict.youdao.com // @connect tts.baidu.com // @connect translate.google.com // @connect translate.googleapis.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================================================== // 音色中文名称映射表(本地系统音色) // ===================================================== const VOICE_ZH_NAME = { 'Microsoft Huihui': '微软 慧慧(普通话·女)', 'Microsoft Kangkang': '微软 康康(普通话·男)', 'Microsoft Yaoyao': '微软 瑶瑶(普通话·女童)', 'Microsoft Tracy': '微软 晓晓(粤语·女)', 'Microsoft Danny': '微软 丹尼(粤语·男)', 'Microsoft HiuGaai': '微软 晓佳(粤语·女)', 'Microsoft HiuMaan': '微软 晓曼(粤语·女)', 'Microsoft WanLung': '微软 云龙(粤语·男)', 'Microsoft Xiaoxiao': '微软 晓晓(普通话·女)', 'Microsoft Yunxi': '微软 云希(普通话·男)', 'Microsoft Yunjian': '微软 云健(普通话·男)', 'Microsoft Xiaochen': '微软 晓辰(普通话·女)', 'Microsoft Xiaohan': '微软 晓涵(普通话·女)', 'Microsoft Xiaomeng': '微软 晓梦(普通话·女)', 'Microsoft Xiaomo': '微软 晓墨(普通话·女)', 'Microsoft Xiaoqiu': '微软 晓秋(普通话·女)', 'Microsoft Xiaorui': '微软 晓睿(普通话·女)', 'Microsoft Xiaoshuang': '微软 晓双(普通话·女童)', 'Microsoft Xiaoyan': '微软 晓颜(普通话·女)', 'Microsoft Xiaoyou': '微软 晓悠(普通话·女童)', 'Microsoft Xiaozhen': '微软 晓甄(普通话·女)', 'Microsoft Yunfeng': '微软 云枫(普通话·男)', 'Microsoft Yunhao': '微软 云皓(普通话·男)', 'Microsoft Yunxia': '微软 云夏(普通话·男)', 'Microsoft Yunyang': '微软 云扬(普通话·男)', 'Microsoft Yunye': '微软 云野(普通话·男)', 'Microsoft Yunze': '微软 云泽(普通话·男)', 'Ting-Ting': '婷婷(普通话·女)', 'Sin-ji': '善慈(粤语·女)', 'Mei-Jia': '美佳(闽南话·女)', 'Yu-shu': '玉书(台湾·女)', 'Google 普通话(中国大陆)': '谷歌 普通话(大陆·女)', 'Google 粤語(香港)': '谷歌 粤语(香港·女)', 'Google 國語(臺灣)': '谷歌 国语(台湾·女)', 'Google 中文(中国大陆)': '谷歌 普通话(大陆)', }; // ===================================================== // 网络音色列表(多引擎,通过 GM_xmlhttpRequest 调用,无跨域限制) // ===================================================== const ONLINE_VOICES = [ // 有道TTS(dict.youdao.com,最稳定,无需Key) { id: 'youdao:0', name: '有道·女声(普通话·自然)', lang: 'zh-CN', gender: '女', engine: 'youdao', type: '0' }, { id: 'youdao:1', name: '有道·男声(普通话·沉稳)', lang: 'zh-CN', gender: '男', engine: 'youdao', type: '1' }, { id: 'youdao:3', name: '有道·萝莉(普通话·女童)', lang: 'zh-CN', gender: '女', engine: 'youdao', type: '3' }, { id: 'youdao:4', name: '有道·大叔(普通话·低沉)', lang: 'zh-CN', gender: '男', engine: 'youdao', type: '4' }, // 百度TTS(tts.baidu.com) { id: 'baidu:0', name: '百度·女声(普通话·标准)', lang: 'zh-CN', gender: '女', engine: 'baidu', type: '0' }, { id: 'baidu:1', name: '百度·男声(普通话·磁性)', lang: 'zh-CN', gender: '男', engine: 'baidu', type: '1' }, // Google TTS(translate.googleapis.com) { id: 'google:zh-CN', name: 'Google·普通话(大陆·女)', lang: 'zh-CN', gender: '女', engine: 'google', type: 'zh-CN' }, { id: 'google:zh-TW', name: 'Google·国语(台湾·女)', lang: 'zh-TW', gender: '女', engine: 'google', type: 'zh-TW' }, ]; // ===================================================== // 状态管理 // ===================================================== const state = { isPlaying: false, isPaused: false, utterance: null, audioEl: null, audioQueue: [], // 待播放音频队列 paragraphs: [], currentIndex: 0, voices: [], selectedVoiceId: GM_getValue('selectedVoiceId', ''), rate: parseFloat(GM_getValue('rate', '1.0')), pitch: parseFloat(GM_getValue('pitch', '1.0')), volume: parseFloat(GM_getValue('volume', '1.0')), highlightEl: null, panelOpen: false, voiceMode: GM_getValue('voiceMode', 'local'), onlineAbort: null, // 中断控制器 }; // ===================================================== // 样式 // ===================================================== GM_addStyle(` #erp-fab { position: fixed !important; bottom: 28px !important; right: 28px !important; z-index: 2147483646 !important; width: 52px !important; height: 52px !important; border-radius: 50% !important; background: linear-gradient(135deg, #7c4dff, #e040fb) !important; box-shadow: 0 4px 20px rgba(124,77,255,0.55) !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 24px !important; border: none !important; outline: none !important; transition: transform 0.2s, box-shadow 0.2s !important; user-select: none !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; clip: auto !important; clip-path: none !important; transform: none !important; } #erp-fab:hover { transform: scale(1.1) !important; box-shadow: 0 6px 28px rgba(124,77,255,0.7) !important; } #erp-fab:active { transform: scale(0.95) !important; } #erp-fab .erp-fab-dot { position: absolute !important; top: 3px !important; right: 3px !important; width: 10px !important; height: 10px !important; border-radius: 50% !important; background: #4caf50 !important; border: 2px solid #fff !important; display: none !important; } #erp-fab .erp-fab-dot.show { display: block !important; } #erp-fab .erp-fab-dot.playing { background: #7c4dff !important; animation: erp-fab-pulse 1.2s infinite !important; } #erp-fab .erp-fab-dot.paused { background: #ff9800 !important; } @keyframes erp-fab-pulse { 0%,100%{opacity:1;transform:scale(1);} 50%{opacity:0.5;transform:scale(1.3);} } #erp-panel { position: fixed !important; bottom: 90px !important; right: 20px !important; z-index: 2147483647 !important; width: 330px !important; background: linear-gradient(160deg,#1a1a2e 0%,#16213e 55%,#0f3460 100%) !important; border-radius: 20px !important; box-shadow: 0 10px 48px rgba(0,0,0,0.55), 0 0 0 1px rgba(255,255,255,0.09) !important; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif !important; font-size: 13px !important; color: #e8eaf6 !important; user-select: none !important; overflow: hidden !important; transform-origin: bottom right !important; transition: opacity 0.2s, transform 0.2s !important; opacity: 0 !important; transform: scale(0.85) translateY(10px) !important; pointer-events: none !important; } #erp-panel.open { opacity: 1 !important; transform: scale(1) translateY(0) !important; pointer-events: auto !important; } #erp-panel .erp-hd { display: flex !important; align-items: center !important; justify-content: space-between !important; padding: 13px 16px 11px !important; background: rgba(255,255,255,0.05) !important; border-bottom: 1px solid rgba(255,255,255,0.07) !important; cursor: move !important; } #erp-panel .erp-hd-title { font-size: 14px !important; font-weight: 700 !important; color: #fff !important; display: flex !important; align-items: center !important; gap: 8px !important; } #erp-panel .erp-status-dot { width: 8px !important; height: 8px !important; border-radius: 50% !important; background: rgba(255,255,255,0.2) !important; transition: background 0.3s !important; flex-shrink: 0 !important; } #erp-panel .erp-status-dot.playing { background: #7c4dff !important; box-shadow: 0 0 6px #7c4dff !important; animation: erp-fab-pulse 1.2s infinite !important; } #erp-panel .erp-status-dot.paused { background: #ff9800 !important; } #erp-panel .erp-hd-close { width: 26px !important; height: 26px !important; border-radius: 7px !important; background: rgba(255,255,255,0.08) !important; border: none !important; color: rgba(255,255,255,0.6) !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 14px !important; transition: all 0.15s !important; } #erp-panel .erp-hd-close:hover { background: rgba(255,100,100,0.25) !important; color: #ff6b6b !important; } #erp-panel .erp-player { padding: 14px 16px 10px !important; } #erp-panel .erp-progress-wrap { margin-bottom: 12px !important; } #erp-panel .erp-progress-bar { height: 3px !important; background: rgba(255,255,255,0.1) !important; border-radius: 3px !important; overflow: hidden !important; } #erp-panel .erp-progress-fill { height: 100% !important; width: 0% !important; background: linear-gradient(90deg,#7c4dff,#e040fb) !important; border-radius: 3px !important; transition: width 0.4s !important; } #erp-panel .erp-progress-info { display: flex !important; justify-content: space-between !important; margin-top: 5px !important; font-size: 11px !important; color: rgba(255,255,255,0.38) !important; } #erp-panel .erp-controls { display: flex !important; align-items: center !important; justify-content: center !important; gap: 10px !important; } #erp-panel .erp-btn { border: none !important; cursor: pointer !important; outline: none !important; background: transparent !important; color: #e8eaf6 !important; transition: all 0.15s !important; padding: 0 !important; margin: 0 !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; font-size: 13px !important; line-height: 1 !important; } #erp-panel .erp-btn:hover { transform: scale(1.12) !important; } #erp-panel .erp-btn:active { transform: scale(0.93) !important; } #erp-panel .erp-ctrl { width: 40px !important; height: 40px !important; border-radius: 50% !important; background: rgba(255,255,255,0.09) !important; font-size: 16px !important; } #erp-panel .erp-ctrl:hover { background: rgba(255,255,255,0.16) !important; } #erp-panel .erp-play-btn { width: 52px !important; height: 52px !important; font-size: 22px !important; background: linear-gradient(135deg,#7c4dff,#e040fb) !important; box-shadow: 0 4px 18px rgba(124,77,255,0.45) !important; border-radius: 50% !important; } #erp-panel .erp-play-btn:hover { box-shadow: 0 6px 24px rgba(124,77,255,0.65) !important; } #erp-panel .erp-stop-btn { color: #ff6b6b !important; } #erp-panel .erp-stop-btn:hover { background: rgba(255,107,107,0.15) !important; } #erp-panel .erp-sep { height: 1px !important; background: rgba(255,255,255,0.07) !important; margin: 10px 0 !important; } #erp-panel .erp-settings { padding: 0 16px 14px !important; } #erp-panel .erp-row { margin-bottom: 11px !important; } #erp-panel .erp-row:last-child { margin-bottom: 0 !important; } #erp-panel .erp-lbl { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.5) !important; margin-bottom: 5px !important; font-weight: 500 !important; } #erp-panel .erp-lbl b { color: rgba(255,255,255,0.85) !important; font-weight: 600 !important; } #erp-panel .erp-tabs { display: flex !important; gap: 4px !important; margin-bottom: 8px !important; background: rgba(255,255,255,0.05) !important; border-radius: 8px !important; padding: 3px !important; } #erp-panel .erp-tab { flex: 1 !important; padding: 5px 0 !important; text-align: center !important; border-radius: 6px !important; font-size: 12px !important; font-weight: 500 !important; cursor: pointer !important; color: rgba(255,255,255,0.45) !important; border: none !important; background: transparent !important; transition: all 0.15s !important; } #erp-panel .erp-tab.active { background: linear-gradient(135deg,#7c4dff,#e040fb) !important; color: #fff !important; box-shadow: 0 2px 8px rgba(124,77,255,0.4) !important; } #erp-panel .erp-select { width: 100% !important; padding: 7px 10px !important; border-radius: 8px !important; background: rgba(255,255,255,0.08) !important; border: 1px solid rgba(255,255,255,0.11) !important; color: #e8eaf6 !important; font-size: 12px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-select:hover, #erp-panel .erp-select:focus { background: rgba(255,255,255,0.12) !important; border-color: rgba(124,77,255,0.55) !important; } #erp-panel .erp-select option, #erp-panel .erp-select optgroup { background: #1a1a2e !important; color: #e8eaf6 !important; } #erp-panel .erp-range { width: 100% !important; height: 4px !important; -webkit-appearance: none !important; appearance: none !important; background: rgba(255,255,255,0.14) !important; border-radius: 2px !important; cursor: pointer !important; outline: none !important; } #erp-panel .erp-range::-webkit-slider-thumb { -webkit-appearance: none !important; width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; box-shadow: 0 0 6px rgba(124,77,255,0.5) !important; } #erp-panel .erp-range::-moz-range-thumb { width: 14px !important; height: 14px !important; border-radius: 50% !important; background: #7c4dff !important; cursor: pointer !important; border: none !important; } #erp-panel .erp-online-tip { font-size: 11px !important; color: rgba(255,255,255,0.4) !important; text-align: center !important; padding: 4px 0 2px !important; } #erp-panel .erp-online-tip.err { color: #ff6b6b !important; } #erp-panel .erp-online-tip.ok { color: #4caf50 !important; } #erp-panel .erp-loading-spin { display: inline-block !important; animation: erp-spin 0.8s linear infinite !important; } @keyframes erp-spin { to { transform: rotate(360deg); } } .erp-highlight { background-color: rgba(124,77,255,0.13) !important; border-left: 3px solid #7c4dff !important; padding-left: 8px !important; border-radius: 3px !important; outline: 2px solid rgba(124,77,255,0.22) !important; outline-offset: 3px !important; transition: background-color 0.3s !important; } `); // ===================================================== // 音色名中文化 // ===================================================== function getVoiceDisplayName(voice) { for (const key of Object.keys(VOICE_ZH_NAME)) { if (voice.name.includes(key)) return VOICE_ZH_NAME[key]; } let name = voice.name.replace(/^Microsoft\s+/i, '微软 ').replace(/^Google\s+/i, '谷歌 '); const langMap = { 'zh-CN': '普通话', 'zh-HK': '粤语', 'zh-TW': '台湾', 'cmn-CN': '普通话', 'yue-HK': '粤语' }; const langLabel = langMap[voice.lang] || voice.lang; return `${name}(${langLabel})`; } // ===================================================== // 提取正文段落 // ===================================================== function extractParagraphs() { const BLOCK = 'p,article,section,blockquote,h1,h2,h3,h4,h5,h6,li,dd,td'; const IGNORE = new Set(['SCRIPT','STYLE','NOSCRIPT','IFRAME','BUTTON','INPUT','TEXTAREA','SELECT','NAV','FOOTER','HEADER']); const isIgnored = el => { let n=el; while(n&&n!==document.body){if(IGNORE.has(n.tagName))return true;n=n.parentElement;} return false; }; const isVisible = el => { const s=getComputedStyle(el); return s.display!=='none'&&s.visibility!=='hidden'&&s.opacity!=='0'; }; const sels = ['article','main','.content','.chapter','.book-content','#content','#chapter','.reader-content','.novel-content','.text-content','.article-content','.post-content','[role="main"]','.entry-content','.read-content','.story-body','.article-body','.page-content']; let container = null; for (const s of sels) { const el = document.querySelector(s); if (el && (el.innerText||'').trim().length > 150) { container = el; break; } } if (!container) container = document.body; const all = [...container.querySelectorAll(BLOCK)]; const filtered = all.filter(el => { if (isIgnored(el)||!isVisible(el)) return false; return (el.innerText||'').trim().replace(/\s+/g,' ').length > 15; }); return filtered .filter((el,i)=>!filtered.some((o,j)=>j!==i&&o.contains(el)&&o!==el)) .map(el=>({text:(el.innerText||'').trim().replace(/\s+/g,' '),el})); } // ===================================================== // 本地 TTS // ===================================================== function loadVoices() { return new Promise(resolve => { let v = speechSynthesis.getVoices(); if (v.length) { resolve(v); return; } speechSynthesis.onvoiceschanged = () => resolve(speechSynthesis.getVoices()); setTimeout(() => resolve(speechSynthesis.getVoices()), 2500); }); } // ===================================================== // 网络 TTS —— 微软 Edge TTS WebSocket API // 通过 wss://speech.platform.bing.com 连接合成语音 // ===================================================== function edgeTTSSpeak(text, voiceName, rate, volume, onAudioReady, onError) { // 生成唯一请求ID // ===================================================== // 网络 TTS —— 多引擎 HTTP 方案(GM_xmlhttpRequest,无跨域限制) // 引擎优先级:有道 → 百度 → Google → 本地兜底 // ===================================================== // 将 ArrayBuffer 转为 Blob URL 并播放 function playAudioBuffer(arrayBuffer, mimeType, onEnd, onError) { try { const blob = new Blob([arrayBuffer], { type: mimeType }); const url = URL.createObjectURL(blob); const audio = new Audio(url); audio.volume = state.volume; state.audioEl = audio; audio.play().catch(onError); audio.onended = () => { URL.revokeObjectURL(url); onEnd(); }; audio.onerror = () => { URL.revokeObjectURL(url); onError(); }; } catch(e) { onError(e); } } // 有道TTS(中文最稳,4种音色) function fetchYoudaoTTS(text, type, onReady, onError) { // 有道每次最多约200字,超长则截断 const chunk = text.slice(0, 200); const url = `https://dict.youdao.com/dictvoice?audio=${encodeURIComponent(chunk)}&type=${type}`; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': 'https://dict.youdao.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => { if (r.status === 200 && r.response && r.response.byteLength > 100) { onReady(r.response, 'audio/mpeg'); } else { onError('有道TTS返回异常: ' + r.status); } }, onerror: () => onError('有道TTS网络错误'), ontimeout: () => onError('有道TTS超时'), }); } // 百度TTS(备用,spd语速1-15,per音色0=女4=情感) function fetchBaiduTTS(text, per, rate, onReady, onError) { const spd = Math.round(rate * 5); // 0.5~2.5 → 2~12 const chunk = text.slice(0, 200); const url = `https://tts.baidu.com/text2audio?lan=zh&ie=UTF-8&spd=${spd}&per=${per}&text=${encodeURIComponent(chunk)}`; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': 'https://www.baidu.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => { if (r.status === 200 && r.response && r.response.byteLength > 100) { onReady(r.response, 'audio/mpeg'); } else { onError('百度TTS返回异常: ' + r.status); } }, onerror: () => onError('百度TTS网络错误'), ontimeout: () => onError('百度TTS超时'), }); } // Google TTS(支持多语言) function fetchGoogleTTS(text, lang, onReady, onError) { const chunk = text.slice(0, 200); const url = `https://translate.googleapis.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunk)}&tl=${lang}&client=gtx&ttsspeed=1`; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', headers: { 'Referer': 'https://translate.google.com/', 'User-Agent': navigator.userAgent }, timeout: 10000, onload: r => { if (r.status === 200 && r.response && r.response.byteLength > 100) { onReady(r.response, 'audio/mpeg'); } else { onError('Google TTS返回异常: ' + r.status); } }, onerror: () => onError('Google TTS网络错误'), ontimeout: () => onError('Google TTS超时'), }); } // 统一网络TTS入口(根据音色ID选引擎,失败自动降级) function fetchOnlineTTS(text, voiceId, onReady, onError) { const vo = ONLINE_VOICES.find(v => v.id === voiceId) || ONLINE_VOICES[0]; if (vo.engine === 'youdao') { fetchYoudaoTTS(text, vo.type, onReady, err => { // 有道失败 → 降级到百度 fetchBaiduTTS(text, '0', state.rate, onReady, err2 => onError(err + ' / ' + err2)); }); } else if (vo.engine === 'baidu') { fetchBaiduTTS(text, vo.type, state.rate, onReady, err => { // 百度失败 → 降级到有道 fetchYoudaoTTS(text, '0', onReady, err2 => onError(err + ' / ' + err2)); }); } else if (vo.engine === 'google') { fetchGoogleTTS(text, vo.type, onReady, err => { // Google失败 → 降级到有道 fetchYoudaoTTS(text, '0', onReady, err2 => onError(err + ' / ' + err2)); }); } else { onError('未知引擎: ' + vo.engine); } } // ===================================================== // 播放控制 // ===================================================== function speak(index) { if (index < 0 || index >= state.paragraphs.length) { stopAll(); return; } // 先停止当前播放 abortCurrent(); state.currentIndex = index; doHighlight(); scrollToEl(state.paragraphs[index].el); state.isPlaying = true; state.isPaused = false; updateUI(); const text = state.paragraphs[index].text; const onEnd = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; const onErr = () => { if (state.isPlaying) speak(state.currentIndex + 1); }; if (state.voiceMode === 'online') { // 网络音色:通过 GM_xmlhttpRequest 获取音频 const voiceId = state.selectedVoiceId || ONLINE_VOICES[0].id; setOnlineTip(' 正在合成音频…'); let cancelled = false; state.onlineAbort = () => { cancelled = true; }; fetchOnlineTTS( text, voiceId, (arrayBuffer, mimeType) => { if (cancelled || !state.isPlaying || state.currentIndex !== index) return; setOnlineTip('🌐 网络音色合成成功', 'ok'); playAudioBuffer(arrayBuffer, mimeType, onEnd, () => { if (state.isPlaying) onErr(); }); }, (errMsg) => { if (cancelled || !state.isPlaying) return; setOnlineTip('⚠ 网络音色失败,已切换到本地音色', 'err'); speakLocal(text, onEnd, onErr); } ); } else { speakLocal(text, onEnd, onErr); } } function speakLocal(text, onEnd, onErr) { speechSynthesis.cancel(); const utter = new SpeechSynthesisUtterance(text); const voices = speechSynthesis.getVoices(); const v = voices.find(v => v.voiceURI === state.selectedVoiceId); if (v) utter.voice = v; utter.rate = state.rate; utter.pitch = state.pitch; utter.volume = state.volume; utter.onend = onEnd; utter.onerror = e => { if (e.error !== 'interrupted' && e.error !== 'canceled') onErr(); }; state.utterance = utter; speechSynthesis.speak(utter); } function abortCurrent() { speechSynthesis.cancel(); if (state.onlineAbort) { state.onlineAbort(); state.onlineAbort = null; } if (state.audioEl) { state.audioEl.pause(); state.audioEl.src = ''; state.audioEl = null; } } function pauseResume() { if (state.isPaused) { if (state.audioEl) { state.audioEl.play().catch(() => {}); } else { speechSynthesis.resume(); } state.isPaused = false; state.isPlaying = true; } else if (state.isPlaying) { if (state.audioEl) { state.audioEl.pause(); } else { speechSynthesis.pause(); } state.isPaused = true; state.isPlaying = false; } else { state.paragraphs = extractParagraphs(); if (!state.paragraphs.length) { showTip('未找到可朗读的正文,请在文章页面使用'); return; } speak(state.currentIndex || 0); return; } updateUI(); } function stopAll() { abortCurrent(); state.isPlaying = false; state.isPaused = false; clearHighlight(); setOnlineTip(''); updateUI(); } function prevPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.max(0, state.currentIndex - 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else { state.currentIndex = idx; } } function nextPara() { if (!state.paragraphs.length) state.paragraphs = extractParagraphs(); const idx = Math.min(state.paragraphs.length - 1, state.currentIndex + 1); const wasPlaying = state.isPlaying || state.isPaused; stopAll(); if (wasPlaying) { state.isPlaying = true; speak(idx); } else { state.currentIndex = idx; } } // ===================================================== // 高亮 // ===================================================== function doHighlight() { clearHighlight(); const p = state.paragraphs[state.currentIndex]; if (p && p.el) { p.el.classList.add('erp-highlight'); state.highlightEl = p.el; } } function clearHighlight() { document.querySelectorAll('.erp-highlight').forEach(el => el.classList.remove('erp-highlight')); state.highlightEl = null; } function scrollToEl(el) { if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } // ===================================================== // 创建 UI(使用宿主容器挂到 body,强制样式保持可见) // ===================================================== function createUI() { if (document.getElementById('erp-host')) return; // 宿主容器(用来做守护检测的锚点) const host = document.createElement('div'); host.id = 'erp-host'; host.style.cssText = 'all:initial;position:fixed;bottom:0;right:0;width:0;height:0;z-index:2147483647;'; document.body.appendChild(host); const fab = document.createElement('button'); fab.id = 'erp-fab'; fab.title = '听书助手(Alt+S 播放)'; fab.innerHTML = `🎧`; // 强制内联样式,双保险防止被页面覆盖 fab.style.cssText = [ 'position:fixed','bottom:28px','right:28px', 'z-index:2147483646','width:52px','height:52px', 'border-radius:50%','border:none','outline:none', 'background:linear-gradient(135deg,#7c4dff,#e040fb)', 'box-shadow:0 4px 20px rgba(124,77,255,0.55)', 'cursor:pointer','display:flex','align-items:center', 'justify-content:center','font-size:24px', 'user-select:none','transition:transform 0.2s,box-shadow 0.2s', 'visibility:visible','opacity:1','pointer-events:auto', ].join('!important;') + '!important'; document.body.appendChild(fab); const panel = document.createElement('div'); panel.id = 'erp-panel'; panel.innerHTML = `
🎧 听书助手
点击播放开始听书
🎤 音色来源
🎙 选择音色
⚡ 语速 ${state.rate.toFixed(1)}x
🎵 音调 ${state.pitch.toFixed(1)}
🔊 音量 ${Math.round(state.volume*100)}%
`; document.body.appendChild(panel); loadVoices().then(voices => { state.voices = voices; renderVoiceSelect(); }); bindUIEvents(fab, panel); makeDraggable(panel, panel.querySelector('#erp-hd')); } // ===================================================== // 渲染音色下拉 // ===================================================== function renderVoiceSelect() { const sel = document.getElementById('erp-voice'); const tip = document.getElementById('erp-voice-tip'); const pitchRow = document.getElementById('erp-pitch-row'); if (!sel) return; sel.innerHTML = ''; if (state.voiceMode === 'local') { if (tip) tip.style.display = 'none'; if (pitchRow) pitchRow.style.display = ''; const zh = state.voices.filter(v => /^(zh|cmn|yue)/i.test(v.lang)); const en = state.voices.filter(v => /^en/i.test(v.lang)); const others = state.voices.filter(v => !zh.includes(v) && !en.includes(v)); const addGroup = (label, list) => { if (!list.length) return; const og = document.createElement('optgroup'); og.label = label; list.forEach(v => { const o = document.createElement('option'); o.value = v.voiceURI; o.textContent = getVoiceDisplayName(v); if (v.voiceURI === state.selectedVoiceId) o.selected = true; og.appendChild(o); }); sel.appendChild(og); }; addGroup('🇨🇳 中文音色', zh); addGroup('🇺🇸 英文音色', en); if (others.length) addGroup('🌐 其他', others); if (!state.selectedVoiceId || state.selectedVoiceId.includes('Neural')) { const first = zh[0] || state.voices[0]; if (first) { state.selectedVoiceId = first.voiceURI; sel.value = first.voiceURI; } } else { sel.value = state.selectedVoiceId; } } else { // 网络音色 if (pitchRow) pitchRow.style.display = 'none'; if (tip) { tip.style.display = 'block'; tip.className = 'erp-online-tip'; tip.innerHTML = '🌐 有道 / 百度 / Google 多引擎,自动降级切换'; } // 按引擎分组 const groups = { '📖 有道 TTS': [], '🔵 百度 TTS': [], '🌍 Google TTS': [] }; ONLINE_VOICES.forEach(v => { const g = v.engine === 'youdao' ? '📖 有道 TTS' : v.engine === 'baidu' ? '🔵 百度 TTS' : '🌍 Google TTS'; groups[g].push(v); }); Object.entries(groups).forEach(([label, list]) => { if (!list.length) return; const og = document.createElement('optgroup'); og.label = label; list.forEach(v => { const o = document.createElement('option'); o.value = v.id; o.textContent = v.name; if (v.id === state.selectedVoiceId) o.selected = true; og.appendChild(o); }); sel.appendChild(og); }); if (!state.selectedVoiceId || !ONLINE_VOICES.find(v => v.id === state.selectedVoiceId)) { state.selectedVoiceId = ONLINE_VOICES[0].id; sel.value = state.selectedVoiceId; } else { sel.value = state.selectedVoiceId; } } } // ===================================================== // 设置网络音色提示文字 // ===================================================== function setOnlineTip(html, cls) { const tip = document.getElementById('erp-voice-tip'); if (!tip) return; if (!html) { if (state.voiceMode === 'online') { tip.style.display='block'; tip.className='erp-online-tip'; tip.innerHTML='🌐 有道 / 百度 / Google 多引擎,自动降级切换'; } return; } tip.style.display = 'block'; tip.className = 'erp-online-tip' + (cls ? ' ' + cls : ''); tip.innerHTML = html; } // ===================================================== // 绑定事件 // ===================================================== function bindUIEvents(fab, panel) { const $ = id => document.getElementById(id); fab.addEventListener('click', () => { state.panelOpen = !state.panelOpen; panel.classList.toggle('open', state.panelOpen); }); $('erp-close').addEventListener('click', () => { state.panelOpen = false; panel.classList.remove('open'); }); $('erp-play').addEventListener('click', () => { if (!state.isPlaying && !state.isPaused) { state.paragraphs = extractParagraphs(); state.currentIndex = 0; } pauseResume(); }); $('erp-stop').addEventListener('click', stopAll); $('erp-prev').addEventListener('click', prevPara); $('erp-next').addEventListener('click', nextPara); // 音色模式 Tab panel.querySelectorAll('.erp-tab').forEach(tab => { tab.addEventListener('click', () => { panel.querySelectorAll('.erp-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); state.voiceMode = tab.dataset.mode; GM_setValue('voiceMode', state.voiceMode); renderVoiceSelect(); if (state.isPlaying || state.isPaused) { const idx = state.currentIndex; stopAll(); state.isPlaying = true; speak(idx); } }); }); // 音色选择 $('erp-voice').addEventListener('change', e => { state.selectedVoiceId = e.target.value; GM_setValue('selectedVoiceId', state.selectedVoiceId); if (state.isPlaying || state.isPaused) { const idx = state.currentIndex; stopAll(); state.isPlaying = true; speak(idx); } }); // 语速 $('erp-rate').addEventListener('input', e => { state.rate = parseFloat(e.target.value); $('erp-rate-v').textContent = state.rate.toFixed(1) + 'x'; GM_setValue('rate', String(state.rate)); if (state.isPlaying || state.isPaused) { const idx = state.currentIndex; stopAll(); state.isPlaying = true; speak(idx); } }); // 音调(仅本地模式) $('erp-pitch').addEventListener('input', e => { state.pitch = parseFloat(e.target.value); $('erp-pitch-v').textContent = state.pitch.toFixed(1); GM_setValue('pitch', String(state.pitch)); if ((state.isPlaying || state.isPaused) && state.voiceMode === 'local') { const idx = state.currentIndex; stopAll(); state.isPlaying = true; speak(idx); } }); // 音量 $('erp-vol').addEventListener('input', e => { state.volume = parseFloat(e.target.value); $('erp-vol-v').textContent = Math.round(state.volume * 100) + '%'; GM_setValue('volume', String(state.volume)); if (state.audioEl) state.audioEl.volume = state.volume; if (state.utterance) state.utterance.volume = state.volume; }); // 点击面板外关闭 document.addEventListener('click', e => { if (state.panelOpen && !panel.contains(e.target) && e.target !== fab && !fab.contains(e.target)) { state.panelOpen = false; panel.classList.remove('open'); } }, true); } // ===================================================== // 更新 UI 状态 // ===================================================== function updateUI() { const playBtn = document.getElementById('erp-play'); const dot = document.getElementById('erp-sdot'); const fabDot = document.getElementById('erp-fab-dot'); const fill = document.getElementById('erp-fill'); const pinfo = document.getElementById('erp-pinfo'); const ppct = document.getElementById('erp-ppct'); const stateClass = state.isPlaying ? 'playing' : state.isPaused ? 'paused' : ''; if (playBtn) { playBtn.textContent = state.isPlaying ? '⏸' : '▶'; playBtn.title = state.isPlaying ? '暂停' : state.isPaused ? '继续' : '播放'; } if (dot) dot.className = 'erp-status-dot' + (stateClass ? ' ' + stateClass : ''); if (fabDot) fabDot.className = 'erp-fab-dot' + (stateClass ? ' show ' + stateClass : ''); const total = state.paragraphs.length; const cur = state.currentIndex; if (total > 0) { const pct = Math.round(((cur + 1) / total) * 100); if (fill) fill.style.width = pct + '%'; if (pinfo) pinfo.textContent = `第 ${cur + 1} / ${total} 段`; if (ppct) ppct.textContent = pct + '%'; } else { if (fill) fill.style.width = '0%'; if (pinfo) pinfo.textContent = '点击播放开始听书'; if (ppct) ppct.textContent = ''; } } // ===================================================== // 拖拽 // ===================================================== function makeDraggable(el, handle) { let sx, sy, sl, st; handle.addEventListener('mousedown', e => { if (e.target.closest('button')) return; sx = e.clientX; sy = e.clientY; const r = el.getBoundingClientRect(); sl = r.left; st = r.top; const onMove = e => { el.style.left = (sl + e.clientX - sx) + 'px'; el.style.top = (st + e.clientY - sy) + 'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); } // ===================================================== // 小提示 // ===================================================== function showTip(msg) { let tip = document.getElementById('erp-toast'); if (!tip) { tip = document.createElement('div'); tip.id = 'erp-toast'; Object.assign(tip.style, { position:'fixed', bottom:'100px', left:'50%', transform:'translateX(-50%)', background:'rgba(0,0,0,0.75)', color:'#fff', padding:'8px 18px', borderRadius:'20px', fontSize:'13px', zIndex:'2147483647', fontFamily:'sans-serif', pointerEvents:'none', transition:'opacity 0.3s' }); document.body.appendChild(tip); } tip.textContent = msg; tip.style.opacity = '1'; clearTimeout(tip._t); tip._t = setTimeout(() => { tip.style.opacity = '0'; }, 2500); } // ===================================================== // 键盘快捷键 // ===================================================== document.addEventListener('keydown', e => { if (e.altKey && e.key === 's') { e.preventDefault(); if (!state.isPlaying && !state.isPaused) { state.paragraphs = extractParagraphs(); state.currentIndex = 0; } pauseResume(); } if (e.altKey && e.key === 'q') { e.preventDefault(); stopAll(); } if (e.altKey && e.key === 'ArrowLeft') { e.preventDefault(); prevPara(); } if (e.altKey && e.key === 'ArrowRight') { e.preventDefault(); nextPara(); } // Alt+H:强制唤回悬浮球(找不到时重建) if (e.altKey && (e.key === 'h' || e.key === 'H')) { e.preventDefault(); guardUI(); const fab = document.getElementById('erp-fab'); if (fab) { fab.style.setProperty('visibility','visible','important'); showTip('听书助手已唤回 🎧'); } } }); // ===================================================== // 启动 + 守护(防止 SPA 路由跳转或页面 DOM 清空导致悬浮球消失) // ===================================================== function guardUI() { // 若悬浮球或宿主被移除,重建 if (!document.getElementById('erp-fab') || !document.body.contains(document.getElementById('erp-fab'))) { const old = document.getElementById('erp-panel'); if (old) old.remove(); const oldHost = document.getElementById('erp-host'); if (oldHost) oldHost.remove(); createUI(); } else { // 悬浮球存在但可能被隐藏,强制可见 const fab = document.getElementById('erp-fab'); if (fab) { fab.style.setProperty('visibility', 'visible', 'important'); fab.style.setProperty('opacity', '1', 'important'); fab.style.setProperty('display', 'flex', 'important'); fab.style.setProperty('pointer-events', 'auto', 'important'); fab.style.setProperty('position', 'fixed', 'important'); fab.style.setProperty('bottom', '28px', 'important'); fab.style.setProperty('right', '28px', 'important'); fab.style.setProperty('z-index', '2147483646', 'important'); } } } // MutationObserver:监听 body 子节点变化 const bodyObserver = new MutationObserver(() => { if (!document.getElementById('erp-fab')) guardUI(); }); function startGuard() { createUI(); if (document.body) { bodyObserver.observe(document.body, { childList: true, subtree: false }); } // 定时兜底,每 3 秒检查一次 setInterval(guardUI, 3000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startGuard); } else { startGuard(); } })();