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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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