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:
John O'Keefe committed 2026-10-22 08:40:54 -04:00
1 parent 295c4b0664
commit fe7eab91e8
5 files changed
+369 -2

No files matched your search

+106
View File
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
SLEEP_CHOICES,
SLEEP_OFF,
armEndOfChapter,
armMinutes,
fadeVolume,
formatRemaining,
sleepChapterFired,
sleepDue,
sleepLabel,
sleepRemainingMs,
} from "./audio-sleep";
describe("armMinutes / sleepDue / sleepRemainingMs", () => {
it("arms a wall-clock deadline", () => {
const state = armMinutes(1_000_000, 5);
expect(state.mode).toBe("minutes");
expect(state.endsAtMs).toBe(1_000_000 + 5 * 60_000);
expect(sleepDue(state, 1_000_000 + 5 * 60_000 - 1)).toBe(false);
expect(sleepDue(state, 1_000_000 + 5 * 60_000)).toBe(true);
expect(sleepDue(state, 1_000_000 + 9 * 60_000)).toBe(true);
});
it("reports remaining time clamped at zero", () => {
const state = armMinutes(0, 10);
expect(sleepRemainingMs(state, 0)).toBe(600_000);
expect(sleepRemainingMs(state, 599_000)).toBe(1000);
expect(sleepRemainingMs(state, 700_000)).toBe(0);
});
it("never fires in non-minutes modes", () => {
expect(sleepDue(SLEEP_OFF, Date.now())).toBe(false);
expect(sleepDue(armEndOfChapter(3), Date.now())).toBe(false);
expect(sleepRemainingMs(SLEEP_OFF, Date.now())).toBeNull();
});
});
describe("armEndOfChapter / sleepChapterFired", () => {
it("fires when the armed chapter is left, in either direction", () => {
const state = armEndOfChapter(7);
expect(sleepChapterFired(state, 7)).toBe(false);
expect(sleepChapterFired(state, 8)).toBe(true);
expect(sleepChapterFired(state, 6)).toBe(true);
});
it("does nothing in other modes", () => {
expect(sleepChapterFired(SLEEP_OFF, 8)).toBe(false);
expect(sleepChapterFired(armMinutes(0, 5), 8)).toBe(false);
});
});
describe("fadeVolume", () => {
it("holds the base at start, ramps linearly, reaches zero at fade end", () => {
expect(fadeVolume(0.5, 0)).toBe(0.5);
expect(fadeVolume(0.5, 2500)).toBeCloseTo(0.25, 10);
expect(fadeVolume(0.5, 5000)).toBe(0);
expect(fadeVolume(0.5, 9000)).toBe(0);
expect(fadeVolume(1, 1000)).toBeCloseTo(0.8, 10);
});
it("clamps negative elapsed to the base and out-of-range volumes", () => {
expect(fadeVolume(0.5, -5)).toBe(0.5);
expect(fadeVolume(2, 1000)).toBeLessThanOrEqual(1);
});
});
describe("formatRemaining / sleepLabel", () => {
it("formats mm:ss, rounding up partial seconds", () => {
expect(formatRemaining(45 * 60_000)).toBe("45:00");
expect(formatRemaining(1000)).toBe("0:01");
expect(formatRemaining(59_500)).toBe("1:00");
expect(formatRemaining(0)).toBe("0:00");
});
it("labels every mode", () => {
expect(sleepLabel(SLEEP_OFF, 0)).toBe("Off");
expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter");
expect(sleepLabel({ mode: "fading", endsAtMs: null, chapterIndex: null }, 0)).toBe("Fading");
expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55");
});
});
describe("SLEEP_CHOICES", () => {
it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter", () => {
expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([
"off",
"5",
"10",
"15",
"30",
"45",
"60",
"eoch",
]);
expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([
5, 10, 15, 30, 45, 60,
]);
});
it("has unique keys and labels", () => {
expect(new Set(SLEEP_CHOICES.map((c) => c.key)).size).toBe(SLEEP_CHOICES.length);
expect(new Set(SLEEP_CHOICES.map((c) => c.label)).size).toBe(SLEEP_CHOICES.length);
});
});
+98
View File
@@ -0,0 +1,98 @@
// Sleep timer state machine (player plan §3). Pure and DOM-free — the
// Alpine component owns the timers/intervals and the audio element;
// this module owns the STATE rules. Per-session only: never persisted
// (the Android counterpart's persisted sleep setting is exactly the
// mistake plan §7 removes).
export type SleepMode = "off" | "minutes" | "end-of-chapter" | "fading";
export interface SleepState {
mode: SleepMode;
// Epoch ms deadline for "minutes" mode; null otherwise.
endsAtMs: number | null;
// Chapter index the end-of-chapter timer was armed at; null otherwise.
chapterIndex: number | null;
}
export const SLEEP_OFF: SleepState = { mode: "off", endsAtMs: null, chapterIndex: null };
export function armMinutes(nowMs: number, minutes: number): SleepState {
return { mode: "minutes", endsAtMs: nowMs + minutes * 60_000, chapterIndex: null };
}
export function armEndOfChapter(chapterIndex: number): SleepState {
return { mode: "end-of-chapter", endsAtMs: null, chapterIndex };
}
// sleepDue is true when a minutes-mode deadline has passed.
export function sleepDue(state: SleepState, nowMs: number): boolean {
return state.mode === "minutes" && state.endsAtMs !== null && nowMs >= state.endsAtMs;
}
// sleepChapterFired is true when an end-of-chapter timer's chapter has
// been left — ANY change from the armed index fires it (forward into
// the next chapter naturally, or a manual jump away from it).
export function sleepChapterFired(state: SleepState, currentIndex: number): boolean {
return (
state.mode === "end-of-chapter" &&
state.chapterIndex !== null &&
currentIndex !== state.chapterIndex
);
}
export function sleepRemainingMs(state: SleepState, nowMs: number): number | null {
if (state.mode !== "minutes" || state.endsAtMs === null) return null;
return Math.max(0, state.endsAtMs - nowMs);
}
// fadeVolume ramps linearly base → 0 across fadeMs and clamps beyond
// both ends.
export function fadeVolume(baseVolume: number, elapsedMs: number, fadeMs = 5000): number {
const base = Math.min(1, Math.max(0, baseVolume));
if (elapsedMs <= 0) return base;
if (elapsedMs >= fadeMs) return 0;
return Math.max(0, base * (1 - elapsedMs / fadeMs));
}
export function formatRemaining(ms: number): string {
const totalSeconds = Math.ceil(Math.max(0, ms) / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
export function sleepLabel(state: SleepState, nowMs: number): string {
switch (state.mode) {
case "off":
return "Off";
case "end-of-chapter":
return "End of chapter";
case "fading":
return "Fading";
case "minutes": {
const remaining = sleepRemainingMs(state, nowMs);
return remaining === null ? "Off" : formatRemaining(remaining);
}
}
}
// Dropdown vocabulary — the templ sleep dropdown iterates this (the
// component exposes it; templ cannot import TS) and chooseSleep()
// consumes it. One source for both the rows and their behavior.
export interface SleepChoice {
key: string;
label: string;
mode: "off" | "minutes" | "end-of-chapter";
minutes?: number;
}
export const SLEEP_CHOICES: readonly SleepChoice[] = [
{ key: "off", label: "Off", mode: "off" },
{ key: "5", label: "5 minutes", mode: "minutes", minutes: 5 },
{ key: "10", label: "10 minutes", mode: "minutes", minutes: 10 },
{ key: "15", label: "15 minutes", mode: "minutes", minutes: 15 },
{ key: "30", label: "30 minutes", mode: "minutes", minutes: 30 },
{ key: "45", label: "45 minutes", mode: "minutes", minutes: 45 },
{ key: "60", label: "60 minutes", mode: "minutes", minutes: 60 },
{ key: "eoch", label: "End of chapter", mode: "end-of-chapter" },
];
+132 -1
View File
@@ -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;