diff --git a/apps/web/src/components/video/RecordingPlayer.tsx b/apps/web/src/components/video/RecordingPlayer.tsx
new file mode 100644
index 00000000..7b03cf59
--- /dev/null
+++ b/apps/web/src/components/video/RecordingPlayer.tsx
@@ -0,0 +1,95 @@
+'use client';
+
+/**
+ * React's half of the recording player.
+ *
+ * Deliberately thin. Everything that decides how the player behaves lives in
+ * `@/lib/player`, which has no framework in it, so that genrewatch.com and
+ * tipoffwatch.com — Hono JSX with a vanilla client bundle — can use the same
+ * player rather than a second one that drifts. This file exists to own a ref, a
+ * mount effect and a teardown.
+ *
+ * Two things are read from `window` inside the effect rather than from props or
+ * `useSearchParams`: the `?t=` deep link and the share URL. Reading them during
+ * render would make the server and client markup disagree; reading them on
+ * mount cannot, and the player has nothing to do before mount anyway.
+ */
+
+import { useEffect, useRef } from 'react';
+import { createPlayer, parseTimeParam, type Chapter } from '@/lib/player';
+import '@/lib/player/player.css';
+
+interface RecordingPlayerProps {
+ src: string;
+ /** Stable key for resume positions — the recording, not the page. */
+ mediaId: string;
+ poster?: string | null;
+ chapters?: Chapter[];
+ /**
+ * Whether to offer "copy link at this time". Off inside an embed: the iframe
+ * has no address a reader could usefully paste.
+ */
+ shareable?: boolean;
+ className?: string;
+}
+
+export function RecordingPlayer({
+ src,
+ mediaId,
+ poster,
+ chapters,
+ shareable = true,
+ className,
+}: RecordingPlayerProps) {
+ const rootRef = useRef
(null);
+ const videoRef = useRef(null);
+
+ useEffect(() => {
+ const root = rootRef.current;
+ const video = videoRef.current;
+ if (!root || !video) return;
+
+ const params = new URLSearchParams(window.location.search);
+ const startAt = parseTimeParam(params.get('t'));
+
+ const handle = createPlayer(video, root, {
+ mediaId,
+ // Spread rather than passed: under exactOptionalPropertyTypes an absent
+ // prop and one explicitly set to undefined are different types.
+ ...(chapters ? { chapters } : {}),
+ startAt,
+ shareUrl: shareable
+ ? (seconds: number) => {
+ const url = new URL(window.location.href);
+ url.searchParams.set('t', String(seconds));
+ url.hash = '';
+ return url.toString();
+ }
+ : null,
+ });
+
+ return () => { handle.destroy(); };
+ // `chapters` is intentionally not a dependency: rebuilding the whole player
+ // on a new array identity would lose the reader's position. Chapters that
+ // arrive later go through the handle's setChapters instead.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mediaId, shareable, src]);
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/lib/player/chapters.ts b/apps/web/src/lib/player/chapters.ts
new file mode 100644
index 00000000..b9b6e5de
--- /dev/null
+++ b/apps/web/src/lib/player/chapters.ts
@@ -0,0 +1,83 @@
+/**
+ * Chapters: the marks on the scrub bar and the label beside the clock.
+ *
+ * The player accepts them from whoever renders it; it does not source them. For
+ * a recorded live the eventual source is the host — a list typed once after the
+ * stream, or derived from what happened during it — and until that exists the
+ * player simply renders none. That is why every function here tolerates an
+ * empty list as an ordinary case rather than a missing input.
+ *
+ * Everything is pure and duration-aware, because the two ways chapter data goes
+ * wrong are both silent: marks that sit off the end of a bar the reader cannot
+ * reach, and marks so close together they render as one smudge.
+ */
+
+export interface Chapter {
+ /** Seconds from the start of the recording. */
+ start: number;
+ title: string;
+}
+
+export interface NormalizedChapter extends Chapter {
+ /** Where this chapter gives way to the next, or the end of the recording. */
+ end: number;
+ /** 0–1 across the whole recording, for placing the mark. */
+ position: number;
+}
+
+/**
+ * Sort, clean and close the ranges.
+ *
+ * Drops anything that cannot be drawn: a non-finite or negative start, a start
+ * past the end of the recording, a blank title, and the second of two chapters
+ * claiming the same second. Returns [] when there is no duration yet, because a
+ * mark cannot be placed on a bar of unknown length — the caller re-runs this
+ * once metadata lands.
+ */
+export function normalizeChapters(
+ chapters: readonly Chapter[] | null | undefined,
+ duration: number
+): NormalizedChapter[] {
+ if (!chapters || chapters.length === 0) return [];
+ if (!Number.isFinite(duration) || duration <= 0) return [];
+
+ const seen = new Set();
+ const cleaned = chapters
+ .filter((chapter): chapter is Chapter => Boolean(chapter) && typeof chapter.title === 'string')
+ .map((chapter) => ({ start: Math.floor(chapter.start), title: chapter.title.trim() }))
+ .filter((chapter) => Number.isFinite(chapter.start) && chapter.start >= 0)
+ .filter((chapter) => chapter.start < duration)
+ .filter((chapter) => chapter.title !== '')
+ .sort((a, b) => a.start - b.start)
+ .filter((chapter) => {
+ if (seen.has(chapter.start)) return false;
+ seen.add(chapter.start);
+ return true;
+ });
+
+ return cleaned.map((chapter, index) => ({
+ ...chapter,
+ end: cleaned[index + 1]?.start ?? duration,
+ position: chapter.start / duration,
+ }));
+}
+
+/**
+ * Which chapter contains this moment.
+ *
+ * Before the first chapter's start there is no chapter — a recording whose
+ * first mark is at 2:00 genuinely has two unlabelled minutes, and inventing an
+ * "Intro" for it would be putting words in the host's mouth.
+ */
+export function activeChapter(
+ chapters: readonly NormalizedChapter[],
+ seconds: number
+): NormalizedChapter | null {
+ if (!Number.isFinite(seconds)) return null;
+ let found: NormalizedChapter | null = null;
+ for (const chapter of chapters) {
+ if (chapter.start <= seconds) found = chapter;
+ else break;
+ }
+ return found;
+}
diff --git a/apps/web/src/lib/player/index.ts b/apps/web/src/lib/player/index.ts
new file mode 100644
index 00000000..fafe95e9
--- /dev/null
+++ b/apps/web/src/lib/player/index.ts
@@ -0,0 +1,22 @@
+/**
+ * The recording player, as one import.
+ *
+ * `player.ts` is the whole thing; the rest are its pure parts, exported because
+ * they are worth testing and reusing on their own — a channel page that wants
+ * to print a recording's length should not have to reimplement `formatTime`.
+ */
+
+export { createPlayer, type PlayerHandle, type PlayerOptions } from './player';
+export { formatTime, formatTimeParam, parseTimeParam } from './time';
+export { activeChapter, normalizeChapters, type Chapter, type NormalizedChapter } from './chapters';
+export { isTvBrowser, tvBrowserType, uiProfile, type UiProfile } from './tv';
+export {
+ clearPosition,
+ loadPosition,
+ loadPrefs,
+ savePosition,
+ savePrefs,
+ shouldResume,
+ type PlayerPrefs,
+ type SavedPosition,
+} from './storage';
diff --git a/apps/web/src/lib/player/player.css b/apps/web/src/lib/player/player.css
new file mode 100644
index 00000000..23a26c8a
--- /dev/null
+++ b/apps/web/src/lib/player/player.css
@@ -0,0 +1,431 @@
+/* =============================================================== the player ==
+ Styles for lib/player/player.ts.
+
+ Plain class names and no build-tool syntax on purpose: this file is meant to
+ be pasted into genrewatch.com's and tipoffwatch.com's `styles.css` unchanged.
+ No Tailwind, no nesting, no custom properties borrowed from the host site --
+ the player has to look the same in three codebases that share no design
+ tokens, and the one thing every player agrees on is that it is dark.
+
+ Everything is scoped under .pux-player so it cannot leak into a host page. */
+
+.pux-player {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ background: #000;
+ overflow: hidden;
+ /* The container is focusable so keyboard control works after a click on the
+ picture. Its focus ring would otherwise sit around the whole video. */
+ outline: none;
+ font-family:
+ ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
+ -webkit-user-select: none;
+ user-select: none;
+}
+.pux-player:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: -2px;
+}
+
+.pux-player video {
+ display: block;
+ width: 100%;
+ height: 100%;
+ /* contain, not cover: cover crops, and cropping the top off a screen share
+ removes the menu bar somebody was demonstrating. */
+ object-fit: contain;
+ background: #000;
+}
+
+/* ------------------------------------------------------------- big play -- */
+
+.pux-player__overlay {
+ position: absolute;
+ inset: 0;
+ display: grid;
+ place-items: center;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: transparent;
+ color: #fff;
+ cursor: pointer;
+ transition: opacity 150ms ease;
+}
+.pux-player__overlay svg {
+ width: 4rem;
+ height: 4rem;
+ fill: currentColor;
+ border-radius: 999px;
+ background: rgba(0, 0, 0, 0.55);
+ padding: 1rem;
+}
+/* While it is playing the overlay must not sit between the reader and the
+ picture -- but it stays clickable, because clicking the picture to pause is
+ the single most used control there is. */
+.pux-player--playing .pux-player__overlay {
+ opacity: 0;
+}
+.pux-player--playing:hover .pux-player__overlay svg,
+.pux-player--playing .pux-player__overlay:focus-visible svg {
+ opacity: 1;
+}
+.pux-player--playing .pux-player__overlay:focus-visible {
+ opacity: 1;
+}
+.pux-player--failed .pux-player__overlay {
+ display: none;
+}
+
+/* ------------------------------------------------------------ buffering -- */
+
+.pux-player__spinner {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ width: 2.5rem;
+ height: 2.5rem;
+ margin: -1.25rem 0 0 -1.25rem;
+ border: 3px solid rgba(255, 255, 255, 0.25);
+ border-top-color: #fff;
+ border-radius: 999px;
+ display: none;
+ animation: pux-spin 800ms linear infinite;
+}
+.pux-player--buffering .pux-player__spinner {
+ display: block;
+}
+@keyframes pux-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ .pux-player__spinner {
+ animation-duration: 2s;
+ }
+}
+
+/* --------------------------------------------------------------- notice -- */
+
+/* Resume, copied links, and the reason a recording will not play. Top-left so
+ it never covers the controls the reader is reaching for. */
+.pux-player__notice {
+ position: absolute;
+ top: 0.75rem;
+ left: 0.75rem;
+ right: 0.75rem;
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ max-width: 34rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.5rem;
+ background: rgba(15, 23, 42, 0.92);
+ color: #f8fafc;
+ font-size: 0.8125rem;
+ line-height: 1.4;
+}
+.pux-player__notice[hidden] {
+ display: none;
+}
+.pux-player__notice-text {
+ flex: 1 1 auto;
+ /* An error message can be long; a URL can be very long and has no spaces. */
+ overflow-wrap: anywhere;
+}
+.pux-player__notice-action {
+ flex: 0 0 auto;
+ padding: 0.25rem 0.6rem;
+ border: 1px solid rgba(248, 250, 252, 0.4);
+ border-radius: 0.375rem;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ cursor: pointer;
+}
+.pux-player__notice-action:hover {
+ background: rgba(248, 250, 252, 0.12);
+}
+.pux-player__notice-action[hidden] {
+ display: none;
+}
+
+/* ------------------------------------------------------------------ bar -- */
+
+.pux-player__bar {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ padding: 2.5rem 0.75rem 0.6rem;
+ /* The gradient is what keeps white controls legible over a white slide. */
+ background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0));
+ opacity: 0;
+ transform: translateY(0.5rem);
+ transition:
+ opacity 150ms ease,
+ transform 150ms ease;
+ pointer-events: none;
+}
+.pux-player--controls .pux-player__bar {
+ opacity: 1;
+ transform: none;
+ pointer-events: auto;
+}
+/* A paused or failed player keeps its controls: there is nothing to watch, and
+ hiding them is how a reader concludes the page is broken. */
+.pux-player:not(.pux-player--playing) .pux-player__bar,
+.pux-player--failed .pux-player__bar {
+ opacity: 1;
+ transform: none;
+ pointer-events: auto;
+}
+
+/* ---------------------------------------------------------------- scrub -- */
+
+.pux-player__scrub {
+ position: relative;
+ height: 1.25rem;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+ touch-action: none;
+}
+.pux-player__scrub:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: 2px;
+ border-radius: 0.25rem;
+}
+.pux-player__track {
+ position: relative;
+ width: 100%;
+ height: 0.25rem;
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.28);
+ transition: height 120ms ease;
+}
+.pux-player__scrub:hover .pux-player__track,
+.pux-player__scrub:focus-visible .pux-player__track {
+ height: 0.4rem;
+}
+.pux-player__buffered,
+.pux-player__played {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ border-radius: 999px;
+}
+.pux-player__buffered {
+ width: 0;
+ background: rgba(255, 255, 255, 0.4);
+}
+.pux-player__played {
+ width: 0;
+ background: #6366f1;
+}
+.pux-player__handle {
+ position: absolute;
+ top: 50%;
+ left: 0;
+ width: 0.85rem;
+ height: 0.85rem;
+ margin: -0.425rem 0 0 -0.425rem;
+ border-radius: 999px;
+ background: #fff;
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+.pux-player__scrub:hover .pux-player__handle,
+.pux-player__scrub:focus-visible .pux-player__handle,
+.pux-player--tv .pux-player__handle {
+ opacity: 1;
+}
+
+/* Chapter marks. Buttons rather than decorations, so they can be clicked and
+ reached, but small enough not to become the bar's main feature. */
+.pux-player__marks {
+ position: absolute;
+ inset: 0;
+ pointer-events: none;
+}
+.pux-player__mark {
+ position: absolute;
+ top: 50%;
+ width: 0.2rem;
+ height: 0.7rem;
+ margin: -0.35rem 0 0 -0.1rem;
+ padding: 0;
+ border: 0;
+ border-radius: 1px;
+ background: rgba(255, 255, 255, 0.9);
+ cursor: pointer;
+ pointer-events: auto;
+}
+.pux-player__mark:hover,
+.pux-player__mark:focus-visible {
+ background: #fff;
+ height: 1rem;
+ margin-top: -0.5rem;
+ outline: none;
+}
+
+.pux-player__tooltip {
+ position: absolute;
+ bottom: 1.4rem;
+ transform: translateX(-50%);
+ padding: 0.1rem 0.4rem;
+ border-radius: 0.25rem;
+ background: rgba(15, 23, 42, 0.92);
+ color: #f8fafc;
+ font-size: 0.7rem;
+ font-variant-numeric: tabular-nums;
+ opacity: 0;
+ pointer-events: none;
+}
+.pux-player__scrub:hover .pux-player__tooltip {
+ opacity: 1;
+}
+
+/* ------------------------------------------------------------------ row -- */
+
+.pux-player__row {
+ display: flex;
+ align-items: center;
+ gap: 0.15rem;
+ color: #fff;
+}
+.pux-player__btn {
+ display: inline-grid;
+ place-items: center;
+ flex: 0 0 auto;
+ width: 2.25rem;
+ height: 2.25rem;
+ padding: 0;
+ border: 0;
+ border-radius: 0.375rem;
+ background: transparent;
+ color: #fff;
+ cursor: pointer;
+}
+.pux-player__btn svg {
+ width: 1.35rem;
+ height: 1.35rem;
+ fill: currentColor;
+}
+.pux-player__btn:hover {
+ background: rgba(255, 255, 255, 0.16);
+}
+.pux-player__btn:focus-visible {
+ outline: 2px solid #fff;
+ outline-offset: -2px;
+}
+.pux-player__btn[hidden] {
+ display: none;
+}
+.pux-player__btn--text {
+ width: auto;
+ min-width: 2.5rem;
+ padding: 0 0.4rem;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+.pux-player__volume {
+ display: flex;
+ align-items: center;
+ flex: 0 0 auto;
+}
+/* The slider is revealed on hover so the bar is not half volume control on a
+ narrow player, but it stays reachable by keyboard at all times. */
+.pux-player__volume-input {
+ width: 0;
+ opacity: 0;
+ margin: 0;
+ accent-color: #6366f1;
+ transition:
+ width 140ms ease,
+ opacity 140ms ease;
+}
+.pux-player__volume:hover .pux-player__volume-input,
+.pux-player__volume-input:focus-visible {
+ width: 4.5rem;
+ opacity: 1;
+ margin-left: 0.25rem;
+}
+.pux-player__volume-input[hidden] {
+ display: none;
+}
+
+.pux-player__time {
+ flex: 0 0 auto;
+ padding: 0 0.5rem;
+ font-size: 0.8125rem;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+.pux-player__chapter {
+ flex: 0 1 auto;
+ min-width: 0;
+ padding-right: 0.5rem;
+ font-size: 0.8125rem;
+ color: rgba(255, 255, 255, 0.75);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+.pux-player__spacer {
+ flex: 1 1 auto;
+}
+
+/* A phone has no room for every control. The ones that go are the ones with a
+ keyboard shortcut or a system equivalent: volume is on the device, and
+ picture-in-picture is in the browser's own menu. */
+@media (max-width: 32rem) {
+ .pux-player__volume-input,
+ .pux-player__chapter {
+ display: none;
+ }
+ .pux-player__btn {
+ width: 2rem;
+ height: 2rem;
+ }
+}
+
+/* ----------------------------------------------------------------- a tv -- */
+
+/* Everything grows: a control aimed at from three metres with a D-pad needs to
+ be visible from there, and the focus ring is the only cursor a television
+ has, so it is never subtle and never hidden. */
+.pux-player--tv .pux-player__bar {
+ padding: 3.5rem 1.5rem 1.25rem;
+}
+.pux-player--tv .pux-player__btn {
+ width: 3rem;
+ height: 3rem;
+}
+.pux-player--tv .pux-player__btn svg {
+ width: 1.75rem;
+ height: 1.75rem;
+}
+.pux-player--tv .pux-player__btn--text {
+ width: auto;
+ min-width: 3.25rem;
+ font-size: 1rem;
+}
+.pux-player--tv .pux-player__time,
+.pux-player--tv .pux-player__chapter {
+ font-size: 1rem;
+}
+.pux-player--tv .pux-player__track {
+ height: 0.4rem;
+}
+.pux-player--tv .pux-player__btn:focus,
+.pux-player--tv .pux-player__mark:focus,
+.pux-player--tv .pux-player__scrub:focus {
+ outline: 3px solid #fff;
+ outline-offset: 2px;
+}
diff --git a/apps/web/src/lib/player/player.test.ts b/apps/web/src/lib/player/player.test.ts
new file mode 100644
index 00000000..127ec29a
--- /dev/null
+++ b/apps/web/src/lib/player/player.test.ts
@@ -0,0 +1,454 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { formatTime, formatTimeParam, parseTimeParam } from './time';
+import { activeChapter, normalizeChapters } from './chapters';
+import { isTvBrowser, tvBrowserType, uiProfile } from './tv';
+import {
+ clearPosition,
+ loadPosition,
+ loadPrefs,
+ savePosition,
+ savePrefs,
+ shouldResume,
+} from './storage';
+import { createPlayer } from './player';
+
+describe('time', () => {
+ it('formats under and over an hour differently', () => {
+ expect(formatTime(0)).toBe('0:00');
+ expect(formatTime(83)).toBe('1:23');
+ expect(formatTime(3723)).toBe('1:02:03');
+ });
+
+ it('never renders NaN at a reader', () => {
+ expect(formatTime(NaN)).toBe('--:--');
+ expect(formatTime(Infinity)).toBe('--:--');
+ expect(formatTime(-5)).toBe('--:--');
+ });
+
+ it('parses every spelling of a share link people actually write', () => {
+ expect(parseTimeParam('372')).toBe(372);
+ expect(parseTimeParam('372s')).toBe(372);
+ expect(parseTimeParam('6m12s')).toBe(372);
+ expect(parseTimeParam('1h2m3s')).toBe(3723);
+ expect(parseTimeParam('6:12')).toBe(372);
+ expect(parseTimeParam('1:02:03')).toBe(3723);
+ expect(parseTimeParam('1h')).toBe(3600);
+ });
+
+ it('rejects what is not a time rather than guessing', () => {
+ expect(parseTimeParam('')).toBeNull();
+ expect(parseTimeParam(null)).toBeNull();
+ expect(parseTimeParam('abc')).toBeNull();
+ expect(parseTimeParam('1:2:3:4')).toBeNull();
+ expect(parseTimeParam('3s2m')).toBeNull();
+ expect(parseTimeParam('-5')).toBeNull();
+ });
+
+ it('round-trips a share parameter', () => {
+ expect(parseTimeParam(formatTimeParam(372.418))).toBe(372);
+ expect(formatTimeParam(-1)).toBe('0');
+ });
+});
+
+describe('chapters', () => {
+ const chapters = [
+ { start: 120, title: 'Setup' },
+ { start: 0, title: 'Intro' },
+ { start: 600, title: 'Deploy' },
+ ];
+
+ it('sorts, closes ranges and places marks', () => {
+ const result = normalizeChapters(chapters, 900);
+ expect(result.map((c) => c.title)).toEqual(['Intro', 'Setup', 'Deploy']);
+ expect(result[0]?.end).toBe(120);
+ expect(result[2]?.end).toBe(900);
+ expect(result[1]?.position).toBeCloseTo(120 / 900);
+ });
+
+ it('drops what cannot be drawn', () => {
+ const result = normalizeChapters(
+ [
+ { start: 10, title: 'Kept' },
+ { start: 10, title: 'Duplicate second' },
+ { start: 5000, title: 'Past the end' },
+ { start: -3, title: 'Before the start' },
+ { start: 40, title: ' ' },
+ { start: NaN, title: 'Not a number' },
+ ],
+ 900
+ );
+ expect(result.map((c) => c.title)).toEqual(['Kept']);
+ });
+
+ it('returns nothing until the duration is known', () => {
+ expect(normalizeChapters(chapters, NaN)).toEqual([]);
+ expect(normalizeChapters(chapters, 0)).toEqual([]);
+ });
+
+ it('has no chapter before the first mark', () => {
+ const result = normalizeChapters([{ start: 120, title: 'Setup' }], 900);
+ expect(activeChapter(result, 60)).toBeNull();
+ expect(activeChapter(result, 130)?.title).toBe('Setup');
+ expect(activeChapter([], 130)).toBeNull();
+ });
+});
+
+describe('tv detection', () => {
+ it('knows the living room', () => {
+ expect(tvBrowserType('Mozilla/5.0 (Linux; Android 9; AFTKA) AppleWebKit')).toBe('firetv');
+ expect(tvBrowserType('Mozilla/5.0 (Linux; Android 9; Android TV) Chrome')).toBe('androidtv');
+ expect(tvBrowserType('Mozilla/5.0 (Web0S; Linux/SmartTV)')).toBe('webos');
+ expect(isTvBrowser('Roku/DVP-9.10')).toBe(true);
+ });
+
+ it('leaves a desktop and a phone alone', () => {
+ expect(tvBrowserType('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/120')).toBeNull();
+ expect(isTvBrowser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0) Safari')).toBe(false);
+ expect(isTvBrowser('')).toBe(false);
+ expect(isTvBrowser(null)).toBe(false);
+ });
+
+ it('gives a television longer to reach a control, and a bigger step', () => {
+ expect(uiProfile(true).hideAfterMs).toBeGreaterThan(uiProfile(false).hideAfterMs);
+ expect(uiProfile(true).seekStep).toBeGreaterThan(uiProfile(false).seekStep);
+ });
+});
+
+describe('storage', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('remembers preferences across recordings', () => {
+ savePrefs({ volume: 0.4, muted: true, rate: 1.5 });
+ expect(loadPrefs()).toEqual({ volume: 0.4, muted: true, rate: 1.5 });
+ });
+
+ it('falls back to defaults on junk rather than throwing', () => {
+ localStorage.setItem('pairux.player.prefs', 'not json');
+ expect(loadPrefs()).toEqual({ volume: 1, muted: false, rate: 1 });
+ localStorage.setItem('pairux.player.prefs', JSON.stringify({ volume: 99, rate: 'fast' }));
+ expect(loadPrefs()).toEqual({ volume: 1, muted: false, rate: 1 });
+ });
+
+ it('survives storage being unavailable entirely', () => {
+ expect(() => savePrefs({ volume: 1, muted: false, rate: 1 }, null)).not.toThrow();
+ expect(loadPrefs(null)).toEqual({ volume: 1, muted: false, rate: 1 });
+ expect(loadPosition('x', null)).toBeNull();
+ expect(() => savePosition('x', { t: 5, d: 10 }, null)).not.toThrow();
+ expect(() => clearPosition('x', null)).not.toThrow();
+ });
+
+ it('keeps positions per recording and can clear one', () => {
+ savePosition('a', { t: 30, d: 600 });
+ savePosition('b', { t: 90, d: 600 });
+ expect(loadPosition('a')?.t).toBe(30);
+ clearPosition('a');
+ expect(loadPosition('a')).toBeNull();
+ expect(loadPosition('b')?.t).toBe(90);
+ });
+
+ it('evicts the least recently touched once it is full', () => {
+ let clock = 1000;
+ for (let i = 0; i < 70; i += 1) {
+ clock += 1000;
+ savePosition(`id-${String(i)}`, { t: 10, d: 600 }, undefined, () => clock);
+ }
+ // The 10 oldest are gone; the newest are not.
+ expect(loadPosition('id-0')).toBeNull();
+ expect(loadPosition('id-9')).toBeNull();
+ expect(loadPosition('id-10')?.t).toBe(10);
+ expect(loadPosition('id-69')?.t).toBe(10);
+ });
+
+ describe('shouldResume', () => {
+ it('resumes somewhere worth resuming', () => {
+ expect(shouldResume({ t: 300, d: 600, at: 0 }, 600)).toBe(true);
+ });
+
+ it('does not resume a few seconds in, or a few seconds from the end', () => {
+ expect(shouldResume({ t: 4, d: 600, at: 0 }, 600)).toBe(false);
+ expect(shouldResume({ t: 595, d: 600, at: 0 }, 600)).toBe(false);
+ });
+
+ it('refuses an offset saved against a different cut of the file', () => {
+ expect(shouldResume({ t: 300, d: 600, at: 0 }, 900)).toBe(false);
+ });
+
+ it('has nothing to say without a position or a duration', () => {
+ expect(shouldResume(null, 600)).toBe(false);
+ expect(shouldResume({ t: 300, d: 600, at: 0 }, NaN)).toBe(false);
+ });
+ });
+});
+
+/**
+ * The mounted player.
+ *
+ * jsdom implements no media pipeline: `duration` is NaN, `play()` is missing and
+ * nothing ever fires on its own. So the element is given the handful of
+ * properties the player reads, and events are dispatched by hand — which is the
+ * honest shape of these tests anyway. They cover the wiring (does pressing this
+ * change that) and not the decoding, which is the browser's job and was checked
+ * against the real file separately.
+ */
+describe('createPlayer', () => {
+ interface Harness {
+ root: HTMLDivElement;
+ video: HTMLVideoElement;
+ handle: ReturnType;
+ }
+
+ function mount(options: Partial[2]> = {}): Harness {
+ const root = document.createElement('div');
+ const video = document.createElement('video');
+ root.append(video);
+ document.body.append(root);
+
+ Object.defineProperty(video, 'duration', { value: 600, writable: true, configurable: true });
+ Object.defineProperty(video, 'paused', { value: true, writable: true, configurable: true });
+ Object.defineProperty(video, 'buffered', {
+ value: { length: 0, start: () => 0, end: () => 0 },
+ writable: true,
+ configurable: true,
+ });
+ video.play = vi.fn().mockResolvedValue(undefined);
+ video.pause = vi.fn(() => {
+ Object.defineProperty(video, 'paused', { value: true, configurable: true });
+ video.dispatchEvent(new Event('pause'));
+ });
+
+ const handle = createPlayer(video, root, { mediaId: 'test', ...options });
+ return { root, video, handle };
+ }
+
+ beforeEach(() => {
+ localStorage.clear();
+ document.body.replaceChildren();
+ });
+
+ function press(root: HTMLElement, key: string): void {
+ root.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
+ }
+
+ it('replaces the browser controls with its own', () => {
+ const { root, video } = mount();
+ expect(video.controls).toBe(false);
+ expect(root.querySelector('.pux-player__bar')).not.toBeNull();
+ expect(root.classList.contains('pux-player')).toBe(true);
+ });
+
+ it('renders the clock once metadata lands', () => {
+ const { root, video } = mount();
+ video.currentTime = 83;
+ video.dispatchEvent(new Event('loadedmetadata'));
+ video.dispatchEvent(new Event('timeupdate'));
+ expect(root.querySelector('.pux-player__time')?.textContent).toBe('1:23 / 10:00');
+ });
+
+ it('seeks with the keyboard, in the desktop step', () => {
+ const { root, video } = mount({ userAgent: 'Mozilla/5.0 (Macintosh) Chrome/120' });
+ video.currentTime = 100;
+ press(root, 'ArrowRight');
+ expect(video.currentTime).toBe(105);
+ press(root, 'ArrowLeft');
+ expect(video.currentTime).toBe(100);
+ press(root, 'l');
+ expect(video.currentTime).toBe(110);
+ press(root, 'j');
+ expect(video.currentTime).toBe(100);
+ });
+
+ it('seeks in a bigger step on a television', () => {
+ const { root, video } = mount({ userAgent: 'Mozilla/5.0 (Linux; Android 9; AFTKA)' });
+ video.currentTime = 100;
+ press(root, 'ArrowRight');
+ expect(video.currentTime).toBe(110);
+ expect(root.classList.contains('pux-player--tv')).toBe(true);
+ });
+
+ it('leaves the arrow keys to the control row, so a D-pad can navigate it', () => {
+ const { root, video } = mount();
+ video.currentTime = 100;
+ const button = root.querySelector('[data-control="play"]');
+ button?.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }));
+ expect(video.currentTime).toBe(100);
+ });
+
+ it('never seeks past either end', () => {
+ const { root, video } = mount();
+ video.currentTime = 3;
+ press(root, 'ArrowLeft');
+ expect(video.currentTime).toBe(0);
+ video.currentTime = 598;
+ press(root, 'l');
+ expect(video.currentTime).toBe(600);
+ });
+
+ // Regression: the bar and the clock were only redrawn on `timeupdate`, which
+ // a paused video never fires. Arrowing along a paused recording moved the
+ // playhead while the display sat where it was.
+ it('redraws the clock when a paused recording is seeked', () => {
+ const { root, video } = mount();
+ video.currentTime = 50;
+ video.dispatchEvent(new Event('loadedmetadata'));
+ press(root, 'ArrowRight');
+ expect(root.querySelector('.pux-player__time')?.textContent).toBe('0:55 / 10:00');
+ expect(root.querySelector('.pux-player__played')?.style.width).not.toBe('0%');
+ });
+
+ it('redraws after a seek it did not make', () => {
+ const { root, video } = mount();
+ video.dispatchEvent(new Event('loadedmetadata'));
+ video.currentTime = 120;
+ video.dispatchEvent(new Event('seeked'));
+ expect(root.querySelector('.pux-player__time')?.textContent).toBe('2:00 / 10:00');
+ });
+
+ it('jumps to a tenth with the number keys', () => {
+ const { root, video } = mount();
+ press(root, '5');
+ expect(video.currentTime).toBe(300);
+ press(root, '0');
+ expect(video.currentTime).toBe(0);
+ });
+
+ it('plays and pauses with space', () => {
+ const { root, video } = mount();
+ press(root, ' ');
+ expect(video.play).toHaveBeenCalled();
+ });
+
+ it('cycles the speed and remembers it', () => {
+ const { root, video } = mount();
+ const rate = root.querySelector('[data-control="rate"]');
+ rate?.click();
+ video.dispatchEvent(new Event('ratechange'));
+ expect(video.playbackRate).toBe(1.25);
+ expect(rate?.textContent).toBe('1.25×');
+ expect(loadPrefs().rate).toBe(1.25);
+ });
+
+ it('starts a new recording at the remembered volume', () => {
+ savePrefs({ volume: 0.25, muted: true, rate: 1.5 });
+ const { video } = mount();
+ expect(video.volume).toBe(0.25);
+ expect(video.muted).toBe(true);
+ expect(video.playbackRate).toBe(1.5);
+ });
+
+ it('resumes where the reader left off, and offers to start over', () => {
+ savePosition('test', { t: 300, d: 600 });
+ const { root, video } = mount();
+ video.dispatchEvent(new Event('loadedmetadata'));
+ expect(video.currentTime).toBe(300);
+
+ const notice = root.querySelector('.pux-player__notice');
+ expect(notice?.hidden).toBe(false);
+ expect(notice?.textContent).toContain('5:00');
+
+ root.querySelector('.pux-player__notice-action')?.click();
+ expect(video.currentTime).toBe(0);
+ expect(loadPosition('test')).toBeNull();
+ });
+
+ it('prefers an explicit ?t= over a saved position', () => {
+ savePosition('test', { t: 300, d: 600 });
+ const { video } = mount({ startAt: 42 });
+ video.dispatchEvent(new Event('loadedmetadata'));
+ expect(video.currentTime).toBe(42);
+ });
+
+ it('forgets the position of a recording that finished', () => {
+ savePosition('test', { t: 300, d: 600 });
+ const { video } = mount();
+ video.dispatchEvent(new Event('ended'));
+ expect(loadPosition('test')).toBeNull();
+ });
+
+ it('draws a mark per chapter and seeks when one is clicked', () => {
+ const { root, video } = mount({
+ chapters: [
+ { start: 0, title: 'Intro' },
+ { start: 300, title: 'Deploy' },
+ ],
+ });
+ video.dispatchEvent(new Event('loadedmetadata'));
+ const marks = root.querySelectorAll('.pux-player__mark');
+ expect(marks).toHaveLength(2);
+ marks[1]?.click();
+ expect(video.currentTime).toBe(300);
+ });
+
+ it('names the chapter the reader is in', () => {
+ const { root, video } = mount({
+ chapters: [
+ { start: 0, title: 'Intro' },
+ { start: 300, title: 'Deploy' },
+ ],
+ });
+ video.dispatchEvent(new Event('loadedmetadata'));
+ video.currentTime = 400;
+ video.dispatchEvent(new Event('timeupdate'));
+ expect(root.querySelector('.pux-player__chapter')?.textContent).toBe('Deploy');
+ });
+
+ it('offers a share link only when it has one to give', () => {
+ const withShare = mount({ shareUrl: (t) => `https://pairux.com/l/X?t=${String(t)}` });
+ expect(withShare.root.querySelector('[data-control="share"]')?.hasAttribute('hidden')).toBe(
+ false
+ );
+ document.body.replaceChildren();
+ const without = mount({ shareUrl: null });
+ expect(without.root.querySelector('[data-control="share"]')?.hasAttribute('hidden')).toBe(true);
+ });
+
+ it('drops the controls a television cannot use', () => {
+ const { root } = mount({ userAgent: 'Mozilla/5.0 (Linux; Android 9; AFTKA)' });
+ // No pointer to hover a volume slider open, and one screen showing one
+ // thing, so picture-in-picture means nothing.
+ expect(root.querySelector('[data-control="volume"]')?.hasAttribute('hidden')).toBe(true);
+ expect(root.querySelector('[data-control="pip"]')?.hasAttribute('hidden')).toBe(true);
+ expect(root.querySelector('[data-control="mute"]')?.hasAttribute('hidden')).toBe(false);
+ });
+
+ it('explains a blocked load instead of showing a black rectangle', () => {
+ const { root, video } = mount();
+ Object.defineProperty(video, 'error', {
+ value: { code: 4, message: 'MEDIA_ELEMENT_ERROR: Media load rejected by URL safety check' },
+ configurable: true,
+ });
+ video.dispatchEvent(new Event('error'));
+ const notice = root.querySelector('.pux-player__notice');
+ expect(notice?.hidden).toBe(false);
+ expect(notice?.textContent).toContain('blocked');
+ expect(root.classList.contains('pux-player--failed')).toBe(true);
+ });
+
+ it('names an ordinary decode failure differently', () => {
+ const { root, video } = mount();
+ Object.defineProperty(video, 'error', {
+ value: { code: 2, message: '' },
+ configurable: true,
+ });
+ video.dispatchEvent(new Event('error'));
+ expect(root.querySelector('.pux-player__notice')?.textContent).toContain('connection dropped');
+ });
+
+ it('saves the position on the way out and leaves the element clean', () => {
+ const { root, video, handle } = mount();
+ video.currentTime = 250;
+ handle.destroy();
+ expect(loadPosition('test')?.t).toBe(250);
+ expect(root.querySelector('.pux-player__bar')).toBeNull();
+ expect(root.classList.contains('pux-player')).toBe(false);
+ });
+
+ it('stops listening once destroyed', () => {
+ const { root, video, handle } = mount();
+ handle.destroy();
+ video.currentTime = 100;
+ press(root, 'ArrowRight');
+ expect(video.currentTime).toBe(100);
+ });
+});
diff --git a/apps/web/src/lib/player/player.ts b/apps/web/src/lib/player/player.ts
new file mode 100644
index 00000000..16bc746e
--- /dev/null
+++ b/apps/web/src/lib/player/player.ts
@@ -0,0 +1,775 @@
+/**
+ * The recording player.
+ *
+ * Deliberately plain DOM with no framework in it. PairUX is a React app, but
+ * genrewatch.com and tipoffwatch.com are Hono JSX with a vanilla client bundle,
+ * and this file is meant to be dropped into all three — the React wrapper in
+ * `components/video/RecordingPlayer.tsx` is thirty lines of `useEffect` around
+ * exactly this. Anything imported here would have to be imported there too, so
+ * nothing is.
+ *
+ * What it replaces is `