useRecordingPlayback.ts3.1 KBView on GitHub
import { useQuery } from '@tanstack/react-query';
import { useCallback, useState } from 'react';

import { useTRPC } from '@/providers/query-provider';

/** How often to re-ask while a provider is still rendering its file. */
const PROCESSING_POLL_MS = 5_000;

/**
 * A minted URL outlives a single view, so the answer is worth holding. Circleback signs for
 * 24h, Fathom's render survives ~24h, Gong signs for 8h — ten minutes is comfortably inside
 * all three and still short enough that a stale answer self-heals within one sitting.
 */
const STALE_TIME_MS = 10 * 60 * 1000;

export type RecordingPlaybackStatus =
  | 'idle'
  | 'loading'
  | 'ready'
  | 'processing'
  | 'unavailable'
  | 'permission_required';

export interface RecordingPlayback {
  /** Playable media URL. Non-null only once `status` is `ready`. */
  recordingUrl: string | null;
  /** A page to open at the provider, if it has one. Available before the media resolves. */
  externalUrl: string | null;
  /** Canonical provider id, once known — drives the provider-shaped fallbacks. */
  provider: string | null;
  status: RecordingPlaybackStatus;
  /** Ask the server for a URL. Idempotent; the first call is what starts the request. */
  request: () => void;
}

/**
 * Resolve a meeting's playable recording, ON DEMAND.
 *
 * WHY IT DOES NOT FETCH ON MOUNT. A coaching document routinely holds five to ten moments
 * from different calls, and every one of them is a separate mint. Fathom's download endpoint
 * allows 30 requests per 60s per API key and renders a cold recording in ~34 seconds, so a
 * document that minted eagerly would spend a third of that minute's budget before the reader
 * had pressed anything, and would do it again on every scroll-back. Nothing is requested
 * until `request()` is called — the player calls it on the reader's first play or seek.
 *
 * `status` distinguishes four unhappy answers because they need four different words in the
 * UI: still rendering, no recording, not allowed to fetch it (Gong's media scope), and not
 * asked yet. Collapsing any pair of them tells the reader something untrue.
 */
export function useRecordingPlayback(externalId: string | null | undefined): RecordingPlayback {
  const trpc = useTRPC();
  const [requested, setRequested] = useState(false);

  const enabled = requested && !!externalId;

  const { data, isLoading, isError } = useQuery({
    ...trpc.crm.getMeetingRecordingUrl.queryOptions({ externalId: externalId || '' }),
    enabled,
    staleTime: STALE_TIME_MS,
    // A provider that renders on demand answers "processing" first. Keep asking until it
    // resolves one way or the other; every other status is final for this view.
    refetchInterval: (query) =>
      query.state.data?.status === 'processing' ? PROCESSING_POLL_MS : false,
  });

  const request = useCallback(() => setRequested(true), []);

  const status: RecordingPlaybackStatus = !enabled
    ? 'idle'
    : isError
      ? 'unavailable'
      : isLoading || !data
        ? 'loading'
        : data.status;

  return {
    recordingUrl: data?.recordingUrl ?? null,
    externalUrl: data?.externalUrl ?? null,
    provider: data?.provider ?? null,
    status,
    request,
  };
}