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 btnNext = document.getElementById("btn-next");
const btnLoop = document.getElementById("btn-loop"); const btnLoop = document.getElementById("btn-loop");
const btnBgm = document.getElementById("btn-bgm"); const btnBgm = document.getElementById("btn-bgm");
const timerSelect = document.getElementById("timer-select");
const pageInfo = document.getElementById("page-info"); const pageInfo = document.getElementById("page-info");
/** 音频缓存:key = `${经id}|${音色}|${段序号}`,value = Blob */ /** 音频缓存:key = `${经id}|${音色}|${段序号}`,value = Blob */
@@ -113,6 +114,9 @@ let playing = false;
let inflight = null; // 进行中的 fetch AbortController let inflight = null; // 进行中的 fetch AbortController
let loopMode = false; // 循环诵读开关:播完全文回到开头继续 let loopMode = false; // 循环诵读开关:播完全文回到开头继续
let bgmOn = false; // 背景音乐开关 let bgmOn = false; // 背景音乐开关
let sleepEndAt = 0; // 定时停止的截止时间戳(毫秒),0 = 未开启
let sleepTimer = null; // 定时器句柄(前台兜底;后台由 onTimeUpdate 判定截止时间)
let loopAutoByTimer = false; // 循环是否为设定定时时自动打开的(是则定时结束一并关回)
let segTimes = []; // 合并音频中各段的起始秒 let segTimes = []; // 合并音频中各段的起始秒
let currentSeg = -1; // 正在朗读的段号 let currentSeg = -1; // 正在朗读的段号
@@ -663,8 +667,12 @@ async function buildCombined(blobs) {
return { url: URL.createObjectURL(new Blob([out], { type: "audio/wav" })), times }; return { url: URL.createObjectURL(new Blob([out], { type: "audio/wav" })), times };
} }
/** 播放进度回调:按当前时间定位段号,高亮并自动翻页滚动。 */ /** 播放进度回调:按当前时间定位段号,高亮并自动翻页滚动;顺带判定定时停止。 */
function onTimeUpdate() { function onTimeUpdate() {
if (sleepEndAt && Date.now() >= sleepEndAt) {
fireSleepTimer();
return;
}
const t = audioEl.currentTime; const t = audioEl.currentTime;
let i = segTimes.length - 1; let i = segTimes.length - 1;
while (i > 0 && segTimes[i] > t) i -= 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", () => { btnLoop.addEventListener("click", () => {
loopMode = !loopMode; setLoopMode(!loopMode);
audioEl.loop = loopMode; // 播放中切换立即生效 loopAutoByTimer = false; // 用户手动干预后以手动状态为准
btnLoop.classList.toggle("active", loopMode);
btnLoop.setAttribute("aria-pressed", String(loopMode));
setStatus(loopMode ? "循环诵读:开" : "循环诵读:关"); 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", () => { btnBgm.addEventListener("click", () => {
bgmOn = !bgmOn; bgmOn = !bgmOn;
+8
View File
@@ -54,6 +54,14 @@
<div class="tool-group" role="group" aria-label="模式"> <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-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-bgm" class="layout-btn" aria-pressed="false">配乐</button>
<label class="tool-name" for="timer-select">定时</label>
<select id="timer-select" aria-label="定时停止">
<option value="0">关闭</option>
<option value="30">30 分钟</option>
<option value="60">60 分钟</option>
<option value="90">90 分钟</option>
<option value="120">120 分钟</option>
</select>
</div> </div>
<div id="status" class="status" role="status"></div> <div id="status" class="status" role="status"></div>
<button type="button" id="btn-expand" class="expand-btn" aria-expanded="false" aria-label="展开设置" title="设置"> <button type="button" id="btn-expand" class="expand-btn" aria-expanded="false" aria-label="展开设置" title="设置">
+17
View File
@@ -179,6 +179,23 @@ body {
} }
#voice-select:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; } #voice-select:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; }
#timer-select {
appearance: none;
font-family: inherit;
font-size: 0.86rem;
color: var(--ink-soft);
letter-spacing: 0.08em;
background:
linear-gradient(45deg, transparent 50%, var(--gold) 50%) right 12px center / 6px 6px no-repeat,
linear-gradient(135deg, var(--gold) 50%, transparent 50%) right 8px center / 6px 6px no-repeat,
transparent;
border: 1px solid var(--hairline);
border-radius: 6px;
padding: 6px 26px 6px 10px;
cursor: pointer;
}
#timer-select:focus-visible { outline: 3px solid var(--gold-soft); outline-offset: 1px; }
/* ===== 字号 / 排版工具(并入控制栏) ===== */ /* ===== 字号 / 排版工具(并入控制栏) ===== */
.tool-group { display: flex; align-items: center; gap: 8px; } .tool-group { display: flex; align-items: center; gap: 8px; }
.tool-name { font-size: 0.8rem; color: var(--ink-soft); letter-spacing: 0.2em; } .tool-name { font-size: 0.8rem; color: var(--ink-soft); letter-spacing: 0.2em; }