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);
});
});