// 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 }; 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 { 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, // Bookmarks (A2d): time-positioned, with notes. Audio locator // convention: position = "audio:" (chapter_number filled for // display); dedup + cross-device sync happen server-side. bookmarks: [] as Record[], 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 | 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 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; const audio = this.audio(); if (!audio) return; audio.src = fileUrl; 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("timeupdate", () => { if (!this.scrubbing) this.currentTime = audio.currentTime; 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("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; }, 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(); } 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:) 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; sync resumes when reachable. } }, 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)) audio.currentTime = Math.max(0, target); this.currentTime = audio.currentTime; }, skip(direction: 1 | -1) { const audio = this.audio(); if (!audio) return; audio.currentTime = Math.max(0, audio.currentTime + direction * (Number(this.skipSeconds) || 10)); }, 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[]; 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 { 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(audio.currentTime); 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) { 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) { 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) { 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; const at = audio.currentTime; 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 = { 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 };