Files
selfreleaseandDevin 6329f3f6b4 feat: 分享传播与经文纸水墨背景
- 分享按钮:系统分享 + 二维码卡片(qr.png),移动端移入播放行不再遮挡标题
- og 分享卡片元数据 + og.png 封面;beforeinstallprompt 安装提示
- robots.txt / sitemap.xml / poster.png 传播物料
- .sutra-paper 宣纸底纹:桌面 bg.jpg,移动端竖版 bg-m.jpg,夜间模式罩层适配
- 移动端设置区重排为双列标签网格

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-05 14:30:43 +08:00

1101 lines
41 KiB
JavaScript
Raw Permalink 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 bgmSelect = document.getElementById("bgm-select");
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 bgmTrack = ""; // 背景音乐曲目(""=关,对应 bgm/<曲目>.m4a)
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 句柄
// ===== 背景音乐:bgm/ 目录寺院氛围音频文件循环播放,手动淡入淡出 =====
const BGM_FILES = { bell: "bgm/bell.m4a", valley: "bgm/valley.m4a" }; // 可选曲目
const bgmAudio = new Audio();
bgmAudio.loop = true;
bgmAudio.volume = 0;
const BGM_VOL = 0.25; // 目标音量,压在人声之下
let bgmFadeTimer = null;
/** 背景音量从当前值线性渐变到 target,ms 毫秒内完成;渐变到 0 时暂停播放。 */
function bgmFadeTo(target, ms) {
if (bgmFadeTimer) clearInterval(bgmFadeTimer);
const from = bgmAudio.volume;
const start = performance.now();
bgmFadeTimer = setInterval(() => {
const p = Math.min(1, (performance.now() - start) / ms);
bgmAudio.volume = Math.max(0, Math.min(1, from + (target - from) * p));
if (p >= 1) {
clearInterval(bgmFadeTimer);
bgmFadeTimer = null;
if (target === 0) bgmAudio.pause();
}
}, 50);
}
/** 开始播放背景音乐:按当前曲目加载音频,2.5 秒淡入;需在用户手势上下文内调用。 */
function startBgm() {
const src = BGM_FILES[bgmTrack];
if (!src) return;
if (!bgmAudio.src.endsWith(src)) {
bgmAudio.src = src;
bgmAudio.currentTime = 0;
}
if (bgmAudio.paused) bgmAudio.play().catch(() => {});
bgmFadeTo(BGM_VOL, 2500);
}
/** 停止背景音乐:1.5 秒淡出后暂停。 */
function stopBgm() {
bgmFadeTo(0, 1500);
}
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 (bgmTrack) 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} 分钟后停止 · 循环已开`);
});
/** 背景音乐开关:播放中开/关即时淡入淡出;未播放时只记录状态,下次播放生效。 */
bgmSelect.addEventListener("change", () => {
bgmTrack = bgmSelect.value;
savePref("bgm", bgmTrack);
if (playing) {
if (bgmTrack) startBgm();
else stopBgm();
}
setStatus(bgmTrack ? `背景音乐:${bgmSelect.options[bgmSelect.selectedIndex].text}` : "背景音乐:关");
});
/** 语速切换:直接改已合并音频的 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, "诵读语速");
attachSheet(bgmSelect, "背景音乐");
// 锁屏/控制中心按键:上一段/下一段、暂停、停止
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);
// 旧版偏好是布尔值 true(开),迁移为默认曲目"晨钟"
{
const track = prefs.bgm === true ? "bell" : String(prefs.bgm || "");
if (track && [...bgmSelect.options].some((o) => o.value === track)) {
bgmSelect.value = track;
bgmTrack = track;
sheetSyncFns.get(bgmSelect)?.();
}
}
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);
})();
// ===== 隐藏后台:2 秒内连点页脚提示 6 次打开管理面板(来源链接点击不计数) =====
const footHint = document.getElementById("foot-hint");
let hintClicks = 0;
let hintTimer = null;
footHint.addEventListener("click", (e) => {
if (e.target.closest("a")) return;
hintClicks += 1;
clearTimeout(hintTimer);
hintTimer = setTimeout(() => { hintClicks = 0; }, 2000);
if (hintClicks >= 6) {
hintClicks = 0;
openAdmin();
}
});
/** 字节数转可读文本。 */
function fmtBytes(n) {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
/** 秒数转可读运行时长(如 2天3时 / 4时12分 / 35秒)。 */
function fmtUptime(sec) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
if (d) return `${d}天${h}时`;
if (h) return `${h}时${m}分`;
if (m) return `${m}分${sec % 60}秒`;
return `${sec}秒`;
}
/** 打开管理面板:本机偏好/播放状态 + 服务器 /api/stats 统计,可点"刷新"重拉。 */
function openAdmin() {
const mask = document.createElement("div");
mask.className = "admin-mask";
const card = document.createElement("div");
card.className = "admin-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-label", "管理面板");
const voiceName = voiceSelect.options[voiceSelect.selectedIndex]?.textContent || "-";
const bgmName = bgmSelect.options[bgmSelect.selectedIndex]?.textContent || "关";
card.innerHTML = `
<div class="admin-title">后台</div>
<div class="admin-sec">本机</div>
<div class="admin-rows">
<div class="admin-row"><span>经典</span><b>${sutra ? sutra.name : "-"}</b></div>
<div class="admin-row"><span>音色</span><b>${voiceName}</b></div>
<div class="admin-row"><span>语速</span><b>${rateSelect.value}×</b></div>
<div class="admin-row"><span>配乐</span><b>${bgmName}</b></div>
<div class="admin-row"><span>循环 / 夜间</span><b>${loopMode ? "开" : "关"} / ${document.body.classList.contains("night") ? "开" : "关"}</b></div>
<div class="admin-row"><span>播放</span><b>${playing ? `第 ${currentSeg + 1}/${segments.length} 段` : "未在播"}</b></div>
<div class="admin-row"><span>浏览器</span><b>${/Mobile/.test(navigator.userAgent) ? "移动端" : "桌面端"}${navigator.serviceWorker?.controller ? " · PWA" : ""}</b></div>
</div>
<div class="admin-sec">服务器</div>
<div class="admin-rows" id="admin-server"><div class="admin-row"><span>载入中…</span></div></div>
<div class="admin-actions">
<button type="button" class="admin-btn" id="admin-refresh">刷新</button>
<button type="button" class="admin-btn" id="admin-close">关闭</button>
</div>`;
mask.appendChild(card);
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener("click", (e) => { if (e.target === mask) close(); });
card.querySelector("#admin-close").addEventListener("click", close);
const loadStats = async () => {
const box = card.querySelector("#admin-server");
try {
const s = await (await fetch("/api/stats")).json();
const hitRate = s.tts_requests ? `${Math.round((s.cache_hits / s.tts_requests) * 100)}%` : "-";
box.innerHTML = `
<div class="admin-row"><span>运行时长</span><b>${fmtUptime(s.uptime_sec)}</b></div>
<div class="admin-row"><span>访客 IP</span><b>${s.visitors}</b></div>
<div class="admin-row"><span>TTS 请求</span><b>${s.tts_requests}(命中 ${s.cache_hits} · ${hitRate})</b></div>
<div class="admin-row"><span>语音缓存</span><b>${s.cache_files} 个 · ${fmtBytes(s.cache_bytes)}</b></div>
<div class="admin-row"><span>经典数 / 模型</span><b>${s.sutra_count} 部 · ${s.model}</b></div>`;
} catch {
box.innerHTML = '<div class="admin-row"><span>服务器统计不可用</span></div>';
}
};
loadStats();
card.querySelector("#admin-refresh").addEventListener("click", loadStats);
}
// ===== 分享与 PWA 安装提示 =====
const btnShare = document.getElementById("btn-share");
let installPromptEvt = null; // beforeinstallprompt 事件暂存,供分享卡片内"安装到主屏幕"使用
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
installPromptEvt = e;
});
/** 分享本站:两端统一弹二维码卡片,手机端贴底升起;支持系统分享时另有"发送给朋友"。 */
btnShare.addEventListener("click", () => {
openShareCard(location.origin + "/");
});
/** 分享卡片:二维码扫码即达 + 复制链接 + 系统分享(如可用)+ 可安装时显示安装按钮。 */
function openShareCard(url) {
const mask = document.createElement("div");
mask.className = "admin-mask share";
const card = document.createElement("div");
card.className = "admin-card share-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-label", "分享本站");
card.innerHTML = `
<div class="admin-title">分享本站</div>
<img class="share-qr" src="qr.png" alt="本站二维码">
<span class="share-link">${url}</span>
<div class="admin-actions">
${navigator.share ? '<button type="button" class="admin-btn" id="share-native">发送给朋友</button>' : ""}
<button type="button" class="admin-btn" id="share-copy">复制链接</button>
${installPromptEvt ? '<button type="button" class="admin-btn" id="share-install">安装到主屏幕</button>' : ""}
<button type="button" class="admin-btn" id="share-close">关闭</button>
</div>`;
mask.appendChild(card);
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener("click", (e) => { if (e.target === mask) close(); });
card.querySelector("#share-close").addEventListener("click", close);
card.querySelector("#share-native")?.addEventListener("click", async () => {
try {
await navigator.share({
title: "经典诵读",
text: "心经、金刚经、道德经等经典在线诵读:千问语音合成,逐字高亮跟读,山寺配乐。",
url,
});
} catch { /* 用户取消分享 */ }
});
card.querySelector("#share-copy").addEventListener("click", async (e) => {
try {
await navigator.clipboard.writeText(url);
e.target.textContent = "已复制";
} catch {
e.target.textContent = "复制失败";
}
});
card.querySelector("#share-install")?.addEventListener("click", async () => {
installPromptEvt.prompt();
await installPromptEvt.userChoice;
installPromptEvt = null;
close();
});
}