Files
bookhoard/web/src/audio.ts
T
John O'Keefe afbf1029a9
Release / build-and-push (push) Successful in 3m12s
feat(web): hold a Screen Wake Lock while reading or listening
New wake-lock.ts engine (sanctioned island, dependency-injected for
tests): feature-detected navigator.wakeLock, silent no-op when
unsupported or denied, and the spec-mandated force-release-on-hide →
re-acquire-on-visible loop handled via the sentinel release event.
The player acquires on play and releases on pause (sleep-timer fades
ride the same path); the reader holds the lock for the session. Both
gate on the keep_screen_awake preference — data-keep-awake on the
player root, keepScreenAwake in readerInitExpr.

Hidden-tab playback intentionally does not keep the screen awake:
the spec forbids screen wake locks for hidden documents.
2026-10-26 13:59:37 -04:00

1496 lines
52 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";
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<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")),
// 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 <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,
skipChoices: SKIP_SECONDS,
// Keyboard shortcuts (audio-keys.ts keymap): the ? dialog and the
// brief OSD confirming keyboard-driven volume/mute changes.
helpOpen: false,
osdVisible: false,
osdText: "",
osdTimer: null as ReturnType<typeof setTimeout> | null,
// Sleep timer (plan §3): session-scoped, NEVER persisted. The pure
// state machine lives in audio-sleep.ts; this holds the state, the
// 1s countdown ticker and the 5s fade interval.
sleep: SLEEP_OFF as SleepState,
sleepChoices: SLEEP_CHOICES,
sleepOpen: false,
sleepLabelStr: "Off",
sleepArmedChoiceKey: "off",
sleepTicker: null as ReturnType<typeof setInterval> | null,
sleepFadeTimer: null as ReturnType<typeof setInterval> | null,
// §17 clock mode: the popover's time input (armed via chooseSleepClock).
sleepClockTime: "23:00",
// §17 wind-down dim: 0..0.35 black overlay opacity across the final
// 30s of a countdown. Reset by stop/finish.
sleepDim: 0,
// Screen wake lock (keep_screen_awake profile preference): held while
// playing so the display doesn't blank/lock mid-listen. The browser
// force-releases when the page hides; the manager re-acquires on
// visibility regain while audio is still going.
wakeLock: new WakeLockManager(),
keepAwakeEnabled: false,
// Settings popover (plan §4): header gear → panel. Autoplay-on-open
// is the first option; the panel is structured for more.
settingsOpen: false,
autoplayOn: normalizeAutoplay(localStorage.getItem("audio_autoplay")),
// 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.keepAwakeEnabled = body.getAttribute("data-keep-awake") === "true";
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. Autoplay
// (if enabled) starts AFTER the position is restored — the ?loc=
// deep link wins on position; autoplay only decides whether
// playback starts.
this.restorePosition().then(() => this.maybeAutoplay());
// 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();
// Manual play during a sleep fade aborts the expiry (the user
// is awake and asked for audio); stopSleep restores the volume.
if (this.sleep.mode === "fading") this.stopSleep();
this.updateMediaSessionState();
if (this.keepAwakeEnabled) void this.wakeLock.acquire();
});
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();
if (this.keepAwakeEnabled) this.wakeLock.release();
});
window.addEventListener("pagehide", () => this.pushProgress(true));
window.addEventListener("resize", () => this.recomputeTicks());
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") this.pushProgress(true);
});
// Buffer readout for the scrubber's per-track buffer span.
// 'progress' alone does not fire reliably for Range-served files
// that go network-idle while paused — ride the events that
// demonstrably do, and re-read the (idempotent) ranges on each.
for (const ev of [
"progress",
"loadedmetadata",
"loadeddata",
"canplay",
"playing",
"timeupdate",
]) {
audio.addEventListener(ev, () => this.updateBuffered());
}
// Keyboard shortcuts (plan §2): one dispatcher resolving through
// the declarative audio-keys.ts keymap. Hardware media keys ride
// the MediaSession handlers above; the Media* keydown codes land
// here and call the SAME methods.
document.addEventListener("keydown", (e) => this.handleKeydown(e));
},
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;
this.bufferedGlobal = null; // buffers are per-track; the next
// 'progress' event re-reads the freshly loading element's ranges.
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();
// §8: the server owns speed — per-book row, else the user's
// default. localStorage stays only as a first-paint cache; the
// payload value wins over it BEFORE the rate is (re)applied to
// the element below.
const serverSpeed = Number(data.playback_speed);
if (SPEEDS.includes(serverSpeed)) {
this.speed = serverSpeed;
localStorage.setItem("audio_speed", String(serverSpeed));
}
const serverDefault = Number(data.default_playback_speed);
if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault;
this.buildTracks(data);
this.recomputeTicks();
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();
// Natural chapter crossing: fire an end-of-chapter sleep.
if (sleepChapterFired(this.sleep, i)) this.onChapterCrossing(i);
}
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;
// Manual jump: leaving the armed chapter fires an end-of-chapter
// sleep the same as a natural crossing.
this.onChapterCrossing(index);
},
setSpeed(rate: number) {
this.speed = rate;
localStorage.setItem("audio_speed", String(rate));
const audio = this.audio();
if (audio) audio.playbackRate = rate;
// §8: the per-book speed lives server-side (localStorage is only a
// first-paint cache). Fire-and-forget; a failure toasts — the rate
// still applies to this session.
fetch(`/api/media-items/${this.mediaItemId}/audio-speed`, {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
})
.catch(() => showToast("Speed saved for this session only — server rejected it", "error"));
},
// setDefaultSpeed pins a user-level default (plan §8): books without
// their own per-book speed pick it up. Rides the profile endpoint,
// same as the profile-page select.
setDefaultSpeed(rate: number) {
fetch("/api/auth/profile", {
method: "PUT",
headers: { ...getAuthHeaders(), "content-type": "application/json" },
body: JSON.stringify({ default_audio_speed: rate }),
})
.then((r) => {
if (!r.ok) throw new Error(String(r.status));
this.defaultSpeed = rate;
showToast(`Default playback speed set to ${rate}×`, "success");
})
.catch(() => showToast("Default speed not saved", "error"));
},
// Popover option handlers (CSP-safe: single method calls from templ).
commitScrub() {
this.seekTo(this.scrubValue);
this.scrubbing = false;
},
pickSkip(seconds: number) {
this.setSkip(seconds);
this.skipOpen = false;
},
pickSpeed(rate: number) {
this.setSpeed(rate);
this.speedOpen = false;
},
pickDefaultSpeed(rate: number) {
this.setDefaultSpeed(rate);
this.speedOpen = false;
},
chooseSleepOption(choice: SleepChoice) {
this.chooseSleep(choice);
this.sleepOpen = false;
},
applyDefaultSpeedFromSelect(e: Event) {
this.setDefaultSpeed(Number((e.target as HTMLSelectElement).value));
},
isDefaultSpeed(rate: number): boolean {
return this.defaultSpeed === rate;
},
onScrubInput(e: Event) {
this.scrubbing = true;
this.scrubValue = Number((e.target as HTMLInputElement).value);
},
setVolumeFromEvent(e: Event) {
this.setVolume(Number((e.target as HTMLInputElement).value));
},
volumeLabel(): string {
return `Volume ${Math.round(Number(this.volume) * 100)}%`;
},
railTickStyle(tick: { start: number }): string {
const span = Math.max(1, this.railTo - this.railFrom);
return `left:${((tick.start - this.railFrom) / span) * 100}%;background-color:var(--text-secondary);opacity:0.5;`;
},
isSkipActive(secs: number): boolean {
return Number(this.skipSeconds) === secs;
},
isSpeedActive(rate: number): boolean {
return Number(this.speed) === rate;
},
isBookmarkEditing(b: { id: unknown }): boolean {
return this.editingBookmarkId === String(b.id);
},
chooseSleepClockMode() {
this.chooseSleepClock();
this.sleepOpen = false;
},
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;
},
// ---- progress bar (audio-scrubber.ts math) ----
recomputeTicks() {
const area = document.getElementById("scrub-area");
const width = area ? area.getBoundingClientRect().width : 0;
this.tickIndices = denseTicks(
this.chapters,
this.chaptersDuration() || this.bookDuration || 1,
width,
);
},
updateBuffered() {
const audio = this.audio();
if (!audio || audio.buffered.length === 0) {
this.bufferedGlobal = null;
return;
}
// Buffered-ahead of the playhead (element-local): the end of the
// range containing currentTime. The moov-index range at a huge
// m4b's tail must not read as "the whole book is buffered".
const ranges = Array.from({ length: audio.buffered.length }, (_, i) => ({
start: audio.buffered.start(i),
end: audio.buffered.end(i),
}));
this.bufferedGlobal = bufferedAheadEnd(ranges, audio.currentTime);
},
chapterTitleAt(seconds: number): string {
const idx = chapterIndexFor(this.chapters, seconds);
if (idx === -1) return this.title;
const ch = this.chapters[idx];
return ch.title || `Chapter ${idx + 1}`;
},
// ---- end-time label (§10): total → book left → chapter left ----
endTimeText(): string {
const duration = this.chaptersDuration() || 1;
const pos = this.scrubbing ? this.scrubValue : Number(this.currentTime);
const idx = chapterIndexFor(this.chapters, pos);
const ch = this.chapters[idx === -1 ? 0 : idx];
return endTimeLabel(this.timeDisplay, pos, duration, ch ? ch.end : null);
},
endTimeAria(): string {
if (this.timeDisplay === "book-left")
return "Book time remaining — click to show chapter remaining";
if (this.timeDisplay === "chapter-left")
return "Chapter time remaining — click to show total duration";
return "Total duration — click to show book remaining";
},
cycleEndTime() {
this.timeDisplay = cycleTimeDisplay(this.timeDisplay);
localStorage.setItem("audio_time_display", this.timeDisplay);
},
scrubFillPct(): number {
const value = this.scrubbing ? this.scrubValue : Number(this.currentTime);
return Math.min(100, Math.max(0, (value / (this.chaptersDuration() || 1)) * 100));
},
bufferSpan(): { left: number; width: number } | null {
return bufferedSpanPct(
this.tracks[this.activeTrack],
this.bufferedGlobal,
this.chaptersDuration() || 1,
);
},
railMarkerPct(): number {
const span = Math.max(1, this.railTo - this.railFrom);
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
},
// Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no
// rail, never sticks at chapter lines); clicks land with pixel
// magnetism on nearby chapter lines; Shift+click/drag opens the
// precision rail (±5 min, free mapping inside); touch drags are an
// incremental cursor whose scale tightens as the finger pulls off the
// bar (progressive precision — the rail is its feedback). The native
// range stays underneath for keyboard/aria — its drag default is
// prevented so the mappings never fight.
scrubPointerDown(e: PointerEvent) {
if (e.button !== 0 || this.scrubbing) return;
e.preventDefault();
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
try {
area.setPointerCapture(e.pointerId);
} catch {
/* synthetic or already-released pointer — drag still works via
the element-level listeners */
}
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
const duration = this.chaptersDuration() || 1;
const at = secondsAtPointer(e.clientX, rect, duration);
if (e.pointerType === "touch") {
// Tap lands magnetically; the drag starts 1:1 and tightens as
// the finger pulls away (scrubPointerMove's incremental cursor).
this.dragMode = "touch";
this.touchLastX = e.clientX;
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
} else if (e.shiftKey) {
// Shift+click/drag: the precision rail, anchored to the NEAREST
// chapter line (Shift+click = snap regardless of radius).
this.dragMode = "rail";
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
this.scrubValue = near ? near.seconds : at;
const window = railWindow(this.scrubValue, duration);
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = 0;
} else {
// Mouse click/drag: the CLICK's landing is magnetic; drags stay
// free-flow (scrubPointerMove overwrites with the raw position).
this.dragMode = "free";
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
}
this.scrubbing = true;
},
scrubPointerMove(e: PointerEvent) {
const area = e.currentTarget as HTMLElement;
const rect = area.getBoundingClientRect();
const duration = this.chaptersDuration() || 1;
if (!this.scrubbing) {
// Hover: time + chapter, locking to a chapter line inside the
// magnet radius so the tooltip shows the click's true landing.
const at = secondsAtPointer(e.clientX, rect, duration);
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
const locked = near !== null && near.distancePx <= MAGNET_RADIUS_PX;
const hoverAt = locked && near ? near.seconds : at;
this.hoverSeconds = hoverAt;
this.hoverPct = rect.width > 0 ? Math.min(100, Math.max(0, (hoverAt / duration) * 100)) : 0;
this.hoverSnapped = locked;
return;
}
if (this.dragMode === "rail") {
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
from: this.railFrom,
to: this.railTo,
});
return;
}
if (this.dragMode === "touch") {
const dx = e.clientX - this.touchLastX;
this.touchLastX = e.clientX;
const dy = rect.top + rect.height / 2 - e.clientY;
this.scrubValue = applyDrag(this.scrubValue, dx, dy, rect.width / duration, duration);
if (Math.abs(dy) >= TOUCH_RAIL_MIN_DY_PX) {
// Feedback: the rail follows the cursor, its window shrinking
// with the zoom, lifting toward the finger.
const window = railWindow(this.scrubValue, duration, touchRailSpan(dy));
this.railFrom = window.from;
this.railTo = window.to;
this.railTickList = railTicks(window, this.chapters);
this.railVisible = true;
this.railLiftY = railLiftPx(dy);
} else {
this.railVisible = false;
this.railLiftY = 0;
}
return;
}
// Free flow: direct 1:1 mapping — dragging across chapter lines
// never sticks.
this.scrubValue = secondsAtPointer(e.clientX, rect, duration);
},
scrubPointerLeave() {
if (!this.scrubbing) {
this.hoverSeconds = null;
this.hoverSnapped = false;
}
},
scrubPointerUp(e: PointerEvent) {
if (!this.scrubbing) return;
const area = e.currentTarget as HTMLElement;
if (area.hasPointerCapture && area.hasPointerCapture(e.pointerId))
area.releasePointerCapture(e.pointerId);
this.seekTo(this.scrubValue);
this.scrubbing = false;
this.dragMode = null;
this.railVisible = false;
this.railLiftY = 0;
this.hoverSeconds = null;
this.hoverSnapped = false;
},
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
onScrubWheel(e: WheelEvent) {
e.preventDefault();
const step = e.shiftKey ? 60 : 5;
const direction = e.deltaY > 0 ? -1 : 1;
const duration = this.chaptersDuration() || 1;
const target = Math.min(duration, Math.max(0, this.globalPosition() + direction * step));
this.seekTo(target);
},
toggleBookmarkCapture() {
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
return;
}
this.bookmarkNote = "";
this.bookmarkCaptureOpen = true;
},
// Keyboard B: open the capture pill and put the cursor in the note
// box (user's explicit flow — Enter then saves, even when blank).
openBookmarkCapture() {
if (!this.bookmarkCaptureOpen) this.toggleBookmarkCapture();
window.setTimeout(() => {
(document.getElementById("bookmark-note") as HTMLTextAreaElement | null)?.focus();
}, 0);
},
// ---- keyboard dispatcher (audio-keys.ts) ----
visibleKeymap() {
return visibleKeymap();
},
// Escape closes the top-most open surface — even while typing, since
// canceling the bookmark note mid-sentence is the expected Esc.
// Returns true when something actually closed.
closeTopSurface(): boolean {
if (this.helpOpen) {
this.helpOpen = false;
return true;
}
if (this.bookmarkCaptureOpen) {
this.bookmarkCaptureOpen = false;
return true;
}
if (this.editingBookmarkId !== "") {
this.editingBookmarkId = "";
return true;
}
if (this.settingsOpen) {
this.settingsOpen = false;
return true;
}
if (this.chaptersOpen) {
this.chaptersOpen = false;
return true;
}
if (this.bookmarksOpen) {
this.bookmarksOpen = false;
return true;
}
if (this.skipOpen || this.speedOpen || this.volumeOpen || this.sleepOpen) {
this.skipOpen = false;
this.speedOpen = false;
this.volumeOpen = false;
this.sleepOpen = false;
return true;
}
return false;
},
showOsd(text: string) {
this.osdText = text;
this.osdVisible = true;
if (this.osdTimer) clearTimeout(this.osdTimer);
this.osdTimer = setTimeout(() => {
this.osdVisible = false;
}, 1200);
},
handleKeydown(e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
const typing = tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable === true;
// Escape first: cancel the top-most surface even while typing.
if (e.key === "Escape") {
if (this.closeTopSurface()) {
e.preventDefault();
e.stopPropagation();
}
return;
}
// Typing in any input (bookmark notes!) never triggers shortcuts.
if (typing) return;
const action: KeyAction | null = resolveShortcut(e.key, e.altKey);
if (!action) return;
// Space/Enter activate a focused control — never hijack them for
// play/pause when the user is keyboard-driving a button.
if (action === "toggle-play" && (tag === "BUTTON" || tag === "SELECT" || tag === "A")) {
return;
}
e.preventDefault();
switch (action) {
case "toggle-play":
case "media-toggle-play":
this.togglePlay();
break;
case "media-play":
if (!this.playing) this.togglePlay();
break;
case "media-pause":
if (this.playing) this.togglePlay();
break;
case "skip-back":
this.skip(-1);
break;
case "skip-forward":
this.skip(1);
break;
case "prev-chapter":
case "media-prev":
this.stepChapter(-1);
break;
case "next-chapter":
case "media-next":
this.stepChapter(1);
break;
case "bookmark":
this.openBookmarkCapture();
break;
case "speed-up":
this.setSpeed(stepSpeed(this.speed, 1));
break;
case "speed-down":
this.setSpeed(stepSpeed(this.speed, -1));
break;
case "volume-up":
case "volume-down": {
const step = action === "volume-up" ? 0.05 : -0.05;
const next = Math.round((Number(this.volume) + step) * 100) / 100;
this.setVolume(next);
this.showOsd(`Volume ${Math.round(Number(this.volume) * 100)}%`);
break;
}
case "toggle-mute":
this.toggleMute();
this.showOsd(this.muted ? "Muted" : "Unmuted");
break;
case "cycle-skip-up":
this.setSkip(cycleSkip(this.skipSeconds, 1));
break;
case "cycle-skip-down":
this.setSkip(cycleSkip(this.skipSeconds, -1));
break;
case "toggle-help":
this.helpOpen = !this.helpOpen;
break;
case "escape":
this.closeTopSurface();
break;
}
},
// ---- sleep timer (audio-sleep.ts state machine) ----
isSleepActive(key: string): boolean {
return this.sleepArmedChoiceKey === key;
},
chooseSleep(choice: SleepChoice) {
this.sleepArmedChoiceKey = choice.key;
if (choice.mode === "off") {
this.stopSleep();
return;
}
if (choice.mode === "end-of-chapter") {
this.stopSleepTicker();
// §17 chain: "end of chapter, then N min" — leaving the chapter
// starts the countdown instead of fading straight away.
this.sleep = armEndOfChapter(
this.currentChapterIdx,
choice.chain ? (choice.minutes ?? 15) : null,
);
} else {
this.stopSleepTicker();
this.sleep = armMinutes(Date.now(), choice.minutes ?? 30);
this.startSleepTicker();
}
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
},
// §17 clock mode: arm against the next occurrence of the popover's
// HH:MM input. Unparseable input keeps the current state.
chooseSleepClock() {
const armed = armClockTime(Date.now(), this.sleepClockTime);
if (!armed) return;
this.stopSleepTicker();
this.sleep = armed;
this.sleepArmedChoiceKey = "clock";
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
this.startSleepTicker();
},
// A chapter crossing fires an end-of-chapter sleep: straight to the
// fade — or (§17 chain arm) convert to the follow-up countdown.
onChapterCrossing(index: number) {
if (!sleepChapterFired(this.sleep, index)) return;
const follow = this.sleep.followUpMinutes;
if (follow !== null) {
this.sleep = armMinutes(Date.now(), follow);
this.startSleepTicker();
this.sleepLabelStr = sleepLabel(this.sleep, Date.now());
} else {
this.beginSleepFade();
}
},
startSleepTicker() {
this.stopSleepTicker();
this.sleepTicker = setInterval(() => {
if (this.sleep.mode !== "minutes") return;
const now = Date.now();
if (sleepDue(this.sleep, now)) {
this.stopSleepTicker();
this.beginSleepFade();
return;
}
this.sleepLabelStr = sleepLabel(this.sleep, now);
// §17 wind-down dim: ease toward 35% black across the final 30s.
const remaining = sleepRemainingMs(this.sleep, now);
this.sleepDim =
remaining !== null && remaining < 30_000 ? ((30_000 - remaining) / 30_000) * 0.35 : 0;
}, 1000);
},
stopSleepTicker() {
if (this.sleepTicker) {
clearInterval(this.sleepTicker);
this.sleepTicker = null;
}
},
// Expiry: fade the element volume over ~5s, then pause. The pause
// fires the SAME listener a manual pause does (progress push + media
// session state); the user's configured volume is restored right
// after, so the next play starts at full level.
beginSleepFade() {
if (this.sleep.mode === "fading") return;
const audio = this.audio();
if (!audio) {
this.stopSleep();
return;
}
this.sleep = { mode: "fading", endsAtMs: null, chapterIndex: null };
this.sleepLabelStr = "Fading";
if (this.muted || Number(this.volume) <= 0) {
// Inaudible anyway: skip the pantomime, just pause.
this.finishSleepPause();
return;
}
const base = Number(this.volume) || 0.5;
const startedAt = Date.now();
this.sleepFadeTimer = setInterval(() => {
const v = fadeVolume(base, Date.now() - startedAt);
audio.volume = v;
if (v <= 0) this.finishSleepPause();
}, 200);
},
finishSleepPause() {
if (this.sleepFadeTimer) {
clearInterval(this.sleepFadeTimer);
this.sleepFadeTimer = null;
}
this.stopSleepTicker();
const audio = this.audio();
if (audio && !audio.paused) audio.pause();
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
this.sleepDim = 0;
// §17: a genuine fire can catch you still awake — one-click undo.
showToast("Sleep timer: playback paused", "info", 8000, {
label: "Resume",
onClick: () => {
const a = this.audio();
if (a) a.play().catch(() => {});
},
});
},
// Manual off / play-abort: undo any partial fade.
stopSleep() {
if (this.sleepFadeTimer) {
clearInterval(this.sleepFadeTimer);
this.sleepFadeTimer = null;
}
this.stopSleepTicker();
const audio = this.audio();
if (audio) audio.volume = Math.min(1, Math.max(0, Number(this.volume) || 0.5));
this.sleep = SLEEP_OFF;
this.sleepArmedChoiceKey = "off";
this.sleepLabelStr = "Off";
this.sleepDim = 0;
},
// ---- settings popover (plan §4) ----
toggleAutoplay() {
this.autoplayOn = !this.autoplayOn;
localStorage.setItem("audio_autoplay", this.autoplayOn ? "1" : "0");
},
// Attempt playback after restorePosition when the user opted in. A
// browser autoplay-policy block (NotAllowedError) — or any transient
// play rejection — is swallowed silently; the user just taps play.
maybeAutoplay() {
if (!this.autoplayOn) return;
const audio = this.audio();
if (!audio || !audio.paused) return;
audio.play().catch(() => {
/* autoplay blocked — stay silent */
});
},
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,
}));