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:
1 parent
902cbe5915
commit
86f0af632e
38 files changed
+3069
-1552
No files matched your search
+156
-10
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user