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