// 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; /** 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(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)) { 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)); }