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
18 changes: 9 additions & 9 deletions apps/web/src/app/embed/[joinCode]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -56,16 +57,15 @@ export default async function EmbedPlayerPage({ params }: PageProps) {
<div className="flex h-screen w-screen flex-col overflow-hidden bg-black">
<div className="relative min-h-0 flex-1">
{showRecording ? (
<video
controls
playsInline
preload="metadata"
poster={session.banner_url ?? undefined}
src={session.recording_url ?? undefined}
<RecordingPlayer
src={session.recording_url ?? ''}
mediaId={`session:${session.join_code}`}
poster={session.banner_url}
// An iframe's own address is not something a reader can paste
// anywhere useful, so the embed keeps the button off.
shareable={false}
className="h-full w-full bg-black"
>
Your browser does not support video playback.
</video>
/>
) : (
<a
href={permalink}
Expand Down
19 changes: 7 additions & 12 deletions apps/web/src/app/l/[joinCode]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { createClient } from '@/lib/supabase/server';
import { renderDescriptionHtml } from '@/lib/markdown';
import { SITE_URL, embedUrl, iframeSnippet, liveUrl, oembedUrl } from '@/lib/embed';
import type { PublicSessionDetail, SessionComment } from '@pairux/shared-types';
import { RecordingPlayer } from '@/components/video/RecordingPlayer';
import { LikeButton } from './LikeButton';
import { Comments } from './Comments';
import { EmbedButton } from './EmbedButton';
Expand Down Expand Up @@ -129,18 +130,12 @@ export default async function LiveDetailPage({ params }: PageProps) {
<main className="flex-1">
<div className="mx-auto w-full max-w-3xl px-4 py-10 sm:px-6 lg:px-8">
{session.recording_url && !session.is_live ? (
<div className="mb-6 aspect-video w-full overflow-hidden rounded-2xl bg-black">
<video
controls
playsInline
preload="metadata"
poster={session.banner_url ?? undefined}
src={session.recording_url}
className="h-full w-full"
>
Your browser does not support video playback.
</video>
</div>
<RecordingPlayer
src={session.recording_url}
mediaId={`session:${session.join_code}`}
poster={session.banner_url}
className="mb-6 aspect-video w-full overflow-hidden rounded-2xl bg-black"
/>
) : (
session.banner_url && (
<div className="mb-6 aspect-video w-full overflow-hidden rounded-2xl bg-gray-100">
Expand Down
95 changes: 95 additions & 0 deletions apps/web/src/components/video/RecordingPlayer.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(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 (
<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
playsInline
preload="metadata"
poster={poster ?? undefined}
src={src}
>
Your browser does not support video playback.
</video>
</div>
);
}
83 changes: 83 additions & 0 deletions apps/web/src/lib/player/chapters.ts
Original file line number Diff line number Diff line change
@@ -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<number>();
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;
}
22 changes: 22 additions & 0 deletions apps/web/src/lib/player/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading
Loading