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 };
|
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) {
|
||||||
|
|||||||
Reference in new issue
Block a user