import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode, } from 'react' import ReactPlayer from 'react-player/youtube' import { useQuery } from '@tanstack/react-query' import { PlayIcon } from '@heroicons/react/24/solid' import { XMarkIcon, FilmIcon } from '@heroicons/react/24/outline' import api from '../lib/api' import { buildCueIndex, extractKeywords, findBestMatchIndexed, type Cue } from '../lib/transcriptMatch' /** * MeetingVideoContext — "evidence links" model for the decision page. * * Any dropped next to an assertion resolves * that text against the meeting's timestamped transcript cues (the SAME IDF * matcher MeetingPlayer uses) and, on click, opens ONE persistent floating * player docked bottom-right (full-width bottom sheet on mobile), seeked to that * moment, with the matched cue quoted underneath. * * HONESTY: timestamps are DERIVED from the real transcript, never fabricated. If * a claim has no confident cue match (or the meeting has no transcript), the * pill renders nothing rather than inventing a moment. */ interface Resolved { seconds: number label: string cueText: string } interface MeetingVideoCtx { /** A video exists (popout can play it, even with no transcript to match). */ hasVideoId: boolean resolve: (text: string) => Resolved | null playClip: (text: string) => void } const Ctx = createContext(null) // Minimum IDF score for a match to count as real evidence. Below this the // keyword overlap is too thin to trust, so we show no pill. const MIN_MATCH_SCORE = 1.5 function fmt(totalSeconds: number): string { const s = Math.max(0, Math.floor(totalSeconds)) const h = Math.floor(s / 3600) const m = Math.floor((s % 3600) / 60) const sec = s % 60 const pad = (n: number) => String(n).padStart(2, '0') return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}` } export function MeetingVideoProvider({ videoId, caption, children, }: { videoId?: string | null caption?: string children: ReactNode }) { const playerRef = useRef(null) const [mounted, setMounted] = useState(false) // player mounted after first open const [open, setOpen] = useState(false) const [playing, setPlaying] = useState(false) const [ready, setReady] = useState(false) const pendingRef = useRef(null) // seek queued until player ready const [active, setActive] = useState(null) const { data } = useQuery({ queryKey: ['meeting-transcript', videoId], enabled: !!videoId, staleTime: 10 * 60 * 1000, queryFn: async () => (await api.get(`/meeting/${videoId}/transcript`)).data, }) const cues: Cue[] = data?.segments ?? [] // Tokenize the transcript ONCE per video; every claim/badge match reuses this // index instead of re-tokenizing all cues (the old hot path: 15-30 badges × // thousands of cues re-tokenized on each render = the slow page render). const cueIndex = useMemo(() => buildCueIndex(cues), [cues]) const matchText = useCallback( (text: string): Resolved | null => { if (!videoId || cueIndex.cues.length === 0 || !text?.trim()) return null const m = findBestMatchIndexed(cueIndex, extractKeywords(text)) if (!m || m.score < MIN_MATCH_SCORE) return null const idxs = m.windowIndices.length ? m.windowIndices : [0] const cueText = idxs .map((i) => cueIndex.cues[i]?.text) .filter(Boolean) .join(' ') return { seconds: m.startSeconds, label: fmt(m.startSeconds), cueText } }, [videoId, cueIndex], ) const seek = useCallback((seconds: number) => { const p = playerRef.current if (p && ready) { p.seekTo(seconds, 'seconds') setPlaying(true) } else { pendingRef.current = seconds } }, [ready]) const playClip = useCallback( (text: string) => { const resolved = matchText(text) const seconds = resolved?.seconds ?? 0 setActive(resolved ?? { seconds: 0, label: fmt(0), cueText: '' }) setMounted(true) setOpen(true) seek(seconds) }, [matchText, seek], ) const onReady = () => { setReady(true) if (pendingRef.current != null) { playerRef.current?.seekTo(pendingRef.current, 'seconds') setPlaying(true) pendingRef.current = null } } const ctx = useMemo( () => ({ hasVideoId: !!videoId, resolve: matchText, playClip }), [videoId, matchText, playClip], ) return ( {children} {mounted && videoId && (
{caption || 'Meeting recording'}
setPlaying(true)} onPause={() => setPlaying(false)} config={{ playerVars: { modestbranding: 1, rel: 0 } }} />
{active?.cueText && (
▶ {active.label}

“{active.cueText}”

)}
)}
) } /** * Inline ▶ mm:ss pill next to a claim. Renders only when the claim text matches * a real transcript cue with enough confidence — otherwise nothing. */ export function EvidenceLink({ text }: { text?: string | null }) { const ctx = useContext(Ctx) const resolved = useMemo(() => (ctx && text ? ctx.resolve(text) : null), [ctx, text]) if (!ctx || !text || !resolved) return null return ( ) } /** Plain "Watch recording" trigger for the hero (opens at the start). */ export function WatchRecordingLink({ className = '' }: { className?: string }) { const ctx = useContext(Ctx) if (!ctx || !ctx.hasVideoId) return null return ( ) }