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
@@ -256,7 +256,14 @@ templ AudioReader(user User, metadata ReaderMetadata) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="inline-block min-w-[4.75rem] text-right" x-text="fmtTime(chaptersDuration() || 1)">0:00</span>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-block min-w-[4.75rem] text-right cursor-pointer"
|
||||
x-text="endTimeText()"
|
||||
:aria-label="endTimeAria()"
|
||||
:title="endTimeAria()"
|
||||
@click="cycleEndTime()"
|
||||
>0:00</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-3 md:gap-5">
|
||||
<button
|
||||
|
||||
@@ -194,7 +194,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, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\" :style=\"railLiftY ? 'transform: translateY(' + railLiftY + 'px);' : ''\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><span claLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t inside the pixel radius locks the tooltip here) --><div x-show=\"hoverSnapped && !scrubbing\" x-cloak class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--accent);'\"></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\" :style=\"railLiftY ? 'transform: translateY(' + railLiftY + 'px);' : ''\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><div class=\"absolute top-1 bottom-1 w-px\" :style=\"'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'\"></div></template><div class=\"absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full\" :style=\"'left:' + railMarkerPct() + '%;background-color:var(--accent);'\"></div><div class=\"absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap\" style=\"color: var(--text-primary);\" x-text=\"fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)\"></div><div class=\"absolute left-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railFrom)\"></div><div class=\"absolute right-1 bottom-0.5 text-[9px] tabular-nums\" style=\"color: var(--text-secondary);\" x-text=\"fmtTime(railTo)\"></div></div></div></div><button tLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
+48
-2
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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