Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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 <EvidenceLink text="…the claim…" /> 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<MeetingVideoCtx | null>(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<ReactPlayer>(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<number | null>(null) // seek queued until player ready | |
| const [active, setActive] = useState<Resolved | null>(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<MeetingVideoCtx>( | |
| () => ({ hasVideoId: !!videoId, resolve: matchText, playClip }), | |
| [videoId, matchText, playClip], | |
| ) | |
| return ( | |
| <Ctx.Provider value={ctx}> | |
| {children} | |
| {mounted && videoId && ( | |
| <div | |
| className={`fixed left-1/2 top-1/2 z-50 max-h-[90vh] w-[92vw] max-w-4xl -translate-x-1/2 -translate-y-1/2 overflow-y-auto transition-opacity ${open ? 'opacity-100' : 'pointer-events-none opacity-0'}`} | |
| > | |
| <div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl ring-1 ring-slate-900/5"> | |
| <div className="flex items-center justify-between bg-slate-900 px-3 py-2"> | |
| <span className="flex items-center gap-2 font-mono text-[11px] text-slate-300"> | |
| <FilmIcon className="h-3.5 w-3.5 text-teal-400" /> | |
| {caption || 'Meeting recording'} | |
| </span> | |
| <button | |
| onClick={() => { | |
| setOpen(false) | |
| setPlaying(false) | |
| }} | |
| aria-label="Close video" | |
| className="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-white" | |
| > | |
| <XMarkIcon className="h-4 w-4" /> | |
| </button> | |
| </div> | |
| <div className="relative w-full bg-black" style={{ aspectRatio: '16 / 9' }}> | |
| <ReactPlayer | |
| ref={playerRef} | |
| url={`https://www.youtube.com/watch?v=${videoId}`} | |
| width="100%" | |
| height="100%" | |
| controls | |
| playing={playing} | |
| onReady={onReady} | |
| onPlay={() => setPlaying(true)} | |
| onPause={() => setPlaying(false)} | |
| config={{ playerVars: { modestbranding: 1, rel: 0 } }} | |
| /> | |
| </div> | |
| {active?.cueText && ( | |
| <div className="px-3 py-3"> | |
| <div className="font-mono text-[10px] uppercase tracking-wider text-teal-600"> | |
| ▶ {active.label} | |
| </div> | |
| <p className="mt-1 text-sm italic leading-relaxed text-slate-600">“{active.cueText}”</p> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </Ctx.Provider> | |
| ) | |
| } | |
| /** | |
| * 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 ( | |
| <button | |
| onClick={() => ctx.playClip(text)} | |
| title={`Jump to ${resolved.label} in the recording`} | |
| className="ml-1.5 inline-flex translate-y-px items-center gap-1 rounded-full border border-teal-200 bg-teal-50 px-2 py-0.5 align-middle font-mono text-[11px] font-medium text-teal-700 transition-colors hover:border-teal-400 hover:bg-teal-100" | |
| > | |
| <PlayIcon className="h-2.5 w-2.5" /> | |
| {resolved.label} | |
| </button> | |
| ) | |
| } | |
| /** 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 ( | |
| <button | |
| onClick={() => ctx.playClip('')} | |
| className={`flex items-center gap-1.5 font-medium text-[#1d6b5f] hover:text-[#155448] ${className}`} | |
| > | |
| <FilmIcon className="h-4 w-4" /> | |
| Watch recording | |
| </button> | |
| ) | |
| } | |