MeetingRecordingPlayer.tsx11.4 KBView on GitHub 'use client';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import {
fathomEmbedUrl,
providerUrlAtSecond,
secondsToClock,
} from '@/modules/documents/recording/timestamps';
import { useRecordingPlayback } from '@/modules/documents/recording/useRecordingPlayback';
export interface RecordingPlayerHandle {
/**
* Play from this second. Safe to call before the media exists — the request is started
* and the seek is applied when it arrives.
*/
seekTo: (seconds: number) => void;
}
interface MeetingRecordingPlayerProps {
/** `crm_meeting_events.external_id` — the server resolves the provider from it. */
externalId: string;
/** Where to open the recording, in seconds. Null plays from the start. */
atSeconds?: number | null;
/** Provider page fallback for when nothing can be minted (Fathom share link, Gong call). */
fallbackUrl?: string | null;
/** Compact height for a player sitting inside a moment card rather than on its own. */
compact?: boolean;
/**
* Fetch the media on mount instead of waiting for the reader's first press.
*
* ONLY for a surface where the recording is the point and there is exactly one of them —
* the meeting dialog, which the reader opened to watch the call. A document renders a
* player per moment, so eager fetching there is what the poster exists to prevent; see
* the note on `useRecordingPlayback`. It never autoplays: it only means the video is
* ready when the reader reaches for it.
*/
autoLoad?: boolean;
}
/**
* The recording, playing inside the document, positioned at the moment being discussed.
*
* WHY A `<video>` AND NOT THE PROVIDER'S EMBED. Only an element we own can be seeked. Gong's
* iframe takes `from`/`to` but demands the reader be signed into Gong; Fathom's iframe is
* frameable but accepts no timestamp at all; Circleback has no embed we can address. Feeding
* our own element a minted media URL is the only path that works identically for all three
* and lands on the right second — see apps/server/docs/inline-meeting-video-in-documents.md.
*
* NOTHING IS FETCHED UNTIL THE READER ASKS, unless `autoLoad` says otherwise. The poster is
* real UI, not a loading state: a document with ten moments would otherwise fire ten mints on
* open, against a Fathom budget of 30 per minute. The first press is what starts the work.
* A surface holding ONE recording that the reader opened to watch passes `autoLoad` and skips
* that press — the budget argument does not apply to a single deliberate open.
*
* The fallback ladder, in order, is: minted media → Fathom's embeddable player at 0:00 →
* a timestamped link out to the provider. Each rung is strictly worse than the one above and
* is only reached when the one above is impossible.
*/
export const MeetingRecordingPlayer = forwardRef<RecordingPlayerHandle, MeetingRecordingPlayerProps>(
function MeetingRecordingPlayer({ externalId, atSeconds, fallbackUrl, compact, autoLoad }, ref) {
const videoRef = useRef<HTMLVideoElement>(null);
const { recordingUrl, externalUrl, provider, status, request } =
useRecordingPlayback(externalId);
// Where playback should resume from. Held separately from `atSeconds` because a click on
// a transcript line moves it, and because it has to survive the wait for a media URL that
// does not exist yet — the seek is replayed against the element once it can accept one.
const [target, setTarget] = useState<number | null>(atSeconds ?? null);
const [pendingPlay, setPendingPlay] = useState(false);
const [failed, setFailed] = useState(false);
const start = useCallback(
(seconds: number | null) => {
setTarget(seconds);
setPendingPlay(true);
setFailed(false);
request();
},
[request],
);
useImperativeHandle(ref, () => ({ seekTo: (seconds: number) => start(seconds) }), [start]);
// Ask for the media as soon as the surface that owns the recording opens. `request()`
// is idempotent and does not play anything, so this only removes the press that stood
// between opening a meeting and having a video there.
useEffect(() => {
if (autoLoad) request();
}, [autoLoad, request]);
// A caller can learn the moment AFTER mounting the player — the meeting view locates a
// citation in a transcript it is still fetching, so `atSeconds` arrives as null and
// becomes a number a moment later. Adopting it without `start()` is deliberate: the
// player re-labels its poster "Play from 12:40" and cues that second, but does not fetch
// or play anything the reader has not asked for.
useEffect(() => setTarget(atSeconds ?? null), [atSeconds]);
// A media element only accepts `currentTime` once it knows its duration, so the seek is
// applied here rather than at click time. Re-runs whenever the target moves, which is what
// makes clicking a second transcript line jump an already-playing video.
const applyTarget = useCallback(() => {
const el = videoRef.current;
if (!el) return;
if (target !== null && Number.isFinite(target)) {
// A stamp past the end of the file means the two clocks disagree; clamping keeps the
// player usable instead of parking it on a black frame at the end.
el.currentTime = el.duration ? Math.min(target, Math.max(0, el.duration - 1)) : target;
}
if (pendingPlay) {
setPendingPlay(false);
void el.play().catch(() => {
// Autoplay refusal is not a failure worth surfacing — the element still shows its
// own controls, positioned correctly, and the reader can press play.
});
}
}, [target, pendingPlay]);
useEffect(() => {
const el = videoRef.current;
if (!el || !recordingUrl) return;
if (el.readyState >= 1) applyTarget();
}, [recordingUrl, applyTarget]);
// A new URL should clear a failure recorded against the previous one.
useEffect(() => setFailed(false), [recordingUrl]);
const timestampedLink = providerUrlAtSecond(externalUrl ?? fallbackUrl, provider, target);
const embedUrl = fathomEmbedUrl(externalUrl ?? fallbackUrl);
const frameClass = compact ? 'aspect-video max-h-64' : 'aspect-video';
// ---- the media itself, once we have it -------------------------------------------
if (recordingUrl && !failed) {
return (
<figure className="my-0">
<video
ref={videoRef}
src={recordingUrl}
controls
preload="metadata"
playsInline
onLoadedMetadata={applyTarget}
onError={() => setFailed(true)}
className={`w-full rounded-md bg-black ${frameClass}`}
/>
<PlayerFooter atSeconds={target} href={timestampedLink} provider={provider} />
</figure>
);
}
// ---- everything else: a surface that says which of the four it is ----------------
return (
<div className="rounded-md border border-border bg-muted/40">
<div className={`flex w-full items-center justify-center ${frameClass}`}>
<RecordingFallback
status={failed ? 'unavailable' : status}
atSeconds={target}
embedUrl={embedUrl}
onPlay={() => start(target)}
/>
</div>
<PlayerFooter atSeconds={target} href={timestampedLink} provider={provider} />
</div>
);
},
);
/**
* The state the player is in when it is not playing, said in the reader's terms.
*
* Each branch exists because collapsing it into another would assert something false: a
* recording still rendering is not a missing one, and a recording Cedar is forbidden to fetch
* is neither — it is a recording with an owner who can fix it in about a minute.
*/
function RecordingFallback({
status,
atSeconds,
embedUrl,
onPlay,
}: {
status: string;
atSeconds: number | null;
embedUrl: string | null;
onPlay: () => void;
}) {
if (status === 'idle') {
return (
<button
type="button"
onClick={onPlay}
className="flex cursor-pointer flex-col items-center gap-2 text-muted-foreground hover:text-foreground"
>
<span className="flex h-12 w-12 items-center justify-center rounded-full border border-border bg-background">
<PlayGlyph />
</span>
<span className="text-sm font-medium">
{atSeconds !== null ? `Play from ${secondsToClock(atSeconds)}` : 'Play recording'}
</span>
</button>
);
}
if (status === 'loading' || status === 'processing') {
return (
<p className="px-6 text-center text-sm text-muted-foreground">
{status === 'processing'
? 'The recorder is still preparing this file — it will start on its own.'
: 'Loading the recording…'}
</p>
);
}
if (status === 'permission_required') {
return (
<div className="px-6 text-center">
<p className="text-sm font-medium text-foreground">Gong will not release this recording</p>
<p className="mt-1 text-sm text-muted-foreground">
Cedar’s Gong API key is missing the <code>api:calls:read:media-url</code>{' '}
permission. A Gong admin can re-issue the key with it from Admin center → Settings
→ Ecosystem → API.
</p>
</div>
);
}
// Last rung: Fathom's own player. It cannot be seeked, so it is offered only once the
// media path has failed, and the timestamp is carried by the link beneath it instead.
if (embedUrl) {
return (
<iframe
src={embedUrl}
title="Meeting recording"
allow="fullscreen"
className="h-full w-full rounded-md border-0"
/>
);
}
return (
<div className="px-6 text-center">
<p className="text-sm text-muted-foreground">No playable recording for this meeting.</p>
</div>
);
}
/** The link out, always carrying the moment when the provider understands one. */
function PlayerFooter({
atSeconds,
href,
provider,
}: {
atSeconds: number | null;
href: string | null;
provider: string | null;
}) {
if (!href) return null;
return (
<div className="flex items-center justify-end px-2 py-1.5">
<OpenAtProvider href={href} provider={provider} atSeconds={atSeconds} />
</div>
);
}
const PROVIDER_LABEL: Record<string, string> = {
fathom: 'Fathom',
gong: 'Gong',
circleback: 'Circleback',
};
function OpenAtProvider({
href,
provider,
atSeconds,
}: {
href: string;
provider: string | null;
atSeconds: number | null;
}) {
const where = (provider && PROVIDER_LABEL[provider]) || 'the recorder';
// Only claim the link lands on the moment for providers whose deep link was verified to
// work — saying "at 12:40" on a URL that opens at 0:00 is a small lie the reader catches.
const deepLinks = provider === 'fathom' || provider === 'gong';
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="cursor-pointer text-sm font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
Open in {where}
{deepLinks && atSeconds !== null ? ` at ${secondsToClock(atSeconds)}` : ''}
</a>
);
}
function PlayGlyph() {
return (
<svg viewBox="0 0 24 24" className="h-5 w-5 translate-x-0.5" fill="currentColor" aria-hidden>
<path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
</svg>
);
}