refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
import { useMemo, useRef } 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 lastSpokenWordRef = useRef<Word | null>(null);
|
||||
if (activeWord) {
|
||||
lastSpokenWordRef.current = activeWord;
|
||||
}
|
||||
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
||||
|
||||
const lastChunkRef = useRef<Word[] | null>(null);
|
||||
|
||||
// Find which chunk contains the active word, holding the last one during pauses
|
||||
const activeChunk = useMemo(() => {
|
||||
if (activeWord) {
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.some((w) => w.start === activeWord.start && w.end === activeWord.end)) {
|
||||
lastChunkRef.current = chunk;
|
||||
return chunk;
|
||||
}
|
||||
}
|
||||
}
|
||||
// No active word (speaker pausing) — hold the last chunk
|
||||
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
||||
return lastChunkRef.current;
|
||||
}
|
||||
// Sentence changed, last chunk no longer valid — use first chunk
|
||||
const fallback = chunks[0] ?? null;
|
||||
lastChunkRef.current = fallback;
|
||||
return fallback;
|
||||
}, [chunks, activeWord]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user