* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
111 lines
3.3 KiB
TypeScript
111 lines
3.3 KiB
TypeScript
import { useMemo, useState } 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;
|
|
activeSentence: Sentence | null;
|
|
activeWordIndex: number | null;
|
|
/** Center captions vertically (e.g. for audio-only playback) */
|
|
centered?: boolean;
|
|
}
|
|
|
|
export function TranscriptOverlay({
|
|
transcript,
|
|
activeSentence,
|
|
activeWordIndex,
|
|
centered = false,
|
|
}: TranscriptOverlayProps) {
|
|
const sentenceWords = useMemo(() => {
|
|
if (!activeSentence) return [];
|
|
return transcript.words.filter(
|
|
(w) => w.start >= activeSentence.start && w.end <= activeSentence.end,
|
|
);
|
|
}, [transcript.words, activeSentence]);
|
|
|
|
const chunks = useMemo(() => chunkWords(sentenceWords), [sentenceWords]);
|
|
|
|
const activeWord =
|
|
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
|
|
|
// Remember the last spoken word so highlights hold during pauses.
|
|
const [lastSpokenWord, setLastSpokenWord] = useState<Word | null>(null);
|
|
if (activeWord && activeWord !== lastSpokenWord) {
|
|
setLastSpokenWord(activeWord);
|
|
}
|
|
const highlightWord = activeWord ?? lastSpokenWord;
|
|
|
|
// The chunk currently being spoken (null during a pause or if not found).
|
|
const spokenChunk = useMemo(() => {
|
|
if (!activeWord) return null;
|
|
return (
|
|
chunks.find((chunk) =>
|
|
chunk.some(
|
|
(w) => w.start === activeWord.start && w.end === activeWord.end,
|
|
),
|
|
) ?? null
|
|
);
|
|
}, [chunks, activeWord]);
|
|
|
|
// Resolve which chunk to display: the spoken one, else hold the last one while
|
|
// it's still part of the current sentence, else fall back to the first chunk.
|
|
const [lastChunk, setLastChunk] = useState<Word[] | null>(null);
|
|
let activeChunk: Word[] | null;
|
|
if (spokenChunk) {
|
|
activeChunk = spokenChunk;
|
|
} else if (lastChunk && chunks.includes(lastChunk)) {
|
|
activeChunk = lastChunk;
|
|
} else {
|
|
activeChunk = chunks[0] ?? null;
|
|
}
|
|
if (activeChunk !== lastChunk) {
|
|
setLastChunk(activeChunk);
|
|
}
|
|
|
|
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
className={
|
|
centered
|
|
? 'absolute inset-0 flex items-center justify-center px-6'
|
|
: 'absolute bottom-15 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 =
|
|
highlightWord !== null && word.start <= highlightWord.end;
|
|
|
|
return (
|
|
<span
|
|
key={`${word.start}-${i}`}
|
|
className={
|
|
isSpoken
|
|
? 'text-white font-medium transition-colors duration-150'
|
|
: 'text-white/40 transition-colors duration-150'
|
|
}
|
|
>
|
|
{i > 0 ? ' ' : ''}
|
|
{word.word}
|
|
</span>
|
|
);
|
|
})}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|