feat: improved layout and captions

This commit is contained in:
talksik
2026-03-25 15:05:38 -07:00
parent 986a389606
commit 3c1d9cd4a7
6 changed files with 125 additions and 75 deletions
@@ -1,35 +1,66 @@
import { useMemo } from "react";
import type { Transcript } from "@/api/types";
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
type Word = Transcript["words"][number];
const CHUNK_SIZE = 9;
/** Split an array of words into fixed-size display chunks */
function chunkWords(words: Word[]): Word[][] {
const chunks: Word[][] = [];
for (let i = 0; i < words.length; i += CHUNK_SIZE) {
chunks.push(words.slice(i, i + CHUNK_SIZE));
}
return chunks;
}
interface TranscriptOverlayProps {
transcript: Transcript;
activeParagraph: Transcript["paragraphs"][number] | null;
activeSentence: Sentence | null;
activeWordIndex: number | null;
/** Center captions vertically (e.g. for audio-only playback) */
centered?: boolean;
}
export function TranscriptOverlay({
transcript,
activeParagraph,
activeSentence,
activeWordIndex,
centered = false,
}: TranscriptOverlayProps) {
// Find the words that belong to the active paragraph by time range
const paragraphWords = useMemo(() => {
if (!activeParagraph) return [];
const sentenceWords = useMemo(() => {
if (!activeSentence) return [];
return transcript.words.filter(
(w) => w.start >= activeParagraph.start && w.end <= activeParagraph.end,
(w) => w.start >= activeSentence.start && w.end <= activeSentence.end,
);
}, [transcript.words, activeParagraph]);
}, [transcript.words, activeSentence]);
if (!activeParagraph || paragraphWords.length === 0) return null;
const chunks = useMemo(() => chunkWords(sentenceWords), [sentenceWords]);
// The active word from the flat array — find it by index to compare
const activeWord =
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
// Find which chunk contains the active word
const activeChunk = useMemo(() => {
if (!activeWord) return chunks[0] ?? null;
for (const chunk of chunks) {
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
return chunk;
}
}
return chunks[0] ?? null;
}, [chunks, activeWord]);
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
return (
<div className="absolute bottom-10 left-0 right-0 flex justify-center px-6 py-4 max-w-md">
<p className="rounded-lg bg-black/20 px-5 py-3 text-base leading-relaxed backdrop-blur-sm text-lg text-left">
{paragraphWords.map((word, i) => {
<div className={centered
? "absolute inset-0 flex items-center justify-center px-6"
: "absolute bottom-10 left-0 right-0 flex justify-center px-6"
}>
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
{activeChunk.map((word, i) => {
const isSpoken =
activeWord !== null && word.start <= activeWord.end;