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

+32
View File
@@ -288,6 +288,38 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</svg>
<span>Bookmark</span>
</button>
<div class="relative" @click.outside="sleepOpen = false">
<button
type="button"
@click="sleepOpen = !sleepOpen"
class="text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5"
:aria-label="'Sleep timer, currently ' + sleepLabelStr"
style="color: var(--text-secondary);"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"></path>
</svg>
<span class="font-semibold tabular-nums" style="color: var(--text-primary);" x-text="sleepLabelStr === 'Off' ? 'Sleep' : sleepLabelStr">Sleep</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
</button>
<div
x-show="sleepOpen"
x-cloak
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 rounded-xl shadow-lg p-1.5 flex flex-col min-w-[8.5rem]"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
>
<template x-for="opt in sleepChoices" :key="opt.key">
<button
type="button"
@click="chooseSleep(opt); sleepOpen = false"
class="w-full text-left px-3 py-1.5 rounded-md text-sm"
:class="isSleepActive(opt.key) ? 'font-semibold' : ''"
:style="isSleepActive(opt.key) ? 'background-color: var(--accent-muted); color: var(--text-primary);' : 'color: var(--text-secondary);'"
x-text="opt.label"
></button>
</template>
</div>
</div>
<div class="relative" @click.outside="volumeOpen = false">
<button
type="button"
+1 -1
View File
@@ -194,7 +194,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span x-text=\"fmtTime(currentTime)\">0:00</span> <input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"flex-1 accent-current\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @pointerup=\"seekTo(scrubValue); scrubbing = false\" @change=\"seekTo(scrubValue); scrubbing = false\" style=\"z-index: 20;\"> <span x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip back ' + skipSeconds + 's'\" :title=\"'Skip back ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8\"></path> <path d=\"M3 3v5h5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button id=\"audio-play\" type=\"button\" @click=\"togglePlay()\" class=\"rounded-full h-16 w-16 grid place-items-center\" style=\"background-color: var(--accent); color: var(--bg-primary);\" aria-label=\"Play or pause\"><span x-show=\"!playing\" class=\"grid place-items-center\"><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\" style=\"margin-left: 2px;\"><path d=\"M8 5.2v13.6L19.2 12z\"></path></svg></span> <span x-show=\"playing\" class=\"grid place-items-center\" x-cloak><svg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"currentColor\" aria-hidden=\"true\"><rect x=\"6.2\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect> <rect x=\"13.6\" y=\"4.5\" width=\"4.2\" height=\"15\" rx=\"1.7\"></rect></svg></span></button> <button type=\"button\" @click=\"skip(1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-label=\"'Skip forward ' + skipSeconds + 's'\" :title=\"'Skip forward ' + skipSeconds + 's'\" style=\"color: var(--text-primary);\"><svg width=\"52\" height=\"52\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8\"></path> <path d=\"M21 3v5h-5\"></path></svg> <span class=\"absolute inset-0 grid place-items-center text-[11px] font-semibold pointer-events-none\"><span x-text=\"skipSeconds\">10</span></span></button> <button type=\"button\" @click=\"stepChapter(1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Next chapter\" title=\"Next chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M5 4l10 8-10 8V4z\"></path> <path d=\"M19 5v14\"></path></svg></button></div></div><!-- Settings strip: Skip ▾ · Speed ▾ · Bookmark · Volume ▾ --><div class=\"w-full max-w-xl flex items-center justify-center gap-2\"><div class=\"relative\" @click.outside=\"skipOpen = false\"><button type=\"button\" @click=\"skipOpen = !skipOpen\" class=\"text-xs px-3 py-2 rounded-lg hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" :aria-label=\"'Skip duration, currently ' + skipSeconds + ' seconds'\" style=\"color: var(--text-secondary);\"><span>Skip</span> <span class=\"font-semibold\" style=\"color: var(--text-primary);\" x-text=\"skipSeconds + 's'\">10s</span> <svg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"m6 9 6 6 6-6\"></path></Line truncated
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
+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;