diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 19ac30e5..1bbfabcf 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -44,6 +44,12 @@ templ AudioReader(user User, metadata ReaderMetadata) { "/api/media-items/"+metadata.MediaItemID+"/chapters", "/api/media-items/"+metadata.MediaItemID+"/progress") } > + +
@@ -482,6 +488,23 @@ templ AudioReader(user User, metadata ReaderMetadata) { x-text="opt.label" > + +
+ + +
diff --git a/templates/audio_reader_templ.go b/templates/audio_reader_templ.go index 07cc8fa9..56c59ec5 100644 --- a/templates/audio_reader_templ.go +++ b/templates/audio_reader_templ.go @@ -140,7 +140,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, 9, "\">
Player settings
Autoplay on open
Default playback speed ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -152,7 +152,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("%g", rate)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 162, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 168, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var10) if templ_7745c5c3_Err != nil { @@ -165,7 +165,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(fmt.Sprintf("Number(defaultSpeed) === %g", rate)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 163, Col: 70} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 169, Col: 70} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11) if templ_7745c5c3_Err != nil { @@ -178,7 +178,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var12 string templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%g×", rate)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 164, Col: 37} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 170, Col: 37} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) if templ_7745c5c3_Err != nil { @@ -201,7 +201,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var13 string templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.CoverImagePath) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 178, Col: 36} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 184, Col: 36} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13) if templ_7745c5c3_Err != nil { @@ -214,7 +214,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var14 string templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 179, Col: 27} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 185, Col: 27} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14) if templ_7745c5c3_Err != nil { @@ -237,13 +237,13 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component { var templ_7745c5c3_Var15 string templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(metadata.Title) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 188, Col: 117} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/audio_reader.templ`, Line: 194, Col: 117} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

—

0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

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

Bookmark at 0:00

Shortcuts & interactions
Mouse
Touch
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "

—

0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

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

Bookmark at 0:00

Shortcuts & interactions
Mouse
Touch
") 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 index 866b746f..6ef1551c 100644 --- a/web/src/audio-sleep.test.ts +++ b/web/src/audio-sleep.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { SLEEP_CHOICES, SLEEP_OFF, + armClockTime, armEndOfChapter, armMinutes, fadeVolume, @@ -77,13 +78,69 @@ describe("formatRemaining / sleepLabel", () => { 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({ mode: "fading", endsAtMs: null, chapterIndex: null, followUpMinutes: null }, 0), + ).toBe("Fading"); expect(sleepLabel(armMinutes(1000, 5), 1000 + 65_000)).toBe("3:55"); }); }); +describe("armClockTime (§17 stop-at-clock)", () => { + it("arms a minutes-state deadline at the next occurrence of HH:MM", () => { + // 10:00 local → 23:00 same day. + const tenAm = new Date(); + tenAm.setHours(10, 0, 0, 0); + const state = armClockTime(tenAm.getTime(), "23:00"); + expect(state?.mode).toBe("minutes"); + const expected = new Date(tenAm); + expected.setHours(23, 0, 0, 0); + expect(state?.endsAtMs).toBe(expected.getTime()); + // Label is the ordinary countdown machinery (12h59m → 779:00). + expect(sleepLabel(state!, tenAm.getTime() + 60_000)).toBe("779:00"); + }); + + it("wraps to tomorrow when the time already passed", () => { + const noon = new Date(); + noon.setHours(12, 0, 0, 0); + const state = armClockTime(noon.getTime(), "09:15"); + const expected = new Date(noon); + expected.setDate(expected.getDate() + 1); + expected.setHours(9, 15, 0, 0); + expect(state?.endsAtMs).toBe(expected.getTime()); + }); + + it("rejects unparseable and out-of-range input", () => { + const now = Date.now(); + expect(armClockTime(now, "nope")).toBeNull(); + expect(armClockTime(now, "24:00")).toBeNull(); + expect(armClockTime(now, "10:61")).toBeNull(); + }); +}); + +describe("§17 chain: end-of-chapter with a follow-up countdown", () => { + it("arms the follow-up on the state; the chapter fire contract is unchanged", () => { + const state = armEndOfChapter(2, 15); + expect(state.followUpMinutes).toBe(15); + // The sync contract (any chapter change fires) is the same — the + // component converts to armMinutes instead of fading. + expect(sleepChapterFired(state, 3)).toBe(true); + expect(sleepChapterFired(state, 1)).toBe(true); + expect(sleepChapterFired(state, 2)).toBe(false); + }); + + it("labels the chapter phase with the follow-up length", () => { + expect(sleepLabel(armEndOfChapter(2, 15), 0)).toBe("Ch + 15m"); + expect(sleepLabel(armEndOfChapter(2), 0)).toBe("End of chapter"); + }); + + it("offers the chain row in the dropdown vocabulary", () => { + const chain = SLEEP_CHOICES.find((c) => c.key === "eoch15"); + expect(chain).toMatchObject({ mode: "end-of-chapter", minutes: 15, chain: true }); + }); +}); + describe("SLEEP_CHOICES", () => { - it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter", () => { + it("is the agreed option list: off, 5/10/15/30/45/60, end-of-chapter, chain", () => { expect(SLEEP_CHOICES.map((c) => c.key)).toEqual([ "off", "5", @@ -93,6 +150,7 @@ describe("SLEEP_CHOICES", () => { "45", "60", "eoch", + "eoch15", ]); expect(SLEEP_CHOICES.filter((c) => c.mode === "minutes").map((c) => c.minutes)).toEqual([ 5, 10, 15, 30, 45, 60, diff --git a/web/src/audio-sleep.ts b/web/src/audio-sleep.ts index 4201eec3..9bacc0e4 100644 --- a/web/src/audio-sleep.ts +++ b/web/src/audio-sleep.ts @@ -12,16 +12,49 @@ export interface SleepState { endsAtMs: number | null; // Chapter index the end-of-chapter timer was armed at; null otherwise. chapterIndex: number | null; + // §17 chain: when set on an end-of-chapter arm, leaving the chapter + // does NOT fade — it starts a minutes countdown of this length + // ("end of chapter, then 15 min"). + followUpMinutes: number | null; } -export const SLEEP_OFF: SleepState = { mode: "off", endsAtMs: null, chapterIndex: null }; +export const SLEEP_OFF: SleepState = { + mode: "off", + endsAtMs: null, + chapterIndex: null, + followUpMinutes: null, +}; export function armMinutes(nowMs: number, minutes: number): SleepState { - return { mode: "minutes", endsAtMs: nowMs + minutes * 60_000, chapterIndex: null }; + return { + mode: "minutes", + endsAtMs: nowMs + minutes * 60_000, + chapterIndex: null, + followUpMinutes: null, + }; } -export function armEndOfChapter(chapterIndex: number): SleepState { - return { mode: "end-of-chapter", endsAtMs: null, chapterIndex }; +export function armEndOfChapter( + chapterIndex: number, + followUpMinutes: number | null = null, +): SleepState { + return { mode: "end-of-chapter", endsAtMs: null, chapterIndex, followUpMinutes }; +} + +// armClockTime (§17): "stop at 23:00" — resolves HH:MM to its NEXT +// occurrence (today if still ahead, otherwise tomorrow) and arms the +// ordinary minutes machinery against that epoch deadline. Returns null +// for unparseable strings; the caller keeps the old state then. +export function armClockTime(nowMs: number, hhmm: string): SleepState | null { + const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim()); + if (!m) return null; + const h = Number(m[1]); + const min = Number(m[2]); + if (h > 23 || min > 59) return null; + const target = new Date(nowMs); + target.setHours(h, min, 0, 0); + if (target.getTime() <= nowMs) target.setDate(target.getDate() + 1); + return { mode: "minutes", endsAtMs: target.getTime(), chapterIndex: null, followUpMinutes: null }; } // sleepDue is true when a minutes-mode deadline has passed. @@ -66,7 +99,7 @@ export function sleepLabel(state: SleepState, nowMs: number): string { case "off": return "Off"; case "end-of-chapter": - return "End of chapter"; + return state.followUpMinutes !== null ? `Ch + ${state.followUpMinutes}m` : "End of chapter"; case "fading": return "Fading"; case "minutes": { @@ -84,6 +117,9 @@ export interface SleepChoice { label: string; mode: "off" | "minutes" | "end-of-chapter"; minutes?: number; + // chain: this end-of-chapter row arms a follow-up minutes countdown + // ("end of chapter, then N min") instead of fading straight away. + chain?: boolean; } export const SLEEP_CHOICES: readonly SleepChoice[] = [ @@ -95,4 +131,11 @@ export const SLEEP_CHOICES: readonly SleepChoice[] = [ { 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" }, + { + key: "eoch15", + label: "End of chapter, then 15 min", + mode: "end-of-chapter", + minutes: 15, + chain: true, + }, ]; diff --git a/web/src/audio.ts b/web/src/audio.ts index 1e27ef6f..1017326c 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -25,12 +25,14 @@ import { import { SLEEP_CHOICES, SLEEP_OFF, + armClockTime, armEndOfChapter, armMinutes, fadeVolume, sleepChapterFired, sleepDue, sleepLabel, + sleepRemainingMs, type SleepChoice, type SleepState, } from "./audio-sleep"; @@ -284,6 +286,11 @@ Alpine.data("audioReader", () => ({ sleepArmedChoiceKey: "off", sleepTicker: null as ReturnType | null, sleepFadeTimer: null as ReturnType | null, + // §17 clock mode: the popover's time input (armed via chooseSleepClock). + sleepClockTime: "23:00", + // §17 wind-down dim: 0..0.35 black overlay opacity across the final + // 30s of a countdown. Reset by stop/finish. + sleepDim: 0, // Settings popover (plan §4): header gear → panel. Autoplay-on-open // is the first option; the panel is structured for more. @@ -579,7 +586,7 @@ Alpine.data("audioReader", () => ({ this.currentChapterIdx = i; this.updateMediaSessionMetadata(); // Natural chapter crossing: fire an end-of-chapter sleep. - if (sleepChapterFired(this.sleep, i)) this.beginSleepFade(); + if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i); } return; } @@ -637,7 +644,7 @@ Alpine.data("audioReader", () => ({ 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(); + this.onChapterCrossing(index); }, setSpeed(rate: number) { @@ -1139,7 +1146,12 @@ Alpine.data("audioReader", () => ({ } if (choice.mode === "end-of-chapter") { this.stopSleepTicker(); - this.sleep = armEndOfChapter(this.currentChapterIdx); + // §17 chain: "end of chapter, then N min" — leaving the chapter + // starts the countdown instead of fading straight away. + this.sleep = armEndOfChapter( + this.currentChapterIdx, + choice.chain ? (choice.minutes ?? 15) : null, + ); } else { this.stopSleepTicker(); this.sleep = armMinutes(Date.now(), choice.minutes ?? 30); @@ -1148,6 +1160,32 @@ Alpine.data("audioReader", () => ({ this.sleepLabelStr = sleepLabel(this.sleep, Date.now()); }, + // §17 clock mode: arm against the next occurrence of the popover's + // HH:MM input. Unparseable input keeps the current state. + chooseSleepClock() { + const armed = armClockTime(Date.now(), this.sleepClockTime); + if (!armed) return; + this.stopSleepTicker(); + this.sleep = armed; + this.sleepArmedChoiceKey = "clock"; + this.sleepLabelStr = sleepLabel(this.sleep, Date.now()); + this.startSleepTicker(); + }, + + // A chapter crossing fires an end-of-chapter sleep: straight to the + // fade — or (§17 chain arm) convert to the follow-up countdown. + onChapterCrossing(index: number) { + if (!sleepChapterFired(this.sleep, index)) return; + const follow = this.sleep.followUpMinutes; + if (follow !== null) { + this.sleep = armMinutes(Date.now(), follow); + this.startSleepTicker(); + this.sleepLabelStr = sleepLabel(this.sleep, Date.now()); + } else { + this.beginSleepFade(); + } + }, + startSleepTicker() { this.stopSleepTicker(); this.sleepTicker = setInterval(() => { @@ -1159,6 +1197,10 @@ Alpine.data("audioReader", () => ({ return; } this.sleepLabelStr = sleepLabel(this.sleep, now); + // §17 wind-down dim: ease toward 35% black across the final 30s. + const remaining = sleepRemainingMs(this.sleep, now); + this.sleepDim = + remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0; }, 1000); }, @@ -1208,6 +1250,15 @@ Alpine.data("audioReader", () => ({ this.sleep = SLEEP_OFF; this.sleepArmedChoiceKey = "off"; this.sleepLabelStr = "Off"; + this.sleepDim = 0; + // §17: a genuine fire can catch you still awake — one-click undo. + showToast("Sleep timer: playback paused", "info", 8000, { + label: "Resume", + onClick: () => { + const a = this.audio(); + if (a) a.play().catch(() => {}); + }, + }); }, // Manual off / play-abort: undo any partial fade. @@ -1222,6 +1273,7 @@ Alpine.data("audioReader", () => ({ this.sleep = SLEEP_OFF; this.sleepArmedChoiceKey = "off"; this.sleepLabelStr = "Off"; + this.sleepDim = 0; }, // ---- settings popover (plan §4) ---- diff --git a/web/src/toast.ts b/web/src/toast.ts index 5dee4130..8702ad4c 100644 --- a/web/src/toast.ts +++ b/web/src/toast.ts @@ -48,15 +48,23 @@ const getToastConfig = (type: ToastType) => { }; // Create a toast element -const createToastElement = (message: string, type: ToastType): HTMLElement => { +const createToastElement = ( + message: string, + type: ToastType, + action?: { label: string; onClick: () => void }, +): HTMLElement => { const toast = document.createElement("div"); const config = getToastConfig(type); toast.className = `${config.bgClass} text-white p-4 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] max-w-[500px] text-sm leading-relaxed pointer-events-auto opacity-0 -translate-y-5 transition-all duration-300 border border-white/10`; + const actionHtml = action + ? `` + : ""; toast.innerHTML = ` ${config.icon} ${toastEscapeHtml(message)} + ${actionHtml} @@ -68,6 +76,16 @@ const createToastElement = (message: string, type: ToastType): HTMLElement => { closeBtn.onclick = () => removeToast(toast); } + // Action button: run the handler and dismiss (an action consumes the + // toast — leaving it up after acting invites double-fires). + const actionBtn = toast.querySelector(".toast-action") as HTMLElement | null; + if (actionBtn && action) { + actionBtn.onclick = () => { + removeToast(toast); + action.onClick(); + }; + } + return toast; }; @@ -90,14 +108,17 @@ const removeToast = (toast: HTMLElement): void => { }, 300); }; -// Show toast notification +// Show toast notification. `action` (optional): a labeled button for +// one-click follow-ups (e.g. the sleep timer's "Resume"); acting +// dismisses the toast. const showToast = ( message: string, type: ToastType, duration: number = TOAST_DEFAULT_DURATION, + action?: { label: string; onClick: () => void }, ): void => { const container = createToastContainer(); - const toast = createToastElement(message, type); + const toast = createToastElement(message, type, action); container.appendChild(toast); animateToastIn(toast);