diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 239b5c20..2f15be9e 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -18,6 +18,17 @@ templ AudioReader(user User, metadata ReaderMetadata) { +
- 0:00 - - 0:00 + 0:00 +
+ +
+ +
+ + + +
+ +
+ + +
+ + +
+ +
+
+
+ +
+
+
+
+
+ 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

Keyboard shortcuts
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

—

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

Keyboard shortcuts
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/audio-scrubber.test.ts b/web/src/audio-scrubber.test.ts new file mode 100644 index 00000000..4c7cabd9 --- /dev/null +++ b/web/src/audio-scrubber.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it } from "vitest"; + +import { + bufferedAheadEnd, + bufferedSpanPct, + chapterIndexFor, + denseTicks, + railSecondsAtPointer, + railTicks, + railWindow, + RAIL_SPAN, + secondsAtPointer, + snapToChapter, +} from "./audio-scrubber"; + +// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour +// single-file book (Gardens-shaped: 34 chapters). +const duration86h = 310_320; +const chapters1189 = Array.from({ length: 1189 }, (_, i) => ({ start: (i * duration86h) / 1189 })); +const duration26h = 93_807; +const chapters34 = Array.from({ length: 34 }, (_, i) => ({ + start: (i * duration26h) / 34, + title: `Chapter ${i + 1}`, +})); + +const rect = (width: number): DOMRect => + ({ + left: 100, + right: 100 + width, + width, + top: 0, + bottom: 10, + height: 10, + x: 100, + y: 0, + toJSON: () => ({}), + }) as DOMRect; + +describe("secondsAtPointer", () => { + it("maps the bar linearly and clamps outside it", () => { + const r = rect(800); + expect(secondsAtPointer(100, r, duration86h)).toBe(0); + expect(secondsAtPointer(500, r, duration86h)).toBeCloseTo(duration86h / 2, 5); + expect(secondsAtPointer(900, r, duration86h)).toBe(duration86h); + expect(secondsAtPointer(50, r, duration86h)).toBe(0); + expect(secondsAtPointer(1200, r, duration86h)).toBe(duration86h); + }); + + it("is safe with degenerate rects and durations", () => { + expect(secondsAtPointer(150, rect(0), duration86h)).toBe(0); + expect(secondsAtPointer(150, rect(800), 0)).toBe(0); + }); +}); + +describe("chapterIndexFor", () => { + it("finds the containing chapter in both shapes", () => { + expect(chapterIndexFor(chapters1189, 0)).toBe(0); + expect(chapterIndexFor(chapters1189, chapters1189[500].start + 1)).toBe(500); + expect(chapterIndexFor(chapters34, chapters34[33].start + 60)).toBe(33); + expect(chapterIndexFor(chapters34, duration26h + 5000)).toBe(33); + }); + + it("returns -1 before the first chapter and for none at all", () => { + expect(chapterIndexFor(chapters1189, -1)).toBe(-1); + expect(chapterIndexFor([], 100)).toBe(-1); + }); +}); + +describe("snapToChapter", () => { + it("snaps within the threshold and not beyond it", () => { + const start = chapters1189[10].start; + expect(snapToChapter(start + 3, chapters1189, 5)).toEqual({ seconds: start, snapped: true }); + expect(snapToChapter(start + 4.9, chapters1189, 5)).toEqual({ seconds: start, snapped: true }); + expect(snapToChapter(start + 5.1, chapters1189, 5).snapped).toBe(false); + expect(snapToChapter(start - 5, chapters1189, 5)).toEqual({ seconds: start, snapped: true }); + }); + + it("picks the NEAREST line when two are in range", () => { + const a = chapters34[5].start; + const b = chapters34[6].start; + const mid = (a + b) / 2; + // Chapter gap (~2759s) means both lines sit ~1380s from the + // midpoint — the threshold must reach past it. + const result = snapToChapter(mid + 1, chapters34, 1500); + expect(result.snapped).toBe(true); + expect(result.seconds).toBe(b); + }); + + it("leaves the position alone with no chapters", () => { + expect(snapToChapter(42, [], 5)).toEqual({ seconds: 42, snapped: false }); + }); +}); + +describe("denseTicks", () => { + it("decimates an 1189-chapter book to a readable number of marks", () => { + const ticks = denseTicks(chapters1189, duration86h, 800, 4); + expect(ticks.length).toBeGreaterThan(50); + expect(ticks.length).toBeLessThan(300); + expect(ticks[0]).toBe(0); + // kept marks are strictly increasing and spaced by the gap rule + for (let i = 1; i < ticks.length; i += 1) { + const gapPx = + ((chapters1189[ticks[i]].start - chapters1189[ticks[i - 1]].start) / duration86h) * 800; + expect(gapPx).toBeGreaterThanOrEqual(4); + } + }); + + it("keeps every chapter for a sparse 34-chapter book", () => { + const ticks = denseTicks(chapters34, duration26h, 800, 4); + expect(ticks).toHaveLength(34); + }); + + it("handles degenerate inputs", () => { + expect(denseTicks([], duration86h, 800)).toEqual([]); + expect(denseTicks(chapters34, 0, 800)).toEqual([]); + expect(denseTicks(chapters34, duration26h, 0)).toEqual([]); + }); +}); + +describe("bufferedAheadEnd", () => { + it("returns the end of the range containing the playhead", () => { + const ranges = [ + { start: 0, end: 2 }, + { start: 92_719, end: 93_808 }, + ]; + // A huge m4b's moov index at the tail is its own range — it must + // NOT read as "the whole book is buffered". + expect(bufferedAheadEnd(ranges, 0)).toBe(2); + expect(bufferedAheadEnd(ranges, 1.5)).toBe(2); + expect(bufferedAheadEnd(ranges, 92_800)).toBe(93_808); + }); + + it("walks into the next range when the playhead sits at a boundary", () => { + expect( + bufferedAheadEnd( + [ + { start: 0, end: 10 }, + { start: 10, end: 30 }, + ], + 10, + ), + ).toBe(30); + }); + + it("returns null when the playhead is in a gap or ranges are empty", () => { + expect(bufferedAheadEnd([{ start: 50, end: 60 }], 10)).toBeNull(); + expect(bufferedAheadEnd([], 10)).toBeNull(); + }); +}); + +describe("bufferedSpanPct", () => { + it("maps only the active track's segment on a multi-track book", () => { + // Track 8 of the KJV shape spans ~2088s..2344s of 310320s. + const track = { start: 2088, end: 2344 }; + const span = bufferedSpanPct(track, 2344, duration86h); + expect(span).not.toBeNull(); + expect(span!.left).toBeCloseTo((2088 / duration86h) * 100, 5); + expect(span!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5); + // Element buffer beyond the track end is clamped to the track. + const clamped = bufferedSpanPct(track, 99999, duration86h); + expect(clamped!.width).toBeCloseTo(((2344 - 2088) / duration86h) * 100, 5); + }); + + it("spans the whole bar for a single-file book (start 0, end unknown)", () => { + const span = bufferedSpanPct({ start: 0, end: 0 }, 40_000, duration26h); + expect(span!.left).toBe(0); + expect(span!.width).toBeCloseTo((40_000 / duration26h) * 100, 5); + }); + + it("returns null with no track or no buffer", () => { + expect(bufferedSpanPct(undefined, 100, duration86h)).toBeNull(); + expect(bufferedSpanPct({ start: 0, end: 0 }, null, duration86h)).toBeNull(); + expect(bufferedSpanPct({ start: 0, end: 0 }, 0, duration86h)).toBeNull(); + }); +}); + +describe("railWindow / railSecondsAtPointer / railTicks", () => { + it("centers a ±5min window and clamps at the book edges", () => { + const mid = railWindow(duration26h / 2, duration26h); + expect(mid.from).toBeCloseTo(duration26h / 2 - RAIL_SPAN / 2, 5); + expect(mid.to).toBeCloseTo(duration26h / 2 + RAIL_SPAN / 2, 5); + const atStart = railWindow(0, duration26h); + expect(atStart.from).toBe(0); + expect(atStart.to).toBe(RAIL_SPAN); + const atEnd = railWindow(duration26h, duration26h); + expect(atEnd.to).toBe(duration26h); + expect(atEnd.to - atEnd.from).toBe(RAIL_SPAN); + // A book shorter than the window clamps to the whole book. + expect(railWindow(60, 120)).toEqual({ from: 0, to: 120 }); + }); + + it("maps pointer X at minutes-per-pixel inside the window", () => { + const r = rect(600); + const window = { from: 3000, to: 3600 }; + expect(railSecondsAtPointer(100, r, window)).toBe(3000); + expect(railSecondsAtPointer(400, r, window)).toBe(3300); + expect(railSecondsAtPointer(700, r, window)).toBe(3600); + }); + + it("lists the chapter starts inside the window with titles", () => { + // from is 1s past chapter 4's start, so chapter 4 is outside. + const ticks = railTicks( + { from: chapters34[3].start + 1, to: chapters34[6].start + 1 }, + chapters34, + ); + expect(ticks.map((t) => t.title)).toEqual(["Chapter 5", "Chapter 6", "Chapter 7"]); + }); +}); diff --git a/web/src/audio-scrubber.ts b/web/src/audio-scrubber.ts new file mode 100644 index 00000000..3da16793 --- /dev/null +++ b/web/src/audio-scrubber.ts @@ -0,0 +1,151 @@ +// Scrubber math (player plan §1 — progress bar overhaul). Pure and +// DOM-free except for passing rects in; the Alpine component owns the +// element, the pointer events and the state. Everything here is +// unit-tested — the snap threshold, the buffer→global mapping and the +// precision-rail mapping are exactly the parts that must hold for an +// 86-hour, 1189-track book AND a 2-hour single-file m4b. + +export interface ScrubChapter { + start: number; + title?: string; +} + +// secondsAtPointer maps a pointer X within the scrub bar's rect to a +// global book position, clamped to [0, duration]. +export function secondsAtPointer(clientX: number, rect: DOMRect, duration: number): number { + if (rect.width <= 0 || duration <= 0) return 0; + const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return fraction * duration; +} + +// chapterIndexFor returns the index of the chapter containing seconds — +// the last chapter whose start is at or before it. Empty chapters → -1. +export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): number { + let found = -1; + for (let i = 0; i < chapters.length; i += 1) { + if (seconds >= chapters[i].start) found = i; + else break; + } + return found; +} + +// snapToChapter returns the chapter start when the position falls +// within thresholdSeconds of one (click-to-snap), otherwise the +// position unchanged. +export function snapToChapter( + seconds: number, + chapters: ScrubChapter[], + thresholdSeconds = 5, +): { seconds: number; snapped: boolean } { + let best: { seconds: number; snapped: boolean } = { seconds, snapped: false }; + let bestDistance = thresholdSeconds; + for (const ch of chapters) { + const distance = Math.abs(ch.start - seconds); + if (distance <= bestDistance) { + bestDistance = distance; + best = { seconds: ch.start, snapped: true }; + } + } + return best; +} + +// denseTicks decides which chapter starts get a rendered tick: a +// chapter earns a mark only when it is at least minGapPx from the last +// kept one — an 1189-chapter book would otherwise paint the bar solid. +// Returns chapter INDICES (into chapters), always including the first. +export function denseTicks( + chapters: ScrubChapter[], + duration: number, + widthPx: number, + minGapPx = 4, +): number[] { + if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return []; + const kept: number[] = [0]; + let lastPx = (chapters[0].start / duration) * widthPx; + for (let i = 1; i < chapters.length; i += 1) { + const px = (chapters[i].start / duration) * widthPx; + if (px - lastPx >= minGapPx) { + kept.push(i); + lastPx = px; + } + } + return kept; +} + +// bufferedAheadEnd returns the end of the buffered range containing +// the playhead — how much audio is actually fetched AHEAD of where you +// are. A huge m4b's moov index at the file tail shows up as its own +// buffered range; counting it would claim the whole book is buffered. +export function bufferedAheadEnd( + ranges: Array<{ start: number; end: number }>, + position: number, +): number | null { + for (const r of ranges) { + if (position >= r.start && position < r.end) return r.end; + } + return null; +} + +// bufferedSpanPct maps the ACTIVE track's buffered-ahead end into the +// global bar. Buffers are per-track: the span is drawn only across the +// active track's segment of the bar, never the whole bar. Returns null +// when there is nothing buffered (or the track is unknown). +export function bufferedSpanPct( + track: { start: number; end: number } | undefined, + elementBufferedEnd: number | null, + duration: number, +): { left: number; width: number } | null { + if (!track || elementBufferedEnd === null || elementBufferedEnd <= 0 || duration <= 0) + return null; + const bufferedEnd = track.end > 0 ? Math.min(elementBufferedEnd, track.end) : elementBufferedEnd; + const span = Math.max(0, bufferedEnd - track.start); + const left = Math.min(100, Math.max(0, (track.start / duration) * 100)); + const width = Math.min(100 - left, Math.max(0, (span / duration) * 100)); + return { left, width }; +} + +// RAIL_SPAN is the precision rail's total width in seconds: ±5 minutes +// around the drag anchor. +export const RAIL_SPAN = 600; + +// railWindow clamps a ±span/2 window around the anchor to the book. +export function railWindow( + anchorSeconds: number, + duration: number, + spanSeconds = RAIL_SPAN, +): { from: number; to: number } { + const from = Math.max( + 0, + Math.min(anchorSeconds - spanSeconds / 2, Math.max(0, duration - spanSeconds)), + ); + const to = Math.min(duration, from + spanSeconds); + return { from, to }; +} + +// railSecondsAtPointer maps a pointer X to a position INSIDE the rail +// window — minutes-per-pixel instead of hours-per-pixel. +export function railSecondsAtPointer( + clientX: number, + rect: DOMRect, + window: { from: number; to: number }, +): number { + if (rect.width <= 0) return window.from; + const fraction = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return window.from + fraction * (window.to - window.from); +} + +// railTicks returns the chapter starts (with titles) inside a rail +// window — readable density is the rail's whole point. +export function railTicks( + window: { from: number; to: number }, + chapters: ScrubChapter[], +): Array<{ start: number; title: string }> { + const out: Array<{ start: number; title: string }> = []; + for (let i = 0; i < chapters.length; i += 1) { + const start = chapters[i].start; + if (start < window.from) continue; + if (start > window.to) break; + out.push({ start, title: chapters[i].title || `Chapter ${i + 1}` }); + } + return out; +} diff --git a/web/src/audio.ts b/web/src/audio.ts index f30c1204..14243271 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -28,6 +28,17 @@ import { type SleepChoice, type SleepState, } from "./audio-sleep"; +import { + bufferedAheadEnd, + bufferedSpanPct, + chapterIndexFor, + denseTicks, + railSecondsAtPointer, + railTicks, + railWindow, + secondsAtPointer, + snapToChapter, +} from "./audio-scrubber"; type AudioChapter = { index: number; title: string; start: number; end: number }; @@ -149,6 +160,17 @@ Alpine.data("audioReader", () => ({ scrubbing: false, scrubValue: 0, + // Progress-bar overhaul (plan §1): chapter ticks, per-track buffer + // span, hover tooltip and the precision rail. Math lives in + // audio-scrubber.ts (pure, unit-tested). + tickIndices: [] as number[], + bufferedGlobal: null as number | null, + hoverSeconds: null as number | null, + hoverPct: 0, + railFrom: 0, + railTo: 0, + railTickList: [] as Array<{ start: number; title: string }>, + // Track manifest: one AudioTrack per playable file. Single-file books // (m4b/mp3) carry exactly one track whose URL is the whole file, so // every code path below can treat playback as (track, local seconds) @@ -331,9 +353,24 @@ Alpine.data("audioReader", () => ({ this.updateMediaSessionState(); }); window.addEventListener("pagehide", () => this.pushProgress(true)); + window.addEventListener("resize", () => this.recomputeTicks()); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") this.pushProgress(true); }); + // Buffer readout for the scrubber's per-track buffer span. + // 'progress' alone does not fire reliably for Range-served files + // that go network-idle while paused — ride the events that + // demonstrably do, and re-read the (idempotent) ranges on each. + for (const ev of [ + "progress", + "loadedmetadata", + "loadeddata", + "canplay", + "playing", + "timeupdate", + ]) { + audio.addEventListener(ev, () => this.updateBuffered()); + } // Keyboard shortcuts (plan §2): one dispatcher resolving through // the declarative audio-keys.ts keymap. Hardware media keys ride @@ -381,6 +418,8 @@ Alpine.data("audioReader", () => ({ if (!audio || index < 0 || index >= this.tracks.length) return; const track = this.tracks[index]; this.activeTrack = index; + this.bufferedGlobal = null; // buffers are per-track; the next + // 'progress' event re-reads the freshly loading element's ranges. if (audio.getAttribute("src") === track.url) { this.pendingSeek = null; audio.currentTime = Math.max(0, localSeconds); @@ -404,6 +443,7 @@ Alpine.data("audioReader", () => ({ this.chapters = (data.chapters || []) as AudioChapter[]; this.bookDuration = data.duration_seconds || this.chapterEndEstimate(); this.buildTracks(data); + this.recomputeTicks(); const audio = this.audio(); if (audio && !audio.getAttribute("src") && this.tracks[0]) { audio.setAttribute("src", this.tracks[0].url); @@ -609,6 +649,128 @@ Alpine.data("audioReader", () => ({ return Number.isFinite(n) ? n : 0; }, + // ---- progress bar (audio-scrubber.ts math) ---- + + recomputeTicks() { + const area = document.getElementById("scrub-area"); + const width = area ? area.getBoundingClientRect().width : 0; + this.tickIndices = denseTicks( + this.chapters, + this.chaptersDuration() || this.bookDuration || 1, + width, + ); + }, + + updateBuffered() { + const audio = this.audio(); + if (!audio || audio.buffered.length === 0) { + this.bufferedGlobal = null; + return; + } + // Buffered-ahead of the playhead (element-local): the end of the + // range containing currentTime. The moov-index range at a huge + // m4b's tail must not read as "the whole book is buffered". + const ranges = Array.from({ length: audio.buffered.length }, (_, i) => ({ + start: audio.buffered.start(i), + end: audio.buffered.end(i), + })); + this.bufferedGlobal = bufferedAheadEnd(ranges, audio.currentTime); + }, + + chapterTitleAt(seconds: number): string { + const idx = chapterIndexFor(this.chapters, seconds); + if (idx === -1) return this.title; + const ch = this.chapters[idx]; + return ch.title || `Chapter ${idx + 1}`; + }, + + scrubFillPct(): number { + const value = this.scrubbing ? this.scrubValue : Number(this.currentTime); + return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100)); + }, + + bufferSpan(): { left: number; width: number } | null { + return bufferedSpanPct( + this.tracks[this.activeTrack], + this.bufferedGlobal, + this.chaptersDuration() || 1, + ); + }, + + railMarkerPct(): number { + const span = Math.max(1, this.railTo - this.railFrom); + return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100)); + }, + + // Click/drag on the bar: the FIRST press maps hours-per-pixel and + // snaps to a chapter line within ~5s; while held, the precision rail + // takes over at minutes-per-pixel (re-grab to travel far). The + // native range stays underneath for keyboard/aria — its drag default + // is prevented so the two mappings never fight. + scrubPointerDown(e: PointerEvent) { + if (e.button !== 0) return; + e.preventDefault(); + const area = e.currentTarget as HTMLElement; + const rect = area.getBoundingClientRect(); + try { + area.setPointerCapture(e.pointerId); + } catch { + /* synthetic or already-released pointer — drag still works via + the element-level listeners */ + } + document.getElementById("audio-scrub")?.focus({ preventScroll: true }); + const duration = this.chaptersDuration() || 1; + const at = snapToChapter(secondsAtPointer(e.clientX, rect, duration), this.chapters, 5).seconds; + this.scrubbing = true; + this.scrubValue = at; + const window = railWindow(at, duration); + this.railFrom = window.from; + this.railTo = window.to; + this.railTickList = railTicks(window, this.chapters); + }, + + scrubPointerMove(e: PointerEvent) { + const area = e.currentTarget as HTMLElement; + const rect = area.getBoundingClientRect(); + const duration = this.chaptersDuration() || 1; + if (!this.scrubbing) { + this.hoverSeconds = secondsAtPointer(e.clientX, rect, duration); + this.hoverPct = + rect.width > 0 + ? Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100)) + : 0; + return; + } + this.scrubValue = railSecondsAtPointer(e.clientX, rect, { + from: this.railFrom, + to: this.railTo, + }); + }, + + scrubPointerLeave() { + if (!this.scrubbing) this.hoverSeconds = null; + }, + + scrubPointerUp(e: PointerEvent) { + if (!this.scrubbing) return; + const area = e.currentTarget as HTMLElement; + if (area.hasPointerCapture && area.hasPointerCapture(e.pointerId)) + area.releasePointerCapture(e.pointerId); + this.seekTo(this.scrubValue); + this.scrubbing = false; + this.hoverSeconds = null; + }, + + // Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward. + onScrubWheel(e: WheelEvent) { + e.preventDefault(); + const step = e.shiftKey ? 60 : 5; + const direction = e.deltaY > 0 ? -1 : 1; + const duration = this.chaptersDuration() || 1; + const target = Math.min(duration, Math.max(0, this.globalPosition() + direction * step)); + this.seekTo(target); + }, + toggleBookmarkCapture() { if (this.bookmarkCaptureOpen) { this.bookmarkCaptureOpen = false;