// 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 { 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