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.
707 lines
25 KiB
TypeScript
707 lines
25 KiB
TypeScript
// 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 };
|