diff --git a/apps/web/src/app/embed/[joinCode]/page.tsx b/apps/web/src/app/embed/[joinCode]/page.tsx index 72ce1e0b..66e27047 100644 --- a/apps/web/src/app/embed/[joinCode]/page.tsx +++ b/apps/web/src/app/embed/[joinCode]/page.tsx @@ -4,6 +4,7 @@ import { Circle, Play } from 'lucide-react'; import { createClient } from '@/lib/supabase/server'; import { SITE_URL, liveUrl } from '@/lib/embed'; import type { PublicSessionDetail } from '@pairux/shared-types'; +import { RecordingPlayer } from '@/components/video/RecordingPlayer'; export const dynamic = 'force-dynamic'; @@ -56,16 +57,15 @@ export default async function EmbedPlayerPage({ params }: PageProps) {
{showRecording ? ( - + /> ) : (
{session.recording_url && !session.is_live ? ( -
- -
+ ) : ( session.banner_url && (
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 `