From 7b7ce398267b6401d3d7746125e91cc776a0de39 Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sun, 1 Nov 2026 17:21:35 -0500 Subject: [PATCH] feat(audio-player): apply the resume-rewind setting on open and unpause MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chapters payload now carries resume_rewind_seconds (server-side per-user setting); the player applies it at two events, each rewinding once from where playback currently sits — never stacked: - open-resume: restorePosition starts the rewind early from the saved spot (percentage and chapter+offset branches, clamped at the book start). Deep links (?loc=) stay exempt — an explicit jump is not a resume. - unpause: a genuine pause→play steps back again (the re-orientation gap). Programmatic plays — post-restore autoplay and track-switch autoplays — set a suppress flag: their play event is the session start the open-rewind already positioned, not an unpause. Math lives in audio-rewind.ts (pure, unit-tested) after the audio-scrubber pattern; payload values gate through the 0/5/10 allow-list so stale or foreign servers degrade to exact resume. --- web/src/audio-rewind.test.ts | 41 ++++++++++++++++++++++++++++++++++++ web/src/audio-rewind.ts | 21 ++++++++++++++++++ web/src/audio.ts | 36 ++++++++++++++++++++++++++++--- 3 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 web/src/audio-rewind.test.ts create mode 100644 web/src/audio-rewind.ts diff --git a/web/src/audio-rewind.test.ts b/web/src/audio-rewind.test.ts new file mode 100644 index 00000000..997171e5 --- /dev/null +++ b/web/src/audio-rewind.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { applyResumeRewind, normalizeResumeRewind } from "./audio-rewind"; + +describe("applyResumeRewind", () => { + it("steps back by the rewind amount", () => { + expect(applyResumeRewind(754, 5)).toBe(749); + expect(applyResumeRewind(754, 10)).toBe(744); + }); + + it("clamps at the start of the book", () => { + expect(applyResumeRewind(3, 5)).toBe(0); + expect(applyResumeRewind(0, 10)).toBe(0); + }); + + it("treats rewind 0 (setting off) as exact resume", () => { + expect(applyResumeRewind(754, 0)).toBe(754); + }); + + it("tolerates non-finite input", () => { + expect(applyResumeRewind(Number.NaN, 5)).toBe(0); + expect(applyResumeRewind(754, Number.NaN)).toBe(754); + expect(applyResumeRewind(Number.POSITIVE_INFINITY, 5)).toBe(0); + }); +}); + +describe("normalizeResumeRewind", () => { + it("passes the allow-listed amounts", () => { + expect(normalizeResumeRewind(0)).toBe(0); + expect(normalizeResumeRewind(5)).toBe(5); + expect(normalizeResumeRewind(10)).toBe(10); + }); + + it("maps anything else to off", () => { + expect(normalizeResumeRewind(7)).toBe(0); + expect(normalizeResumeRewind(-5)).toBe(0); + expect(normalizeResumeRewind("12")).toBe(0); + expect(normalizeResumeRewind(undefined)).toBe(0); + expect(normalizeResumeRewind(Number.NaN)).toBe(0); + }); +}); diff --git a/web/src/audio-rewind.ts b/web/src/audio-rewind.ts new file mode 100644 index 00000000..4ae51923 --- /dev/null +++ b/web/src/audio-rewind.ts @@ -0,0 +1,21 @@ +// Resume rewind (per-user server setting): how many seconds before the +// saved position the audiobook/podcast player starts. Applied on +// open-resume AND again on every genuine unpause — each event rewinds +// once from where playback currently sits, never stacked. Pure math; +// the Alpine component owns when to call it (the play-event suppress +// flag keeps restore-autoplay and track switches from double-firing). + +// applyResumeRewind maps a position back by the rewind amount, clamped +// to the start of the book. +export function applyResumeRewind(positionSeconds: number, rewindSeconds: number): number { + if (!Number.isFinite(positionSeconds)) return 0; + if (!Number.isFinite(rewindSeconds) || rewindSeconds <= 0) return Math.max(0, positionSeconds); + return Math.max(0, positionSeconds - rewindSeconds); +} + +// normalizeResumeRewind gates the server payload value to the setting's +// allow-list (0/5/10) — anything else means off. +export function normalizeResumeRewind(value: unknown): number { + const n = Number(value); + return n === 5 || n === 10 ? n : 0; +} diff --git a/web/src/audio.ts b/web/src/audio.ts index 9ed73307..afe90811 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -16,6 +16,7 @@ import { Alpine } from "./alpine"; import { renderNoteHtml } from "./markdown"; import { showToast } from "./toast"; import { WakeLockManager } from "./wake-lock"; +import { applyResumeRewind, normalizeResumeRewind } from "./audio-rewind"; import { MOUSE_GUIDE, resolveShortcut, @@ -232,6 +233,14 @@ Alpine.data("audioReader", () => ({ // User-level default speed (plan §8): powers the dropdown's // "(default)" marker; hydrated from the chapters payload. defaultSpeed: 1, + // Resume rewind (per-user server setting, chapters payload): seconds + // before the saved position playback starts — on open AND again on + // every genuine unpause. 0 = exact resume. + resumeRewind: 0, + // Set around PROGRAMMATIC play() calls (restore-autoplay, track + // switches): their play event must not fire the unpause rewind — the + // open-rewind already positioned those. Cleared by the listener. + suppressUnpauseRewind: false, // Exposed for the templ dropdown's x-for — templ cannot import the // const directly; this property is the binding. speeds: SPEEDS, @@ -457,6 +466,16 @@ Alpine.data("audioReader", () => ({ audio.addEventListener("play", () => { this.playing = true; this.prevTick = performance.now(); + // Resume rewind: a GENUINE unpause steps back resumeRewind + // seconds (per-user setting; 0 = off). Programmatic plays — + // restore-autoplay, track switches — set suppressUnpauseRewind: + // the open-rewind already positioned those, and the first play + // after open is the session start, not an unpause. + const programmatic = this.suppressUnpauseRewind; + this.suppressUnpauseRewind = false; + if (!programmatic && this.resumeRewind > 0 && audio.currentTime > 0) { + this.seekTo(applyResumeRewind(this.globalPosition(), this.resumeRewind)); + } // 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(); @@ -551,7 +570,12 @@ Alpine.data("audioReader", () => ({ audio.playbackRate = this.speed; audio.load(); } - if (resume) audio.play().catch(() => showToast("Playback failed to start", "error")); + if (resume) { + // Track-switch autoplay is programmatic: its play event must not + // fire the unpause rewind (the seek above IS the position). + this.suppressUnpauseRewind = true; + audio.play().catch(() => showToast("Playback failed to start", "error")); + } }, async restorePosition() { @@ -575,6 +599,7 @@ Alpine.data("audioReader", () => ({ } const serverDefault = Number(data.default_playback_speed); if (SPEEDS.includes(serverDefault)) this.defaultSpeed = serverDefault; + this.resumeRewind = normalizeResumeRewind(data.resume_rewind_seconds); this.buildTracks(data); this.recomputeTicks(); const audio = this.audio(); @@ -593,15 +618,17 @@ Alpine.data("audioReader", () => ({ const offset = Number(p.audio_offset_seconds ?? 0); // Banner/deep-link jumps (?loc=audio:) win over the // stored position: the user explicitly asked for this spot. + // The saved-position branches start resumeRewind seconds early + // (the re-orientation gap; clamped at the book start). 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; + const at = applyResumeRewind(Number(p.percentage) * this.bookDuration, this.resumeRewind); this.seekTo(at); } else if (chapter > 0 && this.chapters[chapter]?.start != null) { - this.seekTo(this.chapters[chapter].start + offset); + this.seekTo(applyResumeRewind(this.chapters[chapter].start + offset, this.resumeRewind)); } this.currentChapterIdx = Math.max(0, chapter); this.tickChapter(); @@ -1460,6 +1487,9 @@ Alpine.data("audioReader", () => ({ if (!this.autoplayOn) return; const audio = this.audio(); if (!audio || !audio.paused) return; + // Post-restore autoplay is programmatic: the open-rewind already + // positioned the element — the play event must not rewind again. + this.suppressUnpauseRewind = true; audio.play().catch(() => { /* autoplay blocked — stay silent */ });