diff --git a/components/hearing/HearingDetails.tsx b/components/hearing/HearingDetails.tsx index e6e482d34..1e7c4b1d7 100644 --- a/components/hearing/HearingDetails.tsx +++ b/components/hearing/HearingDetails.tsx @@ -2,7 +2,8 @@ import { useRouter } from "next/router" import { Trans, useTranslation } from "next-i18next" import { useEffect, useRef, useState } from "react" import styled from "styled-components" -import { Col, Container, Image, Row } from "../bootstrap" +import { ButtonGroup } from "react-bootstrap" +import { Col, Container, Image, Row, Button } from "../bootstrap" import * as links from "../links" import { committeeURL, External } from "../links" import { @@ -14,8 +15,10 @@ import { HearingSidebar } from "./HearingSidebar" import { HearingData, Paragraph, + TranscriptData, convertToString, - fetchTranscriptionData + fetchTranscriptionData, + toVTT } from "./hearing" import { Transcriptions } from "./Transcriptions" @@ -39,6 +42,34 @@ const VideoParent = styled.div` overflow: hidden; ` +const VideoButton = styled(Button)` + border: none; + background: transparent; + color: ${({ $active }) => ($active ? "#212529" : "#6c757d")}; + font-weight: ${({ $active }) => ($active ? "600" : "500")}; + padding: 0.75rem 1rem; + border-radius: 0; + position: relative; + transition: all 0.25s ease-in-out; + + &:hover { + color: #212529; + background-color: rgba(0, 0, 0, 0.03); + } + + &::after { + content: ""; + position: absolute; + bottom: 0; + left: 50%; + width: ${({ $active }) => ($active ? "100%" : "0%")}; + height: 2px; + background-color: #212529; + transition: all 0.3s ease-in-out; + transform: translateX(-50%); + } +` + export const HearingDetails = ({ hearingData: { billsInAgenda, @@ -48,21 +79,97 @@ export const HearingDetails = ({ generalCourtNumber, hearingDate, hearingId, - videoTranscriptionId, - videoURL + videos } }: { hearingData: HearingData }) => { const { t } = useTranslation(["common", "hearing"]) const router = useRouter() + const previousActive = useRef(null) + const routerReady = useRef(false) + const [activeVideo, setActiveVideo] = useState(0) + const [transcripts, setTranscripts] = useState< + (TranscriptData | null)[] | null + >(null) - const [transcriptData, setTranscriptData] = useState(null) - const [videoLoaded, setVideoLoaded] = useState(false) + // Important this occurs before router check; otherwise time will be improperly removed on first render + useEffect(() => { + if ( + previousActive.current === null || + previousActive.current === activeVideo + ) + return + previousActive.current = activeVideo + if (activeVideo !== 0) { + router.replace( + { + pathname: router.pathname, + query: { + hearingId: hearingId, + v: activeVideo + 1 + } + }, + undefined, + { shallow: true } + ) + } else { + router.replace( + { + pathname: router.pathname, + query: { + hearingId: hearingId + } + }, + undefined, + { shallow: true } + ) + } + }, [activeVideo]) - const handleVideoLoad = () => { - setVideoLoaded(true) - } + // Runs once + useEffect(() => { + if (!router.isReady || routerReady.current) return + routerReady.current = true + + const query = router.query.v + if (typeof query !== "string") { + previousActive.current = activeVideo + return + } + const n = parseInt(query, 10) + if (!isNaN(n) && n >= 1 && n <= videos.length) { + setActiveVideo(n - 1) + previousActive.current = n - 1 + } + }, [router.isReady]) + + useEffect(() => { + ;(async function () { + const transcripts = await Promise.all( + videos.map(v => + v.transcriptionId ? fetchTranscriptionData(v.transcriptionId) : null + ) + ) + const result = transcripts.map((t, index) => { + if (!t) return null + const filename = + transcripts.length == 1 + ? `hearing-${hearingId}` + : `hearing-${hearingId}-${index + 1}` + const vtt = toVTT(t) + const blob = new Blob([vtt], { type: "text/vtt" }) + + return { + title: videos[index].title, + transcript: t, + blob: blob, + filename: filename + } + }) + setTranscripts(result) + })() + }, [videos]) const videoRef = useRef(null) function setCurTimeVideo(value: number) { @@ -78,14 +185,6 @@ export const HearingDetails = ({ } }, [router.query.t, videoRef.current]) - useEffect(() => { - ;(async function () { - if (!videoTranscriptionId || transcriptData !== null) return - const docList = await fetchTranscriptionData(videoTranscriptionId) - setTranscriptData(docList) - })() - }, [videoTranscriptionId]) - return ( @@ -94,7 +193,7 @@ export const HearingDetails = ({ - {transcriptData ? ( + {videos.length ? ( {/* ButtonContainer contrains clickable area of link so that it doesn't exceed the button and strech invisibly across the width of the page */} @@ -128,7 +227,7 @@ export const HearingDetails = ({ - {transcriptData ? ( + {transcripts !== null && transcripts.length > 0 ? ( )} - {videoURL ? ( - - - + {videos.length > 1 ? ( + + {videos.map((video, index) => ( + setActiveVideo(index)} + > + {video.title} + + ))} + + ) : ( +
+ )} + + {videos.length > 0 ? ( + <> + + + + ) : ( - {transcriptData - ? t("no_video_on_file", { ns: "hearing" }) - : t("no_video_or_transcript", { ns: "hearing" })} + {t("no_video_or_transcript", { ns: "hearing" })} )} - {transcriptData ? ( + {transcripts && transcripts.length > 0 ? ( - ) : videoURL ? ( + ) : videos.length > 0 ? ( -
{t("no_transcript_on_file", { ns: "hearing" })}
+
{t("transcript_loading", { ns: "hearing" })}
) : null}
diff --git a/components/hearing/HearingSidebar.tsx b/components/hearing/HearingSidebar.tsx index a95b8a5a1..e57ffded3 100644 --- a/components/hearing/HearingSidebar.tsx +++ b/components/hearing/HearingSidebar.tsx @@ -9,7 +9,7 @@ import { firestore } from "../firebase" import * as links from "../links" import { billSiteURL, Internal } from "../links" import { LabeledIcon } from "../shared" -import { Paragraph, formatVTTTimestamp } from "./hearing" +import { Paragraph, TranscriptData, formatVTTTimestamp } from "./hearing" type Bill = { BillNumber: string @@ -114,19 +114,19 @@ const SidebarSubbody = styled.div` ` export const HearingSidebar = ({ + activeVideo, billsInAgenda, committeeCode, generalCourtNumber, hearingDate, - hearingId, - transcriptData + transcripts }: { + activeVideo: number billsInAgenda: any[] | null committeeCode: string | null generalCourtNumber: string | null hearingDate: string | null - hearingId: string - transcriptData: Paragraph[] | null + transcripts: (TranscriptData | null)[] | null }) => { const { t } = useTranslation(["common", "hearing"]) @@ -186,35 +186,14 @@ export const HearingSidebar = ({ }, [committeeCode, generalCourtNumber]) useEffect(() => { - setDownloadName(`hearing-${hearingId}.vtt`) - }, [hearingId]) - - useEffect(() => { - if (!transcriptData) return - const vttLines = ["WEBVTT", ""] - - transcriptData.forEach((paragraph, index) => { - const cueNumber = index + 1 - const startTime = formatVTTTimestamp(paragraph.start) - const endTime = formatVTTTimestamp(paragraph.end) - - vttLines.push( - String(cueNumber), - `${startTime} --> ${endTime}`, - paragraph.text, - "" - ) - }) - - const vtt = vttLines.join("\n") - const blob = new Blob([vtt], { type: "text/vtt" }) - const url = URL.createObjectURL(blob) + if (!transcripts || !transcripts[activeVideo]) return + setDownloadName(transcripts[activeVideo]!.filename) + const url = URL.createObjectURL(transcripts[activeVideo]!.blob) setDownloadURL(url) - return () => { URL.revokeObjectURL(url) } - }, [transcriptData]) + }, [activeVideo, transcripts]) useEffect(() => { committeeCode && generalCourtNumber ? committeeData() : null @@ -245,14 +224,21 @@ export const HearingSidebar = ({ ) : ( <> )} - {downloadURL !== "" ? ( + {downloadURL !== "" && + transcripts !== null && + transcripts[activeVideo] !== null ? ( ) : ( diff --git a/components/hearing/Transcriptions.tsx b/components/hearing/Transcriptions.tsx index 27d0280d3..a297ada8d 100644 --- a/components/hearing/Transcriptions.tsx +++ b/components/hearing/Transcriptions.tsx @@ -10,6 +10,7 @@ import styled from "styled-components" import { Col, Container, Row } from "../bootstrap" import { Paragraph, + TranscriptData, convertToString, formatMilliseconds, formatTotalSeconds @@ -28,6 +29,10 @@ const ClearButton = styled(FontAwesomeIcon)` cursor: pointer; ` +const LegalContainer = styled(Container)` + background-color: white; +` + const ResultNumText = styled.div` position: absolute; right: 4rem; @@ -135,16 +140,16 @@ const TranscriptRow = styled(Row)` const TranscriptRowActive = styled(Row)`` export const Transcriptions = ({ + activeVideo, hearingId, - transcriptData, + transcripts, setCurTimeVideo, - videoLoaded, videoRef }: { + activeVideo: number hearingId: string - transcriptData: Paragraph[] + transcripts: (TranscriptData | null)[] setCurTimeVideo: any - videoLoaded: boolean videoRef: any }) => { const { t } = useTranslation(["common", "hearing"]) @@ -188,32 +193,36 @@ export const Transcriptions = ({ } useEffect(() => { + if (!transcripts[activeVideo]) return + setHighlightedId(-1) + containerRef.current.scrollTop = 0 + setSearchTerm("") + }, [activeVideo]) + + useEffect(() => { + if (!transcripts[activeVideo]) return setFilteredData( - transcriptData.filter(el => + transcripts[activeVideo]!.transcript.filter(el => el.text.toLowerCase().includes(searchTerm.toLowerCase()) ) ) - }, [transcriptData, searchTerm]) + }, [activeVideo, searchTerm]) const router = useRouter() const startTime = router.query.t - const resultString: string = convertToString(startTime) - - let currentIndex = transcriptData.findIndex( - element => parseInt(resultString, 10) <= element.end / 1000 - ) // Set the initial scroll target when we have a startTime and transcripts useEffect(() => { - if ( - startTime && - transcriptData.length > 0 && - currentIndex !== -1 && - !hasScrolledToInitial.current - ) { + const resultString: string = convertToString(startTime) + const currentIndex = transcripts[activeVideo] + ? transcripts[activeVideo]!.transcript.findIndex( + element => parseInt(resultString, 10) <= element.end / 1000 + ) + : -1 + if (startTime && currentIndex !== -1 && !hasScrolledToInitial.current) { setInitialScrollTarget(currentIndex) } - }, [startTime, transcriptData, currentIndex]) + }, [startTime, transcripts]) // Scroll to the initial target when the ref becomes available useEffect(() => { @@ -230,12 +239,13 @@ export const Transcriptions = ({ }, [initialScrollTarget, transcriptRefs.current.size, searchTerm]) useEffect(() => { + if (!transcripts[activeVideo]) return const handleTimeUpdate = () => { - videoLoaded - ? (currentIndex = transcriptData.findIndex( - element => videoRef.current.currentTime <= element.end / 1000 - )) - : null + if (videoRef.current.readyState < HTMLMediaElement.HAVE_CURRENT_DATA) + return + const currentIndex = filteredData.findIndex( + paragraph => videoRef.current.currentTime <= paragraph.end / 1000 + ) if (containerRef.current && currentIndex !== highlightedId) { setHighlightedId(currentIndex) if (currentIndex !== -1 && !searchTerm) { @@ -245,18 +255,16 @@ export const Transcriptions = ({ } const videoElement = videoRef.current - videoLoaded - ? videoElement.addEventListener("timeupdate", handleTimeUpdate) - : null + if (!videoElement) return + + videoElement.addEventListener("timeupdate", handleTimeUpdate) return () => { - videoLoaded - ? videoElement.removeEventListener("timeupdate", handleTimeUpdate) - : null + videoElement.removeEventListener("timeupdate", handleTimeUpdate) } - }, [highlightedId, transcriptData, videoLoaded, videoRef, searchTerm]) + }, [highlightedId, activeVideo, filteredData, videoRef]) - return ( + return transcripts[activeVideo] ? ( <> ( + ) : ( + +
{t("no_transcript_on_file", { ns: "hearing" })}
+
) } // forwardRef must be updated for React 19 migration const TranscriptItem = forwardRef(function TranscriptItem( { + activeVideo, element, hearingId, highlightedId, @@ -325,6 +339,7 @@ const TranscriptItem = forwardRef(function TranscriptItem( setCurTimeVideo, searchTerm }: { + activeVideo: number element: Paragraph hearingId: string highlightedId: number @@ -396,7 +411,9 @@ const TranscriptItem = forwardRef(function TranscriptItem( { @@ -54,6 +66,18 @@ export async function fetchHearingData( ? DateTime.fromISO(maybeDate, { zone: "America/New_York" }).toISO() : null + const videos = docData.videos + ? docData.videos + : docData.videoURL + ? [ + { + title: `Hearing ${hearingId}`, + url: docData.videoURL, + transcriptionId: docData.videoTranscriptionId ?? null + } + ] + : [] + return { billsInAgenda: docData.content?.HearingAgendas[0]?.DocumentsInAgenda ?? null, @@ -64,8 +88,7 @@ export async function fetchHearingData( docData.content?.HearingHost?.GeneralCourtNumber ?? null, hearingDate: hearingDate, hearingId: hearingId, - videoTranscriptionId: docData.videoTranscriptionId ?? null, - videoURL: docData.videoURL ?? null + videos: videos } } @@ -128,3 +151,22 @@ export function formatVTTTimestamp(ms: number): string { return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}` } + +export function toVTT(transcriptData: Paragraph[]): string { + const vttLines = ["WEBVTT", ""] + + transcriptData.forEach((paragraph, index) => { + const cueNumber = index + 1 + const startTime = formatVTTTimestamp(paragraph.start) + const endTime = formatVTTTimestamp(paragraph.end) + + vttLines.push( + String(cueNumber), + `${startTime} --> ${endTime}`, + paragraph.text, + "" + ) + }) + + return vttLines.join("\n") +} diff --git a/public/locales/en/hearing.json b/public/locales/en/hearing.json index b16347b7f..bfd82a69c 100644 --- a/public/locales/en/hearing.json +++ b/public/locales/en/hearing.json @@ -4,6 +4,7 @@ "chairs": "Chairs", "committee_members": "Committee members", "download_transcript": "Download transcript", + "download_transcript_x": "Download transcript for {{title}}", "hearing_details": "Hearing details", "house_chair": "House Chair", "member": "Member", @@ -11,7 +12,7 @@ "no": "No", "no_record": "No Record", "no_results_found": "No Search Results for ”{{searchTerm}}”", - "no_transcript_on_file": "This hearing does not yet have a transcription on file", + "no_transcript_on_file": "This video does not yet have a transcription on file", "no_video_on_file": "This hearing does not yet have a video on file", "no_video_or_transcript": "This hearing does not yet have a video or transcript on file", "no_vote": "No Vote Recorded", @@ -26,6 +27,7 @@ "see_all": "See all", "see_less": "See less", "senate_chair": "Senate Chair", + "transcript_loading": "Loading transcript for this hearing...", "video_and_transcription_feature_callout": "Hearing Video + Transcription", "view_bill": "View Bill Details", "view_votes": "View Committee Votes",