// 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"; import { WakeLockManager } from "./wake-lock"; import { MOUSE_GUIDE, resolveShortcut, TOUCH_GUIDE, visibleKeymap, type KeyAction, } from "./audio-keys"; import { SLEEP_CHOICES, SLEEP_OFF, armClockTime, armEndOfChapter, armMinutes, fadeVolume, sleepChapterFired, sleepDue, sleepLabel, sleepRemainingMs, type SleepChoice, type SleepState, } from "./audio-sleep"; import { applyDrag, bufferedAheadEnd, bufferedSpanPct, chapterIndexFor, denseTicks, MAGNET_RADIUS_PX, nearestChapterLine, railLiftPx, railSecondsAtPointer, railTicks, railWindow, secondsAtPointer, snapToChapterPx, TOUCH_RAIL_MIN_DY_PX, touchRailSpan, } from "./audio-scrubber"; 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. 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). 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. 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. 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))]; } // Skip durations — ONE source (same pattern as SPEEDS). The templ // dropdown iterates it through the component's `skipChoices` property, // and [ / ] keyboard cycling walks it through cycleSkip. export const SKIP_SECONDS: readonly number[] = [1, 5, 10, 15, 30]; // cycleSkip moves through SKIP_SECONDS by delta, clamping at both // ends; an unknown current anchors at 10s (the historical default). export function cycleSkip(current: number, delta: 1 | -1): number { const idx = SKIP_SECONDS.indexOf(current); const base = idx === -1 ? SKIP_SECONDS.indexOf(10) : idx; return SKIP_SECONDS[Math.min(SKIP_SECONDS.length - 1, Math.max(0, base + delta))]; } // Autoplay-on-open setting — persisted as "1"/"0" (default off). The // browser's autoplay policy decides whether the attempt succeeds; a // rejection is swallowed silently (maybeAutoplay). export function normalizeAutoplay(value: string | null): boolean { return value === "1" || value === "true"; } // ---- End-time display mode (plan §10) ---- // Clicking the END time label cycles total → book remaining → chapter // remaining, persisted per device (localStorage audio_time_display). export type TimeDisplayMode = "total" | "book-left" | "chapter-left"; export const TIME_DISPLAY_MODES: readonly TimeDisplayMode[] = [ "total", "book-left", "chapter-left", ]; export function normalizeTimeDisplay(value: string | null): TimeDisplayMode { return value === "book-left" || value === "chapter-left" ? value : "total"; } export function cycleTimeDisplay(current: TimeDisplayMode): TimeDisplayMode { const idx = TIME_DISPLAY_MODES.indexOf(current); return TIME_DISPLAY_MODES[(idx + 1) % TIME_DISPLAY_MODES.length] ?? "total"; } // endTimeLabel renders the END label for a mode. Position is the // playhead (or the scrub cursor mid-drag) so remaining values track // what you'd land on. chapterEnd is the current chapter's end — null // when the book has no chapters, in which case chapter mode falls back // to book remaining. Remainders clamp at 0. export function endTimeLabel( mode: TimeDisplayMode, position: number, duration: number, chapterEnd: number | null, ): string { const remain = (to: number) => `−${fmtTime(Math.max(0, to - position))}`; if (mode === "book-left") return remain(duration); if (mode === "chapter-left") return remain(chapterEnd === null ? duration : chapterEnd); return fmtTime(duration); } 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")), // User-level default speed (plan §8): powers the dropdown's // "(default)" marker; hydrated from the chapters payload. defaultSpeed: 1, // Exposed for the templ dropdown's x-for — templ cannot import the // const directly; this property is the binding. speeds: SPEEDS, // Same binding pattern, for the dialog's two scrub-system sections. mouseGuide: MOUSE_GUIDE, touchGuide: TOUCH_GUIDE, skipSeconds: Number(localStorage.getItem("audio_skip") || 10), // End-label display mode (§10): total → book remaining → chapter // remaining, persisted per device. timeDisplay: normalizeTimeDisplay(localStorage.getItem("audio_time_display")), // Scrub guard: while dragging, timeupdate must not fight the handle scrubbing: false, scrubValue: 0, // Progress-bar overhaul (plan §1): chapter ticks, per-track buffer // span, hover tooltip and the precision rail. Math lives in // audio-scrubber.ts (pure, unit-tested). tickIndices: [] as number[], bufferedGlobal: null as number | null, hoverSeconds: null as number | null, hoverPct: 0, // §9: hover locks to a chapter line inside the magnet radius (the // highlight line + tooltip land where the click will). hoverSnapped: false, railFrom: 0, railTo: 0, railTickList: [] as Array<{ start: number; title: string }>, // §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" = // Shift+drag precision window, "touch" = incremental cursor with // pull-away precision. The rail is only visible for the last two. dragMode: null as null | "free" | "rail" | "touch", railVisible: false, railLiftY: 0, touchLastX: 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