Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 154 additions & 39 deletions components/hearing/HearingDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,8 +15,10 @@ import { HearingSidebar } from "./HearingSidebar"
import {
HearingData,
Paragraph,
TranscriptData,
convertToString,
fetchTranscriptionData
fetchTranscriptionData,
toVTT
} from "./hearing"
import { Transcriptions } from "./Transcriptions"

Expand All @@ -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,
Expand All @@ -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<number | null>(null)
const routerReady = useRef(false)
const [activeVideo, setActiveVideo] = useState<number>(0)
const [transcripts, setTranscripts] = useState<
(TranscriptData | null)[] | null
>(null)

const [transcriptData, setTranscriptData] = useState<Paragraph[] | null>(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<HTMLVideoElement>(null)
function setCurTimeVideo(value: number) {
Expand All @@ -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 (
<Container className="mt-3 mb-3">
<Row className={`mb-3`}>
Expand All @@ -94,7 +193,7 @@ export const HearingDetails = ({
</Col>
</Row>

{transcriptData ? (
{videos.length ? (
<ButtonContainer className={`mb-2`}>
{/* ButtonContainer contrains clickable area of link so that it doesn't exceed
the button and strech invisibly across the width of the page */}
Expand Down Expand Up @@ -128,7 +227,7 @@ export const HearingDetails = ({

<Row>
<Col className={`col-md-8 mt-4`}>
{transcriptData ? (
{transcripts !== null && transcripts.length > 0 ? (
<LegalContainer className={`pb-2 rounded`}>
<Row
className={`d-flex align-items-center justify-content-between`}
Expand Down Expand Up @@ -164,47 +263,63 @@ export const HearingDetails = ({
<></>
)}

{videoURL ? (
<VideoParent className={`mt-3`}>
<VideoChild
ref={videoRef}
src={videoURL}
onLoadedData={handleVideoLoad}
controls
muted
/>
</VideoParent>
{videos.length > 1 ? (
<ButtonGroup aria-label="Video buttons" className={`mt-3`}>
{videos.map((video, index) => (
<VideoButton
key={index}
variant="link"
$active={activeVideo === index}
onClick={() => setActiveVideo(index)}
>
{video.title}
</VideoButton>
))}
</ButtonGroup>
) : (
<div className={`mt-3`}></div>
)}

{videos.length > 0 ? (
<>
<VideoParent>
<VideoChild
ref={videoRef}
src={videos[activeVideo].url}
controls
muted
/>
</VideoParent>
</>
) : (
<LegalContainer className={`fs-6 fw-bold my-3 py-2 rounded`}>
{transcriptData
? t("no_video_on_file", { ns: "hearing" })
: t("no_video_or_transcript", { ns: "hearing" })}
{t("no_video_or_transcript", { ns: "hearing" })}
</LegalContainer>
)}

{transcriptData ? (
{transcripts && transcripts.length > 0 ? (
<Transcriptions
activeVideo={activeVideo}
hearingId={hearingId}
transcriptData={transcriptData}
transcripts={transcripts}
setCurTimeVideo={setCurTimeVideo}
videoLoaded={videoLoaded}
videoRef={videoRef}
/>
) : videoURL ? (
) : videos.length > 0 ? (
<LegalContainer className={`fs-6 fw-bold mb-2 py-2 rounded-bottom`}>
<div>{t("no_transcript_on_file", { ns: "hearing" })}</div>
<div>{t("transcript_loading", { ns: "hearing" })}</div>
</LegalContainer>
) : null}
</Col>

<div className={`col-md-4`}>
<HearingSidebar
activeVideo={activeVideo}
billsInAgenda={billsInAgenda}
committeeCode={committeeCode}
generalCourtNumber={generalCourtNumber}
hearingDate={hearingDate}
hearingId={hearingId}
transcriptData={transcriptData}
transcripts={transcripts}
/>
</div>
</Row>
Expand Down
50 changes: 18 additions & 32 deletions components/hearing/HearingSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -245,14 +224,21 @@ export const HearingSidebar = ({
) : (
<></>
)}
{downloadURL !== "" ? (
{downloadURL !== "" &&
transcripts !== null &&
transcripts[activeVideo] !== null ? (
<div>
<a
href={downloadURL}
download={downloadName}
className="text-blue-600 underline"
>
{t("download_transcript", { ns: "hearing" })}
{transcripts.length == 1
? t("download_transcript", { ns: "hearing" })
: t("download_transcript_x", {
ns: "hearing",
title: transcripts[activeVideo]!.title
})}
</a>
</div>
) : (
Expand Down
Loading
Loading