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

+41
View File
@@ -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);
});
});
+21
View File
@@ -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;
}
+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 */
});