// ==UserScript== // @name 在线听书助手 🎧(WorkBuddy) // @namespace https://github.com/ebook-tts // @version 1.0.4 // @description 在线阅读时朗读网页内容,支持本地系统音色 + 火山引擎 / 腾讯云 TTS 网络音色、语速/音调/音量调节、悬浮球控制面板。快捷键:Alt+S 播放/暂停,Alt+Q 停止,Alt+←/→ 上/下一段,Alt+H 唤回面板。 // @author WorkBuddy // @match *://*/* // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect openspeech.bytedance.com // @connect tts.tencentcloudapi.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; // ===================================================== // 火山引擎 TTS 配置(在面板中填写,无需改代码) // 控制台:https://console.volcengine.com/speech/service/8 // ===================================================== const VOLCANO_CFG = { appid: GM_getValue('volcano_appid', ''), token: GM_getValue('volcano_token', ''), cluster: GM_getValue('volcano_cluster', 'volcano_tts'), }; // ===================================================== // 腾讯云 TTS 配置(在面板中填写,无需改代码) // 控制台:https://console.cloud.tencent.com/tts // ===================================================== const TENCENT_CFG = { secretId: GM_getValue('tencent_secretId', ''), secretKey: GM_getValue('tencent_secretKey', ''), }; // ===================================================== // 腾讯云 TTS 音色列表(基础语音合成接口) // 完整列表:https://cloud.tencent.com/document/product/1073/92668 // ===================================================== const TENCENT_VOICES = [ // ── 超自然大模型音色 ── { id: 502003, name: '智小敏(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 502001, name: '智小柔(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 502005, name: '智小解(超自然·解说男声)', group: '✨ 超自然大模型' }, { id: 502006, name: '智小悟(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 603004, name: '温柔小柠(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 603003, name: '随和老李(超自然·聊天男声)', group: '✨ 超自然大模型' }, { id: 603007, name: '邻家女孩(超自然·聊天女声)', group: '✨ 超自然大模型' }, { id: 603006, name: '沉稳青叔(超自然·聊天男声)', group: '✨ 超自然大模型' }, // ── 大模型音色 ── { id: 501000, name: '智斌(大模型·阅读男声)', group: '🤖 大模型音色' }, { id: 501002, name: '智菊(大模型·阅读女声)', group: '🤖 大模型音色' }, { id: 501001, name: '智兰(大模型·资讯女声)', group: '🤖 大模型音色' }, { id: 601008, name: '爱小豪(大模型·多情感男声)', group: '🤖 大模型音色' }, { id: 601009, name: '爱小芊(大模型·多情感女声)', group: '🤖 大模型音色' }, { id: 601013, name: '爱小伊(大模型·阅读女声)', group: '🤖 大模型音色' }, // ── 精品音色 ── { id: 101001, name: '智瑜(精品·情感女声)', group: '💎 精品音色' }, { id: 101004, name: '智云(精品·通用男声)', group: '💎 精品音色' }, { id: 101011, name: '智燕(精品·新闻女声)', group: '💎 精品音色' }, { id: 101013, name: '智辉(精品·新闻男声)', group: '💎 精品音色' }, { id: 101019, name: '智彤(精品·粤语女声)', group: '💎 精品音色' }, { id: 101016, name: '智甜(精品·女童声)', group: '💎 精品音色' }, { id: 101015, name: '智萌(精品·男童声)', group: '💎 精品音色' }, ]; // ===================================================== // 音色中文名称映射表(本地系统音色) // ===================================================== 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 中文(中国大陆)': '谷歌 普通话(大陆)', }; // ===================================================== // 火山引擎 TTS 网络音色列表 // 更多音色:https://www.volcengine.com/docs/6561/97465 // ===================================================== const ONLINE_VOICES = [ // ── 普通话·女声 ── { id: 'zh_female_shuangkuaisisi_moon_bigtts', name: '爽快思思(普通话·女·活泼)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_tianmeixiaoyuan_moon_bigtts', name: '甜美小源(普通话·女·甜美)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_qingxinnvsheng_moon_bigtts', name: '清新女生(普通话·女·清新)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_wenrouxiaoya_moon_bigtts', name: '温柔小雅(普通话·女·温柔)', group: '🇨🇳 普通话·女声' }, { id: 'zh_female_linjiatong_moon_bigtts', name: '邻家童(普通话·女童·可爱)', group: '🇨🇳 普通话·女声' }, // ── 普通话·男声 ── { id: 'zh_male_qingshuang_moon_bigtts', name: '清爽男声(普通话·男·自然)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_zhubo_moon_bigtts', name: '专业主播(普通话·男·播音)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_chunhou_moon_bigtts', name: '醇厚男声(普通话·男·低沉)', group: '🇨🇳 普通话·男声' }, { id: 'zh_male_dongfang_moon_bigtts', name: '东方男声(普通话·男·磁性)', group: '🇨🇳 普通话·男声' }, // ── 有声书专区 ── { id: 'zh_female_story_moon_bigtts', name: '故事女生(有声书·温情)', group: '📖 有声书专区' }, { id: 'zh_male_story_moon_bigtts', name: '故事男生(有声书·磁性)', group: '📖 有声书专区' }, { id: 'zh_female_yuanshen_moon_bigtts', name: '元神女声(有声书·悬疑)', group: '📖 有声书专区' }, // ── 方言 ── { id: 'zh_female_cantonese_qingxin_bigmodel', name: '粤语·清新女声', group: '🗣️ 方言' }, { id: 'zh_male_cantonese_chunhou_bigmodel', name: '粤语·醇厚男声', group: '🗣️ 方言' }, ]; // ===================================================== // 状态管理 // ===================================================== const state = { isPlaying: false, isPaused: false, utterance: null, audioEl: null, 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'), // 'local' | 'volcano' | 'tencent' 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; border: none !important; outline: none !important; display: flex !important; align-items: center !important; justify-content: center !important; font-size: 24px !important; user-select: none !important; transition: transform 0.2s, box-shadow 0.2s !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !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; border: 2px solid #fff !important; background: #4caf50 !important; display: none !important; } #erp-fab .erp-fab-dot.show { display: block !important; } #erp-fab .erp-fab-dot.playing { background: #7c4dff !important; animation: erp-pulse 1.2s infinite !important; } #erp-fab .erp-fab-dot.paused { background: #ff9800 !important; } @keyframes erp-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; display: block !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-sdot { width: 8px !important; height: 8px !important; border-radius: 50% !important; background: rgba(255,255,255,0.2) !important; flex-shrink: 0 !important; transition: background 0.3s !important; } #erp-panel .erp-sdot.playing { background: #7c4dff !important; box-shadow: 0 0 6px #7c4dff !important; animation: erp-pulse 1.2s infinite !important; } #erp-panel .erp-sdot.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-prog-bar { height: 3px !important; background: rgba(255,255,255,0.1) !important; border-radius: 3px !important; overflow: hidden !important; margin-bottom: 5px !important; } #erp-panel .erp-prog-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-prog-info { display: flex !important; justify-content: space-between !important; font-size: 11px !important; color: rgba(255,255,255,0.38) !important; margin-bottom: 12px !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-tip { font-size: 11px !important; color: rgba(255,255,255,0.4) !important; text-align: center !important; padding: 4px 0 2px !important; display: none !important; } #erp-panel .erp-tip.show { display: block !important; } #erp-panel .erp-tip.err { color: #ff6b6b !important; } #erp-panel .erp-tip.ok { color: #4caf50 !important; } #erp-panel .erp-tip.err.copyable { cursor: pointer !important; text-decoration: underline dotted #ff6b6b !important; } #erp-panel .erp-tip.err.copyable::after { content: ' 📋' !important; font-style: normal !important; } #erp-panel .erp-tip.copied { color: #4caf50 !important; } #erp-panel .erp-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]; } const name = voice.name .replace(/^Microsoft\s+/i, '微软 ') .replace(/^Google\s+/i, '谷歌 '); const langMap = { 'zh-CN':'普通话', 'zh-HK':'粤语', 'zh-TW':'台湾', 'cmn-CN':'普通话', 'yue-HK':'粤语' }; return `${name}(${langMap[voice.lang] || voice.lang})`; } function genUUID() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { const r = (Math.random() * 16) | 0; return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); }); } function base64ToArrayBuffer(b64) { const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); return buf.buffer; } // ===================================================== // 提取正文段落 // ===================================================== 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 })); } // ===================================================== // 加载本地声音列表 // ===================================================== function loadVoices() { return new Promise(resolve => { const v = speechSynthesis.getVoices(); if (v.length) { resolve(v); return; } speechSynthesis.onvoiceschanged = () => resolve(speechSynthesis.getVoices()); setTimeout(() => resolve(speechSynthesis.getVoices()), 2500); }); } // ===================================================== // 网络 TTS —— 火山引擎 // 文档:https://www.volcengine.com/docs/6561/79820 // ===================================================== 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); } } function fetchVolcanoTTS(text, voiceType, onReady, onError) { if (!VOLCANO_CFG.appid || !VOLCANO_CFG.token) { onError('请先在面板中填写火山引擎 AppID 和 Token'); return; } const chunk = text.slice(0, 500); const body = JSON.stringify({ app: { appid: VOLCANO_CFG.appid, token: VOLCANO_CFG.token, cluster: VOLCANO_CFG.cluster, }, user: { uid: 'ebook_tts_user' }, audio: { voice_type: voiceType, encoding: 'mp3', rate: 24000, speed_ratio: state.rate, volume_ratio: state.volume, pitch_ratio: state.pitch, }, request: { reqid: genUUID(), text: chunk, text_type: 'plain', operation: 'query', }, }); GM_xmlhttpRequest({ method: 'POST', url: 'https://openspeech.bytedance.com/api/v1/tts', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer;${VOLCANO_CFG.token}`, }, data: body, timeout: 15000, onload: r => { if (r.status !== 200) { onError('火山引擎 HTTP ' + r.status); return; } let json; try { json = JSON.parse(r.responseText); } catch (e) { onError('返回解析失败'); return; } if (json.code !== 3000) { onError(`火山引擎错误 ${json.code}:${json.message || '未知'}`); return; } if (!json.data) { onError('返回无音频数据'); return; } try { onReady(base64ToArrayBuffer(json.data), 'audio/mpeg'); } catch (e) { onError('音频解码失败'); } }, onerror: () => onError('火山引擎网络错误'), ontimeout: () => onError('火山引擎请求超时'), }); } // ===================================================== // 网络 TTS —— 腾讯云(TC3-HMAC-SHA256 签名) // 文档:https://cloud.tencent.com/document/product/1073/37995 // ===================================================== // 工具:Uint8Array → 十六进制字符串 function buf2hex(buf) { return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join(''); } // SHA-256(返回 hex) async function sha256hex(message) { const enc = new TextEncoder(); const buf = await crypto.subtle.digest('SHA-256', enc.encode(message)); return buf2hex(buf); } // HMAC-SHA256(返回 Uint8Array) async function hmacSHA256raw(keyData, message) { const enc = new TextEncoder(); const keyBuf = typeof keyData === 'string' ? enc.encode(keyData) : keyData; const cryptoKey = await crypto.subtle.importKey( 'raw', keyBuf, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); return new Uint8Array(await crypto.subtle.sign('HMAC', cryptoKey, enc.encode(message))); } // HMAC-SHA256(返回 hex) async function hmacSHA256hex(keyData, message) { return buf2hex(await hmacSHA256raw(keyData, message)); } async function buildTencentAuth(secretId, secretKey, payload) { const service = 'tts'; const host = 'tts.tencentcloudapi.com'; const algorithm = 'TC3-HMAC-SHA256'; const timestamp = Math.floor(Date.now() / 1000); const date = new Date(timestamp * 1000).toISOString().slice(0, 10); // YYYY-MM-DD // Step 1 – 规范请求串 const httpMethod = 'POST'; const canonicalUri = '/'; const canonicalQuery = ''; const canonicalHeaders= `content-type:application/json\nhost:${host}\nx-tc-action:texttovoice\n`; const signedHeaders = 'content-type;host;x-tc-action'; const hashedPayload = await sha256hex(payload); const canonicalReq = [httpMethod, canonicalUri, canonicalQuery, canonicalHeaders, signedHeaders, hashedPayload].join('\n'); // Step 2 – 待签名字符串 const credScope = `${date}/${service}/tc3_request`; const hashedCanReq = await sha256hex(canonicalReq); const strToSign = [algorithm, String(timestamp), credScope, hashedCanReq].join('\n'); // Step 3 – 计算签名 const enc = new TextEncoder(); const secretDate = await hmacSHA256raw(enc.encode('TC3' + secretKey), date); const secretSvc = await hmacSHA256raw(secretDate, service); const secretSig = await hmacSHA256raw(secretSvc, 'tc3_request'); const signature = await hmacSHA256hex(secretSig, strToSign); // Step 4 – 拼装 Authorization const authorization = `${algorithm} Credential=${secretId}/${credScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`; return { authorization, timestamp }; } async function fetchTencentTTS(text, voiceType, onReady, onError) { const { secretId, secretKey } = TENCENT_CFG; if (!secretId || !secretKey) { onError('请先在面板中填写腾讯云 SecretId 和 SecretKey'); return; } // 基础语音合成限制 150 汉字 / 500 字母 const chunk = text.slice(0, 150); // Speed 参数:腾讯云 [-2,6],state.rate 是 [0.5,2.5] → 线性映射到 [-2,6] // rate=1.0 → speed=0;rate=0.5 → speed=-2;rate=2.5 → speed=6 const speed = parseFloat(((state.rate - 1.0) * (8 / 2)).toFixed(1)); const clampedSpeed = Math.min(6, Math.max(-2, speed)); const bodyObj = { Text: chunk, SessionId: genUUID(), VoiceType: voiceType, Codec: 'mp3', SampleRate: 24000, Speed: clampedSpeed, Volume: 0, // 腾讯云音量 [-10,10],默认 0 }; const payload = JSON.stringify(bodyObj); let auth; try { auth = await buildTencentAuth(secretId, secretKey, payload); } catch (e) { onError('腾讯云签名失败:' + e.message); return; } GM_xmlhttpRequest({ method: 'POST', url: 'https://tts.tencentcloudapi.com/', headers: { 'Content-Type': 'application/json', 'Host': 'tts.tencentcloudapi.com', 'X-TC-Action': 'TextToVoice', 'X-TC-Version': '2019-08-23', 'X-TC-Timestamp': String(auth.timestamp), 'Authorization': auth.authorization, }, data: payload, timeout: 15000, onload: r => { if (r.status !== 200) { onError(`腾讯云 HTTP ${r.status}:${r.responseText}`); return; } let json; try { json = JSON.parse(r.responseText); } catch (e) { onError('腾讯云返回解析失败:' + r.responseText); return; } const resp = json.Response; if (!resp) { onError('腾讯云返回格式异常:' + r.responseText); return; } if (resp.Error) { onError(`腾讯云错误 ${resp.Error.Code}:${resp.Error.Message}`); return; } if (!resp.Audio) { onError('腾讯云返回无音频数据:' + r.responseText); return; } try { onReady(base64ToArrayBuffer(resp.Audio), 'audio/mpeg'); } catch (e) { onError('音频解码失败:' + e.message); } }, onerror: () => onError('腾讯云网络错误'), ontimeout: () => onError('腾讯云请求超时'), }); } function fetchOnlineTTS(text, voiceId, onReady, onError) { const engine = state.voiceMode; // 'volcano' or 'tencent' if (engine === 'tencent') { fetchTencentTTS(text, parseInt(voiceId, 10), onReady, onError); } else { const vo = ONLINE_VOICES.find(v => v.id === voiceId) || ONLINE_VOICES[0]; fetchVolcanoTTS(text, vo.id, onReady, onError); } } // ===================================================== // 播放控制 // ===================================================== function speak(index) { if (index < 0 || index >= state.paragraphs.length) { stopAll(); return; } abortCurrent(); state.currentIndex = index; state.isPlaying = true; state.isPaused = false; doHighlight(); scrollToEl(state.paragraphs[index].el); 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 !== 'local') { const voiceId = state.selectedVoiceId || (state.voiceMode === 'tencent' ? String(TENCENT_VOICES[0].id) : ONLINE_VOICES[0].id); const engineLabel = state.voiceMode === 'tencent' ? '腾讯云' : '火山引擎'; setTip(` ${engineLabel}合成中…`); let cancelled = false; state.onlineAbort = () => { cancelled = true; }; fetchOnlineTTS(text, voiceId, (buf, mime) => { if (cancelled || !state.isPlaying || state.currentIndex !== index) return; setTip('✅ 合成成功', 'ok'); playAudioBuffer(buf, mime, onEnd, () => { if (state.isPlaying) onErr(); }); }, (errMsg) => { if (cancelled || !state.isPlaying) return; setTip('⚠ ' + (errMsg || '合成失败') + ',降级本地音色', '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) { showToast('未找到可朗读的正文,请在文章页面使用'); return; } speak(state.currentIndex || 0); return; } updateUI(); } function stopAll() { abortCurrent(); state.isPlaying = false; state.isPaused = false; clearHighlight(); setTip(''); 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 // ===================================================== function createUI() { if (document.getElementById('erp-fab')) return; // 悬浮球 const fab = document.createElement('button'); fab.id = 'erp-fab'; fab.title = '听书助手(Alt+S 播放)'; fab.innerHTML = `🎧`; 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(); // 初始化时同步配置区可见性 const volcanoToggle = document.getElementById('erp-volcano-toggle'); const tencentToggle = document.getElementById('erp-tencent-toggle'); if (volcanoToggle) volcanoToggle.style.display = state.voiceMode === 'volcano' ? '' : 'none'; if (tencentToggle) tencentToggle.style.display = state.voiceMode === 'tencent' ? '' : 'none'; }); bindUIEvents(fab, panel); makeDraggable(panel, panel.querySelector('#erp-hd')); } // ===================================================== // 渲染音色下拉 // ===================================================== function renderVoiceSelect() { const sel = document.getElementById('erp-voice'); const tip = document.getElementById('erp-tip'); const pitchRow = document.getElementById('erp-pitch-row'); if (!sel) return; sel.innerHTML = ''; if (state.voiceMode === 'local') { if (tip) { tip.className = 'erp-tip'; tip.textContent = ''; } 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.voices.find(v => v.voiceURI === state.selectedVoiceId)) { const first = zh[0] || state.voices[0]; if (first) { state.selectedVoiceId = first.voiceURI; sel.value = first.voiceURI; } } else { sel.value = state.selectedVoiceId; } // 隐藏所有在线 Key 配置区 ['erp-volcano-form','erp-volcano-toggle','erp-tencent-form','erp-tencent-toggle'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); } else if (state.voiceMode === 'volcano') { // 火山引擎网络音色 if (pitchRow) pitchRow.style.display = 'none'; const hasKey = VOLCANO_CFG.appid && VOLCANO_CFG.token; if (tip) { tip.className = 'erp-tip show' + (hasKey ? '' : ' err'); tip.innerHTML = hasKey ? '🌋 火山引擎 TTS · 已配置 AppID' : '⚠ 未配置 AppID/Token,请展开下方设置'; } // 按 group 分组渲染 const groupMap = {}; ONLINE_VOICES.forEach(v => { if (!groupMap[v.group]) groupMap[v.group] = []; groupMap[v.group].push(v); }); Object.entries(groupMap).forEach(([label, list]) => { 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; // 隐藏腾讯云、显示火山配置区 ['erp-tencent-form','erp-tencent-toggle'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); renderVolcanoKeyForm(); const toggle = document.getElementById('erp-volcano-toggle'); if (toggle) toggle.style.display = ''; } else if (state.voiceMode === 'tencent') { // 腾讯云网络音色 if (pitchRow) pitchRow.style.display = 'none'; const hasKey = TENCENT_CFG.secretId && TENCENT_CFG.secretKey; if (tip) { tip.className = 'erp-tip show' + (hasKey ? '' : ' err'); tip.innerHTML = hasKey ? '🐧 腾讯云 TTS · 已配置 SecretId' : '⚠ 未配置 SecretId/SecretKey,请展开下方设置'; } // 按 group 分组渲染 const tGroupMap = {}; TENCENT_VOICES.forEach(v => { if (!tGroupMap[v.group]) tGroupMap[v.group] = []; tGroupMap[v.group].push(v); }); Object.entries(tGroupMap).forEach(([label, list]) => { const og = document.createElement('optgroup'); og.label = label; list.forEach(v => { const o = document.createElement('option'); o.value = String(v.id); o.textContent = v.name; if (String(v.id) === state.selectedVoiceId) o.selected = true; og.appendChild(o); }); sel.appendChild(og); }); const firstId = String(TENCENT_VOICES[0].id); if (!state.selectedVoiceId || !TENCENT_VOICES.find(v => String(v.id) === state.selectedVoiceId)) { state.selectedVoiceId = firstId; } sel.value = state.selectedVoiceId; // 隐藏火山引擎、显示腾讯云配置区 ['erp-volcano-form','erp-volcano-toggle'].forEach(id => { const el = document.getElementById(id); if (el) el.style.display = 'none'; }); renderTencentKeyForm(); const toggle = document.getElementById('erp-tencent-toggle'); if (toggle) toggle.style.display = ''; } } // ===================================================== // 火山引擎 Key 配置表单 // ===================================================== function renderVolcanoKeyForm() { if (document.getElementById('erp-volcano-form')) return; const settings = document.querySelector('#erp-panel .erp-settings'); if (!settings) return; const div = document.createElement('div'); div.id = 'erp-volcano-form'; div.style.cssText = 'display:none'; div.innerHTML = `
🔑 AppID
🔒 Access Token
🗄 Cluster(默认 volcano_tts)
获取 Key
`; settings.appendChild(div); // 折叠开关按钮 const toggleBtn = document.createElement('div'); toggleBtn.id = 'erp-volcano-toggle'; toggleBtn.style.cssText = 'text-align:center;padding:4px 0 10px;cursor:pointer;font-size:11px;color:rgba(255,255,255,0.38);display:none'; toggleBtn.textContent = '▼ 展开 API 设置'; settings.appendChild(toggleBtn); toggleBtn.addEventListener('click', () => { const shown = div.style.display !== 'none'; div.style.display = shown ? 'none' : 'block'; toggleBtn.textContent = shown ? '▼ 展开 API 设置' : '▲ 收起 API 设置'; }); document.getElementById('erp-v-save').addEventListener('click', () => { const appid = document.getElementById('erp-v-appid').value.trim(); const token = document.getElementById('erp-v-token').value.trim(); const cluster = document.getElementById('erp-v-cluster').value.trim() || 'volcano_tts'; const msg = document.getElementById('erp-v-msg'); if (!appid || !token) { msg.className = 'erp-tip show err'; msg.textContent = 'AppID 和 Token 不能为空'; return; } GM_setValue('volcano_appid', appid); GM_setValue('volcano_token', token); GM_setValue('volcano_cluster', cluster); VOLCANO_CFG.appid = appid; VOLCANO_CFG.token = token; VOLCANO_CFG.cluster = cluster; msg.className = 'erp-tip show ok'; msg.textContent = '✅ 配置已保存!'; setTimeout(() => { msg.className = 'erp-tip'; }, 2500); setTip(''); }); } // ===================================================== // 腾讯云 Key 配置表单 // ===================================================== function renderTencentKeyForm() { if (document.getElementById('erp-tencent-form')) return; const settings = document.querySelector('#erp-panel .erp-settings'); if (!settings) return; const div = document.createElement('div'); div.id = 'erp-tencent-form'; div.style.cssText = 'display:none'; div.innerHTML = `
🔑 SecretId
🔒 SecretKey
获取 Key
`; settings.appendChild(div); // 折叠开关按钮 const toggleBtn = document.createElement('div'); toggleBtn.id = 'erp-tencent-toggle'; toggleBtn.style.cssText = 'text-align:center;padding:4px 0 10px;cursor:pointer;font-size:11px;color:rgba(255,255,255,0.38);display:none'; toggleBtn.textContent = '▼ 展开 API 设置'; settings.appendChild(toggleBtn); toggleBtn.addEventListener('click', () => { const shown = div.style.display !== 'none'; div.style.display = shown ? 'none' : 'block'; toggleBtn.textContent = shown ? '▼ 展开 API 设置' : '▲ 收起 API 设置'; }); document.getElementById('erp-t-save').addEventListener('click', () => { const sid = document.getElementById('erp-t-sid').value.trim(); const skey = document.getElementById('erp-t-skey').value.trim(); const msg = document.getElementById('erp-t-msg'); if (!sid || !skey) { msg.className = 'erp-tip show err'; msg.textContent = 'SecretId 和 SecretKey 不能为空'; return; } GM_setValue('tencent_secretId', sid); GM_setValue('tencent_secretKey', skey); TENCENT_CFG.secretId = sid; TENCENT_CFG.secretKey = skey; msg.className = 'erp-tip show ok'; msg.textContent = '✅ 配置已保存!'; setTimeout(() => { msg.className = 'erp-tip'; }, 2500); setTip(''); }); } // ===================================================== // ===================================================== function setTip(html, cls) { const tip = document.getElementById('erp-tip'); if (!tip) return; // 清除旧的复制监听 if (tip._copyHandler) { tip.removeEventListener('click', tip._copyHandler); tip._copyHandler = null; } if (!html) { if (state.voiceMode === 'volcano') { const hasKey = VOLCANO_CFG.appid && VOLCANO_CFG.token; tip.className = 'erp-tip show' + (hasKey ? '' : ' err'); tip.innerHTML = hasKey ? '🌋 火山引擎 TTS · 已配置 AppID' : '⚠ 未配置 AppID/Token,请展开下方设置'; } else if (state.voiceMode === 'tencent') { const hasKey = TENCENT_CFG.secretId && TENCENT_CFG.secretKey; tip.className = 'erp-tip show' + (hasKey ? '' : ' err'); tip.innerHTML = hasKey ? '🐧 腾讯云 TTS · 已配置 SecretId' : '⚠ 未配置 SecretId/SecretKey,请展开下方设置'; } else { tip.className = 'erp-tip'; } return; } tip.className = 'erp-tip show' + (cls ? ' ' + cls : ''); tip.innerHTML = html; // 错误提示:点击可复制 if (cls === 'err') { tip.classList.add('copyable'); tip.title = '点击复制错误信息'; const rawText = tip.textContent; const handler = () => { navigator.clipboard.writeText(rawText).then(() => { const prev = tip.className; const prevHtml = tip.innerHTML; tip.className = 'erp-tip show copied'; tip.textContent = '✅ 已复制!'; setTimeout(() => { tip.className = prev; tip.innerHTML = prevHtml; }, 1500); }).catch(() => { // 降级:prompt 让用户手动复制 prompt('复制错误信息:', rawText); }); }; tip._copyHandler = handler; tip.addEventListener('click', handler); } } // ===================================================== // 绑定 UI 事件 // ===================================================== 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) && !fab.contains(e.target)) { state.panelOpen = false; panel.classList.remove('open'); } }, true); } // ===================================================== // 更新 UI 状态显示 // ===================================================== function updateUI() { const playBtn = document.getElementById('erp-play'); const sdot = 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 sc = state.isPlaying ? 'playing' : state.isPaused ? 'paused' : ''; if (playBtn) { playBtn.textContent = state.isPlaying ? '⏸' : '▶'; playBtn.title = state.isPlaying ? '暂停' : state.isPaused ? '继续' : '播放'; } if (sdot) sdot.className = 'erp-sdot' + (sc ? ' ' + sc : ''); if (fabDot) fabDot.className = 'erp-fab-dot' + (sc ? ' show ' + sc : ''); 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); }); } // ===================================================== // Toast 提示 // ===================================================== function showToast(msg) { let el = document.getElementById('erp-toast'); if (!el) { el = document.createElement('div'); el.id = 'erp-toast'; Object.assign(el.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(el); } el.textContent = msg; el.style.opacity = '1'; clearTimeout(el._t); el._t = setTimeout(() => { el.style.opacity = '0'; }, 2500); } // ===================================================== // 键盘快捷键 // ===================================================== document.addEventListener('keydown', e => { if (!e.altKey) return; if (e.key === 's' || e.key === 'S') { e.preventDefault(); if (!state.isPlaying && !state.isPaused) { state.paragraphs = extractParagraphs(); state.currentIndex = 0; } pauseResume(); } else if (e.key === 'q' || e.key === 'Q') { e.preventDefault(); stopAll(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); prevPara(); } else if (e.key === 'ArrowRight') { e.preventDefault(); nextPara(); } else if (e.key === 'h' || e.key === 'H') { e.preventDefault(); guardUI(); showToast('听书助手已唤回 🎧'); } }); // ===================================================== // 守护(防止 SPA 路由跳转导致悬浮球消失) // ===================================================== function guardUI() { if (!document.body.contains(document.getElementById('erp-fab'))) { document.getElementById('erp-panel')?.remove(); createUI(); } } const bodyObserver = new MutationObserver(() => { if (!document.getElementById('erp-fab')) guardUI(); }); function startGuard() { createUI(); bodyObserver.observe(document.body, { childList: true, subtree: false }); setInterval(guardUI, 3000); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startGuard); } else { startGuard(); } })();