feat(player): the OS media controls, the basic keys and resume for the library and shared-link players

Plex, library and /watch tell the operating system what plays (title, line, picture) and take its
play / pause / skip / seek through one hook (lib/mediaSession); the Plex player drives it with its own
seek and clock, so the lock screen shows the film's position, not the stream's. The YouTube player is
left alone: its iframe already publishes all of it, and the OS routes to the frame that plays.

The library and /watch players get the basic keys (lib/mediaKeys, the keymap's mediaPlayer rows) and
open where the viewer stopped (lib/resume): the library keeps the point on the server, per user and
library item (library_states, POST /api/downloads/{id}/progress, listed back on both listings), the
shared link in this browser. A started library row continues, its menu can start it over, and its
picture carries the cards' progress bar. The resume rule (too early / near the end = from the start)
is one function shared with the YouTube route.

The library dialog opens focused on its panel (Modal initialFocus "player"): on the ✕, Space closed
the film. While the media element itself has the focus, Space and the arrows stay its own controls'
— the browser acts on them whatever a listener does, and one Space played and paused.
This commit is contained in:
2026-10-06 23:55:41 +02:00
parent 02feaa9a1c
commit 59b27467ab
32 changed files with 1489 additions and 133 deletions
+31 -1
View File
@@ -1,7 +1,7 @@
// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the
// phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only
// reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core.
import { req, uploadReq, getActiveAccount } from "./core";
import { beacon, req, uploadReq, getActiveAccount } from "./core";
export interface DownloadSpec {
mode: "av" | "v" | "a"; // audio+video | video-only | audio-only
@@ -97,6 +97,9 @@ export interface DownloadJob {
asset: DownloadAsset | null;
shared?: boolean;
user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
position_seconds?: number;
}
export interface ShareRecipient {
@@ -274,6 +277,33 @@ export const downloadsApi = {
req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }),
revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> =>
req(`/api/downloads/links/${linkId}`, { method: "DELETE" }),
// The library player's resume checkpoint. A position too early or too close to the end clears it.
saveDownloadProgress: (
id: number,
positionSeconds: number,
durationSeconds: number,
): Promise<{ ok: true; job_id: number; position_seconds: number }> =>
req(
`/api/downloads/${id}/progress`,
{
method: "POST",
body: JSON.stringify({
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
},
{ idempotent: true },
),
// The same, as a keepalive POST that survives the page going away (a reload, a closed tab).
saveDownloadProgressBeacon: (
id: number,
positionSeconds: number,
durationSeconds: number,
): void =>
beacon(`/api/downloads/${id}/progress`, {
position_seconds: Math.floor(positionSeconds),
duration_seconds: Math.floor(durationSeconds),
}),
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active
// account via ?account= (the server honours it only for an account in this browser's wallet) —
// otherwise the download resolves to the session-default account and 404s for a per-tab
+24 -6
View File
@@ -36,6 +36,7 @@ export type KeyScope =
| "contextMenu"
| "ytPlayer"
| "plexPlayer"
| "mediaPlayer"
| "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -48,7 +49,7 @@ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
export const isPageLevelScope = (scope: KeyScope): boolean => PAGE_LEVEL.has(scope);
/** Extra render condition for the help sheet (the shortcut itself is always live). */
type ShortcutWhen = "episode";
type ShortcutWhen = "episode" | "video";
export interface Shortcut {
/** Unique within its scope; the handler key `dispatch()` demands. */
@@ -213,6 +214,23 @@ export const SHORTCUTS = [
allowInInput: true,
},
// --- a player on the browser's own media element (the library's, a shared link's) -----------
// The basic set (`lib/mediaKeys.ts`): the element's own controls carry the rest. The step is the
// YouTube player's 5 s, the other short-form player; Escape is the dialog's.
{ id: "playPause", scope: "mediaPlayer", keys: ["Space", "k"], labelKey: "keys.playPause" },
{ id: "seekBack", scope: "mediaPlayer", keys: ["ArrowLeft"], labelKey: "keys.seek5" },
{ id: "seekFwd", scope: "mediaPlayer", keys: ["ArrowRight"], labelKey: "keys.seek5" },
// An audio file has no picture to fill the screen with: the key does nothing there, so the sheet
// does not promise it.
{
id: "fullscreen",
scope: "mediaPlayer",
keys: ["f"],
labelKey: "keys.fullscreen",
when: "video",
},
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[];
@@ -540,14 +558,14 @@ export function useGlobalKeymap(
export type HelpConditions = { readonly [K in ShortcutWhen]?: boolean };
const helpConditions = createStore<HelpConditions>({});
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode?). The
* sheet lives in App and cannot ask, so the surface tells it. */
/** Publish this surface's answers while it is mounted (the Plex player: is this an episode? the
* library's: is it a video?). The sheet lives in App and cannot ask, so the surface tells it. */
export function useHelpConditions(conditions: HelpConditions): void {
const episode = conditions.episode;
const { episode, video } = conditions;
useEffect(() => {
helpConditions.set({ episode });
helpConditions.set({ episode, video });
return () => helpConditions.set({});
}, [episode]);
}, [episode, video]);
}
// ── The module's search box ───────────────────────────────────────────────────────────────────
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
//
// The basic keys of a player on the browser's own media element (the library's, a shared link's):
// Space/k play and pause — but Space on a focused button presses the button — the arrows step 5 s
// inside the item, m mutes, f fills the screen with a video (an audio file has no picture). While the
// element itself has the focus, Space and the arrows are its own controls' (they act on them anyway,
// so a second action from here was a double play/pause and a double step).
import { describe, it, expect, vi, afterEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
const fs = vi.hoisted(() => ({ requestFullscreen: vi.fn(), exitFullscreen: vi.fn() }));
vi.mock("./fullscreen", () => ({
fullscreenElement: () => null,
requestFullscreen: fs.requestFullscreen,
exitFullscreen: fs.exitFullscreen,
}));
import { MEDIA_STEP_S, useMediaKeys } from "./mediaKeys";
afterEach(() => {
cleanup();
vi.clearAllMocks();
document.body.innerHTML = "";
});
function media(tag: "video" | "audio" = "video", duration = 100) {
const el = document.createElement(tag);
document.body.append(el);
let paused = true;
Object.defineProperty(el, "duration", { value: duration, configurable: true });
Object.defineProperty(el, "paused", { get: () => paused, configurable: true });
vi.spyOn(el, "play").mockImplementation(() => {
paused = false;
return Promise.resolve();
});
vi.spyOn(el, "pause").mockImplementation(() => {
paused = true;
});
return el;
}
function press(key: string, target: EventTarget = document.body): KeyboardEvent {
const e = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
target.dispatchEvent(e);
return e;
}
describe("useMediaKeys", () => {
it("Space and k play and pause, and take the key from the page", () => {
const el = media();
renderHook(() => useMediaKeys(el));
const e = press(" ");
expect(el.play).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(true);
press("k");
expect(el.pause).toHaveBeenCalledOnce();
});
it("on the focused element, Space and the arrows are left to its own controls; k still is ours", () => {
const el = media("video", 100);
renderHook(() => useMediaKeys(el));
el.currentTime = 50;
const space = press(" ", el);
const arrow = press("ArrowRight", el);
expect(el.play).not.toHaveBeenCalled();
expect(el.currentTime).toBe(50);
expect([space.defaultPrevented, arrow.defaultPrevented]).toEqual([false, false]);
press("k", el);
expect(el.play).toHaveBeenCalledOnce();
});
it("Space on a focused button presses the button instead", () => {
const el = media();
const button = document.createElement("button");
document.body.append(button);
renderHook(() => useMediaKeys(el));
const e = press(" ", button);
expect(el.play).not.toHaveBeenCalled();
expect(e.defaultPrevented).toBe(false);
});
it("the arrows step inside the item", () => {
const el = media("video", 100);
renderHook(() => useMediaKeys(el));
el.currentTime = 2;
press("ArrowLeft");
expect(el.currentTime).toBe(0);
el.currentTime = 50;
press("ArrowRight");
expect(el.currentTime).toBe(50 + MEDIA_STEP_S);
el.currentTime = 98;
press("ArrowRight");
expect(el.currentTime).toBe(100);
});
it("m mutes and unmutes", () => {
const el = media();
renderHook(() => useMediaKeys(el));
press("m");
expect(el.muted).toBe(true);
press("m");
expect(el.muted).toBe(false);
});
it("f fills the screen with a video, and leaves an audio file alone", () => {
const v = media("video");
const first = renderHook(() => useMediaKeys(v));
press("f");
expect(fs.requestFullscreen).toHaveBeenCalledWith(v);
first.unmount();
const a = media("audio");
renderHook(() => useMediaKeys(a));
const e = press("f");
expect(fs.requestFullscreen).toHaveBeenCalledOnce();
expect(e.defaultPrevented).toBe(false);
});
it("stays out of a text field, and goes with the player", () => {
const el = media();
const input = document.createElement("input");
document.body.append(input);
const { unmount } = renderHook(() => useMediaKeys(el));
press(" ", input);
expect(el.play).not.toHaveBeenCalled();
unmount();
press(" ");
expect(el.play).not.toHaveBeenCalled();
});
});
+58
View File
@@ -0,0 +1,58 @@
// The keys of a player built on the browser's own media element — the library's and a shared link's:
// the keymap's `mediaPlayer` rows (play/pause, a 5 s step, fullscreen, mute), bound to the element.
// Its own controls do the rest; Escape stays with the dialog around it.
//
// While the element ITSELF has the focus (a click on its controls puts it there), Space and the
// arrows are its own: the browser's controls act on them whatever a listener does with the keydown,
// so ours as well played and paused on one Space, and stepped twice on one arrow (measured in Chrome).
import { useEffect, useEffectEvent } from "react";
import { dispatch, isActivationTarget, useHelpConditions, useKeyScope } from "./keymap";
import { exitFullscreen, fullscreenElement, requestFullscreen } from "./fullscreen";
export const MEDIA_STEP_S = 5;
/** Bind the `mediaPlayer` keys to `media` while it is mounted. */
export function useMediaKeys(media: HTMLMediaElement | null): void {
useKeyScope("mediaPlayer", media != null);
useHelpConditions({ video: media instanceof HTMLVideoElement });
const onKey = useEffectEvent((e: KeyboardEvent) => {
if (!media) return;
const step = (ev: KeyboardEvent, by: number) => {
if (ev.target === media) return;
ev.preventDefault();
const end = Number.isFinite(media.duration) ? media.duration : Infinity;
media.currentTime = Math.min(end, Math.max(0, media.currentTime + by));
};
const ownKey = (ev: KeyboardEvent) => ev.target === media && ev.key !== "k";
dispatch(e, "mediaPlayer", {
playPause: (ev) => {
// Space on a focused button presses it (the dialog's ✕, a link's Download), as in every
// player here.
if (isActivationTarget(ev) || ownKey(ev)) return;
ev.preventDefault();
if (media.paused) void media.play().catch(() => {});
else media.pause();
},
seekBack: (ev) => step(ev, -MEDIA_STEP_S),
seekFwd: (ev) => step(ev, MEDIA_STEP_S),
fullscreen: (ev) => {
// An audio file has no picture to fill the screen with.
if (!(media instanceof HTMLVideoElement)) return;
ev.preventDefault();
if (fullscreenElement()) exitFullscreen();
else requestFullscreen(media);
},
mute: (ev) => {
ev.preventDefault();
media.muted = !media.muted;
},
});
});
useEffect(() => {
if (!media) return;
const handler = (e: KeyboardEvent) => onKey(e);
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [media]);
}
+124
View File
@@ -0,0 +1,124 @@
// @vitest-environment jsdom
//
// The OS's media controls: what is playing (title, line, picture), the buttons driving the player —
// through ITS controls when it has its own clock and seek (the Plex player), else the element's —
// the progress bar anchored to the player's position, and a clean hand-over: unmounting clears only
// a session that is still ours.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import { SESSION_SKIP_S, useMediaSession, type MediaSessionControls } from "./mediaSession";
type Handler = (d: MediaSessionActionDetails) => void;
let handlers: Map<string, Handler | null>;
let session: {
metadata: MediaMetadata | null;
setActionHandler: (a: string, h: Handler | null) => void;
setPositionState: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
handlers = new Map();
session = {
metadata: null,
setActionHandler: (a, h) => void handlers.set(a, h),
setPositionState: vi.fn(),
};
Object.defineProperty(navigator, "mediaSession", { value: session, configurable: true });
vi.stubGlobal(
"MediaMetadata",
class {
constructor(public init: MediaMetadataInit) {}
},
);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
const act = (name: string, d: Partial<MediaSessionActionDetails> = {}) =>
handlers.get(name)?.({ action: name as MediaSessionAction, ...d });
function controls(position = 100, duration = 3000): MediaSessionControls {
return {
play: vi.fn(),
pause: vi.fn(),
seekTo: vi.fn(),
position: () => position,
duration: () => duration,
};
}
describe("useMediaSession", () => {
it("tells the OS what is playing, with the picture as an absolute URL", () => {
const media = document.createElement("video");
renderHook(() =>
useMediaSession(media, { title: "Pilot", artist: "Westworld", artwork: "/api/plex/image/9" }),
);
const init = (session.metadata as unknown as { init: MediaMetadataInit }).init;
expect(init.title).toBe("Pilot");
expect(init.artist).toBe("Westworld");
expect(init.artwork?.[0]?.src).toBe(new URL("/api/plex/image/9", document.baseURI).href);
});
it("drives the player through its own controls, skips clamped to the item", () => {
const media = document.createElement("video");
const c = controls(5, 3000);
renderHook(() => useMediaSession(media, { title: "T" }, c));
act("play");
act("pause");
expect(c.play).toHaveBeenCalledOnce();
expect(c.pause).toHaveBeenCalledOnce();
act("seekbackward");
expect(c.seekTo).toHaveBeenLastCalledWith(0);
act("seekforward", { seekOffset: 30 });
expect(c.seekTo).toHaveBeenLastCalledWith(35);
act("seekto", { seekTime: 1234 });
expect(c.seekTo).toHaveBeenLastCalledWith(1234);
});
it("anchors the OS's progress bar to the player's own position, not the element's clock", () => {
const media = document.createElement("video");
renderHook(() => useMediaSession(media, { title: "T" }, controls(1800, 3000)));
media.dispatchEvent(new Event("seeked"));
expect(session.setPositionState).toHaveBeenLastCalledWith({
duration: 3000,
position: 1800,
playbackRate: 1,
});
});
it("without controls of its own, plays and seeks the element", () => {
const media = document.createElement("video");
const play = vi.spyOn(media, "play").mockResolvedValue();
Object.defineProperty(media, "duration", { value: 600, configurable: true });
renderHook(() => useMediaSession(media, { title: "T" }));
act("play");
expect(play).toHaveBeenCalledOnce();
act("seekforward");
expect(media.currentTime).toBe(SESSION_SKIP_S);
});
it("clears the session on unmount — unless another player has taken it since", () => {
const a = document.createElement("video");
const first = renderHook(() => useMediaSession(a, { title: "A" }));
first.unmount();
expect(session.metadata).toBeNull();
expect(handlers.get("play")).toBeNull();
const one = renderHook(() => useMediaSession(a, { title: "A" }));
const two = renderHook(() => useMediaSession(document.createElement("video"), { title: "B" }));
one.unmount();
expect((session.metadata as unknown as { init: MediaMetadataInit }).init.title).toBe("B");
expect(handlers.get("play")).toBeTypeOf("function");
two.unmount();
});
it("does nothing without a title or an element", () => {
renderHook(() => useMediaSession(null, { title: "T" }));
renderHook(() => useMediaSession(document.createElement("video"), null));
expect(session.metadata).toBeNull();
expect(handlers.size).toBe(0);
});
});
+161
View File
@@ -0,0 +1,161 @@
// What is playing, told to the operating system through the Media Session API: the lock screen, the
// notification shade, a headset's buttons and the keyboard's media keys show the title and the
// picture and control the player — play, pause, skip back and forward, and a seek from the OS's own
// progress bar. One definition for the Plex, library and shared-link players.
//
// The YouTube player is not one of them, by measurement: the browser routes the OS controls to the
// frame that plays the sound, which is YouTube's own iframe, and that iframe already says its title,
// channel and picture and takes play/pause/seek itself. Ours, set on the page, would never be shown.
//
// It is also what lets a browser's own "background playback" keep the sound going when the page is
// backgrounded — how youtube.com stays playing on Brave iOS. The sound stays on a plain media element
// for that reason: routing it through the Web Audio API (`createMediaElementSource`) pulls it out of
// the pipeline that background playback hooks into, and iOS then suspends the AudioContext — which
// STOPPED playback instead of keeping it alive (measured on iPad Brave).
import { useEffect, useEffectEvent } from "react";
export interface MediaSessionInfo {
title: string;
/** The line under the title: a channel, a show, an uploader. */
artist?: string | null;
/** A picture of it (same-origin or absolute URL). */
artwork?: string | null;
}
/** How the OS's controls drive a player whose playhead is not simply its element's (a Plex
* transcode starts its stream at the resume point, and a seek outside it restarts the stream). */
export interface MediaSessionControls {
play(): void;
pause(): void;
seekTo(seconds: number): void;
/** Where it is, in the item's own seconds. */
position(): number;
/** How long the item is; 0 while unknown. */
duration(): number;
}
/** The OS's skip back / forward step when it does not say one. */
export const SESSION_SKIP_S = 10;
/** The controls of a plain media element: its own clock, its own seek. */
function elementControls(media: HTMLMediaElement): MediaSessionControls {
return {
play: () => void media.play().catch(() => {}),
pause: () => media.pause(),
seekTo: (s) => {
media.currentTime = s;
},
position: () => media.currentTime,
duration: () => (Number.isFinite(media.duration) ? media.duration : 0),
};
}
// The events after which the OS's progress bar is re-anchored. Between them it runs on by itself at
// the stated rate, so nothing is sent per frame.
const POSITION_EVENTS = [
"loadedmetadata",
"durationchange",
"play",
"pause",
"seeked",
"ratechange",
] as const;
const ACTIONS = ["play", "pause", "seekbackward", "seekforward", "seekto"] as const;
// The API's throwing calls, each in a module helper of its own: a `try` inside the hook makes the
// React Compiler skip it (see CLAUDE.md's async-handler row).
/** The metadata, told to the OS — or null when this browser refuses it (then no session at all,
* rather than half of one). */
function claim(session: MediaSession, init: MediaMetadataInit): MediaMetadata | null {
try {
const metadata = new MediaMetadata(init);
session.metadata = metadata;
return metadata;
} catch {
return null;
}
}
function handle(
session: MediaSession,
action: MediaSessionAction,
handler: MediaSessionActionHandler | null,
): void {
try {
session.setActionHandler(action, handler);
} catch {
/* an action this browser does not know */
}
}
function setPosition(session: MediaSession, state?: MediaPositionState): void {
try {
session.setPositionState(state);
} catch {
/* a position the browser refuses (it checks position <= duration itself), or nothing to clear */
}
}
/**
* Tell the OS that `media` plays `info`, for as long as both are given; `controls` (default: the
* element's own) is what its buttons do. Cleared on unmount, unless another player has already
* claimed the session since.
*/
export function useMediaSession(
media: HTMLMediaElement | null,
info: MediaSessionInfo | null | undefined,
controls?: MediaSessionControls,
): void {
const title = info?.title ?? null;
const artist = info?.artist ?? "";
const artwork = info?.artwork ?? null;
// Read when an action arrives, not bound at the effect: the Plex player's controls are fresh
// closures every render, and re-telling the OS on each render would be churn.
const ctl = useEffectEvent(
(): MediaSessionControls | null => controls ?? (media ? elementControls(media) : null),
);
useEffect(() => {
const session = typeof navigator !== "undefined" ? navigator.mediaSession : undefined;
if (!session || !media || title == null || typeof MediaMetadata === "undefined") return;
const metadata = claim(session, {
title,
artist,
artwork: artwork ? [{ src: new URL(artwork, document.baseURI).href }] : [],
});
if (!metadata) return;
const skip = (dir: -1 | 1) => (d: MediaSessionActionDetails) => {
const c = ctl();
if (!c) return;
const end = c.duration() || Infinity;
c.seekTo(Math.min(end, Math.max(0, c.position() + dir * (d.seekOffset ?? SESSION_SKIP_S))));
};
handle(session, "play", () => ctl()?.play());
handle(session, "pause", () => ctl()?.pause());
handle(session, "seekbackward", skip(-1));
handle(session, "seekforward", skip(1));
handle(session, "seekto", (d) => {
if (d.seekTime != null) ctl()?.seekTo(d.seekTime);
});
const anchor = () => {
const c = ctl();
const duration = c?.duration() ?? 0;
if (!c || !(duration > 0)) return;
setPosition(session, {
duration,
position: Math.min(duration, Math.max(0, c.position())),
playbackRate: media.playbackRate || 1,
});
};
for (const type of POSITION_EVENTS) media.addEventListener(type, anchor);
anchor();
return () => {
for (const type of POSITION_EVENTS) media.removeEventListener(type, anchor);
if (session.metadata !== metadata) return; // another player has the session now
session.metadata = null;
for (const action of ACTIONS) handle(session, action, null);
setPosition(session);
};
}, [media, title, artist, artwork]);
}
+181
View File
@@ -0,0 +1,181 @@
// @vitest-environment jsdom
//
// The resume of a player on the browser's own media element: it opens at the kept point, reports
// where it stops (a pause, the periodic checkpoint, the end, the page going away, the player
// closing), and reports NOTHING before the item played — a player closed at once must not overwrite
// the kept point with the 0:00 it never left. A shared link's point lives in this browser.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { renderHook, cleanup } from "@testing-library/react";
import {
CHECKPOINT_S,
LINK_POINTS_MAX,
keptPosition,
linkPoint,
saveLinkPoint,
useResume,
} from "./resume";
import { LS } from "./storage";
/** A media element whose clock and length the test sets (jsdom plays nothing). */
function fakeMedia(duration = 600) {
const el = document.createElement("video");
let t = 0;
let paused = true;
let ready = 0;
Object.defineProperties(el, {
currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true },
duration: { get: () => (ready ? duration : NaN), configurable: true },
readyState: { get: () => ready, configurable: true },
paused: { get: () => paused, configurable: true },
ended: { get: () => ready > 0 && t >= duration, configurable: true },
});
return {
el,
metadata() {
ready = 1;
el.dispatchEvent(new Event("loadedmetadata"));
},
play() {
paused = false;
el.dispatchEvent(new Event("playing"));
},
at(s: number) {
t = s;
el.dispatchEvent(new Event("timeupdate"));
},
pause() {
paused = true;
el.dispatchEvent(new Event("pause"));
},
};
}
afterEach(() => {
cleanup();
vi.useRealTimers();
});
describe("useResume", () => {
it("opens at the kept point once the element knows its length", () => {
const m = fakeMedia();
renderHook(() => useResume(m.el, "7", 300, () => {}));
expect(m.el.currentTime).toBe(0);
m.metadata();
expect(m.el.currentTime).toBe(300);
});
it("drops a kept point past the end rather than clamping onto it", () => {
const m = fakeMedia(200);
renderHook(() => useResume(m.el, "7", 300, () => {}));
m.metadata();
expect(m.el.currentTime).toBe(0);
});
it("reports nothing when the player closes before the item played", () => {
const m = fakeMedia();
const save = vi.fn();
const { unmount } = renderHook(() => useResume(m.el, "7", 300, save));
m.metadata();
unmount();
expect(save).not.toHaveBeenCalled();
});
it("reports a pause, and the close as leaving", () => {
const m = fakeMedia();
const save = vi.fn();
const { unmount } = renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(42);
m.pause();
expect(save).toHaveBeenLastCalledWith(42, 600, false);
unmount();
expect(save).toHaveBeenLastCalledWith(42, 600, true);
});
it("checkpoints while it plays, at most once per interval", () => {
vi.useFakeTimers();
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(1);
expect(save).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(CHECKPOINT_S * 1000 - 1);
m.at(10);
expect(save).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1);
m.at(11);
expect(save).toHaveBeenCalledTimes(2);
expect(save).toHaveBeenLastCalledWith(11, 600, false);
});
it("reports the end as the full length (finished)", () => {
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(600);
m.el.dispatchEvent(new Event("ended"));
expect(save).toHaveBeenLastCalledWith(600, 600, false);
});
it("reports the page going away as leaving", () => {
const m = fakeMedia();
const save = vi.fn();
renderHook(() => useResume(m.el, "7", 0, save));
m.metadata();
m.play();
m.at(30);
window.dispatchEvent(new Event("pagehide"));
expect(save).toHaveBeenLastCalledWith(30, 600, true);
});
});
describe("keptPosition (the server's rule)", () => {
it("keeps a point in the middle, clears one too early or too near the end", () => {
expect(keptPosition(754.9, 3000)).toBe(754);
expect(keptPosition(4, 3000)).toBe(0);
expect(keptPosition(2995, 3000)).toBe(0);
expect(keptPosition(120, 0)).toBe(120); // an unknown length cannot be "near the end"
});
});
describe("a shared link's point", () => {
beforeEach(() => localStorage.clear());
it("is kept per link, and forgotten at a point not worth keeping", () => {
saveLinkPoint("tok-a", 125, 600);
saveLinkPoint("tok-b", 300, 600);
expect(linkPoint("tok-a")).toBe(125);
expect(linkPoint("tok-b")).toBe(300);
saveLinkPoint("tok-a", 598, 600);
expect(linkPoint("tok-a")).toBe(0);
expect(linkPoint("tok-b")).toBe(300);
});
it("keeps only the most recent links", () => {
vi.useFakeTimers();
for (let i = 0; i <= LINK_POINTS_MAX; i++) {
vi.advanceTimersByTime(1000);
saveLinkPoint(`tok-${i}`, 100, 600);
}
expect(linkPoint("tok-0")).toBe(0);
expect(linkPoint(`tok-${LINK_POINTS_MAX}`)).toBe(100);
expect(Object.keys(JSON.parse(localStorage.getItem(LS.watchResume)!))).toHaveLength(
LINK_POINTS_MAX,
);
});
it("reads a broken stored value as from the start", () => {
localStorage.setItem(LS.watchResume, JSON.stringify({ "tok-a": "nonsense", x: [1] }));
expect(linkPoint("tok-a")).toBe(0);
localStorage.setItem(LS.watchResume, "[1,2]");
expect(linkPoint("tok-a")).toBe(0);
saveLinkPoint("tok-a", 100, 600);
expect(linkPoint("tok-a")).toBe(100);
});
});
+124
View File
@@ -0,0 +1,124 @@
// 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: 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;
let played = false;
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));
}
+3
View File
@@ -74,6 +74,9 @@ export const LS = {
// The public /watch page's repeat toggle (JSON boolean; on by default). Browser-wide, NOT per account:
// that page runs outside the app, with no account at all.
watchRepeat: "siftlode.watchRepeat",
// Where this browser stopped in each shared /watch link (`lib/resume.ts`: token -> {at, t}). Same
// reason as above: the viewer has no account to keep it in.
watchResume: "siftlode.watchResume",
} as const;
// --- per-account scoping -----------------------------------------------------------------