Files
fj/app.js
T
selfreleaseandDevin 0b0da2d002 feat: 经文数据外置 sutras/*.json,加减经不改代码
- 7部经文迁移至 sutras/ 目录,/api/sutras 实时扫描返回清单
- 前端改为按需懒加载经文 JSON(selectToken 防快速切换乱序)
- add_sutra.sh:校验+上传/删除 一键管理,免重启
- 修正生僻异体字(𫗪→喂、𫄨→絺、𦈎→繟、鬪→斗)
- run.md 同步新架构与经文管理文档

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 18:41:24 +08:00

1177 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 心经 / 金刚经 诵读页前端逻辑:经文渲染、分页阅读、逐段高亮朗读、音色选择。
*/
// ===== 经文数据 =====
// segments 项:{ h: 品名(可选), g: 品名小注(可选), t: 正文 }
// ===== 经文数据(外置) =====
// 每部经对应 sutras/<id>.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/<id>.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<Blob>} 音频数据
*/
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 = `<div class="sheet-grabber"></div><div class="sheet-title">${title}</div><div class="sheet-opts"></div>`;
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);
})();