Release / build-and-push (push) Successful in 3m12s
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.
1496 lines
52 KiB
TypeScript
1496 lines
52 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";
|
||
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,
|
||
}));
|