feat(audio-player): apply the resume-rewind setting on open and unpause

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.
This commit is contained in:
John O'Keefe committed 2026-11-01 17:21:35 -05:00
1 parent 9cf9e1d305
commit 7b7ce39826
3 files changed
+95 -3

No files matched your search

+33 -3
View File
@@ -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:<seconds>) 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 */
});