From 3c1d9cd4a72e08e6bbd6d5bb6eefabaf10a68b4f Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 25 Mar 2026 15:05:38 -0700 Subject: [PATCH] feat: improved layout and captions --- .../features/compose/controls-indicator.tsx | 50 ++++++++--------- js/src/features/network-root.tsx | 4 +- .../particles/media-particle-view.tsx | 29 ++++++---- js/src/features/particles/stream-view.tsx | 37 ++++++++----- .../features/particles/transcript-overlay.tsx | 55 +++++++++++++++---- js/src/hooks/use-transcript-playback.ts | 25 ++++++--- 6 files changed, 125 insertions(+), 75 deletions(-) diff --git a/js/src/features/compose/controls-indicator.tsx b/js/src/features/compose/controls-indicator.tsx index 42da631..f5a7605 100644 --- a/js/src/features/compose/controls-indicator.tsx +++ b/js/src/features/compose/controls-indicator.tsx @@ -2,17 +2,36 @@ import { Video, Mic } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMediaSettingsStore } from "@/stores/media-settings-store"; import { Button } from "@/components/ui/button"; -import { PropsWithChildren } from "react"; -interface ControlsIndicatorProps extends PropsWithChildren { +interface ControlsIndicatorProps { type: "reply" | "new"; } -export default function ControlsIndicator({ type, children }: ControlsIndicatorProps) { +export default function ControlsIndicator({ type }: ControlsIndicatorProps) { const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); return ( -
+
+
+ Hold{" "} + + ` + {" "} + to {type === "reply" ? "reply " : "start "} + · Press{" "} + + T + {" "} + for text +
- {children ?
{children}
: -
- } -
- Hold{" "} - - ` - {" "} - to {type === "reply" ? "reply " : "start "} - · Press{" "} - - T - {" "} - for text -
); } diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 2a261a7..f7af48b 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -18,7 +18,9 @@ export default function NetworkRoot() { - +
+ +
); } diff --git a/js/src/features/particles/media-particle-view.tsx b/js/src/features/particles/media-particle-view.tsx index 1193261..19dfffe 100644 --- a/js/src/features/particles/media-particle-view.tsx +++ b/js/src/features/particles/media-particle-view.tsx @@ -30,7 +30,7 @@ export function MediaParticleView({ const [currentTime, setCurrentTime] = useState(0); const transcript = particle.properties.transcript; - const { activeParagraph, activeWordIndex } = useTranscriptPlayback( + const { activeSentence, activeWordIndex } = useTranscriptPlayback( transcript, currentTime, ); @@ -70,14 +70,6 @@ export function MediaParticleView({ if (duration > 0) onProgress?.(time / duration); }; - const captionOverlay = transcript ? ( - - ) : null; - if (isAudio) { return (
@@ -94,12 +86,19 @@ export function MediaParticleView({ /> {audioSource && ( -
+
)} - {captionOverlay} + {transcript && ( + + )}
); } @@ -116,7 +115,13 @@ export function MediaParticleView({ className="h-full w-full object-cover" /> - {captionOverlay} + {transcript && ( + + )}
); } diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index fa891c1..96908aa 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -240,6 +240,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { return (
+ {/* Top gradient safe zone — keeps progress bar & breadcrumbs readable */} +
+ + {/* Bottom gradient safe zone — keeps captions & controls readable */} +
+ {/* Progress indicator */}
- {/* Bottom overlay: stream info + reply */} -
- -
- {currentParticle && ( - - )} - {/* Exit countdown */} - {exitRemainingMs !== null && ( - - Closing in {Math.ceil(exitRemainingMs / 1000)}s - - )} -
-
+ {/* Bottom bar: metadata left, controls right */} +
+ {/* Left: seen indicator + exit countdown */} +
+ {currentParticle && ( + + )} + {exitRemainingMs !== null && ( + + Closing in {Math.ceil(exitRemainingMs / 1000)}s + + )} +
+ + {/* Right: recording controls */} +
); diff --git a/js/src/features/particles/transcript-overlay.tsx b/js/src/features/particles/transcript-overlay.tsx index e1cefd2..bd52f15 100644 --- a/js/src/features/particles/transcript-overlay.tsx +++ b/js/src/features/particles/transcript-overlay.tsx @@ -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 ( -
-

- {paragraphWords.map((word, i) => { +

+

+ {activeChunk.map((word, i) => { const isSpoken = activeWord !== null && word.start <= activeWord.end; diff --git a/js/src/hooks/use-transcript-playback.ts b/js/src/hooks/use-transcript-playback.ts index 33ae8fd..b047362 100644 --- a/js/src/hooks/use-transcript-playback.ts +++ b/js/src/hooks/use-transcript-playback.ts @@ -1,9 +1,11 @@ import { useMemo } from "react"; import type { Transcript } from "@/api/types"; +type Sentence = Transcript["paragraphs"][number]["sentences"][number]; + interface TranscriptPlaybackState { - /** The paragraph currently being spoken, or null if before/after speech */ - activeParagraph: Transcript["paragraphs"][number] | null; + /** The sentence currently being spoken, or null if between sentences */ + activeSentence: Sentence | null; /** Index of the active word within the transcript's flat words array */ activeWordIndex: number | null; } @@ -13,12 +15,19 @@ export function useTranscriptPlayback( currentTime: number, ): TranscriptPlaybackState { return useMemo(() => { - if (!transcript) return { activeParagraph: null, activeWordIndex: null }; + if (!transcript) return { activeSentence: null, activeWordIndex: null }; - const activeParagraph = - transcript.paragraphs.find( - (p) => currentTime >= p.start && currentTime <= p.end, - ) ?? null; + // Find the active sentence across all paragraphs + let activeSentence: Sentence | null = null; + for (const paragraph of transcript.paragraphs) { + const sentence = paragraph.sentences.find( + (s) => currentTime >= s.start && currentTime <= s.end, + ); + if (sentence) { + activeSentence = sentence; + break; + } + } // Binary search for active word const words = transcript.words; @@ -35,6 +44,6 @@ export function useTranscriptPlayback( } } - return { activeParagraph, activeWordIndex }; + return { activeSentence, activeWordIndex }; }, [transcript, currentTime]); }