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
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user