- 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>
1177 lines
42 KiB
JavaScript
1177 lines
42 KiB
JavaScript
/**
|
||
* 心经 / 金刚经 诵读页前端逻辑:经文渲染、分页阅读、逐段高亮朗读、音色选择。
|
||
*/
|
||
|
||
// ===== 经文数据 =====
|
||
// 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);
|
||
})();
|