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:
1 parent
6216bd9bcf
commit
79ce5ab89d
1 file changed
+131
-11
+131
-11
@@ -18,6 +18,36 @@ import { showToast } from "./toast";
|
||||
|
||||
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;
|
||||
|
||||
interface AudioConfig {
|
||||
@@ -57,6 +87,17 @@ Alpine.data("audioReader", () => ({
|
||||
scrubbing: false,
|
||||
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
|
||||
// convention: position = "audio:<seconds>" (chapter_number filled for
|
||||
// display); dedup + cross-device sync happen server-side.
|
||||
@@ -105,17 +146,19 @@ Alpine.data("audioReader", () => ({
|
||||
document.title = "REJ: " + (e.reason?.message || String(e.reason));
|
||||
});
|
||||
const body = document.body as HTMLElement;
|
||||
const config = readConfig();
|
||||
const mediaId = body.getAttribute("data-media-item-id") || "";
|
||||
const title = body.getAttribute("data-title") || "";
|
||||
const cover = body.getAttribute("data-cover") || "";
|
||||
const fileUrl = body.getAttribute("data-file-url") || "";
|
||||
this.mediaItemId = mediaId;
|
||||
this.title = title;
|
||||
this.fileUrl = fileUrl;
|
||||
|
||||
const audio = this.audio();
|
||||
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.preload = "auto";
|
||||
// Restored volume/mute from the settings strip's persisted state.
|
||||
@@ -145,8 +188,17 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
|
||||
// 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", () => {
|
||||
if (!this.scrubbing) this.currentTime = audio.currentTime;
|
||||
if (!this.scrubbing) this.currentTime = this.globalPosition();
|
||||
this.tickChapter();
|
||||
// Honest session accounting: only ACTUAL PLAYING time counts. The
|
||||
// prevSnapshot walk keeps internal seeks/crossfades from inflating
|
||||
@@ -158,6 +210,13 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
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", () => {
|
||||
this.playing = true;
|
||||
this.prevTick = performance.now();
|
||||
@@ -199,6 +258,53 @@ Alpine.data("audioReader", () => ({
|
||||
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() {
|
||||
try {
|
||||
const [chaptersResp, progress] = await Promise.all([
|
||||
@@ -212,6 +318,11 @@ Alpine.data("audioReader", () => ({
|
||||
const data = await chaptersResp.json();
|
||||
this.chapters = (data.chapters || []) as AudioChapter[];
|
||||
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) {
|
||||
const p = await progress.json();
|
||||
@@ -233,7 +344,13 @@ Alpine.data("audioReader", () => ({
|
||||
this.tickChapter();
|
||||
}
|
||||
} 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();
|
||||
if (!audio) return;
|
||||
const target = Number(seconds);
|
||||
if (Number.isFinite(target)) audio.currentTime = Math.max(0, target);
|
||||
this.currentTime = audio.currentTime;
|
||||
if (!Number.isFinite(target)) return;
|
||||
// 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) {
|
||||
const audio = this.audio();
|
||||
if (!audio) return;
|
||||
audio.currentTime = Math.max(0, audio.currentTime + direction * (Number(this.skipSeconds) || 10));
|
||||
const target = Math.max(0, this.globalPosition() + direction * (Number(this.skipSeconds) || 10));
|
||||
this.seekTo(target);
|
||||
},
|
||||
|
||||
stepChapter(delta: 1 | -1) {
|
||||
@@ -406,7 +525,7 @@ Alpine.data("audioReader", () => ({
|
||||
async saveBookmark() {
|
||||
const audio = this.audio();
|
||||
if (!audio) return;
|
||||
const at = Math.round(audio.currentTime);
|
||||
const at = Math.round(this.globalPosition());
|
||||
const chapter = this.chapters[this.currentChapterIdx];
|
||||
const title = chapter
|
||||
? `${chapter.title || `Chapter ${chapter.index + 1}`} — ${fmtTime(at)}`
|
||||
@@ -502,7 +621,8 @@ Alpine.data("audioReader", () => ({
|
||||
if (this.chaptersDuration() <= 0) return;
|
||||
const audio = this.audio();
|
||||
if (!audio) return;
|
||||
const at = audio.currentTime;
|
||||
// Global book position, whatever track is physically playing.
|
||||
const at = this.globalPosition();
|
||||
let chapter = 0;
|
||||
let offset = 0;
|
||||
for (let i = 0; i < this.chapters.length; i += 1) {
|
||||
|
||||
Reference in new issue
Block a user