- 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.
127 lines
5.6 KiB
TypeScript
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));
|
|
}
|