Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
65 changes: 65 additions & 0 deletions apps/web/src/components/video/RecordingPlayer.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<RecordingPlayer src="https://media.example/rec.mp4" mediaId="session:abc" />
);
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(
<RecordingPlayer
src="https://media.example/rec.mp4"
mediaId="session:abc"
shareable={false}
/>
);
const share = container.querySelector<HTMLButtonElement>('[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();
});
});
62 changes: 50 additions & 12 deletions apps/web/src/components/video/RecordingPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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) => {
Expand All @@ -83,9 +121,9 @@ export function RecordingPlayer({
<div ref={rootRef} className={className}>
<video
ref={videoRef}
// Controls are on until the script that replaces them runs. If it never
// runs -- a bundle that failed, JavaScript off -- the reader still gets
// a working player rather than a still frame with no way to start it.
// Controls are on until the player replaces them. If it never runs --
// a bundle that failed, JavaScript off -- the reader still gets a
// working player rather than a still frame with no way to start it.
controls
playsInline
preload="metadata"
Expand Down
83 changes: 0 additions & 83 deletions apps/web/src/lib/player/chapters.ts

This file was deleted.

22 changes: 0 additions & 22 deletions apps/web/src/lib/player/index.ts

This file was deleted.

Loading
Loading