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&rsquo;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&nbsp;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>
  );
}