import { useEffect, useMemo, useRef, useState } from 'react' import ReactPlayer from 'react-player/youtube' import { useQuery } from '@tanstack/react-query' import { VideoCameraIcon, MagnifyingGlassIcon, PlayIcon, MapPinIcon, } from '@heroicons/react/24/outline' import api from '../lib/api' import { extractKeywords, findBestMatch } from '../lib/transcriptMatch' /** * Embedded meeting recording (react-player) plus a timed, clickable transcript. * * The transcript cues come from /meeting/{videoId}/transcript. Clicking a cue * seeks the player to that second ("jump to points in the meeting"); the cue * under the playhead is highlighted and auto-scrolled into view as the video * plays. If no transcript exists the player still embeds, sans cues. * * Reused on the decision and legislation drilldowns — anywhere a meeting has a * `meeting_video_id`. */ interface TranscriptCue { start: number text: string } interface MeetingTranscript { video_id: string has_transcript: boolean language?: string | null segment_count: number segments: TranscriptCue[] } interface MeetingPlayerProps { videoId: string /** Optional caption shown under the player (e.g. the meeting name + date). */ caption?: string /** * Optional text describing the specific decision/bill on this page (headline + * statement). When supplied, the player tries to locate where it was discussed * in the transcript and offers a "jump to this moment" seek + cue highlighting. */ targetText?: string } /** Seconds -> H:MM:SS (or M:SS for sub-hour) for the cue timestamp chips. */ function formatTimestamp(totalSeconds: number): string { const s = Math.max(0, Math.floor(totalSeconds)) const hours = Math.floor(s / 3600) const minutes = Math.floor((s % 3600) / 60) const seconds = s % 60 const mm = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes) const ss = String(seconds).padStart(2, '0') return hours > 0 ? `${hours}:${mm}:${ss}` : `${mm}:${ss}` } export default function MeetingPlayer({ videoId, caption, targetText }: MeetingPlayerProps) { const playerRef = useRef(null) const activeCueRef = useRef(null) const [playedSeconds, setPlayedSeconds] = useState(0) const [playing, setPlaying] = useState(false) const [query, setQuery] = useState('') const { data, isLoading } = useQuery({ queryKey: ['meeting-transcript', videoId], queryFn: async () => { const response = await api.get(`/meeting/${videoId}/transcript`) return response.data }, enabled: !!videoId, staleTime: 5 * 60 * 1000, }) const cues = data?.segments ?? [] // Best-guess location of *this* decision/bill within the transcript, derived // by IDF-weighted keyword matching against the page's target text. const match = useMemo(() => { if (!targetText || cues.length === 0) return null return findBestMatch(cues, extractKeywords(targetText)) }, [cues, targetText]) const highlightSet = useMemo( () => new Set(match?.windowIndices ?? []), [match], ) // Index of the cue currently under the playhead: the last cue whose start is // <= the current time. Recomputed only when time or cues change. const activeIndex = useMemo(() => { if (cues.length === 0) return -1 let lo = 0 let hi = cues.length - 1 let found = -1 while (lo <= hi) { const mid = (lo + hi) >> 1 if (cues[mid].start <= playedSeconds + 0.25) { found = mid lo = mid + 1 } else { hi = mid - 1 } } return found }, [cues, playedSeconds]) // Filtered view of the transcript (case-insensitive substring on cue text). const visibleCues = useMemo(() => { if (!query.trim()) return cues.map((cue, idx) => ({ cue, idx })) const q = query.trim().toLowerCase() return cues .map((cue, idx) => ({ cue, idx })) .filter(({ cue }) => cue.text.toLowerCase().includes(q)) }, [cues, query]) // Keep the active cue scrolled into view while playing (but not while the user // is filtering, since the active cue may be hidden). useEffect(() => { if (!query.trim() && playing && activeCueRef.current) { activeCueRef.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) } }, [activeIndex, playing, query]) const seekTo = (seconds: number) => { playerRef.current?.seekTo(seconds, 'seconds') setPlaying(true) } return (

Meeting recording

{/* Player */}
setPlaying(true)} onPause={() => setPlaying(false)} progressInterval={500} onProgress={({ playedSeconds }) => setPlayedSeconds(playedSeconds)} config={{ playerVars: { modestbranding: 1, rel: 0 } }} />
{caption &&

{caption}

} {/* Auto-located moment for this specific decision/bill */} {match && ( )} {/* Transcript */} {isLoading ? (
Loading transcript…
) : cues.length > 0 ? (

Transcript {cues.length.toLocaleString()} cues · click to jump

setQuery(e.target.value)} placeholder="Search the transcript…" className="w-full rounded-md border border-gray-200 py-2 pl-9 pr-3 text-sm focus:border-blue-400 focus:outline-none focus:ring-1 focus:ring-blue-400" />
{visibleCues.length === 0 ? (
No cues match “{query}”.
) : ( visibleCues.map(({ cue, idx }) => { const isActive = idx === activeIndex const isMatch = highlightSet.has(idx) return ( ) }) )}
) : ( Open on YouTube → )}
) }