Files
bookhoard/web/src/audio.ts
T
John O'Keefe db96ea91cd refactor(audio-player): one source for the speed list (plan §5)
The rate list was duplicated — hardcoded in the templ x-for AND
implicitly in audio.ts — and phase 2's keyboard stepping would have
needed a third copy. SPEEDS is now the single exported const (the
agreed nine values, 0.5 through 3); the component exposes it as
'speeds' for the dropdown's x-for (templ cannot import TS), and
stepSpeed() walks the same array for the future audio-keys consumer.

normalizeSpeed() maps persisted values onto the list: garbage or
out-of-list localStorage entries fall back to 1x, and init() writes
the normalized value back so storage heals instead of staying
poisoned.

Pure logic unit-tested in audio.test.ts; live-verified with Playwright
on the KJV folder book: nine options render, pick/reload/persist and
the stale-value fallback all behave, button label and
audio.playbackRate agree in every case.
2026-10-21 21:08:27 -04:00

707 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}
// Playback speeds — ONE source. The templ dropdown iterates it through
// the component's `speeds` property, and keyboard stepping (audio-keys,
// player plan §2) walks the same array through stepSpeed. Never hardcode
// a rate list elsewhere.
export const SPEEDS: readonly number[] = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5, 3];
// normalizeSpeed maps a persisted speed onto the SPEEDS list: missing,
// malformed or out-of-list values (e.g. a stale localStorage entry from
// before the list changed) fall back to 1×.
export function normalizeSpeed(value: string | null): number {
const n = Number(value);
return SPEEDS.includes(n) ? n : 1;
}
// stepSpeed moves through SPEEDS by delta, clamping at both ends; an
// unknown current anchors at 1× before stepping.
export function stepSpeed(current: number, delta: 1 | -1): number {
const idx = SPEEDS.indexOf(current);
const base = idx === -1 ? SPEEDS.indexOf(1) : idx;
return SPEEDS[Math.min(SPEEDS.length - 1, Math.max(0, base + delta))];
}
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: normalizeSpeed(localStorage.getItem("audio_speed")),
// Exposed for the templ dropdown's x-for — templ cannot import the
// const directly; this property is the binding.
speeds: SPEEDS,
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;
// Heal a stale/out-of-list persisted speed so the storage matches
// what the player actually uses (normalizeSpeed already fixed the
// in-memory value at state creation).
if (localStorage.getItem("audio_speed") !== String(this.speed)) {
localStorage.setItem("audio_speed", String(this.speed));
}
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 = this.speed;
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 = this.speed;
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);
// Setting src resets playbackRate to the element default —
// re-apply, mirroring the seekTo() src-swap path. Without this
// the pill said 1.5× while audio actually played at 1× after
// every reload (surfaced by the speed-list browser check).
audio.playbackRate = this.speed;
}
}
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);
audio.playbackRate = this.speed;
}
}
},
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 };