feat: 配乐改用即梦生成音频文件,双曲目可选
- 新增 bgm/ 目录:bell.m4a(晨钟)/ valley.m4a(空山),96k AAC - 配乐开关升级为曲 select(关/晨钟/空山),移动端走底部面板 - Web Audio 合成引擎整体移除,改 <audio> 循环 + 手动淡入淡出 - 目标音量 0.25,压在人声之下;旧布尔偏好迁移为"晨钟" - deploy.sh 同步 bgm/ 目录;run.md 同步说明 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -37,7 +37,7 @@ 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 bgmSelect = document.getElementById("bgm-select");
|
||||
const timerSelect = document.getElementById("timer-select");
|
||||
const rateSelect = document.getElementById("rate-select");
|
||||
const btnNight = document.getElementById("btn-night");
|
||||
@@ -51,7 +51,7 @@ let playToken = 0; // 播放会话令牌,防止旧异步任务串扰
|
||||
let playing = false;
|
||||
let inflight = null; // 进行中的 fetch AbortController
|
||||
let loopMode = false; // 循环诵读开关:播完全文回到开头继续
|
||||
let bgmOn = false; // 背景音乐开关
|
||||
let bgmTrack = ""; // 背景音乐曲目(""=关,对应 bgm/<曲目>.m4a)
|
||||
let sleepEndAt = 0; // 定时停止的截止时间戳(毫秒),0 = 未开启
|
||||
let sleepTimer = null; // 定时器句柄(前台兜底;后台由 onTimeUpdate 判定截止时间)
|
||||
let loopAutoByTimer = false; // 循环是否为设定定时时自动打开的(是则定时结束一并关回)
|
||||
@@ -77,273 +77,45 @@ 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;
|
||||
// ===== 背景音乐: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;
|
||||
|
||||
/** 基频慢移:只滑向相邻根音、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);
|
||||
/** 背景音量从当前值线性渐变到 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);
|
||||
}
|
||||
|
||||
/** 给打击声部接随机方位的输出链,返回挂到主增益下的 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 秒淡入。需在用户手势内调用。
|
||||
*/
|
||||
/** 开始播放背景音乐:按当前曲目加载音频,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);
|
||||
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 秒淡出后停掉振荡器、钟鼓调度器并断开节点。 */
|
||||
/** 停止背景音乐: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);
|
||||
bgmFadeTo(0, 1500);
|
||||
}
|
||||
|
||||
let sutra = null; // 当前经
|
||||
@@ -851,7 +623,7 @@ async function playFrom(index) {
|
||||
await audioEl.play();
|
||||
btnPlay.classList.remove("preparing");
|
||||
btnPlay.querySelector(".play-label").textContent = "停 止";
|
||||
if (bgmOn) startBgm(); // play() 成功后起步背景音乐(用户手势上下文内)
|
||||
if (bgmTrack) startBgm(); // play() 成功后起步背景音乐(用户手势上下文内)
|
||||
updateMediaSession();
|
||||
cursorRaf = requestAnimationFrame(cursorTick); // 启动逐字光标
|
||||
setStatus(`正在朗读 · ${currentVoice()}`);
|
||||
@@ -923,14 +695,14 @@ timerSelect.addEventListener("change", () => {
|
||||
});
|
||||
|
||||
/** 背景音乐开关:播放中开/关即时淡入淡出;未播放时只记录状态,下次播放生效。 */
|
||||
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 ? "背景音乐:开" : "背景音乐:关");
|
||||
bgmSelect.addEventListener("change", () => {
|
||||
bgmTrack = bgmSelect.value;
|
||||
savePref("bgm", bgmTrack);
|
||||
if (playing) {
|
||||
if (bgmTrack) startBgm();
|
||||
else stopBgm();
|
||||
}
|
||||
setStatus(bgmTrack ? `背景音乐:${bgmSelect.options[bgmSelect.selectedIndex].text}` : "背景音乐:关");
|
||||
});
|
||||
|
||||
/** 语速切换:直接改已合并音频的 playbackRate,立即生效。 */
|
||||
@@ -1116,6 +888,7 @@ attachSheet(sutraSelect, "选择经典");
|
||||
attachSheet(voiceSelect, "诵读音色");
|
||||
attachSheet(timerSelect, "定时停止");
|
||||
attachSheet(rateSelect, "诵读语速");
|
||||
attachSheet(bgmSelect, "背景音乐");
|
||||
|
||||
// 锁屏/控制中心按键:上一段/下一段、暂停、停止
|
||||
if ("mediaSession" in navigator) {
|
||||
@@ -1136,10 +909,14 @@ if (prefs.voice && [...voiceSelect.options].some((o) => o.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");
|
||||
// 旧版偏好是布尔值 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;
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -27,6 +27,7 @@ echo "==> 同步文件到 $USER@$HOST:$DEST/"
|
||||
rsync -az --timeout=30 -e "$SSH_CMD" "${FILES[@]}" "$USER@$HOST:$DEST/"
|
||||
# 经文数据目录整目录同步(删除远端多余文件,加新经只需往 sutras/ 丢 json)
|
||||
rsync -az --timeout=30 --delete -e "$SSH_CMD" sutras/ "$USER@$HOST:$DEST/sutras/"
|
||||
rsync -az --timeout=30 --delete -e "$SSH_CMD" bgm/ "$USER@$HOST:$DEST/bgm/"
|
||||
|
||||
echo "==> 重启 fj.service"
|
||||
$SSH_CMD "$USER@$HOST" 'systemctl restart fj.service && sleep 1 && systemctl is-active fj.service'
|
||||
|
||||
+10
-3
@@ -13,7 +13,7 @@
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="经典诵读">
|
||||
<link rel="stylesheet" href="style.css?v=9">
|
||||
<link rel="stylesheet" href="style.css?v=10">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -61,8 +61,15 @@
|
||||
</div>
|
||||
<div class="tool-group" role="group" aria-label="模式">
|
||||
<button type="button" id="btn-loop" class="layout-btn" aria-pressed="false">循环</button>
|
||||
<button type="button" id="btn-bgm" class="layout-btn" aria-pressed="false">配乐</button>
|
||||
<button type="button" id="btn-night" class="layout-btn" aria-pressed="false">夜间</button>
|
||||
<div class="field-pair">
|
||||
<label class="tool-name" for="bgm-select">配乐</label>
|
||||
<select id="bgm-select" aria-label="背景音乐">
|
||||
<option value="" selected>关</option>
|
||||
<option value="bell">晨钟</option>
|
||||
<option value="valley">空山</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-pair">
|
||||
<label class="tool-name" for="timer-select">定时</label>
|
||||
<select id="timer-select" aria-label="定时停止">
|
||||
@@ -109,6 +116,6 @@
|
||||
</footer>
|
||||
|
||||
<audio id="audio" preload="auto"></audio>
|
||||
<script src="app.js?v=10"></script>
|
||||
<script src="app.js?v=12"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| 后端 | Python 3 标准库 `http.server`(无第三方依赖)[Native] |
|
||||
| TTS | 千问 `qwen3-tts-flash`,走 DashScope 兼容接口 `/api/v1/services/aigc/multimodal-generation/generation` |
|
||||
| 经文数据 | `sutras/*.json` 外置数据文件,目录实时扫描,加经不改代码 |
|
||||
| 背景音乐 | `bgm/` 目录音频文件循环播放(前端淡入淡出),deploy.sh 随 `sutras/` 一并同步 |
|
||||
|
||||
## 首次准备
|
||||
|
||||
|
||||
@@ -188,6 +188,7 @@ body {
|
||||
|
||||
#timer-select,
|
||||
#rate-select,
|
||||
#bgm-select,
|
||||
#sutra-select {
|
||||
appearance: none;
|
||||
font-family: inherit;
|
||||
@@ -203,7 +204,7 @@ body {
|
||||
padding: 6px 26px 6px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
#timer-select:focus-visible, #rate-select:focus-visible, #sutra-select:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; }
|
||||
#timer-select:focus-visible, #rate-select:focus-visible, #bgm-select:focus-visible, #sutra-select:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; }
|
||||
#sutra-select { color: var(--ink); font-size: 0.9rem; }
|
||||
|
||||
/* ===== 字号 / 排版工具(并入控制栏) ===== */
|
||||
@@ -484,11 +485,11 @@ body.night .jump-now { background: rgba(43, 36, 27, 0.92); }
|
||||
.player-row > .field-pair .sheet-btn, .player-row > .field-pair select { flex: 1; min-width: 0; }
|
||||
.tool-group[aria-label="字号"] { order: 5; flex: 1; justify-content: center; }
|
||||
.tool-group[aria-label="排版"] { order: 6; display: none; } /* 手机端不提供竖排 */
|
||||
/* 模式组:三个开关等宽一行,定时/语速两个下拉带小标签再占一行 */
|
||||
/* 模式组:循环/夜间两个开关等宽一行,配乐/定时/语速三个带标签下拉再占一行 */
|
||||
.tool-group[aria-label="模式"] { order: 7; flex: 1 1 100%; flex-wrap: wrap; }
|
||||
.tool-group[aria-label="模式"] .layout-btn { flex: 1 1 28%; text-align: center; }
|
||||
.tool-group[aria-label="模式"] .field-pair { flex: 1 1 46%; }
|
||||
.tool-group[aria-label="模式"] select { flex: 1; min-width: 0; }
|
||||
.tool-group[aria-label="模式"] .layout-btn { flex: 1 1 46%; text-align: center; }
|
||||
.tool-group[aria-label="模式"] .field-pair { flex: 1 1 30%; }
|
||||
.tool-group[aria-label="模式"] select, .tool-group[aria-label="模式"] .field-pair .sheet-btn { flex: 1; min-width: 0; }
|
||||
.tool-group[aria-label="模式"] .tool-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -568,9 +569,9 @@ body.night .jump-now { background: rgba(43, 36, 27, 0.92); }
|
||||
.pager { gap: 12px; }
|
||||
.page-info { min-width: 0; font-size: 0.8rem; }
|
||||
.site-footer { padding: 8px 14px; font-size: 0.7rem; }
|
||||
#timer-select, #rate-select { min-height: 30px; padding-top: 4px; padding-bottom: 4px; font-size: 0.78rem; }
|
||||
#timer-select, #rate-select, #bgm-select { min-height: 30px; padding-top: 4px; padding-bottom: 4px; font-size: 0.78rem; }
|
||||
/* 原生下拉隐藏,改由底部升起面板选择 */
|
||||
#voice-select, #timer-select, #rate-select, #sutra-select { display: none; }
|
||||
#voice-select, #timer-select, #rate-select, #bgm-select, #sutra-select { display: none; }
|
||||
.sheet-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -630,7 +631,7 @@ body.night .sutra-paper {
|
||||
body.night .sutra-paper::before { border-color: rgba(185, 151, 95, 0.22); }
|
||||
body.night .sutra-paper::after { border-color: rgba(185, 151, 95, 0.11); }
|
||||
body.night #voice-select { background-color: rgba(43, 36, 27, 0.9); color: var(--ink); }
|
||||
body.night #timer-select, body.night #rate-select, body.night #sutra-select { background-color: rgba(43, 36, 27, 0.6); }
|
||||
body.night #timer-select, body.night #rate-select, body.night #bgm-select, body.night #sutra-select { background-color: rgba(43, 36, 27, 0.6); }
|
||||
body.night .progress .dot { background: rgba(190, 158, 105, 0.2); }
|
||||
body.night .seg:hover { background: rgba(185, 151, 95, 0.12); }
|
||||
body.night .seg.playing { background: rgba(201, 106, 92, 0.16); color: #f0c9c2; }
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* - /api/tts 不拦截,直接透传网络
|
||||
*/
|
||||
const CACHE = "fj-shell-v1";
|
||||
const SHELL = ["/", "/index.html", "/style.css?v=9", "/app.js?v=10", "/icon.svg", "/icon-180.png", "/icon-192.png", "/icon-512.png", "/manifest.json"];
|
||||
const SHELL = ["/", "/index.html", "/style.css?v=10", "/app.js?v=12", "/icon.svg", "/icon-180.png", "/icon-192.png", "/icon-512.png", "/manifest.json"];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
|
||||
Reference in New Issue
Block a user