feat(audio-player): sleep timer — moon pill, countdown, 5s fade then pause (plan §3)
Off / 5 / 10 / 15 / 30 / 45 / 60 minutes / end-of-chapter. The moon pill sits in the settings strip between Bookmark and Volume and shows the live mm:ss countdown while armed (or 'End of chapter'); the dropdown's armed row highlights like Skip/Speed. audio-sleep.ts is the pure, DOM-free state machine (arm/due/fired/ fade-ramp/label + the SLEEP_CHOICES vocabulary the templ dropdown iterates). The component owns the timers: a 1s countdown ticker in minutes mode, and a ~5s element-volume fade on expiry that ends in audio.pause() — the SAME pause listener a manual pause fires, so the progress push and media-session state land. The user's configured volume is restored right after the pause, and the fade operates on the element's volume the user controls, not a parallel path. End-of-chapter fires when the armed chapter is left — natural crossing (tickChapter) or a manual jump (jumpToChapter) alike. Manual play during a fade aborts the expiry (you're awake, you asked); stopSleep restores any partial fade. Per-session ONLY: nothing touches localStorage — the reload check proves it starts Off. sleepOpen joins closeTopSurface's dropdown branch, so Esc closes it with the other settings-strip surfaces. Muted (or zero-volume) expiry skips the pantomime and pauses immediately. Unit: audio-sleep.test.ts (deadline mechanics, chapter-fired semantics both directions, fade ramp clamps, label formatting, SLEEP_CHOICES integrity). Live: 24 Playwright checks on the KJV book — dropdown, countdown ticking, armed-row highlight, end-of-chapter fire + reset, internal 3s deadline fade→pause→restore, play-abort, reload-starts-Off; probe user created and deleted.
This commit is contained in:
1 parent
295c4b0664
commit
fe7eab91e8
5 files changed
+369
-2
No files matched your search
+132
-1
@@ -16,6 +16,18 @@ import { Alpine } from "./alpine";
|
||||
import { renderNoteHtml } from "./markdown";
|
||||
import { showToast } from "./toast";
|
||||
import { resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
|
||||
import {
|
||||
SLEEP_CHOICES,
|
||||
SLEEP_OFF,
|
||||
armEndOfChapter,
|
||||
armMinutes,
|
||||
fadeVolume,
|
||||
sleepChapterFired,
|
||||
sleepDue,
|
||||
sleepLabel,
|
||||
type SleepChoice,
|
||||
type SleepState,
|
||||
} from "./audio-sleep";
|
||||
|
||||
type AudioChapter = { index: number; title: string; start: number; end: number };
|
||||
|
||||
@@ -164,6 +176,17 @@ Alpine.data("audioReader", () => ({
|
||||
osdText: "",
|
||||
osdTimer: null as ReturnType<typeof setTimeout> | null,
|
||||
|
||||
// Sleep timer (plan §3): session-scoped, NEVER persisted. The pure
|
||||
// state machine lives in audio-sleep.ts; this holds the state, the
|
||||
// 1s countdown ticker and the 5s fade interval.
|
||||
sleep: SLEEP_OFF as SleepState,
|
||||
sleepChoices: SLEEP_CHOICES,
|
||||
sleepOpen: false,
|
||||
sleepLabelStr: "Off",
|
||||
sleepArmedChoiceKey: "off",
|
||||
sleepTicker: null as ReturnType<typeof setInterval> | null,
|
||||
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
|
||||
|
||||
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
|
||||
// on unmute (spec: icon shows level arcs, crossed when muted).
|
||||
volume: Math.min(1, Math.max(0, Number(localStorage.getItem("audio_volume") ?? 1) || 1)),
|
||||
@@ -277,6 +300,9 @@ Alpine.data("audioReader", () => ({
|
||||
audio.addEventListener("play", () => {
|
||||
this.playing = true;
|
||||
this.prevTick = performance.now();
|
||||
// Manual play during a sleep fade aborts the expiry (the user
|
||||
// is awake and asked for audio); stopSleep restores the volume.
|
||||
if (this.sleep.mode === "fading") this.stopSleep();
|
||||
this.updateMediaSessionState();
|
||||
});
|
||||
audio.addEventListener("pause", () => {
|
||||
@@ -417,6 +443,8 @@ Alpine.data("audioReader", () => ({
|
||||
if (this.currentChapterIdx !== i) {
|
||||
this.currentChapterIdx = i;
|
||||
this.updateMediaSessionMetadata();
|
||||
// Natural chapter crossing: fire an end-of-chapter sleep.
|
||||
if (sleepChapterFired(this.sleep, i)) this.beginSleepFade();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -472,6 +500,9 @@ Alpine.data("audioReader", () => ({
|
||||
const ch = this.chapters[index];
|
||||
if (ch) this.seekTo(ch.start);
|
||||
this.currentChapterIdx = index;
|
||||
// Manual jump: leaving the armed chapter fires an end-of-chapter
|
||||
// sleep the same as a natural crossing.
|
||||
if (sleepChapterFired(this.sleep, index)) this.beginSleepFade();
|
||||
},
|
||||
|
||||
setSpeed(rate: number) {
|
||||
@@ -611,10 +642,11 @@ Alpine.data("audioReader", () => ({
|
||||
this.bookmarksOpen = false;
|
||||
return true;
|
||||
}
|
||||
if (this.skipOpen || this.speedOpen || this.volumeOpen) {
|
||||
if (this.skipOpen || this.speedOpen || this.volumeOpen || this.sleepOpen) {
|
||||
this.skipOpen = false;
|
||||
this.speedOpen = false;
|
||||
this.volumeOpen = false;
|
||||
this.sleepOpen = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@@ -717,6 +749,105 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
},
|
||||
|
||||
// ---- sleep timer (audio-sleep.ts state machine) ----
|
||||
|
||||
isSleepActive(key: string): boolean {
|
||||
return this.sleepArmedChoiceKey === key;
|
||||
},
|
||||
|
||||
chooseSleep(choice: SleepChoice) {
|
||||
this.sleepArmedChoiceKey = choice.key;
|
||||
if (choice.mode === "off") {
|
||||
this.stopSleep();
|
||||
return;
|
||||
}
|
||||
if (choice.mode === "end-of-chapter") {
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armEndOfChapter(this.currentChapterIdx);
|
||||
} else {
|
||||
this.stopSleepTicker();
|
||||
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
|
||||
this.startSleepTicker();
|
||||
}
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
|
||||
},
|
||||
|
||||
startSleepTicker() {
|
||||
this.stopSleepTicker();
|
||||
this.sleepTicker = setInterval(() => {
|
||||
if (this.sleep.mode !== "minutes") return;
|
||||
const now = Date.now();
|
||||
if (sleepDue(this.sleep, now)) {
|
||||
this.stopSleepTicker();
|
||||
this.beginSleepFade();
|
||||
return;
|
||||
}
|
||||
this.sleepLabelStr = sleepLabel(this.sleep, now);
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
stopSleepTicker() {
|
||||
if (this.sleepTicker) {
|
||||
clearInterval(this.sleepTicker);
|
||||
this.sleepTicker = null;
|
||||
}
|
||||
},
|
||||
|
||||
// Expiry: fade the element volume over ~5s, then pause. The pause
|
||||
// fires the SAME listener a manual pause does (progress push + media
|
||||
// session state); the user's configured volume is restored right
|
||||
// after, so the next play starts at full level.
|
||||
beginSleepFade() {
|
||||
if (this.sleep.mode === "fading") return;
|
||||
const audio = this.audio();
|
||||
if (!audio) {
|
||||
this.stopSleep();
|
||||
return;
|
||||
}
|
||||
this.sleep = { mode: "fading", endsAtMs: null, chapterIndex: null };
|
||||
this.sleepLabelStr = "Fading";
|
||||
if (this.muted || Number(this.volume) <= 0) {
|
||||
// Inaudible anyway: skip the pantomime, just pause.
|
||||
this.finishSleepPause();
|
||||
return;
|
||||
}
|
||||
const base = Number(this.volume) || 0.5;
|
||||
const startedAt = Date.now();
|
||||
this.sleepFadeTimer = setInterval(() => {
|
||||
const v = fadeVolume(base, Date.now() - startedAt);
|
||||
audio.volume = v;
|
||||
if (v <= 0) this.finishSleepPause();
|
||||
}, 200);
|
||||
},
|
||||
|
||||
finishSleepPause() {
|
||||
if (this.sleepFadeTimer) {
|
||||
clearInterval(this.sleepFadeTimer);
|
||||
this.sleepFadeTimer = null;
|
||||
}
|
||||
this.stopSleepTicker();
|
||||
const audio = this.audio();
|
||||
if (audio && !audio.paused) audio.pause();
|
||||
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
|
||||
this.sleep = SLEEP_OFF;
|
||||
this.sleepArmedChoiceKey = "off";
|
||||
this.sleepLabelStr = "Off";
|
||||
},
|
||||
|
||||
// Manual off / play-abort: undo any partial fade.
|
||||
stopSleep() {
|
||||
if (this.sleepFadeTimer) {
|
||||
clearInterval(this.sleepFadeTimer);
|
||||
this.sleepFadeTimer = null;
|
||||
}
|
||||
this.stopSleepTicker();
|
||||
const audio = this.audio();
|
||||
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
|
||||
this.sleep = SLEEP_OFF;
|
||||
this.sleepArmedChoiceKey = "off";
|
||||
this.sleepLabelStr = "Off";
|
||||
},
|
||||
|
||||
async saveBookmark() {
|
||||
const audio = this.audio();
|
||||
if (!audio) return;
|
||||
|
||||
Reference in new issue
Block a user