diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 7ea9e76b..e6b2ff81 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -288,6 +288,38 @@ templ AudioReader(user User, metadata ReaderMetadata) { Bookmark +
+ +
+ +
+
0:00 0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Keyboard shortcuts
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

—

0:00 0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Keyboard shortcuts
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/audio-sleep.test.ts b/web/src/audio-sleep.test.ts new file mode 100644 index 00000000..866b746f --- /dev/null +++ b/web/src/audio-sleep.test.ts @@ -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); + }); +}); diff --git a/web/src/audio-sleep.ts b/web/src/audio-sleep.ts new file mode 100644 index 00000000..4201eec3 --- /dev/null +++ b/web/src/audio-sleep.ts @@ -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" }, +]; diff --git a/web/src/audio.ts b/web/src/audio.ts index 7c91eebf..be2b2945 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -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 | 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 | null, + sleepFadeTimer: null as ReturnType | 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;