Files
bookhoard/web/src/audio.ts
T
John O'Keefe 79ce5ab89d 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.
2026-10-19 07:54:45 -04:00

671 lines
23 KiB
TypeScript

// Audio reader (web audiobook player — handoff item 3 Batch A).
// Player settings live IN the player as quick controls and persist via
// localStorage (skip duration, playback speed); the settings-section
// placement question stays open in the handoff.
//
// Progress sync: pushes {percentage, chapter, audio_offset_seconds,
// elapsed_seconds} every 30s WHILE PLAYING and on pause/hide. The
// elapsed seconds are honest wall-clock playing time — pauses never
// count — and the server keeps the 2h attributed ceiling.
//
// Media Session: lockscreen/notification transport controls (play,
// pause, skip backward/forward honoring the skip setting, chapter
// tracks) — the mobile-web native feel from handoff item 6.
import { Alpine } from "./alpine";
import { renderNoteHtml } from "./markdown";
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 {
chapterEndpoint: string;
progressEndpoint: string;
}
function readConfig(): AudioConfig {
const body = document.body as HTMLElement;
return JSON.parse(body.dataset.json || "{}");
}
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem("token");
return token ? { Authorization: `Bearer ${token}` } : {};
}
function fmtTime(seconds: number): string {
const s = Math.max(0, Math.floor(seconds));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
return `${m}:${String(sec).padStart(2, "0")}`;
}
Alpine.data("audioReader", () => ({
mediaItemId: "",
title: "",
chapters: [] as AudioChapter[],
chaptersOpen: false,
currentTime: 0,
playing: false,
speed: Number(localStorage.getItem("audio_speed") || 1),
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
// Scrub guard: while dragging, timeupdate must not fight the handle
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.
bookmarks: [] as Record<string, unknown>[],
bookmarksOpen: false,
bookmarkCaptureOpen: false,
bookmarkNote: "",
editingBookmarkId: "",
editingNote: "",
// Settings strip dropdowns (open-state; panels close on outside click)
skipOpen: false,
speedOpen: false,
volumeOpen: false,
// Volume: level 0..1 + mute, persisted separately. prevVolume restores
// on unmute (spec: icon shows level arcs, crossed when muted).
volume: Math.min(1, Math.max(0, Number(localStorage.getItem("audio_volume") ?? 1) || 1)),
muted: localStorage.getItem("audio_muted") === "1",
prevVolume: 1,
// chapters are absolute seconds into the book; the scrubber spans the
// whole book, not per-chapter
bookDuration: 0,
currentChapterIdx: 0,
// progress-push bookkeeping: pendingElapsed accumulates REAL PLAYING
// time via timeupdate deltas (pauses never count); pushed once per
// 30s tick, then zeroed. prevTick is the last performance.now().
pendingElapsed: 0,
prevTick: 0,
lastPush: Date.now(),
pushTimer: undefined as ReturnType<typeof setInterval> | undefined,
errorMessage: "",
initialized: false,
init() {
if (this.initialized) return; // Alpine can re-init the body tree
this.initialized = true;
window.addEventListener("error", (e) => {
document.title = "ERR: " + (e.message || "unknown");
});
window.addEventListener("unhandledrejection", (e) => {
document.title = "REJ: " + (e.reason?.message || String(e.reason));
});
const body = document.body as HTMLElement;
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;
// 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.
audio.volume = Math.min(1, Math.max(0, Number(this.volume)));
audio.muted = !!this.muted;
// Restore position: chapters + progress from the server.
this.restorePosition();
// Periodic progress push (playing-only; pause/hide push too)
this.startPushTimer();
// Bookmarks list (time-located; notes optional)
this.loadBookmarks();
// Media Session (lockscreen/notification transport).
if ("mediaSession" in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
album: "Bookhoard",
artwork: cover ? [{ src: cover, sizes: "512x512" }] : [],
});
navigator.mediaSession.setActionHandler("play", () => this.togglePlay());
navigator.mediaSession.setActionHandler("pause", () => this.togglePlay());
navigator.mediaSession.setActionHandler("seekbackward", () => this.skip(-1));
navigator.mediaSession.setActionHandler("seekforward", () => this.skip(1));
navigator.mediaSession.setActionHandler("previoustrack", () => this.stepChapter(-1));
navigator.mediaSession.setActionHandler("nexttrack", () => this.stepChapter(1));
}
// 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 = this.globalPosition();
this.tickChapter();
// Honest session accounting: only ACTUAL PLAYING time counts. The
// prevSnapshot walk keeps internal seeks/crossfades from inflating
// the sum; progress pushes take the delta since the last push.
const now = performance.now();
if (!audio.paused) {
const span = now - this.prevTick;
this.pendingElapsed += span;
}
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();
this.updateMediaSessionState();
});
audio.addEventListener("pause", () => {
const now = performance.now();
if (this.playing) {
this.pendingElapsed += Math.max(0, now - this.prevTick);
}
this.prevTick = now;
this.playing = false;
this.pushProgress(true);
this.updateMediaSessionState();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Spacebar = play/pause (the reader's keyboard contract, audio flavor).
document.addEventListener("keydown", (e) => {
const target = e.target as HTMLElement | null;
if (target?.tagName === "INPUT" || target?.tagName === "TEXTAREA") return;
if (e.code === "Space") {
e.preventDefault();
this.togglePlay();
} else if (e.code === "ArrowLeft") {
e.preventDefault();
this.skip(-1);
} else if (e.code === "ArrowRight") {
e.preventDefault();
this.skip(1);
}
});
},
audio(): 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() {
try {
const [chaptersResp, progress] = await Promise.all([
fetch(readConfig().chapterEndpoint, { headers: getAuthHeaders() }),
fetch(
`/api/media-items/${this.mediaItemId}/progress`,
{ headers: getAuthHeaders() },
),
]);
if (chaptersResp.ok) {
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();
const chapter = Number(p.chapter ?? 0);
const offset = Number(p.audio_offset_seconds ?? 0);
// Banner/deep-link jumps (?loc=audio:<seconds>) win over the
// stored position: the user explicitly asked for this spot.
const locParam = new URLSearchParams(window.location.search).get("loc") || "";
if (locParam.startsWith("audio:")) {
const at = Number(locParam.slice("audio:".length));
if (Number.isFinite(at)) this.seekTo(at);
} else if (Number.isFinite(p.percentage) && this.bookDuration > 0) {
const at = Number(p.percentage) * this.bookDuration;
this.seekTo(at);
} else if (chapter > 0 && this.chapters[chapter]?.start != null) {
this.seekTo(this.chapters[chapter].start + offset);
}
this.currentChapterIdx = Math.max(0, chapter);
this.tickChapter();
}
} catch {
// 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);
}
}
},
chapterEndEstimate(): number {
const last = this.chapters[this.chapters.length - 1];
return last ? Number(last.end) || 0 : 0;
},
tickChapter() {
// Keep currentChapterIdx pointing at the chapter containing the playhead.
for (let i = this.chapters.length - 1; i >= 0; i -= 1) {
const ch = this.chapters[i];
if (this.currentTime >= ch.start) {
if (this.currentChapterIdx !== i) {
this.currentChapterIdx = i;
this.updateMediaSessionMetadata();
}
return;
}
}
},
chapterTitle(): string {
const ch = this.chapters[this.currentChapterIdx];
return ch
? ch.title || `Chapter ${ch.index + 1}`
: this.title;
},
currentChapter(): number {
return this.chapters.length ? this.currentChapterIdx : -1;
},
chaptersDuration(): number {
return this.bookDuration || this.chapterEndEstimate();
},
togglePlay() {
const audio = this.audio();
if (!audio) return;
if (audio.paused) audio.play().catch(() => showToast("Playback failed to start", "error"));
else audio.pause();
},
seekTo(seconds: number | string) {
const audio = this.audio();
if (!audio) return;
const target = Number(seconds);
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 target = Math.max(0, this.globalPosition() + direction * (Number(this.skipSeconds) || 10));
this.seekTo(target);
},
stepChapter(delta: 1 | -1) {
const next = this.currentChapterIdx + delta;
if (next < 0 || next >= this.chapters.length) return;
this.jumpToChapter(next);
},
jumpToChapter(index: number) {
const ch = this.chapters[index];
if (ch) this.seekTo(ch.start);
this.currentChapterIdx = index;
},
setSpeed(rate: number) {
this.speed = rate;
localStorage.setItem("audio_speed", String(rate));
const audio = this.audio();
if (audio) audio.playbackRate = rate;
},
setSkip(seconds: number) {
this.skipSeconds = seconds;
localStorage.setItem("audio_skip", String(seconds));
},
backToDetail() {
this.pushProgress(true);
window.location.href = `/media/${this.mediaItemId}`;
},
// ---- volume (settings strip) ----
volumeIcon(): "muted" | "low" | "mid" | "high" {
const v = this.muted ? 0 : Number(this.volume) || 0;
if (v <= 0) return "muted";
if (v < 0.34) return "low";
if (v < 0.67) return "mid";
return "high";
},
setVolume(level: number) {
const v = Math.min(1, Math.max(0, Number(level)));
this.volume = v;
localStorage.setItem("audio_volume", String(v));
const audio = this.audio();
if (audio) audio.volume = v;
if (v > 0 && this.muted) this.toggleMute();
},
toggleMute() {
const audio = this.audio();
if (this.muted) {
// Unmute restores the last real level; a zero level gets a floor
// so unmute is audible again.
if (Number(this.volume) <= 0) {
this.volume = 0.5;
localStorage.setItem("audio_volume", "0.5");
if (audio) audio.volume = 0.5;
}
this.muted = false;
if (audio) audio.muted = false;
} else {
this.prevVolume = Number(this.volume) || 0.5;
this.muted = true;
if (audio) audio.muted = true;
}
localStorage.setItem("audio_muted", this.muted ? "1" : "0");
},
// ---- bookmarks (A2d) ----
async loadBookmarks() {
try {
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, {
headers: getAuthHeaders(),
});
if (!resp.ok) return;
const data = await resp.json();
// GET /bookmarks returns a bare array of rows.
const all = (Array.isArray(data) ? data : []) as Record<string, unknown>[];
this.bookmarks = all
.filter((b) => String(b.position || "").startsWith("audio:"))
.sort(
(a, b) =>
this.bookmarkSeconds(String(a.position)) -
this.bookmarkSeconds(String(b.position)),
);
} catch {
// bookmarks are non-critical UI
}
},
// Markdown notes: the drawer renders sanitized HTML from the same
// pipeline the reader's annotations use; editing stays raw markdown.
bookmarkNoteHtml(b: Record<string, unknown>): string {
return renderNoteHtml(String(b.notes ?? ""));
},
bookmarkSeconds(position: string): number {
const n = parseInt(String(position).slice("audio:".length), 10);
return Number.isFinite(n) ? n : 0;
},
toggleBookmarkCapture() {
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
return;
}
this.bookmarkNote = "";
this.bookmarkCaptureOpen = true;
},
async saveBookmark() {
const audio = this.audio();
if (!audio) return;
const at = Math.round(this.globalPosition());
const chapter = this.chapters[this.currentChapterIdx];
const title = chapter
? `${chapter.title || `Chapter ${chapter.index + 1}`} — ${fmtTime(at)}`
: `Bookmark — ${fmtTime(at)}`;
try {
const resp = await fetch(`/api/media-items/${this.mediaItemId}/bookmarks`, {
method: "POST",
headers: { ...getAuthHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
title,
position: `audio:${at}`,
notes: this.bookmarkNote.trim(),
chapter_number: this.currentChapterIdx,
origin: "web",
}),
});
if (!resp.ok) throw new Error("save failed");
this.bookmarkCaptureOpen = false;
this.bookmarkNote = "";
showToast(`Bookmark saved at ${fmtTime(at)}`, "success");
await this.loadBookmarks();
} catch {
showToast("Could not save the bookmark", "error");
}
},
startEditNote(b: Record<string, unknown>) {
this.editingBookmarkId = String(b.id || "");
this.editingNote = String(b.notes || "");
},
async saveNote() {
const b = this.bookmarks.find(
(x) => String(x.id) === this.editingBookmarkId,
);
if (!b) return;
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${this.editingBookmarkId}`,
{
method: "PUT",
headers: { ...getAuthHeaders(), "Content-Type": "application/json" },
body: JSON.stringify({
title: String(b.title || "Bookmark"),
notes: this.editingNote,
position: String(b.position || ""),
}),
},
);
if (!resp.ok) throw new Error("save failed");
this.editingBookmarkId = "";
await this.loadBookmarks();
} catch {
showToast("Could not update the note", "error");
}
},
async deleteBookmark(b: Record<string, unknown>) {
try {
const resp = await fetch(
`/api/media-items/${this.mediaItemId}/bookmarks/${b.id}`,
{ method: "DELETE", headers: getAuthHeaders() },
);
if (!resp.ok) throw new Error("delete failed");
await this.loadBookmarks();
} catch {
showToast("Could not delete the bookmark", "error");
}
},
jumpToBookmark(b: Record<string, unknown>) {
this.seekTo(this.bookmarkSeconds(String(b.position || "")));
},
updateMediaSessionMetadata() {
if (!("mediaSession" in navigator)) return;
const ch = this.chapters[this.currentChapterIdx];
navigator.mediaSession.metadata = new MediaMetadata({
title: ch ? ch.title || this.title : this.title,
artist: this.title,
album: "Bookhoard",
});
},
updateMediaSessionState() {
if (!("mediaSession" in navigator)) return;
navigator.mediaSession.playbackState = this.playing ? "playing" : "paused";
},
// ---- progress sync ----
async pushProgress(exit = false) {
if (this.chaptersDuration() <= 0) return;
const audio = this.audio();
if (!audio) return;
// 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) {
if (at >= this.chapters[i].start) {
chapter = i;
offset = Math.max(0, Math.round(at - this.chapters[i].start));
}
}
const elapsed = Math.max(0, Math.round(this.pendingElapsed));
const body: Record<string, unknown> = {
percentage: this.chaptersDuration() ? at / this.chaptersDuration() : 0,
chapter,
audio_offset_seconds: offset,
elapsed_seconds: elapsed >= 1 || (exit && this.playing) ? elapsed : 0,
reading_mode: "audio",
};
this.pendingElapsed = 0;
try {
await fetch(`/api/media-items/${this.mediaItemId}/progress`, {
method: "PUT",
headers: {
...getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
this.lastPush = Date.now();
if (exit) clearInterval(this.pushTimer);
} catch {
// non-critical; the next tick syncs
}
},
startPushTimer() {
this.pushTimer = setInterval(() => {
const audio = this.audio();
if (audio?.paused) return; // paused = nothing to sync
this.pushProgress();
}, PROGRESS_INTERVAL_MS);
},
fmtTime,
}));
export { fmtTime };