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:
1 parent
9cf9e1d305
commit
7b7ce39826
3 files changed
+95
-3
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 */
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user