From a9430f3c06974c1c0c9eb93a05eec5c97b548aa3 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Thu, 22 Oct 2026 13:11:16 -0400 Subject: [PATCH] =?UTF-8?q?feat(audio-player):=20end-time=20label=20toggle?= =?UTF-8?q?s=20total=20/=20book-left=20/=20chapter-left=20(plan=20=C2=A710?= =?UTF-8?q?=20web)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking the end time cycles the three display modes, persisted per device in localStorage (audio_time_display). Pure helpers (mode list, normalize, cycle, endTimeLabel with 0-clamp and no-chapters fallback to book remaining) + vitest; aria-label announces the current mode and the next. Remaining values track the scrub cursor mid-drag. 140 unit tests; 15 Playwright checks on KJV + Gardens. --- templates/audio_reader.templ | 9 ++++- templates/audio_reader_templ.go | 2 +- web/src/audio.test.ts | 50 ++++++++++++++++++++++++-- web/src/audio.ts | 64 +++++++++++++++++++++++++++++++++ 4 files changed, 121 insertions(+), 4 deletions(-) diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index e902484a..5a68b1b4 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -256,7 +256,14 @@ templ AudioReader(user User, metadata ReaderMetadata) { - 0:00 +
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

Shortcuts & interactions
Mouse & touch
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

—

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.test.ts b/web/src/audio.test.ts index fb6bae96..c27237fd 100644 --- a/web/src/audio.test.ts +++ b/web/src/audio.test.ts @@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest"; import { SPEEDS, - normalizeSpeed, - stepSpeed, SKIP_SECONDS, + TIME_DISPLAY_MODES, cycleSkip, + cycleTimeDisplay, + endTimeLabel, normalizeAutoplay, + normalizeSpeed, + normalizeTimeDisplay, + stepSpeed, } from "./audio"; describe("SPEEDS", () => { @@ -93,3 +97,45 @@ describe("normalizeAutoplay", () => { expect(normalizeAutoplay("2")).toBe(false); }); }); + +describe("time display mode (§10 end-label toggle)", () => { + it("cycles total → book remaining → chapter remaining → total", () => { + expect(TIME_DISPLAY_MODES).toEqual(["total", "book-left", "chapter-left"]); + expect(cycleTimeDisplay("total")).toBe("book-left"); + expect(cycleTimeDisplay("book-left")).toBe("chapter-left"); + expect(cycleTimeDisplay("chapter-left")).toBe("total"); + }); + + it("normalizes persisted values, defaulting to total", () => { + expect(normalizeTimeDisplay("total")).toBe("total"); + expect(normalizeTimeDisplay("book-left")).toBe("book-left"); + expect(normalizeTimeDisplay("chapter-left")).toBe("chapter-left"); + expect(normalizeTimeDisplay(null)).toBe("total"); + expect(normalizeTimeDisplay("")).toBe("total"); + expect(normalizeTimeDisplay("remaining")).toBe("total"); + }); + + it("renders total as the book duration", () => { + // 93807s = 26:03:27 (Gardens-shaped) + expect(endTimeLabel("total", 0, 93_807, null)).toBe("26:03:27"); + }); + + it("renders book remaining, clamped at zero", () => { + expect(endTimeLabel("book-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02"); + expect(endTimeLabel("book-left", 93_807, 93_807, null)).toBe("−0:00"); + expect(endTimeLabel("book-left", 99_999, 93_807, null)).toBe("−0:00"); + }); + + it("renders chapter remaining with minutes:seconds formatting", () => { + // 842s left in the chapter → −14:02 + expect(endTimeLabel("chapter-left", 5_000, 93_807, 5_842)).toBe("−14:02"); + }); + + it("falls back to book remaining when the book has no chapters", () => { + expect(endTimeLabel("chapter-left", 93_807 - 3_662, 93_807, null)).toBe("−1:01:02"); + }); + + it("treats a chapter end in the past as zero remaining", () => { + expect(endTimeLabel("chapter-left", 6_000, 93_807, 5_842)).toBe("−0:00"); + }); +}); diff --git a/web/src/audio.ts b/web/src/audio.ts index e3f38a71..32927f31 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -124,6 +124,44 @@ export function normalizeAutoplay(value: string | null): boolean { return value === "1" || value === "true"; } +// ---- End-time display mode (plan §10) ---- +// Clicking the END time label cycles total → book remaining → chapter +// remaining, persisted per device (localStorage audio_time_display). + +export type TimeDisplayMode = "total" | "book-left" | "chapter-left"; + +export const TIME_DISPLAY_MODES: readonly TimeDisplayMode[] = [ + "total", + "book-left", + "chapter-left", +]; + +export function normalizeTimeDisplay(value: string | null): TimeDisplayMode { + return value === "book-left" || value === "chapter-left" ? value : "total"; +} + +export function cycleTimeDisplay(current: TimeDisplayMode): TimeDisplayMode { + const idx = TIME_DISPLAY_MODES.indexOf(current); + return TIME_DISPLAY_MODES[(idx + 1) % TIME_DISPLAY_MODES.length] ?? "total"; +} + +// endTimeLabel renders the END label for a mode. Position is the +// playhead (or the scrub cursor mid-drag) so remaining values track +// what you'd land on. chapterEnd is the current chapter's end — null +// when the book has no chapters, in which case chapter mode falls back +// to book remaining. Remainders clamp at 0. +export function endTimeLabel( + mode: TimeDisplayMode, + position: number, + duration: number, + chapterEnd: number | null, +): string { + const remain = (to: number) => `−${fmtTime(Math.max(0, to - position))}`; + if (mode === "book-left") return remain(duration); + if (mode === "chapter-left") return remain(chapterEnd === null ? duration : chapterEnd); + return fmtTime(duration); +} + const PROGRESS_INTERVAL_MS = 30_000; interface AudioConfig { @@ -164,6 +202,9 @@ Alpine.data("audioReader", () => ({ // Same binding pattern, for the dialog's Mouse & touch section. pointerGuide: POINTER_GUIDE, skipSeconds: Number(localStorage.getItem("audio_skip") || 10), + // End-label display mode (§10): total → book remaining → chapter + // remaining, persisted per device. + timeDisplay: normalizeTimeDisplay(localStorage.getItem("audio_time_display")), // Scrub guard: while dragging, timeupdate must not fight the handle scrubbing: false, scrubValue: 0, @@ -702,6 +743,29 @@ Alpine.data("audioReader", () => ({ return ch.title || `Chapter ${idx + 1}`; }, + // ---- end-time label (§10): total → book left → chapter left ---- + + endTimeText(): string { + const duration = this.chaptersDuration() || 1; + const pos = this.scrubbing ? this.scrubValue : Number(this.currentTime); + const idx = chapterIndexFor(this.chapters, pos); + const ch = this.chapters[idx === -1 ? 0 : idx]; + return endTimeLabel(this.timeDisplay, pos, duration, ch ? ch.end : null); + }, + + endTimeAria(): string { + if (this.timeDisplay === "book-left") + return "Book time remaining — click to show chapter remaining"; + if (this.timeDisplay === "chapter-left") + return "Chapter time remaining — click to show total duration"; + return "Total duration — click to show book remaining"; + }, + + cycleEndTime() { + this.timeDisplay = cycleTimeDisplay(this.timeDisplay); + localStorage.setItem("audio_time_display", this.timeDisplay); + }, + scrubFillPct(): number { const value = this.scrubbing ? this.scrubValue : Number(this.currentTime); return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100));