feat(audio-player): end-time label toggles total / book-left / chapter-left (plan §10 web)

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.
This commit is contained in:
John O'Keefe committed 2026-10-22 13:11:16 -04:00
1 parent 96fd2aad42
commit a9430f3c06
4 files changed
+121 -4

No files matched your search

+48 -2
View File
@@ -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");
});
});
+64
View File
@@ -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));