/** * 心经 / 金刚经 诵读页前端逻辑:经文渲染、分页阅读、逐段高亮朗读、音色选择。 */ // ===== 经文数据 ===== // segments 项:{ h: 品名(可选), g: 品名小注(可选), t: 正文 } // ===== 经文数据(外置) ===== // 每部经对应 sutras/.json:{id, name, title, seal, cat, translator, source, sourceUrl, pageSize, segments[{h?,g?,t}]} // 启动时经 /api/sutras 拉目录清单建下拉,选中时按需加载经文并缓存。 // 新增经典 = 往 sutras/ 目录放一个 JSON 文件(order 字段决定排序),无需改动任何代码。 const SUTRAS = []; // 目录清单 [{id,name,cat,order}],启动时填充 const sutraDataCache = new Map(); // id → 完整经文数据(加载一次后复用) let selectToken = 0; // 切换令牌,防止快速连续切换时旧响应覆盖新选择 // ===== 可选音色(模型 qwen3-tts-flash) ===== const VOICE_GROUPS = [ { label: "诵读音色", voices: [ ["Seren", "Seren 塞伦 · 女声"], ["Ethan", "Ethan 伊森 · 男声"], ], }, ]; const sutraEl = document.getElementById("sutra"); const sutraSelect = document.getElementById("sutra-select"); const voiceSelect = document.getElementById("voice-select"); const btnPlay = document.getElementById("btn-play"); const statusEl = document.getElementById("status"); const progressEl = document.getElementById("progress"); const audioEl = document.getElementById("audio"); const btnLayout = document.getElementById("btn-layout"); const pagerEl = document.getElementById("pager"); const playerCard = document.getElementById("player-card"); const btnExpand = document.getElementById("btn-expand"); const btnPrev = document.getElementById("btn-prev"); const btnNext = document.getElementById("btn-next"); const btnLoop = document.getElementById("btn-loop"); const btnBgm = document.getElementById("btn-bgm"); const timerSelect = document.getElementById("timer-select"); const rateSelect = document.getElementById("rate-select"); const btnNight = document.getElementById("btn-night"); const sleepRemainEl = document.getElementById("sleep-remaining"); const btnJumpNow = document.getElementById("btn-jump-now"); const pageInfo = document.getElementById("page-info"); /** 音频缓存:key = `${经id}|${音色}|${段序号}`,value = Blob */ const audioCache = new Map(); let playToken = 0; // 播放会话令牌,防止旧异步任务串扰 let playing = false; let inflight = null; // 进行中的 fetch AbortController let loopMode = false; // 循环诵读开关:播完全文回到开头继续 let bgmOn = false; // 背景音乐开关 let sleepEndAt = 0; // 定时停止的截止时间戳(毫秒),0 = 未开启 let sleepTimer = null; // 定时器句柄(前台兜底;后台由 onTimeUpdate 判定截止时间) let loopAutoByTimer = false; // 循环是否为设定定时时自动打开的(是则定时结束一并关回) // ===== 用户偏好记忆(localStorage):经、音色、字号、循环、配乐、语速、夜间 ===== const PREFS_KEY = "fj.prefs.v1"; const prefs = (() => { try { return JSON.parse(localStorage.getItem(PREFS_KEY)) || {}; } catch { return {}; } })(); /** 写一条偏好并落盘;localStorage 不可用时静默降级为仅内存。 */ function savePref(key, value) { prefs[key] = value; try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch { /* 隐私模式等场景忽略 */ } } let segTimes = []; // 合并音频中各段的起始秒 let currentSeg = -1; // 正在朗读的段号 let lastUserScroll = 0; // 用户最近一次手动滚动经文的时间戳(用于暂缓跟读强拽) let cursorRaf = null; // 逐字光标 rAF 句柄 // ===== 背景音乐:Web Audio 实时合成颂钵泛音 drone(零资源文件、无缝循环) ===== let bgmCtx = null; let bgmGain = null; let bgmNodes = []; let bgmBellTimer = null; let bgmDrumTimer = null; let bgmChimeTimer = null; let bgmGlideTimer = null; let bgmOscs = []; // {osc, ratio},供基频滑变统一改频率 const BGM_ROOTS = [55, 65.41, 73.42, 82.41, 98]; // A1 C2 D2 E2 G2,低音区垫底 let bgmRoot = 73.42; /** 基频慢移:只滑向相邻根音、16 秒缓慢过渡,变化若有似无,避免警报式的滑音。 */ function bgmGlide() { const idx = BGM_ROOTS.indexOf(bgmRoot); const neighbors = [idx - 1, idx + 1].filter((i) => i >= 0 && i < BGM_ROOTS.length); bgmRoot = BGM_ROOTS[neighbors[Math.floor(Math.random() * neighbors.length)]]; const t = bgmCtx.currentTime; for (const { osc, ratio } of bgmOscs) { osc.frequency.cancelScheduledValues(t); osc.frequency.setValueAtTime(osc.frequency.value, t); osc.frequency.exponentialRampToValueAtTime(bgmRoot * ratio, t + 16); } bgmGlideTimer = setTimeout(() => { if (bgmGain) bgmGlide(); }, 50000 + Math.random() * 40000); } /** 给打击声部接随机方位的输出链,返回挂到主增益下的 GainNode。 */ function bgmStrikeOut(panRange) { const out = bgmCtx.createGain(); if (bgmCtx.createStereoPanner) { const pan = bgmCtx.createStereoPanner(); pan.pan.value = (Math.random() - 0.5) * 2 * panRange; out.connect(pan); pan.connect(bgmGain); } else { out.connect(bgmGain); } return out; } /** 晨钟一声:大钟的非谐泛音簇,毫秒级起音,泛音越高衰减越快(长达十余秒)。 */ function bgmBell() { const now = bgmCtx.currentTime; const root = [98, 110, 123.47][Math.floor(Math.random() * 3)]; // G2 / A2 / B2 附近的大钟 const bellGain = bgmStrikeOut(0.8); bellGain.gain.setValueAtTime(0, now); bellGain.gain.linearRampToValueAtTime(0.5, now + 0.02); // 大钟泛音列:哼音 0.5、基音 1、小三度 1.19、五度 1.5、名义音 2、上泛音 2.74 // [频率比, 振幅, 衰减秒数] —— 低频泛音余韵更长 [[0.5, 0.5, 15], [1, 1, 13], [1.19, 0.3, 9], [1.5, 0.24, 7], [2.0, 0.18, 5], [2.74, 0.1, 3.5]].forEach(([ratio, amp, dur]) => { const o = bgmCtx.createOscillator(); o.type = "sine"; o.frequency.value = root * ratio; o.detune.value = (Math.random() - 0.5) * 5; const og = bgmCtx.createGain(); og.gain.setValueAtTime(amp, now); og.gain.exponentialRampToValueAtTime(0.0001, now + dur); o.connect(og); og.connect(bellGain); o.start(now); o.stop(now + dur + 0.5); bgmNodes.push(o); }); } /** 磬声一声:高音引磬,快起音 + 约 6 秒衰减的小泛音簇,稀疏点缀。 */ function bgmChime() { const now = bgmCtx.currentTime; const freq = [392, 523.25, 659.25, 783.99][Math.floor(Math.random() * 4)]; // 五声音阶附近 const chimeGain = bgmStrikeOut(0.6); chimeGain.gain.setValueAtTime(0, now); chimeGain.gain.linearRampToValueAtTime(0.35, now + 0.03); chimeGain.gain.exponentialRampToValueAtTime(0.0001, now + 6); [[1, 1], [2.76, 0.28], [5.4, 0.08]].forEach(([ratio, amp]) => { const o = bgmCtx.createOscillator(); o.type = "sine"; o.frequency.value = freq * ratio; o.detune.value = (Math.random() - 0.5) * 6; const og = bgmCtx.createGain(); og.gain.value = amp; o.connect(og); og.connect(chimeGain); o.start(now); o.stop(now + 6.5); bgmNodes.push(o); }); } /** 暮鼓一记:低频膜鸣(72→44Hz 下滑)+ 短促噪声击边,钝而沉。 */ function bgmDrum() { const now = bgmCtx.currentTime; const drumGain = bgmStrikeOut(0.5); drumGain.gain.setValueAtTime(0, now); drumGain.gain.linearRampToValueAtTime(0.55, now + 0.012); drumGain.gain.exponentialRampToValueAtTime(0.0001, now + 1.8); // 鼓皮基音:起振约 72Hz,迅速下滑到 44Hz const o = bgmCtx.createOscillator(); o.type = "sine"; o.frequency.setValueAtTime(72, now); o.frequency.exponentialRampToValueAtTime(44, now + 0.3); o.connect(drumGain); o.start(now); o.stop(now + 2); bgmNodes.push(o); // 击边噪声:0.08s 噪声过 260Hz 带通,模拟鼓槌触皮的一声"扑" const len = Math.floor(bgmCtx.sampleRate * 0.08); const buf = bgmCtx.createBuffer(1, len, bgmCtx.sampleRate); const data = buf.getChannelData(0); for (let i = 0; i < len; i += 1) data[i] = Math.random() * 2 - 1; const noise = bgmCtx.createBufferSource(); noise.buffer = buf; const bp = bgmCtx.createBiquadFilter(); bp.type = "bandpass"; bp.frequency.value = 260; bp.Q.value = 0.8; const ng = bgmCtx.createGain(); ng.gain.setValueAtTime(0.4, now); ng.gain.exponentialRampToValueAtTime(0.0001, now + 0.15); noise.connect(bp); bp.connect(ng); ng.connect(drumGain); noise.start(now); bgmNodes.push(noise); } /** * 开始播放背景音乐:低频颂钵式泛音 drone 垫底(基频缓慢游走), * 叠稀疏的晨钟(大钟非谐泛音簇、长余韵)、暮鼓(低频膜鸣 + 击边噪声) * 与引磬(高音泛音点缀),三种寺院打击声部随机方位错开出现, * 方位随机、音量压在人声之下;2.5 秒淡入。需在用户手势内调用。 */ function startBgm() { if (!bgmCtx) bgmCtx = new (window.AudioContext || window.webkitAudioContext)(); if (bgmCtx.state === "suspended") bgmCtx.resume(); const now = bgmCtx.currentTime; const g = bgmCtx.createGain(); g.gain.setValueAtTime(0, now); // 低通只做轻微软化,保留高频空气感(太低会发闷) const lp = bgmCtx.createBiquadFilter(); lp.type = "lowpass"; lp.frequency.value = 3200; lp.Q.value = 0.4; g.connect(lp); lp.connect(bgmCtx.destination); const base = bgmRoot; // 初始根音,之后由 bgmGlide 慢移 const partials = [[1, 0.45], [2.02, 0.2], [3.01, 0.13], [4.16, 0.08], [5.43, 0.05], [7.2, 0.025]]; const nodes = []; bgmOscs = []; partials.forEach(([ratio, amp], pi) => { const og = bgmCtx.createGain(); og.gain.value = amp; // 泛音左右宽展开 + 极慢声像漂移,空间缓慢呼吸 if (bgmCtx.createStereoPanner) { const pan = bgmCtx.createStereoPanner(); const side = pi % 2 === 0 ? -1 : 1; pan.pan.value = side * 0.6; og.connect(pan); pan.connect(g); const plfo = bgmCtx.createOscillator(); plfo.frequency.value = 0.02 + Math.random() * 0.03; const plg = bgmCtx.createGain(); plg.gain.value = 0.3; plfo.connect(plg); plg.connect(pan.pan); plfo.start(now + pi * 0.5); nodes.push(plfo); } else { og.connect(g); } // 双振荡器 ±3 音分失谐,产生颂钵的拍频颤动 for (const det of [-3, 3]) { const osc = bgmCtx.createOscillator(); osc.type = "sine"; osc.frequency.value = base * ratio; osc.detune.value = det + (Math.random() - 0.5) * 2; osc.connect(og); osc.start(now + Math.random() * 0.3); nodes.push(osc); bgmOscs.push({ osc, ratio }); } // 每个泛音带独立慢速呼吸,避免死板 const lfo = bgmCtx.createOscillator(); lfo.frequency.value = 0.04 + Math.random() * 0.07; const lg = bgmCtx.createGain(); lg.gain.value = amp * 0.4; lfo.connect(lg); lg.connect(og.gain); lfo.start(now); nodes.push(lfo); }); g.gain.linearRampToValueAtTime(0.07, now + 2.5); // 淡入,drone 压低垫底 bgmGain = g; bgmNodes = nodes; // 晨钟:淡入后 4 秒先撞一声,之后每 30~55 秒一声 const scheduleBell = () => { bgmBellTimer = setTimeout(() => { if (!bgmGain) return; bgmBell(); scheduleBell(); }, 30000 + Math.random() * 25000); }; bgmBellTimer = setTimeout(() => { if (!bgmGain) return; bgmBell(); scheduleBell(); }, 4000); // 暮鼓:每 45~80 秒一记,偶尔连击两记(间隔约 2.4s) const scheduleDrum = () => { bgmDrumTimer = setTimeout(() => { if (!bgmGain) return; bgmDrum(); if (Math.random() < 0.45) setTimeout(() => { if (bgmGain) bgmDrum(); }, 2400); scheduleDrum(); }, 45000 + Math.random() * 35000); }; scheduleDrum(); // 引磬:最稀疏,每 60~100 秒一声清越点缀 const scheduleChime = () => { bgmChimeTimer = setTimeout(() => { if (!bgmGain) return; bgmChime(); scheduleChime(); }, 60000 + Math.random() * 40000); }; scheduleChime(); // 首次基频滑变:约 50 秒后 bgmGlideTimer = setTimeout(() => { if (bgmGain) bgmGlide(); }, 50000); } /** 停止背景音乐:1.5 秒淡出后停掉振荡器、钟鼓调度器并断开节点。 */ function stopBgm() { if (bgmBellTimer) { clearTimeout(bgmBellTimer); bgmBellTimer = null; } if (bgmDrumTimer) { clearTimeout(bgmDrumTimer); bgmDrumTimer = null; } if (bgmChimeTimer) { clearTimeout(bgmChimeTimer); bgmChimeTimer = null; } if (bgmGlideTimer) { clearTimeout(bgmGlideTimer); bgmGlideTimer = null; } if (!bgmCtx || !bgmGain) return; const g = bgmGain; const nodes = bgmNodes; const now = bgmCtx.currentTime; g.gain.cancelScheduledValues(now); g.gain.setValueAtTime(g.gain.value, now); g.gain.linearRampToValueAtTime(0, now + 1.5); bgmGain = null; bgmNodes = []; setTimeout(() => { nodes.forEach((n) => { try { n.stop(); } catch { /* 已停止 */ } }); g.disconnect(); }, 1700); } let sutra = null; // 当前经 let segments = []; // 当前经分段 let currentPage = 0; let pageCount = 1; let segEls = []; let dots = []; const currentVoice = () => voiceSelect.value; const pageOf = (i) => Math.floor(i / sutra.pageSize); // ===== 音色下拉 ===== VOICE_GROUPS.forEach((group) => { const target = VOICE_GROUPS.length > 1 ? voiceSelect.appendChild(Object.assign(document.createElement("optgroup"), { label: group.label })) : voiceSelect; group.voices.forEach(([value, label]) => { const opt = document.createElement("option"); opt.value = value; opt.textContent = label; target.appendChild(opt); }); }); voiceSelect.value = "Seren"; // ===== 经典下拉:按 cat 分组(佛经 / 道家),清单来自 /api/sutras ===== function buildSutraOptions() { sutraSelect.innerHTML = ""; const cats = [...new Set(SUTRAS.map((s) => s.cat || "其他"))]; cats.forEach((cat) => { const group = sutraSelect.appendChild(Object.assign(document.createElement("optgroup"), { label: cat })); SUTRAS.filter((s) => (s.cat || "其他") === cat).forEach((s) => { const opt = document.createElement("option"); opt.value = s.id; opt.textContent = s.name; group.appendChild(opt); }); }); } // ===== 状态 / 高亮 ===== let statusBase = ""; // 状态主干文字;定时开启时自动追加剩余倒计时 function setStatus(text, isError = false) { statusBase = text; statusEl.classList.toggle("error", isError); renderStatus(); } /** 渲染状态条:主干 + 定时剩余 mm:ss;移动端同步到播放旁的剩余胶囊。 */ function renderStatus() { let remain = ""; if (sleepEndAt) { const s = Math.max(0, Math.ceil((sleepEndAt - Date.now()) / 1000)); remain = `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; } statusEl.textContent = statusBase + (remain ? ` · ${remain} 后停` : ""); sleepRemainEl.textContent = remain ? `⏱ ${remain}` : ""; sleepRemainEl.classList.toggle("show", !!sleepEndAt); } setInterval(renderStatus, 1000); function setPlayingUI(on) { playing = on; btnPlay.classList.toggle("playing", on); btnPlay.setAttribute("aria-label", on ? "停止" : "播放"); btnPlay.querySelector(".play-label").textContent = on ? "停 止" : "播 放"; if ("mediaSession" in navigator) navigator.mediaSession.playbackState = on ? "playing" : "none"; } /** 更新锁屏/控制中心元数据:经名 + 音色 + 印章封面,并接管播放控制按键。 */ function updateMediaSession() { if (!("mediaSession" in navigator) || !sutra) return; navigator.mediaSession.metadata = new MediaMetadata({ title: `《${sutra.name}》诵读`, artist: currentVoice(), album: sutra.title, artwork: [ { src: "icon-180.png", sizes: "180x180", type: "image/png" }, { src: "icon.svg", sizes: "any", type: "image/svg+xml" }, ], }); } function highlight(index) { segEls.forEach((el) => { const i = Number(el.dataset.index); el.classList.toggle("playing", i === index); el.classList.toggle("done", i < index); }); dots.forEach((d, i) => { d.classList.toggle("now", i === index); d.classList.toggle("done", index >= 0 && i < index); }); // 跟读滚动:定位到光标所在行(而非整段中心); // 用户刚手动滚动过(6 秒内)则不打断阅读,改由"回到诵读处"按钮兜底 const active = segEls.find((el) => Number(el.dataset.index) === index); const userBusy = playing && Date.now() - lastUserScroll < 6000; if (active && !userBusy) { const target = active.querySelector(".ch.cur") || active; target.scrollIntoView({ behavior: reduceMotionMQ.matches ? "auto" : "smooth", block: "center", inline: "center" }); } requestAnimationFrame(updateJumpNow); } /** 诵读光标是否在经文视区内;不在则显示"回到诵读处"浮动按钮。 */ function updateJumpNow() { if (!playing || currentSeg < 0) { btnJumpNow.hidden = true; return; } const active = segEls.find((el) => Number(el.dataset.index) === currentSeg); if (!active) { btnJumpNow.hidden = false; // 当前段不在本页,提示可跳回 return; } const target = active.querySelector(".ch.cur") || active; const r = target.getBoundingClientRect(); const c = sutraEl.getBoundingClientRect(); btnJumpNow.hidden = r.top >= c.top && r.bottom <= c.bottom; } function clearHighlight() { segEls.forEach((el) => { el.classList.remove("playing", "done"); el.querySelectorAll(".ch").forEach((s) => s.classList.remove("read", "cur")); delete el.dataset.cursorN; }); dots.forEach((d) => d.classList.remove("now", "done")); if (cursorRaf) { cancelAnimationFrame(cursorRaf); cursorRaf = null; } } const reduceMotionMQ = matchMedia("(prefers-reduced-motion: reduce)"); // ===== 渲染 ===== function renderDots() { progressEl.innerHTML = ""; dots = segments.map((_, i) => { const d = document.createElement("span"); d.className = "dot"; d.title = `第 ${i + 1} 段,点击起读`; d.addEventListener("click", () => playFrom(i)); progressEl.appendChild(d); return d; }); } /** 渲染当前页的经文分段(含品名标题)。 */ function renderPage(page) { currentPage = page; sutraEl.innerHTML = ""; const start = page * sutra.pageSize; const end = Math.min(start + sutra.pageSize, segments.length); segEls = []; for (let i = start; i < end; i += 1) { const seg = segments[i]; if (seg.h) { const h = document.createElement("h4"); h.className = "chap"; h.innerHTML = ""; const name = document.createElement("span"); name.className = "chap-name"; name.textContent = seg.h; h.appendChild(name); if (seg.g) { const gloss = document.createElement("span"); gloss.className = "chap-gloss"; gloss.textContent = seg.g; h.appendChild(gloss); } sutraEl.appendChild(h); } const p = document.createElement("p"); p.className = "seg"; p.dataset.index = i; p.textContent = seg.t; p.setAttribute("role", "button"); p.setAttribute("tabindex", "0"); const label = seg.h ? seg.h : `第 ${i + 1} 段`; p.setAttribute("aria-label", `朗读:${label}`); p.addEventListener("click", () => playFrom(i)); p.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); playFrom(i); } }); sutraEl.appendChild(p); segEls.push(p); } sutraEl.scrollTop = 0; sutraEl.scrollLeft = 0; updatePager(); } function updatePager() { const multi = pageCount > 1; pagerEl.hidden = !multi; pageInfo.textContent = `第 ${currentPage + 1} / ${pageCount} 页`; btnPrev.disabled = currentPage === 0; btnNext.disabled = currentPage === pageCount - 1; } /** 手动翻页(停止当前朗读)。 */ function gotoPage(page) { if (page < 0 || page >= pageCount || page === currentPage) return; stopPlayback(); renderPage(page); setStatus(`已翻到第 ${page + 1} / ${pageCount} 页`); } /** 切换经文:按需加载 sutras/.json 并缓存;selectToken 防止快速切换时旧响应覆盖。 */ async function selectSutra(id) { const meta = SUTRAS.find((s) => s.id === id); if (!meta || (sutra && sutra.id === id)) return; const token = ++selectToken; if (!sutraDataCache.has(id)) { setStatus(`正在载入《${meta.name}》…`); try { const resp = await fetch(`sutras/${id}.json`); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); sutraDataCache.set(id, await resp.json()); } catch (err) { if (token === selectToken) setStatus(`《${meta.name}》载入失败:${err.message}`, true); return; } } if (token !== selectToken) return; stopPlayback(); sutra = sutraDataCache.get(id); savePref("sutra", id); segments = sutra.segments; pageCount = Math.ceil(segments.length / sutra.pageSize); document.title = sutra.name; document.getElementById("sutra-title").textContent = sutra.title; document.getElementById("sutra-sub").textContent = sutra.translator; const sealEl = document.getElementById("seal"); sealEl.textContent = sutra.seal; sealEl.dataset.chars = sutra.seal.length; const src = document.getElementById("sutra-source"); src.textContent = sutra.source; src.href = sutra.sourceUrl; sutraSelect.value = id; sheetSyncFns.get(sutraSelect)?.(); renderDots(); renderPage(0); setStatus(`已切换到《${sutra.name}》· 共 ${segments.length} 段${pageCount > 1 ? ` / ${pageCount} 页` : ""}`); } // ===== 播放控制 ===== /** 取消进行中的请求与播放,令牌递增使旧循环失效。 */ function stopPlayback() { playToken += 1; setPlayingUI(false); btnPlay.classList.remove("preparing"); if (inflight) { inflight.abort(); inflight = null; } audioEl.pause(); audioEl.ontimeupdate = null; audioEl.onended = null; stopBgm(); clearHighlight(); btnJumpNow.hidden = true; currentSeg = -1; } /** * 获取指定段的音频 Blob,命中缓存则直接返回。 * 失败自动重试(最多 3 次,间隔 1s/2s 递增),吸收服务重启等瞬时 502; * 用户停止播放导致的 AbortError 不重试。 * @param {number} index 段序号 * @returns {Promise} 音频数据 */ async function fetchSegment(index) { const key = `${sutra.id}|${currentVoice()}|${index}`; if (audioCache.has(key)) return audioCache.get(key); let lastErr = null; for (let attempt = 0; attempt < 3; attempt += 1) { if (attempt > 0) await new Promise((r) => setTimeout(r, attempt * 1000)); inflight = new AbortController(); try { const resp = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: segments[index].t, voice: currentVoice() }), signal: inflight.signal, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || `HTTP ${resp.status}`); } const blob = await resp.blob(); audioCache.set(key, blob); return blob; } catch (err) { if (err.name === "AbortError") throw err; // 主动停止,不重试 lastErr = err; } finally { inflight = null; } } throw lastErr; } /** * 解析 WAV 文件头,返回 {rate, channels, pcm};格式不符返回 null。 * @param {ArrayBuffer} buf WAV 数据 */ function parseWav(buf) { const v = new DataView(buf); const tag = (o) => v.getUint32(o, true); // 块 ID 按小端读 if (buf.byteLength < 44 || tag(0) !== 0x46464952) return null; // 'RIFF' let off = 12; let fmt = null; let pcm = null; while (off + 8 <= buf.byteLength) { const id = tag(off); const sz = v.getUint32(off + 4, true); if (id === 0x20746d66) { // 'fmt ' fmt = { channels: v.getUint16(off + 10, true), rate: v.getUint32(off + 12, true), bits: v.getUint16(off + 22, true) }; } else if (id === 0x61746164) { // 'data' // 流式编码的 WAV 可能写占位符长度(如 0x7fffffxx),按实际文件长度截断 const realLen = Math.min(sz, buf.byteLength - (off + 8)); pcm = new Uint8Array(buf, off + 8, realLen); break; } off += 8 + sz + (sz % 2); } if (!fmt || !pcm || fmt.bits !== 16) return null; return { rate: fmt.rate, channels: fmt.channels, pcm }; } /** * 将全部段落 PCM 合并为一个 WAV:段间 0.8s 静音、末尾 3s 静音(循环间隔)。 * 合并成单文件后整个诵读只播一个音频,无 JS 事件链,手机锁屏/后台也不中断。 * @param {Blob[]} blobs 各段音频 * @returns {Promise<{url: string, times: number[]}>} 音频地址与各段起始秒 */ async function buildCombined(blobs) { const parts = []; const times = []; let rate = 24000; let channels = 1; let cursor = 0; // 已写入字节数 for (const b of blobs) { const w = parseWav(await b.arrayBuffer()); if (!w) throw new Error("音频格式不支持"); rate = w.rate; channels = w.channels; if (parts.length) { const gap = new Uint8Array(Math.floor(rate * 0.8) * channels * 2); parts.push(gap); cursor += gap.length; } times.push(cursor / (rate * channels * 2)); parts.push(w.pcm); cursor += w.pcm.length; } parts.push(new Uint8Array(Math.floor(rate * 3) * channels * 2)); // 尾部 3s 静音 const total = parts.reduce((s, p) => s + p.length, 0); const out = new ArrayBuffer(44 + total); const v = new DataView(out); const wstr = (o, s) => { for (let k = 0; k < s.length; k += 1) v.setUint8(o + k, s.charCodeAt(k)); }; wstr(0, "RIFF"); v.setUint32(4, 36 + total, true); wstr(8, "WAVE"); wstr(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, channels, true); v.setUint32(24, rate, true); v.setUint32(28, rate * channels * 2, true); v.setUint16(32, channels * 2, true); v.setUint16(34, 16, true); wstr(36, "data"); v.setUint32(40, total, true); let o = 44; for (const p of parts) { new Uint8Array(out, o, p.length).set(p); o += p.length; } return { url: URL.createObjectURL(new Blob([out], { type: "audio/wav" })), times }; } /** 把段落文字逐字包成 .ch span(仅当前诵读段需要,幂等)。 */ function wrapChars(el) { if (el.dataset.wrapped) return; const frag = document.createDocumentFragment(); for (const c of el.textContent) { const s = document.createElement("span"); s.className = "ch"; s.textContent = c; frag.appendChild(s); } el.innerHTML = ""; el.appendChild(frag); el.dataset.wrapped = "1"; } /** * 逐字光标:按音频当前时间在当前段内按比例推进, * 已读字染朱砂、当前字挂金色下划线光标。TTS 无逐字时间戳,按语速均匀估算。 */ function cursorTick() { if (!playing || !segTimes.length) { cursorRaf = null; return; } const t = audioEl.currentTime; let i = segTimes.length - 1; while (i > 0 && segTimes[i] > t) i -= 1; const active = segEls.find((el) => Number(el.dataset.index) === i); if (active) { wrapChars(active); const chs = active.querySelectorAll(".ch"); const segDur = (i + 1 < segTimes.length ? segTimes[i + 1] - 0.8 : audioEl.duration - 3) - segTimes[i]; const n = Math.min(chs.length, Math.floor(((t - segTimes[i]) / Math.max(segDur, 0.1)) * chs.length)); const prev = Number(active.dataset.cursorN ?? -1); if (n !== prev) { const lo = Math.max(0, Math.min(n, prev)); const hi = Math.max(n, prev); for (let k = lo; k <= hi && k < chs.length; k += 1) chs[k].classList.toggle("read", k < n); active.dataset.cursorN = n; const old = active.querySelector(".ch.cur"); old?.classList.remove("cur"); if (n < chs.length) chs[n].classList.add("cur"); } cursorTick._n = (cursorTick._n || 0) + 1; if (cursorTick._n % 15 === 0) { // 光标行跟随:光标走出视区 25%~75% 舒适带时,平滑滚回中部 if (Date.now() - lastUserScroll >= 6000 && chs[n]) { const r = chs[n].getBoundingClientRect(); const c = sutraEl.getBoundingClientRect(); if (r.top < c.top + c.height * 0.25 || r.bottom > c.top + c.height * 0.75) { chs[n].scrollIntoView({ behavior: reduceMotionMQ.matches ? "auto" : "smooth", block: "center", inline: "center" }); } } updateJumpNow(); } } cursorRaf = requestAnimationFrame(cursorTick); } /** 播放进度回调:按当前时间定位段号,高亮并自动翻页滚动;顺带判定定时停止。 */ function onTimeUpdate() { if (sleepEndAt && Date.now() >= sleepEndAt) { fireSleepTimer(); return; } const t = audioEl.currentTime; let i = segTimes.length - 1; while (i > 0 && segTimes[i] > t) i -= 1; if (i === currentSeg) return; currentSeg = i; const targetPage = pageOf(i); if (targetPage !== currentPage) renderPage(targetPage); highlight(i); } /** * 从第 index 段开始连续朗读到全文结束;开启循环时 audio.loop 原生回卷。 * 先并发补齐所有段音频再合并为单个 WAV 播放,中途无 JS 事件链衔接, * 手机锁屏/后台不会中断;高亮靠 timeupdate 按 segTimes 时间轴定位。 */ async function playFrom(index) { stopPlayback(); const token = ++playToken; setPlayingUI(true); // 准备阶段按钮脉冲 + 文字提示(移动端没有状态栏,这条反馈很关键) btnPlay.classList.add("preparing"); btnPlay.querySelector(".play-label").textContent = "准备中"; setStatus("正在准备音频…"); const blobs = new Array(segments.length); let next = 0; let done = 0; const worker = async () => { while (next < segments.length && token === playToken) { const i = next; next += 1; blobs[i] = await fetchSegment(i); done += 1; setStatus(`正在准备音频 ${done} / ${segments.length}…`); } }; try { await Promise.all([worker(), worker(), worker(), worker()]); if (token !== playToken) return; const built = await buildCombined(blobs); if (token !== playToken) return; segTimes = built.times; currentSeg = -1; audioEl.src = built.url; audioEl.loop = loopMode; audioEl.ontimeupdate = onTimeUpdate; audioEl.onended = () => { setPlayingUI(false); setStatus("诵读完毕"); stopBgm(); clearHighlight(); }; audioEl.currentTime = built.times[index]; await audioEl.play(); btnPlay.classList.remove("preparing"); btnPlay.querySelector(".play-label").textContent = "停 止"; if (bgmOn) startBgm(); // play() 成功后起步背景音乐(用户手势上下文内) updateMediaSession(); cursorRaf = requestAnimationFrame(cursorTick); // 启动逐字光标 setStatus(`正在朗读 · ${currentVoice()}`); } catch (err) { if (err.name === "AbortError") return; // 已被停止/新播放取代 setStatus(`语音合成失败:${err.message}`, true); setPlayingUI(false); btnPlay.classList.remove("preparing"); clearHighlight(); } } /** 设置循环开关状态并同步按钮外观(供点击与定时联动共用)。 */ function setLoopMode(on) { loopMode = on; audioEl.loop = on; btnLoop.classList.toggle("active", on); btnLoop.setAttribute("aria-pressed", String(on)); } /** 循环诵读开关:开时读完全文自动回到第一段继续;定时关闭时即无限循环。 */ btnLoop.addEventListener("click", () => { setLoopMode(!loopMode); loopAutoByTimer = false; // 用户手动干预后以手动状态为准 savePref("loop", loopMode); setStatus(loopMode ? "循环诵读:开" : "循环诵读:关"); }); /** 定时停止到期:停止播放、下拉复位,并收回定时自动带开的循环。 */ function fireSleepTimer() { sleepEndAt = 0; sleepTimer = null; timerSelect.value = "0"; sheetSyncFns.get(timerSelect)?.(); if (loopAutoByTimer) { loopAutoByTimer = false; setLoopMode(false); } if (playing || inflight) { stopPlayback(); setStatus("定时结束,已停止播放"); } } /** 定时停止:选 30/60/90/120 分钟后到点自动停播;选"关闭"取消。 */ timerSelect.addEventListener("change", () => { if (sleepTimer) { clearTimeout(sleepTimer); sleepTimer = null; } const mins = Number(timerSelect.value); if (!mins) { sleepEndAt = 0; if (loopAutoByTimer) { // 定时带开的循环随"关闭"一并收回;用户手动开的循环不动 loopAutoByTimer = false; setLoopMode(false); } setStatus("定时停止:关"); return; } sleepEndAt = Date.now() + mins * 60000; sleepTimer = setTimeout(fireSleepTimer, mins * 60000); if (!loopMode) { setLoopMode(true); // 定时场景自动开启循环,播到点为止 loopAutoByTimer = true; } setStatus(`定时 ${mins} 分钟后停止 · 循环已开`); }); /** 背景音乐开关:播放中开/关即时淡入淡出;未播放时只记录状态,下次播放生效。 */ btnBgm.addEventListener("click", () => { bgmOn = !bgmOn; btnBgm.classList.toggle("active", bgmOn); btnBgm.setAttribute("aria-pressed", String(bgmOn)); savePref("bgm", bgmOn); if (bgmOn && playing) startBgm(); if (!bgmOn) stopBgm(); setStatus(bgmOn ? "背景音乐:开" : "背景音乐:关"); }); /** 语速切换:直接改已合并音频的 playbackRate,立即生效。 */ rateSelect.addEventListener("change", () => { audioEl.playbackRate = Number(rateSelect.value); savePref("rate", rateSelect.value); setStatus(`语速:${rateSelect.options[rateSelect.selectedIndex].text}`); }); /** 夜间模式:暗色宣纸配色,睡前诵读不刺眼。 */ btnNight.addEventListener("click", () => { const on = document.body.classList.toggle("night"); btnNight.classList.toggle("active", on); btnNight.setAttribute("aria-pressed", String(on)); btnNight.textContent = on ? "日间" : "夜间"; savePref("night", on); }); btnPlay.addEventListener("click", () => { if (playing || inflight) { stopPlayback(); setStatus("已停止"); } else { playFrom(0); } }); voiceSelect.addEventListener("change", () => { if (playing || inflight) stopPlayback(); savePref("voice", voiceSelect.value); setStatus(`已切换音色:${voiceSelect.value},点击播放开始诵读`); }); // 移动端设置区折叠/展开(齿轮图标按钮,桌面端不可见) btnExpand.addEventListener("click", () => { const expanded = playerCard.classList.toggle("expanded"); btnExpand.classList.toggle("open", expanded); btnExpand.querySelector(".expand-label").textContent = expanded ? "收起" : "设置"; btnExpand.setAttribute("aria-expanded", String(expanded)); }); btnPrev.addEventListener("click", () => gotoPage(currentPage - 1)); btnNext.addEventListener("click", () => gotoPage(currentPage + 1)); // 手动滚轮/触摸滚动经文:记录时间,6 秒内跟读滚动让位给读者 ["wheel", "touchmove"].forEach((ev) => { sutraEl.addEventListener(ev, () => { lastUserScroll = Date.now(); }, { passive: true }); }); // 任何滚动(含自动滚动)都刷新"回到诵读处"按钮的可见性 sutraEl.addEventListener("scroll", () => { if (playing) updateJumpNow(); }, { passive: true }); btnJumpNow.addEventListener("click", () => { lastUserScroll = 0; let active = segEls.find((el) => Number(el.dataset.index) === currentSeg); if (!active && currentSeg >= 0) { renderPage(pageOf(currentSeg)); // 当前段不在本页,先翻过去 active = segEls.find((el) => Number(el.dataset.index) === currentSeg); } const target = active?.querySelector(".ch.cur") || active; target?.scrollIntoView({ behavior: "smooth", block: "center", inline: "center" }); btnJumpNow.hidden = true; }); sutraSelect.addEventListener("change", () => selectSutra(sutraSelect.value)); // 键盘 ← → 翻页(下拉框聚焦时不拦截) document.addEventListener("keydown", (e) => { if (e.target.tagName === "SELECT") return; if (e.key === "ArrowLeft") gotoPage(currentPage - 1); if (e.key === "ArrowRight") gotoPage(currentPage + 1); }); // ===== 字号与排版 ===== /** 应用字号档位并同步按钮高亮。 */ function setFontSize(size) { if (!["sm", "md", "lg"].includes(size)) return; document.querySelectorAll(".size-btn").forEach((b) => b.classList.toggle("active", b.dataset.size === size)); sutraEl.classList.remove("size-sm", "size-md", "size-lg"); sutraEl.classList.add(`size-${size}`); } document.querySelectorAll(".size-btn").forEach((btn) => { btn.addEventListener("click", () => { setFontSize(btn.dataset.size); savePref("size", btn.dataset.size); }); }); const mobileMQ = matchMedia("(max-width: 640px)"); btnLayout.addEventListener("click", () => { const vertical = sutraEl.classList.toggle("vertical"); btnLayout.textContent = vertical ? "切换横排" : "切换竖排"; btnLayout.classList.toggle("active", vertical); }); // 移动端不提供竖排:窗口收窄时若处于竖排则切回横排 mobileMQ.addEventListener("change", () => { if (mobileMQ.matches && sutraEl.classList.contains("vertical")) { btnLayout.click(); } }); // ===== 移动端底部升起选择器:原生 select 保留为状态源,手机上改由面板选择 ===== const sheetSyncFns = new Map(); // select → 同步按钮文字的函数 let sheetEls = null; // 当前打开的面板 {mask, panel} /** 关闭底部选择面板(若开着)。 */ function closeSheet() { if (!sheetEls) return; const { mask, panel } = sheetEls; sheetEls = null; mask.classList.remove("open"); panel.classList.remove("open"); setTimeout(() => { mask.remove(); panel.remove(); }, 220); } /** 打开底部升起面板:列出 select 全部选项,点选后同步并派发 change。 */ function openSheet(select, title) { closeSheet(); const mask = document.createElement("div"); mask.className = "sheet-mask"; const panel = document.createElement("div"); panel.className = "sheet-panel"; panel.setAttribute("role", "dialog"); panel.setAttribute("aria-label", title); panel.innerHTML = `
${title}
`; const opts = panel.querySelector(".sheet-opts"); let lastGroup = null; // optgroup 分组标题(如"佛经/道家"),无分组时不渲染 [...select.options].forEach((opt, i) => { const group = opt.parentElement?.tagName === "OPTGROUP" ? opt.parentElement.label : null; if (group && group !== lastGroup) { lastGroup = group; const gh = document.createElement("div"); gh.className = "sheet-group"; gh.textContent = group; opts.appendChild(gh); } const item = document.createElement("div"); item.className = `sheet-opt${i === select.selectedIndex ? " sel" : ""}`; item.textContent = opt.textContent; item.setAttribute("role", "option"); item.addEventListener("click", () => { select.value = opt.value; select.dispatchEvent(new Event("change")); closeSheet(); }); opts.appendChild(item); }); const cancel = document.createElement("div"); cancel.className = "sheet-cancel"; cancel.textContent = "取 消"; cancel.addEventListener("click", closeSheet); panel.appendChild(cancel); mask.addEventListener("click", closeSheet); document.body.appendChild(mask); document.body.appendChild(panel); sheetEls = { mask, panel }; requestAnimationFrame(() => { mask.classList.add("open"); panel.classList.add("open"); }); } /** 为 select 生成伪下拉按钮(仅移动端显示),点击打开底部面板。 */ function attachSheet(select, title) { if (!select) return; const btn = document.createElement("button"); btn.type = "button"; btn.className = "sheet-btn"; btn.setAttribute("aria-haspopup", "dialog"); const text = document.createElement("span"); text.className = "sheet-btn-text"; const sync = () => { text.textContent = select.options[select.selectedIndex]?.textContent || ""; }; sync(); sheetSyncFns.set(select, sync); select.addEventListener("change", sync); btn.appendChild(text); select.insertAdjacentElement("afterend", btn); btn.addEventListener("click", () => openSheet(select, title)); } attachSheet(sutraSelect, "选择经典"); attachSheet(voiceSelect, "诵读音色"); attachSheet(timerSelect, "定时停止"); attachSheet(rateSelect, "诵读语速"); // 锁屏/控制中心按键:上一段/下一段、暂停、停止 if ("mediaSession" in navigator) { navigator.mediaSession.setActionHandler("play", () => { if (!playing && !inflight) playFrom(Math.max(currentSeg, 0)); }); navigator.mediaSession.setActionHandler("pause", () => stopPlayback()); navigator.mediaSession.setActionHandler("stop", () => stopPlayback()); navigator.mediaSession.setActionHandler("previoustrack", () => playFrom(Math.max(currentSeg - 1, 0))); navigator.mediaSession.setActionHandler("nexttrack", () => { if (currentSeg + 1 < segments.length) playFrom(currentSeg + 1); }); } // ===== PWA:注册 Service Worker,使"添加到主屏幕"后无地址栏独立运行 ===== if ("serviceWorker" in navigator) { window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js").catch(() => {})); } // ===== 初始化:恢复偏好(hash 锚点优先于记忆的经典/排版) ===== if (prefs.voice && [...voiceSelect.options].some((o) => o.value === prefs.voice)) voiceSelect.value = prefs.voice; sheetSyncFns.get(voiceSelect)?.(); if (prefs.size) setFontSize(prefs.size); if (prefs.loop) setLoopMode(true); if (prefs.bgm) { bgmOn = true; btnBgm.classList.add("active"); btnBgm.setAttribute("aria-pressed", "true"); } if (prefs.rate && [...rateSelect.options].some((o) => o.value === String(prefs.rate))) { rateSelect.value = prefs.rate; audioEl.playbackRate = Number(prefs.rate); sheetSyncFns.get(rateSelect)?.(); } if (prefs.night) { document.body.classList.add("night"); btnNight.classList.add("active"); btnNight.setAttribute("aria-pressed", "true"); btnNight.textContent = "日间"; } if (location.hash.includes("vertical") && !mobileMQ.matches) { btnLayout.click(); } // URL hash 可直达某部经(如 #jingang、#daodejing),优先于记忆的经典 const hashId = location.hash.replace(/^#/, ""); /** 启动:拉经典目录清单 → 建下拉 → 选中初始经(hash > 记忆 > 第一部)。 */ (async function boot() { try { const resp = await fetch("/api/sutras"); const data = await resp.json(); SUTRAS.push(...(data.sutras || [])); } catch { setStatus("经典目录载入失败", true); } buildSutraOptions(); const initSutraId = SUTRAS.some((s) => s.id === hashId) ? hashId : (SUTRAS.some((s) => s.id === prefs.sutra) ? prefs.sutra : SUTRAS[0]?.id); if (initSutraId) selectSutra(initSutraId); })();