feat: 定时停止(30/60/90/120 分钟)与循环联动

- 设置区新增"定时"下拉,到点自动停止播放并淡出配乐;
  截止时间判定走 timeupdate,手机锁屏/后台也能按时停
- 设定定时自动带开循环(播到点为止);定时关闭或到点后
  收回自动循环,手动开启的循环不受影响

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-10-04 07:46:50 +08:00
co-authored by Devin
parent 198ee1c911
commit ad56a147a6
3 changed files with 86 additions and 6 deletions
+61 -6
View File
@@ -104,6 +104,7 @@ 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 pageInfo = document.getElementById("page-info");
/** 音频缓存:key = `${经id}|${音色}|${段序号}`,value = Blob */
@@ -113,6 +114,9 @@ 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; // 循环是否为设定定时时自动打开的(是则定时结束一并关回)
let segTimes = []; // 合并音频中各段的起始秒
let currentSeg = -1; // 正在朗读的段号
@@ -663,8 +667,12 @@ async function buildCombined(blobs) {
return { url: URL.createObjectURL(new Blob([out], { type: "audio/wav" })), times };
}
/** 播放进度回调:按当前时间定位段号,高亮并自动翻页滚动。 */
/** 播放进度回调:按当前时间定位段号,高亮并自动翻页滚动;顺带判定定时停止。 */
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;
@@ -727,15 +735,62 @@ async function playFrom(index) {
}
}
/** 循环诵读开关:开时读完全文自动回到第一段继续。 */
/** 设置循环开关状态并同步按钮外观(供点击与定时联动共用)。 */
function setLoopMode(on) {
loopMode = on;
audioEl.loop = on;
btnLoop.classList.toggle("active", on);
btnLoop.setAttribute("aria-pressed", String(on));
}
/** 循环诵读开关:开时读完全文自动回到第一段继续;定时关闭时即无限循环。 */
btnLoop.addEventListener("click", () => {
loopMode = !loopMode;
audioEl.loop = loopMode; // 播放中切换立即生效
btnLoop.classList.toggle("active", loopMode);
btnLoop.setAttribute("aria-pressed", String(loopMode));
setLoopMode(!loopMode);
loopAutoByTimer = false; // 用户手动干预后以手动状态为准
setStatus(loopMode ? "循环诵读:开" : "循环诵读:关");
});
/** 定时停止到期:停止播放、下拉复位,并收回定时自动带开的循环。 */
function fireSleepTimer() {
sleepEndAt = 0;
sleepTimer = null;
timerSelect.value = "0";
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;