feat(web): multi-track audiobook player — per-track src switching, global-seconds position

The audio reader pointed <audio src> at ONE file — correct for m4b but
impossible for folder-of-tracks and zip books (the element holds a
single source). The player now generalizes to a track list built from
the /chapters manifest: single-file books are exactly one track whose
url is the whole file (zero behavior change — same element, same URL
flow), multi-file books swap src at track boundaries.

Mapping helpers (exported, unit-testable): trackIndexFor resolves a
global position to the owning track, localOffsetFor/globalSecondsFor
convert between global seconds — the coordinate system of chapters,
bookmarks and audio_offset_seconds — and (track, local offset). Every
consumer keeps global semantics: scrubber, skip ±N, bookmark saves,
?loc=audio: deep links and progress pushes are byte-identical payloads
server-side; the server contract does not move.

Mechanics: setActiveTrack swaps src and queues the position for
loadedmetadata (setting currentTime before the new metadata loads
resets it); ended advances to the next track and keeps playing;
timeupdate reports globalPosition(); playbackRate/volume re-apply
after swaps; src is deferred until the manifest loads (data-file-url
is a directory/zip root for multi-file books, not playable) with the
catch branch falling back to it for offline single-file starts.
This commit is contained in:
John O'Keefe committed 2026-10-19 07:54:45 -04:00
1 parent 6216bd9bcf
commit 79ce5ab89d
1 file changed
+131 -11
+131 -11
View File
@@ -18,6 +18,36 @@ import { showToast } from "./toast";
type AudioChapter = { index: number; title: string; start: number; end: number }; type AudioChapter = { index: number; title: string; start: number; end: number };
// One playable file of an audiobook. Multi-file books (folder/zip
// layouts) carry several; single-file books carry exactly one spanning
// the whole file. start/end are GLOBAL seconds — the same coordinate
// system as chapters, bookmarks and audio_offset_seconds.
export type AudioTrack = { url: string; start: number; end: number; title?: string };
// trackIndexFor finds the track holding a global position: the last one
// whose start is at or before it (zero-duration/unknown-end tracks keep
// working because only start matters for membership).
export function trackIndexFor(tracks: AudioTrack[], globalSeconds: number): number {
for (let i = tracks.length - 1; i > 0; i -= 1) {
if (globalSeconds >= tracks[i].start) return i;
}
return 0;
}
// localOffsetFor converts a global position to seconds within track i.
export function localOffsetFor(tracks: AudioTrack[], index: number, globalSeconds: number): number {
const track = tracks[index];
if (!track) return Math.max(0, globalSeconds);
return Math.max(0, globalSeconds - track.start);
}
// globalSecondsFor converts (track i, local seconds) back to the global
// position — the inverse of localOffsetFor.
export function globalSecondsFor(tracks: AudioTrack[], index: number, localSeconds: number): number {
const track = tracks[index];
return (track ? track.start : 0) + Math.max(0, localSeconds);
}
const PROGRESS_INTERVAL_MS = 30_000; const PROGRESS_INTERVAL_MS = 30_000;
interface AudioConfig { interface AudioConfig {
@@ -57,6 +87,17 @@ Alpine.data("audioReader", () => ({
scrubbing: false, scrubbing: false,
scrubValue: 0, scrubValue: 0,
// 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)
// and map through globalSecondsFor/localOffsetFor.
fileUrl: "",
tracks: [] as AudioTrack[],
activeTrack: 0,
// Local seconds to apply to the <audio> element once the freshly
// switched track's metadata loads (src swaps reset currentTime).
pendingSeek: null as number | null,
// Bookmarks (A2d): time-positioned, with notes. Audio locator // Bookmarks (A2d): time-positioned, with notes. Audio locator
// convention: position = "audio:<seconds>" (chapter_number filled for // convention: position = "audio:<seconds>" (chapter_number filled for
// display); dedup + cross-device sync happen server-side. // display); dedup + cross-device sync happen server-side.
@@ -105,17 +146,19 @@ Alpine.data("audioReader", () => ({
document.title = "REJ: " + (e.reason?.message || String(e.reason)); document.title = "REJ: " + (e.reason?.message || String(e.reason));
}); });
const body = document.body as HTMLElement; const body = document.body as HTMLElement;
const config = readConfig();
const mediaId = body.getAttribute("data-media-item-id") || ""; const mediaId = body.getAttribute("data-media-item-id") || "";
const title = body.getAttribute("data-title") || ""; const title = body.getAttribute("data-title") || "";
const cover = body.getAttribute("data-cover") || ""; const cover = body.getAttribute("data-cover") || "";
const fileUrl = body.getAttribute("data-file-url") || ""; const fileUrl = body.getAttribute("data-file-url") || "";
this.mediaItemId = mediaId; this.mediaItemId = mediaId;
this.title = title; this.title = title;
this.fileUrl = fileUrl;
const audio = this.audio(); const audio = this.audio();
if (!audio) return; if (!audio) return;
audio.src = fileUrl; // src is set by restorePosition once the track manifest is known —
// for multi-file books data-file-url (a directory or a zip root) is
// NOT directly playable. The catch branch below falls back to it.
audio.playbackRate = Number(this.speed) || 1; audio.playbackRate = Number(this.speed) || 1;
audio.preload = "auto"; audio.preload = "auto";
// Restored volume/mute from the settings strip's persisted state. // Restored volume/mute from the settings strip's persisted state.
@@ -145,8 +188,17 @@ Alpine.data("audioReader", () => ({
} }
// Element events. // Element events.
audio.addEventListener("loadedmetadata", () => {
// A freshly switched track applies its queued position once the
// browser knows the duration; same-track seeks set currentTime
// directly in setActiveTrack.
if (this.pendingSeek != null) {
audio.currentTime = Math.max(0, this.pendingSeek);
this.pendingSeek = null;
}
});
audio.addEventListener("timeupdate", () => { audio.addEventListener("timeupdate", () => {
if (!this.scrubbing) this.currentTime = audio.currentTime; if (!this.scrubbing) this.currentTime = this.globalPosition();
this.tickChapter(); this.tickChapter();
// Honest session accounting: only ACTUAL PLAYING time counts. The // Honest session accounting: only ACTUAL PLAYING time counts. The
// prevSnapshot walk keeps internal seeks/crossfades from inflating // prevSnapshot walk keeps internal seeks/crossfades from inflating
@@ -158,6 +210,13 @@ Alpine.data("audioReader", () => ({
} }
this.prevTick = now; this.prevTick = now;
}); });
audio.addEventListener("ended", () => {
// Natural end of track n: roll into n+1 and keep playing. At the
// last track the book is done — nothing to advance into.
if (this.activeTrack < this.tracks.length - 1) {
this.setActiveTrack(this.activeTrack + 1, 0, true);
}
});
audio.addEventListener("play", () => { audio.addEventListener("play", () => {
this.playing = true; this.playing = true;
this.prevTick = performance.now(); this.prevTick = performance.now();
@@ -199,6 +258,53 @@ Alpine.data("audioReader", () => ({
return document.getElementById("audio-element") as HTMLAudioElement | null; return document.getElementById("audio-element") as HTMLAudioElement | null;
}, },
// ---- track manifest (multi-file books) ----
buildTracks(data: Record<string, unknown>) {
const raw = (data.tracks || []) as Array<Record<string, unknown>>;
if (raw.length > 0) {
this.tracks = raw.map((t) => ({
url: String(t.url || ""),
start: Number(t.start) || 0,
end: Number(t.end) || 0,
title: t.title ? String(t.title) : undefined,
}));
} else {
// Single-file book: the whole file is the one track. end stays 0
// (unknown) — the browser's own duration governs the element and
// membership never needs it for a single track.
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
}
this.activeTrack = 0;
},
globalPosition(): number {
const audio = this.audio();
if (!audio) return this.currentTime;
return globalSecondsFor(this.tracks, this.activeTrack, audio.currentTime);
},
// setActiveTrack points the <audio> element at track i at localSeconds.
// Same-track moves set currentTime directly; a src swap queues the
// position for loadedmetadata (setting currentTime before the new
// metadata loads throws or silently resets).
setActiveTrack(index: number, localSeconds: number, resume: boolean) {
const audio = this.audio();
if (!audio || index < 0 || index >= this.tracks.length) return;
const track = this.tracks[index];
this.activeTrack = index;
if (audio.getAttribute("src") === track.url) {
this.pendingSeek = null;
audio.currentTime = Math.max(0, localSeconds);
} else {
this.pendingSeek = Math.max(0, localSeconds);
audio.setAttribute("src", track.url);
audio.playbackRate = Number(this.speed) || 1;
audio.load();
}
if (resume) audio.play().catch(() => showToast("Playback failed to start", "error"));
},
async restorePosition() { async restorePosition() {
try { try {
const [chaptersResp, progress] = await Promise.all([ const [chaptersResp, progress] = await Promise.all([
@@ -212,6 +318,11 @@ Alpine.data("audioReader", () => ({
const data = await chaptersResp.json(); const data = await chaptersResp.json();
this.chapters = (data.chapters || []) as AudioChapter[]; this.chapters = (data.chapters || []) as AudioChapter[];
this.bookDuration = data.duration_seconds || this.chapterEndEstimate(); this.bookDuration = data.duration_seconds || this.chapterEndEstimate();
this.buildTracks(data);
const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.tracks[0]) {
audio.setAttribute("src", this.tracks[0].url);
}
} }
if (progress.ok) { if (progress.ok) {
const p = await progress.json(); const p = await progress.json();
@@ -233,7 +344,13 @@ Alpine.data("audioReader", () => ({
this.tickChapter(); this.tickChapter();
} }
} catch { } catch {
// Offline start: still playable; sync resumes when reachable. // Offline start: still playable from the plain file URL (single-
// file books only; multi-file books need the manifest).
const audio = this.audio();
if (audio && !audio.getAttribute("src") && this.fileUrl) {
this.tracks = [{ url: this.fileUrl, start: 0, end: 0 }];
audio.setAttribute("src", this.fileUrl);
}
} }
}, },
@@ -282,14 +399,16 @@ Alpine.data("audioReader", () => ({
const audio = this.audio(); const audio = this.audio();
if (!audio) return; if (!audio) return;
const target = Number(seconds); const target = Number(seconds);
if (Number.isFinite(target)) audio.currentTime = Math.max(0, target); if (!Number.isFinite(target)) return;
this.currentTime = audio.currentTime; // Global seconds in: resolve the owning track and move within it.
const index = trackIndexFor(this.tracks, target);
this.setActiveTrack(index, localOffsetFor(this.tracks, index, target), false);
this.currentTime = target;
}, },
skip(direction: 1 | -1) { skip(direction: 1 | -1) {
const audio = this.audio(); const target = Math.max(0, this.globalPosition() + direction * (Number(this.skipSeconds) || 10));
if (!audio) return; this.seekTo(target);
audio.currentTime = Math.max(0, audio.currentTime + direction * (Number(this.skipSeconds) || 10));
}, },
stepChapter(delta: 1 | -1) { stepChapter(delta: 1 | -1) {
@@ -406,7 +525,7 @@ Alpine.data("audioReader", () => ({
async saveBookmark() { async saveBookmark() {
const audio = this.audio(); const audio = this.audio();
if (!audio) return; if (!audio) return;
const at = Math.round(audio.currentTime); const at = Math.round(this.globalPosition());
const chapter = this.chapters[this.currentChapterIdx]; const chapter = this.chapters[this.currentChapterIdx];
const title = chapter const title = chapter
? `${chapter.title || `Chapter ${chapter.index + 1}`} — ${fmtTime(at)}` ? `${chapter.title || `Chapter ${chapter.index + 1}`} — ${fmtTime(at)}`
@@ -502,7 +621,8 @@ Alpine.data("audioReader", () => ({
if (this.chaptersDuration() <= 0) return; if (this.chaptersDuration() <= 0) return;
const audio = this.audio(); const audio = this.audio();
if (!audio) return; if (!audio) return;
const at = audio.currentTime; // Global book position, whatever track is physically playing.
const at = this.globalPosition();
let chapter = 0; let chapter = 0;
let offset = 0; let offset = 0;
for (let i = 0; i < this.chapters.length; i += 1) { for (let i = 0; i < this.chapters.length; i += 1) {