open-navigator / web_app /src /components /MeetingVideoContext.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
8.08 kB
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>
)
}