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:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user