Annotations banner: audio/text bookmarks join notes and highlights — third row kind (bookmark glyph indicator, H:MM:SS / Page N / Chapter N friendly labels), edit-note/delete actions branch by kind, and the banner's export now covers all annotations. The archived ledger (ListDeletedAnnotationsForBook) already tombstoned bookmarks equally and Restore/Purge take annotation_type=bookmark — verified live: delete → restore brings the row back with its note intact. Volume: the dragger sits BELOW the mute icon in the panel (stacked flyout shape) with the horizontal slider from the last round. Badges: the real fix for comics/manga — format_group only knows 'comic_archive' (manga_type is unreliable across sources), so the comic/manga split is resolved from the LIBRARY type server-side. handlers.MediaBadgeKind + BookInfo.Badge at every construction site (dashboard SSR + sections API, collections, series, and the bookshelf search grid — which was also missing FormatGroup entirely, so no card ever badged there). Both renderers (templ + dashboard.ts) switch on the resolved badge. Verified: All-Books grid renders 2 Comic + 3 Manga badges, dashboard the audiobook one. Infra: header.templ main.js src gains ?v=<stamp> — browsers keyed an intermediate bundle by URL and served it across rebuilds (the stale 'Add note' the user saw); bump the stamp when JS stale-shadows.
551 lines
18 KiB
TypeScript
551 lines
18 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 };
|
|
|
|
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: 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:<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;
|
|
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:<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; 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<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(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<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;
|
|
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<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 };
|