feat(audiobooks): A2 — narrator metadata, audio-aware UX, player polish, bookmarks

A2a (metadata round): narrators TEXT[] + narrators_search (tags
pattern: accumulator + GIN pairs) + abridged bool on media_items;
scanner fills from the Libation/ABS sidecar (narrators verbatim,
abridged flag) with audio-payload promotion; editorial fields ride
the real columns while audio_metadata stays scan-derived (duration/
codec/chapters). Detail/edit/save paths carry the fields; the web
edit dialog grows an Audio section (Narrator(s) comma-input, Abridged
toggle, scanned duration read-only); search weights + narrator filter
(word_similarity over narrators_search) + narrator autocomplete added.

A2c (per-media-type wording): copy helpers keyed on format_group —
Listen Now, Mark Complete/Not Complete, Listening Progress, Your
Listening Stats, Time Listening, Time listened (pages tile swapped for
audio), Position H:MM:SS / total H:MM:SS on detail + progress pages
(duration parsed from audio_metadata). Dashboard: system collections
swap to Continue Listening / Recently Listened at DISPLAY time when
the audiobooks library is selected (stored names unchanged; restores
keep working); week card = Listening Time / Sessions / Books Touched;
cover-corner badges identify audiobook (headphones) / comic / manga
cards on mixed grids — books stay unbadged; hover action icon follows
the media type.

A2b (player): the scrubber binds value to the playhead with an explicit
scrub guard (the handle previously froze at its initial max); header
row = reader-style back arrow + Chapter n/total launcher; chapters
open in a floating top-down drawer (current chapter highlighted, page
never shifts); Skip/Speed rows share one label-left layout; honest
wall-clock session accounting (playing-only deltas, paused flush).

A2d (bookmarks): note-bearing time bookmarks ride the existing
media_bookmarks substrate (notes column + sync-aware service already
there) — audio locator convention audio:<seconds>, chapter_number
filled, auto-title '<chapter> — H:MM:SS', capture popover with optional
note, drawer with jump/edit-note/delete. Zero server changes needed.

Verified live on the Libation sample: narrators backfilled by rescan;
PUT edit persisted + overrides detected (narrators, abridged); force
rescan preserved the user edit; reset-to-scanned-of-defaults restored
truth; narrator filter/autocomplete scoped by library; dashboard
listsening wording + badges render; bookmark save with note deduped to
one row.
This commit is contained in:
John O'Keefe committed 2026-10-15 18:03:35 -04:00
1 parent 902cbe5915
commit 86f0af632e
38 files changed
+3069 -1552

No files matched your search

+156 -10
View File
@@ -18,7 +18,6 @@ import { showToast } from "./toast";
type AudioChapter = { index: number; title: string; start: number; end: number };
const PROGRESS_INTERVAL_MS = 30_000;
const PLAYING_THRESHOLD = 0.5; // seconds of audio that count as "playing"
interface AudioConfig {
chapterEndpoint: string;
@@ -53,14 +52,30 @@ Alpine.data("audioReader", () => ({
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: "",
// chapters are absolute seconds into the book; the scrubber spans the
// whole book, not per-chapter
bookDuration: 0,
currentChapterIdx: 0,
// progress-push bookkeeping
// 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,
@@ -96,6 +111,8 @@ Alpine.data("audioReader", () => ({
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) {
@@ -114,19 +131,29 @@ Alpine.data("audioReader", () => ({
// Element events.
audio.addEventListener("timeupdate", () => {
this.currentTime = audio.currentTime;
if (!this.scrubbing) this.currentTime = audio.currentTime;
this.tickChapter();
this.pendingElapsed = Math.max(
this.pendingElapsed,
Math.round(audio.currentTime - this.sessionStart),
);
// 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.sessionStart = audio.currentTime;
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();
@@ -267,6 +294,126 @@ Alpine.data("audioReader", () => ({
localStorage.setItem("audio_skip", String(seconds));
},
backToDetail() {
this.pushProgress(true);
window.location.href = `/media/${this.mediaItemId}`;
},
// ---- 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
}
},
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];
@@ -297,13 +444,12 @@ Alpine.data("audioReader", () => ({
offset = Math.max(0, Math.round(at - this.chapters[i].start));
}
}
const playing = this.playing && audio.currentTime - this.sessionStart > PLAYING_THRESHOLD;
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: playing || exit ? elapsed : 0,
elapsed_seconds: elapsed >= 1 || (exit && this.playing) ? elapsed : 0,
reading_mode: "audio",
};
this.pendingElapsed = 0;
+12
View File
@@ -86,6 +86,18 @@ function collectFormData(editorTags: string[]): Record<string, unknown> {
.filter(Boolean);
}
// Narrators arrive as one comma-separated form field (mirrors
// contributors); empty → drop the key so non-audio saves don't ship [].
const narrStr = data.narrators as string;
if (typeof narrStr === "string") {
const narrators = narrStr
.split(",")
.map((n: string) => n.trim())
.filter(Boolean);
if (narrators.length) data.narrators = narrators;
else delete data.narrators;
}
return data;
}
+5
View File
@@ -444,6 +444,11 @@ Alpine.data("bookshelf", () => ({
await this.fetchFieldValues("series", input.value, "series-datalist");
},
// Fetch narrator values for autocomplete (A2a)
async fetchNarratorValues(input: HTMLInputElement): Promise<void> {
await this.fetchFieldValues("narrator", input.value, "narrator-datalist");
},
// Fetch language values for autocomplete
async fetchLanguageValues(input: HTMLInputElement): Promise<void> {
await this.fetchFieldValues("language", input.value, "language-datalist");