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:
1 parent
96fd2aad42
commit
a9430f3c06
4 files changed
+121
-4
No files matched your search
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user