Files
siftlode/frontend/src/lib/resume.ts
T
peter dd6a666ad1 test(player): hold the session stop and the kept-point rule to the server
- The client's resume thresholds equal the server's (read from both sources).
- The session stop's URL matches the route the server serves it on.
- The player stops its server session by its item and its own id on an unload and on close,
  and keeps it when the page only goes into the back/forward cache.
2026-10-07 23:16:38 +02:00

127 lines
5.6 KiB
TypeScript

// Where a player built on the browser's own media element (the library's, a shared link's) stopped,
// and opening it there again. The Plex and YouTube players keep their resume in their own session
// code (a Plex session starts AT the point, a YouTube player is created with it); this is the same
// promise for the two that play a plain file.
//
// Where the point is kept is the caller's: the library's on the server (`library_states`), a shared
// link's in this browser (the viewer has no account). The rule for which point is worth keeping is
// the server's own (`routes/_common.kept_position`), repeated here for the link that has no server
// side (`resume.contract.test.ts` holds the two numbers equal): too early to count as started, or
// close enough to the end to count as finished, is "from the start".
import { useEffect, useEffectEvent } from "react";
import { LS, readJSON, writeJSON } from "./storage";
const PROGRESS_MIN_S = 5;
const FINISH_MARGIN_S = 10;
/** How often a playing item checkpoints its point. */
export const CHECKPOINT_S = 10;
/** The whole-second point worth keeping, or 0 for "from the start" (the server's rule). */
export function keptPosition(position: number, duration: number): number {
const at = Math.max(0, Math.floor(position));
const nearEnd = duration > 0 && at > Math.floor(duration) - FINISH_MARGIN_S;
return at >= PROGRESS_MIN_S && !nearEnd ? at : 0;
}
// A point inside the item, applied once the element knows its length. A start the element cannot
// honour (past its end: a shorter file under the same entry) is dropped, not clamped.
function openAt(media: HTMLMediaElement, start: number): void {
if (start > 0 && Number.isFinite(media.duration) && start < media.duration) {
media.currentTime = start;
}
}
/**
* Open `media` at `start` (once per `itemKey`) and report where it stops through `save`: every
* CHECKPOINT_S while it plays, on a pause, at the end (which reads as finished), and with
* `leaving` when the page goes away or the player closes — the moment a normal request may not
* finish. Nothing is reported before the item actually played: a player closed at once, or the
* development double mount, must not overwrite the kept point with the 0:00 it never left.
*/
export function useResume(
media: HTMLMediaElement | null,
itemKey: string | null,
start: number,
save: (position: number, duration: number, leaving: boolean) => void,
): void {
const report = useEffectEvent(save);
useEffect(() => {
if (!media || itemKey == null) return;
// Already playing counts as played: a cached file that autoplays from the start can fire its
// one `playing` before this effect subscribes, and would then never report at all.
let played = !media.paused;
let lastAt = 0;
const duration = () => (Number.isFinite(media.duration) ? media.duration : 0);
const put = (leaving: boolean) => {
if (!played) return;
lastAt = Date.now();
report(media.ended ? duration() : media.currentTime, duration(), leaving);
};
const restore = () => openAt(media, start);
if (media.readyState >= HTMLMediaElement.HAVE_METADATA) restore();
else media.addEventListener("loadedmetadata", restore, { once: true });
const onPlaying = () => {
played = true;
};
const onTime = () => {
if (played && !media.paused && Date.now() - lastAt >= CHECKPOINT_S * 1000) put(false);
};
const onStop = () => put(false);
const onHide = () => put(true);
media.addEventListener("playing", onPlaying);
media.addEventListener("timeupdate", onTime);
media.addEventListener("pause", onStop);
media.addEventListener("ended", onStop);
window.addEventListener("pagehide", onHide);
return () => {
media.removeEventListener("loadedmetadata", restore);
media.removeEventListener("playing", onPlaying);
media.removeEventListener("timeupdate", onTime);
media.removeEventListener("pause", onStop);
media.removeEventListener("ended", onStop);
window.removeEventListener("pagehide", onHide);
put(true);
};
}, [media, itemKey, start]);
}
// --- a shared link's point, kept in this browser ---------------------------------------------------
type LinkPoints = Record<string, { at: number; t: number }>;
/** The most links whose point this browser keeps — the oldest go first. */
export const LINK_POINTS_MAX = 50;
// Whatever the storage holds is read as untrusted: another version of the page, or a hand edit, may
// have left anything there, and a broken entry must read as "from the start", not throw.
function readPoints(): LinkPoints {
const raw = readJSON<unknown>(LS.watchResume, {});
if (raw == null || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: LinkPoints = {};
for (const [token, v] of Object.entries(raw as Record<string, unknown>)) {
const p = v as { at?: unknown; t?: unknown } | null;
if (typeof p?.at === "number" && p.at > 0 && typeof p.t === "number") {
out[token] = { at: p.at, t: p.t };
}
}
return out;
}
/** Where this browser stopped in the shared link `token` (0 = from the start). */
export function linkPoint(token: string): number {
return readPoints()[token]?.at ?? 0;
}
/** Keep (or, at a point not worth keeping, forget) where this browser stopped in `token`. */
export function saveLinkPoint(token: string, position: number, duration: number): void {
const points = readPoints();
const at = keptPosition(position, duration);
if (at) points[token] = { at, t: Date.now() };
else delete points[token];
const kept = Object.entries(points)
.sort(([, a], [, b]) => b.t - a.t)
.slice(0, LINK_POINTS_MAX);
writeJSON(LS.watchResume, Object.fromEntries(kept));
}