diff --git a/apps/web/package.json b/apps/web/package.json
index 8a86674a..ff5fed47 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -27,6 +27,7 @@
"@profullstack/live-translate": "^0.3.0",
"@profullstack/notifications": "0.1.4",
"@profullstack/pairux-embed": "workspace:*",
+ "@profullstack/player": "0.17.0",
"@profullstack/profile": "0.1.8",
"@profullstack/redeem-credits": "^0.2.0",
"@profullstack/stack": "^0.5.0",
diff --git a/apps/web/src/components/video/RecordingPlayer.test.tsx b/apps/web/src/components/video/RecordingPlayer.test.tsx
new file mode 100644
index 00000000..a9daf305
--- /dev/null
+++ b/apps/web/src/components/video/RecordingPlayer.test.tsx
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render } from '@testing-library/react';
+import { RecordingPlayer, migrateLegacyPlayerStorage } from './RecordingPlayer';
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+beforeEach(() => {
+ window.localStorage.clear();
+ HTMLMediaElement.prototype.play = vi.fn().mockResolvedValue(undefined);
+ HTMLMediaElement.prototype.pause = vi.fn();
+ HTMLMediaElement.prototype.load = vi.fn();
+});
+
+describe('RecordingPlayer', () => {
+ it('mounts @profullstack/player around the recording', () => {
+ const { container } = render(
+
+ );
+ const root = container.firstElementChild as HTMLElement;
+ expect(root.classList.contains('pux-player')).toBe(true);
+ const video = root.querySelector('video');
+ expect(video).not.toBeNull();
+ // The player drives the server-rendered element and takes over its controls.
+ expect(video?.controls).toBe(false);
+ expect(root.querySelector('[data-control="play"]')).not.toBeNull();
+ expect(root.querySelector('[data-control="share"]')).not.toBeNull();
+ });
+
+ it('drops the share button inside an embed', () => {
+ const { container } = render(
+
+ );
+ const share = container.querySelector('[data-control="share"]');
+ expect(share === null || share.hidden).toBe(true);
+ });
+});
+
+describe('migrateLegacyPlayerStorage', () => {
+ it('carries saved positions and prefs over to the package keys, once', () => {
+ const positions = JSON.stringify({ 'session:abc': { t: 120, d: 600, at: 1 } });
+ window.localStorage.setItem('pairux.player.positions', positions);
+ window.localStorage.setItem('pairux.player.prefs', '{"volume":0.5,"muted":false,"rate":1.5}');
+ migrateLegacyPlayerStorage(window.localStorage);
+ expect(window.localStorage.getItem('profullstack.player.positions')).toBe(positions);
+ expect(window.localStorage.getItem('profullstack.player.prefs')).toContain('"rate":1.5');
+ expect(window.localStorage.getItem('pairux.player.positions')).toBeNull();
+ });
+
+ it('never overwrites what the package already saved', () => {
+ window.localStorage.setItem('profullstack.player.prefs', '{"volume":1,"muted":true,"rate":1}');
+ window.localStorage.setItem('pairux.player.prefs', '{"volume":0.2,"muted":false,"rate":2}');
+ migrateLegacyPlayerStorage(window.localStorage);
+ expect(window.localStorage.getItem('profullstack.player.prefs')).toContain('"muted":true');
+ });
+
+ it('tolerates storage that is not there', () => {
+ expect(() => migrateLegacyPlayerStorage(null)).not.toThrow();
+ });
+});
diff --git a/apps/web/src/components/video/RecordingPlayer.tsx b/apps/web/src/components/video/RecordingPlayer.tsx
index f3691149..317109c0 100644
--- a/apps/web/src/components/video/RecordingPlayer.tsx
+++ b/apps/web/src/components/video/RecordingPlayer.tsx
@@ -1,13 +1,15 @@
'use client';
/**
- * React's half of the recording player.
+ * The recording player: @profullstack/player, the one player every Profullstack
+ * site uses.
*
- * 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.
+ * PairUX's hand-rolled player (`src/lib/player`) became that package: the same
+ * control bar, chapters, resume positions, `?t=` deep links, speed, skip,
+ * picture-in-picture, share-at-time and television profile, plus auto-hide,
+ * fullscreen on iPhone and HLS / MPEG-TS when a recording ever needs them. This
+ * file owns a ref, a mount effect and a teardown, and carries readers' saved
+ * positions over from the old storage keys.
*
* 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
@@ -17,8 +19,40 @@
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
-import { createPlayer, parseTimeParam, type Chapter } from '@/lib/player';
-import '@/lib/player/player.css';
+import { createPlayer, parseTimeParam, type Chapter } from '@profullstack/player';
+import '@profullstack/player/player.css';
+
+/**
+ * Old key, new key. PairUX's player filed preferences and resume positions
+ * under `pairux.player.*`; the package files the same shapes under
+ * `profullstack.player.*`. Copied once, never overwriting, so a reader halfway
+ * through a recording does not start it again from zero.
+ */
+const LEGACY_KEYS = [
+ ['pairux.player.prefs', 'profullstack.player.prefs'],
+ ['pairux.player.positions', 'profullstack.player.positions'],
+] as const;
+
+export function migrateLegacyPlayerStorage(storage: Storage | null): void {
+ if (!storage) return;
+ for (const [from, to] of LEGACY_KEYS) {
+ try {
+ const old = storage.getItem(from);
+ if (old !== null && storage.getItem(to) === null) storage.setItem(to, old);
+ if (old !== null) storage.removeItem(from);
+ } catch {
+ // Private windows and blocked storage throw on touch; nothing to carry.
+ }
+ }
+}
+
+function localStorageOrNull(): Storage | null {
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
interface RecordingPlayerProps {
src: string;
@@ -51,14 +85,18 @@ export function RecordingPlayer({
const video = videoRef.current;
if (!root || !video) return;
+ migrateLegacyPlayerStorage(localStorageOrNull());
const params = new URLSearchParams(window.location.search);
const startAt = parseTimeParam(params.get('t'));
- const handle = createPlayer(video, root, {
+ const handle = createPlayer(root, {
+ src,
+ media: video,
mediaId,
// Spread rather than passed: under exactOptionalPropertyTypes an absent
// prop and one explicitly set to undefined are different types.
...(chapters ? { chapters } : {}),
+ ...(poster ? { poster } : {}),
startAt,
shareUrl: shareable
? (seconds: number) => {
@@ -83,9 +121,9 @@ export function RecordingPlayer({
();
- 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
deleted file mode 100644
index fafe95e9..00000000
--- a/apps/web/src/lib/player/index.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * 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
deleted file mode 100644
index 530fa528..00000000
--- a/apps/web/src/lib/player/player.css
+++ /dev/null
@@ -1,438 +0,0 @@
-/* =============================================================== 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
deleted file mode 100644
index 127ec29a..00000000
--- a/apps/web/src/lib/player/player.test.ts
+++ /dev/null
@@ -1,454 +0,0 @@
-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
deleted file mode 100644
index 6c4998b7..00000000
--- a/apps/web/src/lib/player/player.ts
+++ /dev/null
@@ -1,797 +0,0 @@
-/**
- * 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 ``, whose control bar is a different
- * shape and a different set of features in every browser, has no speed control
- * on some, no way to skip ten seconds on any, and cannot be reached with a
- * D-pad on a television. What it does NOT replace is the decoding: these are
- * ordinary MP4 files over https that every browser plays natively. There is no
- * demuxer here and there should never be one.
- */
-
-import { activeChapter, normalizeChapters, type Chapter, type NormalizedChapter } from './chapters';
-import {
- DEFAULT_PREFS,
- clearPosition,
- loadPosition,
- loadPrefs,
- savePosition,
- savePrefs,
- shouldResume,
- type PlayerPrefs,
-} from './storage';
-import { formatTime, formatTimeParam, parseTimeParam } from './time';
-import { isTvBrowser, uiProfile } from './tv';
-
-export interface PlayerOptions {
- /** Stable key for this recording; what resume positions are filed under. */
- mediaId: string;
- chapters?: readonly Chapter[];
- /**
- * Where the reader arrived pointing, in seconds. Wins over a saved position:
- * somebody followed a link to a moment and that is what they asked for.
- */
- startAt?: number | null;
- /**
- * Builds the "copy link at this time" URL. Omit to drop the button — the
- * embedded player has no address of its own worth handing out.
- */
- shareUrl?: ((seconds: number) => string) | null;
- /** Overridable for tests. */
- userAgent?: string;
- storage?: Storage | null;
- now?: () => number;
-}
-
-export interface PlayerHandle {
- destroy: () => void;
- /** Re-render chapter marks after the caller learns them late. */
- setChapters: (chapters: readonly Chapter[]) => void;
-}
-
-/** The speeds the button cycles. 1x first so a press away from it always returns. */
-const RATES = [1, 1.25, 1.5, 1.75, 2, 0.75] as const;
-
-/** How often a position is written while playing. */
-const SAVE_EVERY_MS = 5000;
-
-/** Skip buttons, and what j/l are worth. */
-const SKIP_SECONDS = 10;
-
-const ICONS = {
- play: ' ',
- pause: ' ',
- replay:
- ' ',
- back10:
- '10 ',
- fwd10:
- '10 ',
- volume:
- ' ',
- muted:
- ' ',
- pip: ' ',
- link: ' ',
- enterFullscreen:
- ' ',
- exitFullscreen:
- ' ',
-} as const;
-
-interface FullscreenVideo extends HTMLVideoElement {
- webkitEnterFullscreen?: () => void;
-}
-
-function el(
- tag: K,
- className: string,
- attrs: Record = {}
-): HTMLElementTagNameMap[K] {
- const node = document.createElement(tag);
- node.className = className;
- for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
- return node;
-}
-
-/**
- * Every control carries a `data-control` name.
- *
- * It is what the tests select on and what a host site can style or hide without
- * depending on the order buttons happen to sit in — which changes whenever one
- * is added, and which is exactly how a test ends up asserting against
- * picture-in-picture while believing it is looking at the share button.
- */
-function iconButton(
- className: string,
- label: string,
- icon: string,
- control: string
-): HTMLButtonElement {
- const button = el('button', className, {
- type: 'button',
- 'aria-label': label,
- title: label,
- 'data-control': control,
- });
- button.innerHTML = icon;
- return button;
-}
-
-/**
- * Turn a media error into something a reader can act on.
- *
- * The default is a black rectangle and nothing else, which is how a policy
- * problem spends a week being reported as "the player is broken" — a blocked
- * media load is a console-only event, and the code the element carries is the
- * only in-page evidence there is.
- */
-function errorMessage(video: HTMLVideoElement): string {
- const error = video.error;
- const detail = error?.message ?? '';
- // Chrome's wording when a Content-Security-Policy media-src refused the load.
- if (/URL safety check/i.test(detail)) {
- return 'This recording was blocked before it could load. That is a configuration problem on our side, not on yours — please report it.';
- }
- switch (error?.code) {
- case 1:
- return 'Playback was stopped before it started.';
- case 2:
- return 'The connection dropped while loading this recording. Check your network and try again.';
- case 3:
- return 'This recording could not be decoded by your browser.';
- case 4:
- return 'This recording is missing or in a format your browser cannot play.';
- default:
- return 'This recording could not be played.';
- }
-}
-
-export function createPlayer(
- video: HTMLVideoElement,
- root: HTMLElement,
- options: PlayerOptions
-): PlayerHandle {
- const {
- mediaId,
- startAt = null,
- shareUrl = null,
- storage,
- now = Date.now,
- userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent,
- } = options;
-
- const isTv = isTvBrowser(userAgent);
- const profile = uiProfile(isTv);
- let chapters: NormalizedChapter[] = [];
- let chapterSource: readonly Chapter[] = options.chapters ?? [];
-
- root.classList.add('pux-player');
- if (isTv) root.classList.add('pux-player--tv');
- // The container takes focus so keyboard control works the moment somebody
- // clicks the picture, rather than only after they have tabbed to a button.
- if (!root.hasAttribute('tabindex')) root.setAttribute('tabindex', '0');
-
- // ---------------------------------------------------------------- markup --
-
- const overlay = iconButton('pux-player__overlay', 'Play', ICONS.play, 'overlay');
- const spinner = el('div', 'pux-player__spinner', { 'aria-hidden': 'true' });
- const notice = el('div', 'pux-player__notice', { role: 'status', 'aria-live': 'polite' });
- notice.hidden = true;
- const noticeText = el('span', 'pux-player__notice-text');
- const noticeAction = el('button', 'pux-player__notice-action', { type: 'button' });
- noticeAction.hidden = true;
- notice.append(noticeText, noticeAction);
-
- const bar = el('div', 'pux-player__bar');
-
- const scrub = el('div', 'pux-player__scrub', {
- role: 'slider',
- tabindex: '0',
- 'aria-label': 'Seek',
- 'aria-valuemin': '0',
- 'aria-valuenow': '0',
- });
- const track = el('div', 'pux-player__track');
- const buffered = el('div', 'pux-player__buffered');
- const played = el('div', 'pux-player__played');
- const marks = el('div', 'pux-player__marks', { 'aria-hidden': 'true' });
- const handle = el('div', 'pux-player__handle', { 'aria-hidden': 'true' });
- const tooltip = el('div', 'pux-player__tooltip', { 'aria-hidden': 'true' });
- track.append(buffered, played, marks, handle);
- scrub.append(track, tooltip);
-
- const row = el('div', 'pux-player__row');
- const playButton = iconButton('pux-player__btn', 'Play', ICONS.play, 'play');
- const backButton = iconButton(
- 'pux-player__btn',
- `Back ${String(SKIP_SECONDS)} seconds`,
- ICONS.back10,
- 'back'
- );
- const forwardButton = iconButton(
- 'pux-player__btn',
- `Forward ${String(SKIP_SECONDS)} seconds`,
- ICONS.fwd10,
- 'forward'
- );
-
- const volumeWrap = el('div', 'pux-player__volume');
- const muteButton = iconButton('pux-player__btn', 'Mute', ICONS.volume, 'mute');
- const volumeInput = el('input', 'pux-player__volume-input', {
- type: 'range',
- min: '0',
- max: '1',
- step: '0.05',
- 'aria-label': 'Volume',
- 'data-control': 'volume',
- });
- volumeWrap.append(muteButton, volumeInput);
-
- const timeLabel = el('div', 'pux-player__time');
- const chapterLabel = el('div', 'pux-player__chapter');
- const spacer = el('div', 'pux-player__spacer');
-
- const rateButton = el('button', 'pux-player__btn pux-player__btn--text', {
- type: 'button',
- 'aria-label': 'Playback speed',
- title: 'Playback speed',
- 'data-control': 'rate',
- });
- const shareButton = iconButton('pux-player__btn', 'Copy link at this time', ICONS.link, 'share');
- const pipButton = iconButton('pux-player__btn', 'Picture in picture', ICONS.pip, 'pip');
- const fullscreenButton = iconButton(
- 'pux-player__btn',
- 'Fullscreen',
- ICONS.enterFullscreen,
- 'fullscreen'
- );
-
- row.append(
- playButton,
- backButton,
- forwardButton,
- volumeWrap,
- timeLabel,
- chapterLabel,
- spacer,
- rateButton,
- shareButton,
- pipButton,
- fullscreenButton
- );
- bar.append(scrub, row);
- root.append(overlay, spinner, notice, bar);
-
- // A television has no pointer to hover a volume slider open, and no reliable
- // way to drag one; the mute button and the volume keys are the whole control
- // there. Picture-in-picture is equally meaningless on a screen showing one
- // thing at a time.
- if (isTv) {
- volumeInput.hidden = true;
- pipButton.hidden = true;
- }
- if (!shareUrl) shareButton.hidden = true;
- if (typeof document === 'undefined' || !document.pictureInPictureEnabled) pipButton.hidden = true;
-
- // ----------------------------------------------------------------- state --
-
- const cleanups: (() => void)[] = [];
- let hideTimer: ReturnType | null = null;
- let noticeTimer: ReturnType | null = null;
- let lastSaved = 0;
- let scrubbing = false;
- let destroyed = false;
-
- function on(
- target: EventTarget,
- type: string,
- handler: EventListenerOrEventListenerObject,
- opts?: AddEventListenerOptions
- ): void {
- target.addEventListener(type, handler, opts);
- cleanups.push(() => {
- target.removeEventListener(type, handler, opts);
- });
- }
-
- function showNotice(message: string, action?: { label: string; run: () => void }): void {
- noticeText.textContent = message;
- notice.hidden = false;
- if (noticeTimer) clearTimeout(noticeTimer);
- if (action) {
- noticeAction.hidden = false;
- noticeAction.textContent = action.label;
- noticeAction.onclick = () => {
- action.run();
- hideNotice();
- };
- } else {
- noticeAction.hidden = true;
- noticeAction.onclick = null;
- }
- // Long enough to read and reach, then out of the way of the picture.
- noticeTimer = setTimeout(hideNotice, action ? 9000 : 4000);
- }
-
- function hideNotice(): void {
- notice.hidden = true;
- noticeAction.onclick = null;
- }
-
- // ---------------------------------------------------------------- render --
-
- function renderMarks(): void {
- marks.replaceChildren();
- for (const chapter of chapters) {
- const mark = el('button', 'pux-player__mark', {
- type: 'button',
- 'aria-label': `${chapter.title}, ${formatTime(chapter.start)}`,
- title: chapter.title,
- });
- mark.style.left = `${String(chapter.position * 100)}%`;
- mark.addEventListener('click', (event) => {
- event.stopPropagation();
- video.currentTime = chapter.start;
- });
- marks.append(mark);
- }
- }
-
- function rebuildChapters(): void {
- chapters = normalizeChapters(chapterSource, video.duration);
- renderMarks();
- renderProgress();
- }
-
- function renderProgress(): void {
- const duration = video.duration;
- const current = video.currentTime;
- const known = Number.isFinite(duration) && duration > 0;
- const fraction = known ? Math.min(1, Math.max(0, current / duration)) : 0;
-
- played.style.width = `${String(fraction * 100)}%`;
- handle.style.left = `${String(fraction * 100)}%`;
-
- if (video.buffered.length > 0 && known) {
- // The range covering the playhead, which is the only one that tells the
- // reader whether the next few seconds are already here.
- let end = 0;
- for (let i = 0; i < video.buffered.length; i += 1) {
- if (video.buffered.start(i) <= current && video.buffered.end(i) >= current) {
- end = video.buffered.end(i);
- break;
- }
- }
- buffered.style.width = `${String(Math.min(1, end / duration) * 100)}%`;
- }
-
- timeLabel.textContent = `${formatTime(current)} / ${formatTime(duration)}`;
- scrub.setAttribute('aria-valuenow', String(Math.floor(current)));
- if (known) scrub.setAttribute('aria-valuemax', String(Math.floor(duration)));
- scrub.setAttribute(
- 'aria-valuetext',
- known ? `${formatTime(current)} of ${formatTime(duration)}` : formatTime(current)
- );
-
- const chapter = activeChapter(chapters, current);
- chapterLabel.textContent = chapter ? chapter.title : '';
- }
-
- function renderPlayState(): void {
- const isPlaying = !video.paused && !video.ended;
- const ended = video.ended;
- const icon = ended ? ICONS.replay : isPlaying ? ICONS.pause : ICONS.play;
- const label = ended ? 'Play again' : isPlaying ? 'Pause' : 'Play';
- playButton.innerHTML = icon;
- playButton.setAttribute('aria-label', label);
- playButton.title = label;
- overlay.innerHTML = icon;
- overlay.setAttribute('aria-label', label);
- root.classList.toggle('pux-player--playing', isPlaying);
- root.classList.toggle('pux-player--ended', ended);
- if (!isPlaying) showControls(false);
- }
-
- function renderVolume(): void {
- const off = video.muted || video.volume === 0;
- muteButton.innerHTML = off ? ICONS.muted : ICONS.volume;
- muteButton.setAttribute('aria-label', off ? 'Unmute' : 'Mute');
- muteButton.setAttribute('aria-pressed', off ? 'true' : 'false');
- volumeInput.value = String(off ? 0 : video.volume);
- }
-
- function renderRate(): void {
- rateButton.textContent = `${String(video.playbackRate)}×`;
- rateButton.setAttribute('aria-label', `Playback speed, ${String(video.playbackRate)} times`);
- }
-
- // --------------------------------------------------------------- controls --
-
- function showControls(autoHide = true): void {
- root.classList.add('pux-player--controls');
- if (hideTimer) clearTimeout(hideTimer);
- if (!autoHide) return;
- hideTimer = setTimeout(() => {
- // Never hide the controls out from under the reader's own focus: on a
- // television that is the only thing telling them where they are.
- if (video.paused || !root.contains(document.activeElement)) {
- if (!video.paused) root.classList.remove('pux-player--controls');
- }
- }, profile.hideAfterMs);
- }
-
- function togglePlay(): void {
- if (video.paused || video.ended) void video.play().catch(() => undefined);
- else video.pause();
- }
-
- function seekBy(delta: number): void {
- const duration = Number.isFinite(video.duration) ? video.duration : Infinity;
- video.currentTime = Math.min(duration, Math.max(0, video.currentTime + delta));
- // Redrawn here and not left to `timeupdate`, which a PAUSED video never
- // fires: without this, arrowing along a paused recording moves the playhead
- // while the bar and the clock sit exactly where they were.
- renderProgress();
- showControls();
- }
-
- function seekToFraction(fraction: number): void {
- if (!Number.isFinite(video.duration) || video.duration <= 0) return;
- video.currentTime = Math.min(1, Math.max(0, fraction)) * video.duration;
- renderProgress();
- }
-
- function fractionFromPointer(clientX: number): number {
- const rect = track.getBoundingClientRect();
- if (rect.width === 0) return 0;
- return (clientX - rect.left) / rect.width;
- }
-
- function cycleRate(step: number): void {
- const index = RATES.indexOf(video.playbackRate as (typeof RATES)[number]);
- const next = RATES[(index + step + RATES.length) % RATES.length] ?? 1;
- video.playbackRate = next;
- }
-
- async function toggleFullscreen(): Promise {
- try {
- if (document.fullscreenElement) {
- await document.exitFullscreen();
- return;
- }
- await root.requestFullscreen();
- } catch {
- // iPhone Safari has no element fullscreen -- `requestFullscreen` is not
- // there to call -- but the video element has one of its own. Every other
- // browser's refusal lands here too, and none of them is worth an error
- // message: the reader pressed a button and can press it again.
- (video as FullscreenVideo).webkitEnterFullscreen?.();
- }
- }
-
- async function togglePip(): Promise {
- try {
- if (document.pictureInPictureElement) await document.exitPictureInPicture();
- else await video.requestPictureInPicture();
- } catch {
- showNotice('Picture in picture is not available here.');
- }
- }
-
- async function copyLink(): Promise {
- if (!shareUrl) return;
- const at = Math.floor(video.currentTime);
- const url = shareUrl(at);
- try {
- await navigator.clipboard.writeText(url);
- showNotice(`Link copied, starting at ${formatTime(at)}.`);
- } catch {
- // Clipboard access is refused in plenty of ordinary situations (no
- // permission, an insecure context, a browser that wants a fresher
- // gesture). Showing the URL is still an answer.
- showNotice(url);
- }
- }
-
- // ----------------------------------------------------------------- events --
-
- on(video, 'loadedmetadata', () => {
- rebuildChapters();
- renderProgress();
-
- // Where to begin. An explicit ?t= is an instruction; a saved position is a
- // guess, and one the reader is told about and can undo.
- const wanted = typeof startAt === 'number' && startAt > 0 ? startAt : null;
- if (wanted !== null && Number.isFinite(video.duration) && wanted < video.duration) {
- video.currentTime = wanted;
- return;
- }
- const saved = loadPosition(mediaId, storage);
- if (shouldResume(saved, video.duration) && saved) {
- video.currentTime = saved.t;
- showNotice(`Resumed at ${formatTime(saved.t)}.`, {
- label: 'Start over',
- run: () => {
- video.currentTime = 0;
- clearPosition(mediaId, storage);
- },
- });
- }
- });
-
- on(video, 'timeupdate', () => {
- if (!scrubbing) renderProgress();
- const current = video.currentTime;
- const duration = video.duration;
- if (now() - lastSaved < SAVE_EVERY_MS) return;
- lastSaved = now();
- if (Number.isFinite(duration) && duration > 0) {
- savePosition(mediaId, { t: current, d: duration }, storage, now);
- }
- });
-
- on(video, 'progress', renderProgress);
- // Catches the seeks this player did not make: a chapter mark, the media keys,
- // another script, and the browser's own snap to the nearest keyframe.
- on(video, 'seeked', renderProgress);
- on(video, 'seeking', renderProgress);
- on(video, 'durationchange', rebuildChapters);
- on(video, 'play', renderPlayState);
- on(video, 'pause', () => {
- renderPlayState();
- if (Number.isFinite(video.duration) && video.duration > 0) {
- savePosition(mediaId, { t: video.currentTime, d: video.duration }, storage, now);
- }
- });
- on(video, 'ended', () => {
- renderPlayState();
- // A finished recording has no position worth keeping; leaving one means
- // every future visit resumes 3 seconds from the end.
- clearPosition(mediaId, storage);
- });
- on(video, 'volumechange', () => {
- renderVolume();
- savePrefs({ volume: video.volume, muted: video.muted, rate: video.playbackRate }, storage);
- });
- on(video, 'ratechange', () => {
- renderRate();
- savePrefs({ volume: video.volume, muted: video.muted, rate: video.playbackRate }, storage);
- });
- on(video, 'waiting', () => {
- root.classList.add('pux-player--buffering');
- });
- on(video, 'playing', () => {
- root.classList.remove('pux-player--buffering');
- });
- on(video, 'canplay', () => {
- root.classList.remove('pux-player--buffering');
- });
- on(video, 'error', () => {
- root.classList.remove('pux-player--buffering');
- root.classList.add('pux-player--failed');
- showNotice(errorMessage(video));
- });
-
- on(overlay, 'click', togglePlay);
- on(playButton, 'click', togglePlay);
- on(backButton, 'click', () => {
- seekBy(-SKIP_SECONDS);
- });
- on(forwardButton, 'click', () => {
- seekBy(SKIP_SECONDS);
- });
- on(muteButton, 'click', () => {
- video.muted = !video.muted;
- });
- on(volumeInput, 'input', () => {
- video.volume = Number(volumeInput.value);
- video.muted = Number(volumeInput.value) === 0;
- });
- on(rateButton, 'click', () => {
- cycleRate(1);
- });
- on(shareButton, 'click', () => void copyLink());
- on(pipButton, 'click', () => void togglePip());
- on(fullscreenButton, 'click', () => void toggleFullscreen());
-
- on(document, 'fullscreenchange', () => {
- const isFull = document.fullscreenElement === root;
- root.classList.toggle('pux-player--fullscreen', isFull);
- fullscreenButton.innerHTML = isFull ? ICONS.exitFullscreen : ICONS.enterFullscreen;
- fullscreenButton.setAttribute('aria-label', isFull ? 'Exit fullscreen' : 'Fullscreen');
- });
-
- // Scrubbing. Pointer events rather than mouse, so a touch drag and a pen work
- // without a second code path; capture so a drag that leaves the bar still
- // tracks, which is most drags.
- on(scrub, 'pointerdown', (event) => {
- const pointer = event as PointerEvent;
- scrubbing = true;
- scrub.setPointerCapture(pointer.pointerId);
- seekToFraction(fractionFromPointer(pointer.clientX));
- renderProgress();
- });
- on(scrub, 'pointermove', (event) => {
- const pointer = event as PointerEvent;
- const fraction = fractionFromPointer(pointer.clientX);
- if (Number.isFinite(video.duration) && video.duration > 0) {
- tooltip.textContent = formatTime(Math.max(0, Math.min(1, fraction)) * video.duration);
- tooltip.style.left = `${String(Math.max(0, Math.min(1, fraction)) * 100)}%`;
- }
- if (!scrubbing) return;
- seekToFraction(fraction);
- renderProgress();
- });
- const endScrub = (event: Event): void => {
- if (!scrubbing) return;
- scrubbing = false;
- const pointer = event as PointerEvent;
- if (scrub.hasPointerCapture(pointer.pointerId)) scrub.releasePointerCapture(pointer.pointerId);
- };
- on(scrub, 'pointerup', endScrub);
- on(scrub, 'pointercancel', endScrub);
-
- on(root, 'pointermove', () => {
- showControls();
- });
- on(root, 'pointerleave', () => {
- if (!video.paused) root.classList.remove('pux-player--controls');
- });
- on(root, 'focusin', () => {
- showControls();
- });
-
- /**
- * Keys.
- *
- * The arrow keys are the awkward ones: they are the seek and volume controls
- * for somebody watching, and they are how a D-pad reader moves between the
- * buttons in the bar. So arrows are only ours when focus is on the picture or
- * the scrubber — once focus is in the control row the browser's own focus
- * movement wins, which is what makes the bar usable from a sofa.
- */
- on(root, 'keydown', (event) => {
- const key = (event as KeyboardEvent).key;
- const target = event.target as HTMLElement | null;
- const inRow = target ? row.contains(target) : false;
- const arrows = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'];
- if (inRow && arrows.includes(key)) return;
-
- switch (key) {
- case ' ':
- case 'k':
- case 'Enter':
- // Enter on a button is that button's own business.
- if (key === 'Enter' && target?.closest('button')) return;
- event.preventDefault();
- togglePlay();
- break;
- case 'ArrowLeft':
- event.preventDefault();
- seekBy(-profile.seekStep);
- break;
- case 'ArrowRight':
- event.preventDefault();
- seekBy(profile.seekStep);
- break;
- case 'j':
- seekBy(-SKIP_SECONDS);
- break;
- case 'l':
- seekBy(SKIP_SECONDS);
- break;
- case 'ArrowUp':
- event.preventDefault();
- video.volume = Math.min(1, video.volume + 0.1);
- video.muted = false;
- showControls();
- break;
- case 'ArrowDown':
- event.preventDefault();
- video.volume = Math.max(0, video.volume - 0.1);
- showControls();
- break;
- case 'm':
- video.muted = !video.muted;
- showControls();
- break;
- case 'f':
- void toggleFullscreen();
- break;
- case 'p':
- if (!pipButton.hidden) void togglePip();
- break;
- case 'Home':
- event.preventDefault();
- video.currentTime = 0;
- break;
- case 'End':
- event.preventDefault();
- if (Number.isFinite(video.duration)) video.currentTime = video.duration;
- break;
- case '<':
- case ',':
- cycleRate(-1);
- showControls();
- break;
- case '>':
- case '.':
- cycleRate(1);
- showControls();
- break;
- default:
- // 0–9 jump to that tenth, the one shortcut everybody already knows.
- if (/^[0-9]$/.test(key) && !inRow) {
- event.preventDefault();
- seekToFraction(Number(key) / 10);
- }
- }
- });
-
- // Leaving the page is the most common way a reader stops watching, and it
- // fires no pause. pagehide rather than unload: it is the one that fires on
- // iOS and on a back/forward navigation.
- const persist = (): void => {
- if (Number.isFinite(video.duration) && video.duration > 0 && !video.ended) {
- savePosition(mediaId, { t: video.currentTime, d: video.duration }, storage, now);
- }
- };
- on(window, 'pagehide', persist);
-
- // ------------------------------------------------------------------ init --
-
- const prefs: PlayerPrefs = loadPrefs(storage);
- video.volume = prefs.volume;
- video.muted = prefs.muted;
- video.playbackRate = prefs.rate === 0 ? DEFAULT_PREFS.rate : prefs.rate;
- // The browser's own controls would be a second, differently-shaped set of the
- // same buttons sitting on top of these.
- video.controls = false;
-
- renderPlayState();
- renderVolume();
- renderRate();
- renderProgress();
- rebuildChapters();
- showControls(false);
-
- return {
- destroy(): void {
- if (destroyed) return;
- destroyed = true;
- persist();
- if (hideTimer) clearTimeout(hideTimer);
- if (noticeTimer) clearTimeout(noticeTimer);
- for (const cleanup of cleanups) cleanup();
- overlay.remove();
- spinner.remove();
- notice.remove();
- bar.remove();
- root.classList.remove(
- 'pux-player',
- 'pux-player--tv',
- 'pux-player--controls',
- 'pux-player--playing',
- 'pux-player--ended',
- 'pux-player--buffering',
- 'pux-player--failed',
- 'pux-player--fullscreen'
- );
- },
- setChapters(next: readonly Chapter[]): void {
- chapterSource = next;
- rebuildChapters();
- },
- };
-}
-
-export { parseTimeParam, formatTime, formatTimeParam };
-export type { Chapter };
diff --git a/apps/web/src/lib/player/storage.ts b/apps/web/src/lib/player/storage.ts
deleted file mode 100644
index 4c57838f..00000000
--- a/apps/web/src/lib/player/storage.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-/**
- * What the player remembers between visits, and where.
- *
- * Two different things with two different lifetimes:
- *
- * - **Preferences** (volume, muted, speed) belong to the reader and are the same
- * on every recording. Setting the volume once per video is the kind of small
- * tax that makes a site feel unfinished.
- * - **Positions** belong to one recording each. An hour-long live that somebody
- * watches over two evenings is the normal case, not the exotic one.
- *
- * Both live in localStorage, which means both are per-browser and can vanish:
- * a private window, cleared site data, a different device. Every read is
- * therefore allowed to return nothing and every write is allowed to fail — in
- * some contexts merely *touching* localStorage throws, so the accessor itself
- * is wrapped, not just the parse.
- *
- * Positions are one key holding a map rather than a key per recording. A key
- * per recording grows without bound in the browsers of the people who use the
- * site most, and there is no natural moment to sweep it.
- */
-
-const PREFS_KEY = 'pairux.player.prefs';
-const POSITIONS_KEY = 'pairux.player.positions';
-
-/** How many recordings keep a position. Oldest touched is dropped first. */
-const MAX_POSITIONS = 60;
-
-export interface PlayerPrefs {
- volume: number;
- muted: boolean;
- rate: number;
-}
-
-export interface SavedPosition {
- /** Seconds into the recording. */
- t: number;
- /** Duration when it was saved, so a re-encode can invalidate the offset. */
- d: number;
- /** Epoch ms, used only to decide what to evict. */
- at: number;
-}
-
-export const DEFAULT_PREFS: PlayerPrefs = { volume: 1, muted: false, rate: 1 };
-
-/**
- * localStorage if it is usable, else null.
- *
- * `window.localStorage` is a getter that throws outright when site data is
- * blocked, so this cannot be a truthiness check on a property.
- */
-function store(explicit?: Storage | null): Storage | null {
- if (explicit !== undefined) return explicit;
- try {
- return typeof window === 'undefined' ? null : window.localStorage;
- } catch {
- return null;
- }
-}
-
-function readJson(storage: Storage | null, key: string): unknown {
- if (!storage) return null;
- try {
- const raw = storage.getItem(key);
- if (raw === null) return null;
- return JSON.parse(raw);
- } catch {
- return null;
- }
-}
-
-function writeJson(storage: Storage | null, key: string, value: unknown): void {
- if (!storage) return;
- try {
- storage.setItem(key, JSON.stringify(value));
- } catch {
- // Full, or blocked. Remembering where somebody was is a convenience; it is
- // never worth breaking playback over.
- }
-}
-
-/** Clamp anything that came out of storage back into a range the player can use. */
-function clamp(value: unknown, min: number, max: number, fallback: number): number {
- return typeof value === 'number' && Number.isFinite(value)
- ? Math.min(max, Math.max(min, value))
- : fallback;
-}
-
-export function loadPrefs(storage?: Storage | null): PlayerPrefs {
- const raw = readJson(store(storage), PREFS_KEY) as Partial | null;
- if (!raw) return { ...DEFAULT_PREFS };
- return {
- volume: clamp(raw.volume, 0, 1, DEFAULT_PREFS.volume),
- muted: typeof raw.muted === 'boolean' ? raw.muted : DEFAULT_PREFS.muted,
- // A stored 8x from a bad write would be unusable and hard to escape, so the
- // range is enforced on the way in as well as in the UI.
- rate: clamp(raw.rate, 0.25, 4, DEFAULT_PREFS.rate),
- };
-}
-
-export function savePrefs(prefs: PlayerPrefs, storage?: Storage | null): void {
- writeJson(store(storage), PREFS_KEY, prefs);
-}
-
-/**
- * Read the map, and believe none of it.
- *
- * Anything in localStorage was written by some earlier version of this code, or
- * by a different tab, or by hand. Validating here rather than at each use is
- * what lets the rest of the file treat a SavedPosition as the number it claims
- * to be — an entry that cannot be repaired is simply not returned.
- */
-function readPositions(storage: Storage | null): Record {
- const raw = readJson(storage, POSITIONS_KEY);
- if (!raw || typeof raw !== 'object') return {};
-
- const positions: Record = {};
- for (const [key, value] of Object.entries(raw as Record)) {
- if (!value || typeof value !== 'object') continue;
- const entry = value as Partial;
- if (typeof entry.t !== 'number' || !Number.isFinite(entry.t)) continue;
- positions[key] = {
- t: entry.t,
- d: typeof entry.d === 'number' && Number.isFinite(entry.d) ? entry.d : 0,
- // An entry from before positions carried a timestamp sorts oldest, which
- // is the right guess: it has not been touched by this version yet.
- at: typeof entry.at === 'number' && Number.isFinite(entry.at) ? entry.at : 0,
- };
- }
- return positions;
-}
-
-export function loadPosition(mediaId: string, storage?: Storage | null): SavedPosition | null {
- return readPositions(store(storage))[mediaId] ?? null;
-}
-
-/**
- * Remember where the reader is.
- *
- * Evicts the least recently touched entry rather than the oldest *created*, so
- * a recording somebody returns to weekly outlives one they opened once.
- */
-export function savePosition(
- mediaId: string,
- position: { t: number; d: number },
- storage?: Storage | null,
- now: () => number = Date.now
-): void {
- const storageRef = store(storage);
- if (!storageRef) return;
- const positions = readPositions(storageRef);
- positions[mediaId] = { t: position.t, d: position.d, at: now() };
-
- // Most recently touched first, then keep the first MAX_POSITIONS of them.
- const kept = Object.entries(positions)
- .sort(([, a], [, b]) => b.at - a.at)
- .slice(0, MAX_POSITIONS);
- writeJson(storageRef, POSITIONS_KEY, Object.fromEntries(kept));
-}
-
-export function clearPosition(mediaId: string, storage?: Storage | null): void {
- const storageRef = store(storage);
- if (!storageRef) return;
- const positions = readPositions(storageRef);
- if (!(mediaId in positions)) return;
- const remaining = Object.entries(positions).filter(([key]) => key !== mediaId);
- writeJson(storageRef, POSITIONS_KEY, Object.fromEntries(remaining));
-}
-
-/** Don't offer to resume this near the start. */
-const RESUME_MIN_SECONDS = 15;
-/** Don't offer to resume this near the end — that is a finished recording. */
-const RESUME_TAIL_SECONDS = 20;
-
-/**
- * Should this saved position be used at all?
- *
- * Separate from reading it, and pure, because the rule is the whole feature:
- * resuming somebody 4 seconds in is noise, resuming them 6 seconds from the end
- * is worse than useless, and resuming into an offset past the end of a file
- * that has since been re-encoded strands them on a black frame.
- */
-export function shouldResume(saved: SavedPosition | null, duration: number): boolean {
- if (!saved) return false;
- if (!Number.isFinite(duration) || duration <= 0) return false;
- // A duration that has moved by more than a second is a different file.
- if (Number.isFinite(saved.d) && saved.d > 0 && Math.abs(saved.d - duration) > 1) return false;
- return saved.t >= RESUME_MIN_SECONDS && saved.t <= duration - RESUME_TAIL_SECONDS;
-}
diff --git a/apps/web/src/lib/player/time.ts b/apps/web/src/lib/player/time.ts
deleted file mode 100644
index 819d7322..00000000
--- a/apps/web/src/lib/player/time.ts
+++ /dev/null
@@ -1,80 +0,0 @@
-/**
- * Clock formatting and the `?t=` URL parameter.
- *
- * Split out from the player because it is the part with real edge cases and no
- * DOM: a duration that is NaN until metadata lands, a share link somebody typed
- * by hand, and the fact that the same number is written three different ways by
- * the three sites people arrive from.
- */
-
-/**
- * Seconds as a clock, `1:23` or `1:02:03`.
- *
- * Hours appear only when there are hours, so an ordinary talk is not padded out
- * to `0:07:31` — the reader is looking at this every second the player is open.
- * Anything not a finite number is `--:--` rather than `NaN:NaN`, which is the
- * honest reading before metadata arrives.
- */
-export function formatTime(seconds: number): string {
- if (!Number.isFinite(seconds) || seconds < 0) return '--:--';
- const total = Math.floor(seconds);
- const hrs = Math.floor(total / 3600);
- const mins = Math.floor((total % 3600) / 60);
- const secs = total % 60;
- const pad = (n: number): string => String(n).padStart(2, '0');
- return hrs > 0 ? `${String(hrs)}:${pad(mins)}:${pad(secs)}` : `${String(mins)}:${pad(secs)}`;
-}
-
-/**
- * Read a `?t=` value.
- *
- * Accepts every form anyone actually writes: bare seconds (`372`), YouTube's
- * unit string (`6m12s`, `1h2m3s`), and a pasted clock (`6:12`, `1:02:03`). A
- * link is a thing people edit by hand and forward to each other, so refusing a
- * reasonable spelling is a broken link rather than a teaching moment.
- *
- * @returns whole seconds, or null if this is not a time at all
- */
-export function parseTimeParam(raw: string | null | undefined): number | null {
- if (typeof raw !== 'string') return null;
- const value = raw.trim().toLowerCase();
- if (value === '') return null;
-
- // 6:12 or 1:02:03 — a pasted clock.
- if (value.includes(':')) {
- const parts = value.split(':');
- if (parts.length > 3) return null;
- let total = 0;
- for (const part of parts) {
- if (!/^\d+$/.test(part)) return null;
- total = total * 60 + Number(part);
- }
- return total;
- }
-
- // Bare seconds, with or without a trailing s.
- if (/^\d+s?$/.test(value)) return Number(value.replace('s', ''));
-
- // 1h2m3s, in any subset, but the units must be in descending order and each
- // may appear once — `3s2m` is a typo, not an intention worth guessing at.
- const match = /^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?$/.exec(value);
- if (!match || (match[1] === undefined && match[2] === undefined && match[3] === undefined)) {
- return null;
- }
- const hrs = Number(match[1] ?? 0);
- const mins = Number(match[2] ?? 0);
- const secs = Number(match[3] ?? 0);
- return hrs * 3600 + mins * 60 + secs;
-}
-
-/**
- * The `t` value to put in a share link.
- *
- * Whole seconds, because it round-trips through every parser above including
- * other sites', and because a fractional offset is a false precision — nobody
- * means 372.418 when they say "watch from here".
- */
-export function formatTimeParam(seconds: number): string {
- if (!Number.isFinite(seconds) || seconds <= 0) return '0';
- return String(Math.floor(seconds));
-}
diff --git a/apps/web/src/lib/player/tv.ts b/apps/web/src/lib/player/tv.ts
deleted file mode 100644
index 63ae3c92..00000000
--- a/apps/web/src/lib/player/tv.ts
+++ /dev/null
@@ -1,76 +0,0 @@
-/**
- * Is this a television?
- *
- * A recorded live is watched on a sofa at least as often as at a desk, and the
- * two want different things from the same player. A television has no pointer:
- * every control has to be reachable by a D-pad, hit targets have to survive
- * being aimed at from three metres, and controls that fade after three seconds
- * fade while the reader is still travelling towards them.
- *
- * There is no feature to detect here — the difference is the device, not the
- * API surface — so this is a user agent test, which is the kind of thing that
- * ages badly and is therefore kept to one list, in one file, with a test beside
- * it.
- *
- * The pattern list is deliberately the same one genrewatch.com and
- * tipoffwatch.com use in `apps/web/src/client/tv.js`. These players face the
- * same living rooms, and a device that is a television in one and a desktop in
- * the other is a bug waiting in whichever nobody looked at.
- */
-
-/**
- * Ordered most specific first, so a Fire TV is a Fire TV rather than a generic
- * Android that happens to carry the Silk browser.
- */
-const TV_PATTERNS: readonly (readonly [RegExp, string])[] = [
- // Amazon Fire TV — the model string, which is the only reliable marker: some
- // Fire TV builds report a plain Chrome user agent with no "Silk" in it.
- [/\bAFT[A-Z0-9]+\b/i, 'firetv'],
- // Kindle Fire tablets.
- [/\bKF[A-Z]+\b/, 'silk'],
- // The Silk browser anywhere else.
- [/\bSilk\b/i, 'silk'],
- [/\bAndroid TV\b/i, 'androidtv'],
- [/\bGoogleTV\b/i, 'googletv'],
- [/\bTizen\b/i, 'tizen'],
- [/\bWeb0S\b/i, 'webos'],
- [/\bRoku\b/i, 'roku'],
- [/AppleTV/i, 'appletv'],
- [/\bCrKey\b/i, 'chromecast'],
- [/\bSMART-TV\b/i, 'smarttv'],
- [/\bSmartTV\b/i, 'smarttv'],
-];
-
-/** Which television, or null for anything else. */
-export function tvBrowserType(userAgent: string | null | undefined): string | null {
- if (!userAgent) return null;
- for (const [pattern, type] of TV_PATTERNS) if (pattern.test(userAgent)) return type;
- return null;
-}
-
-export function isTvBrowser(userAgent: string | null | undefined): boolean {
- return tvBrowserType(userAgent) !== null;
-}
-
-export interface UiProfile {
- /** How long the controls stay up after the last input, while playing. */
- hideAfterMs: number;
- /** What an arrow press is worth, in seconds. */
- seekStep: number;
- /** Whether to keep focus rings on always — a D-pad reader is always "tabbing". */
- alwaysShowFocus: boolean;
-}
-
-/**
- * The two profiles.
- *
- * A television seeks in larger steps because a D-pad press is a deliberate act
- * and 5 seconds of an hour-long recording is not worth the trip; a desktop
- * seeks in smaller ones because the reader has a scrub bar for anything bigger
- * and is usually correcting an overshoot.
- */
-export function uiProfile(isTv: boolean): UiProfile {
- return isTv
- ? { hideAfterMs: 6000, seekStep: 10, alwaysShowFocus: true }
- : { hideAfterMs: 2800, seekStep: 5, alwaysShowFocus: false };
-}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7e6d279a..343e49ff 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -409,6 +409,9 @@ importers:
'@profullstack/pairux-embed':
specifier: workspace:*
version: link:../../packages/embed
+ '@profullstack/player':
+ specifier: 0.17.0
+ version: 0.17.0(react@19.3.1)
'@profullstack/profile':
specifier: 0.1.8
version: 0.1.8(react@19.3.1)
@@ -3037,6 +3040,15 @@ packages:
resolution: {integrity: sha512-cdFsBbfRXurvy5KqHDNcKXxurK2s/YlfVgnILFRpVl2hq3860rExqDee8IfsFGxw5cPdqGv2fxUxtek1Xy2U5Q==}
engines: {node: '>=22'}
+ '@profullstack/player@0.17.0':
+ resolution: {integrity: sha512-rd1aQzXqBwMMp6aIuyJ7K3O3+k7W83J1AXl7TG9PZmAVeK85lxEsuE1DwUnUbaa6zwBhJ9Xfcb0YxHVqtncYGg==}
+ engines: {node: '>=20'}
+ peerDependencies:
+ react: '>=18'
+ peerDependenciesMeta:
+ react:
+ optional: true
+
'@profullstack/profile@0.1.8':
resolution: {integrity: sha512-MQ/AGGM++NZEbjMglRlEvjCrmsL46CXDz2GKb510eMBEKUik1cwix9ag20gakYrrVCaB5mNcNCMYon53vwDTfw==}
engines: {node: '>=20'}
@@ -5256,6 +5268,9 @@ packages:
es6-error@4.1.1:
resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==}
+ es6-promise@4.2.8:
+ resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==}
+
esbuild@0.21.5:
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
engines: {node: '>=12'}
@@ -6002,6 +6017,9 @@ packages:
resolution: {integrity: sha512-ciq6hFsSG/Bpt2DmrZJtv+56zpPdnq+NQ4ijEFrveKN0ZG1mhl/LdT1NQZ9se6ty1fACcI4d4vYqC9v8EYpH2A==}
hasBin: true
+ hls.js@1.7.4:
+ resolution: {integrity: sha512-3GlxGdPvT2YHvQYOt+yOzT/DEior/qJVNIylHA77kgeoBTdQQqi70GwefNEUmHQeNGyjC8U62+bruqW/Tpm+tg==}
+
hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
@@ -7289,6 +7307,10 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ mpegts.js@1.8.2:
+ resolution: {integrity: sha512-cZYMa5muASH55wrS6JK5IGkUqnCA96c0f+WdnW91jIK9s/N6NzjmTQDJNYg9A5u/G2Q5nj2b3w1Zwzze/9guxA==}
+ engines: {node: '>=20.9.0'}
+
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
@@ -12549,6 +12571,13 @@ snapshots:
'@profullstack/openprofile@0.2.0': {}
+ '@profullstack/player@0.17.0(react@19.3.1)':
+ dependencies:
+ hls.js: 1.7.4
+ mpegts.js: 1.8.2
+ optionalDependencies:
+ react: 19.3.1
+
'@profullstack/profile@0.1.8(react@19.3.1)':
dependencies:
'@profullstack/openprofile': 0.2.0
@@ -15131,6 +15160,8 @@ snapshots:
es6-error@4.1.1:
optional: true
+ es6-promise@4.2.8: {}
+
esbuild@0.21.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.21.5
@@ -16111,6 +16142,8 @@ snapshots:
hexy@0.2.11: {}
+ hls.js@1.7.4: {}
+
hoist-non-react-statics@3.3.2:
dependencies:
react-is: 16.13.1
@@ -17492,6 +17525,11 @@ snapshots:
mkdirp@1.0.4: {}
+ mpegts.js@1.8.2:
+ dependencies:
+ es6-promise: 4.2.8
+ events: 3.3.0
+
ms@2.0.0: {}
ms@2.1.2: {}