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({