feat: improved layout and captions
This commit is contained in:
@@ -2,17 +2,36 @@ import { Video, Mic } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
import { useMediaSettingsStore } from "@/stores/media-settings-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { PropsWithChildren } from "react";
|
|
||||||
|
|
||||||
interface ControlsIndicatorProps extends PropsWithChildren {
|
interface ControlsIndicatorProps {
|
||||||
type: "reply" | "new";
|
type: "reply" | "new";
|
||||||
}
|
}
|
||||||
export default function ControlsIndicator({ type, children }: ControlsIndicatorProps) {
|
export default function ControlsIndicator({ type }: ControlsIndicatorProps) {
|
||||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-xs rounded-full pl-1 pr-3 py-1 bg-black/30 backdrop-blur-sm m-2">
|
<div className="flex items-center gap-2 text-xs rounded-full pl-2 pr-1 py-1 bg-black/30 backdrop-blur-sm">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Hold{" "}
|
||||||
|
<kbd
|
||||||
|
className={cn(
|
||||||
|
"bg-muted rounded px-1.5 py-0.5 font-mono",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
`
|
||||||
|
</kbd>{" "}
|
||||||
|
to {type === "reply" ? "reply " : "start "}
|
||||||
|
· Press{" "}
|
||||||
|
<kbd
|
||||||
|
className={cn(
|
||||||
|
"bg-muted rounded px-1.5 py-0.5 font-mono",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
T
|
||||||
|
</kbd>{" "}
|
||||||
|
for text
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setRecordingMode(recordingMode === "video" ? "audio" : "video")
|
setRecordingMode(recordingMode === "video" ? "audio" : "video")
|
||||||
@@ -40,29 +59,6 @@ export default function ControlsIndicator({ type, children }: ControlsIndicatorP
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{children ? <div className="flex-1">{children}</div> :
|
|
||||||
<div className="flex-1" />
|
|
||||||
}
|
|
||||||
<div className="text-muted-foreground">
|
|
||||||
Hold{" "}
|
|
||||||
<kbd
|
|
||||||
className={cn(
|
|
||||||
"bg-muted rounded px-1.5 py-0.5 font-mono",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
`
|
|
||||||
</kbd>{" "}
|
|
||||||
to {type === "reply" ? "reply " : "start "}
|
|
||||||
· Press{" "}
|
|
||||||
<kbd
|
|
||||||
className={cn(
|
|
||||||
"bg-muted rounded px-1.5 py-0.5 font-mono",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
T
|
|
||||||
</kbd>{" "}
|
|
||||||
for text
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export default function NetworkRoot() {
|
|||||||
<ParticleListView path={path} />
|
<ParticleListView path={path} />
|
||||||
<AutoplayOverlay networkId={networkId!} />
|
<AutoplayOverlay networkId={networkId!} />
|
||||||
<ComposeOverlay networkId={networkId!} />
|
<ComposeOverlay networkId={networkId!} />
|
||||||
<ControlsIndicator type={"new"} />
|
<div className="flex justify-end">
|
||||||
|
<ControlsIndicator type={"new"} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export function MediaParticleView({
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
const transcript = particle.properties.transcript;
|
const transcript = particle.properties.transcript;
|
||||||
const { activeParagraph, activeWordIndex } = useTranscriptPlayback(
|
const { activeSentence, activeWordIndex } = useTranscriptPlayback(
|
||||||
transcript,
|
transcript,
|
||||||
currentTime,
|
currentTime,
|
||||||
);
|
);
|
||||||
@@ -70,14 +70,6 @@ export function MediaParticleView({
|
|||||||
if (duration > 0) onProgress?.(time / duration);
|
if (duration > 0) onProgress?.(time / duration);
|
||||||
};
|
};
|
||||||
|
|
||||||
const captionOverlay = transcript ? (
|
|
||||||
<TranscriptOverlay
|
|
||||||
transcript={transcript}
|
|
||||||
activeParagraph={activeParagraph}
|
|
||||||
activeWordIndex={activeWordIndex}
|
|
||||||
/>
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
<div className="relative flex h-full w-full items-center justify-center bg-black/90">
|
||||||
@@ -94,12 +86,19 @@ export function MediaParticleView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{audioSource && (
|
{audioSource && (
|
||||||
<div className="z-10 absolute bottom-15">
|
<div className="z-10 absolute bottom-5">
|
||||||
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
<AudioLevelBars sourceNode={audioSource.sourceNode} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{captionOverlay}
|
{transcript && (
|
||||||
|
<TranscriptOverlay
|
||||||
|
transcript={transcript}
|
||||||
|
activeSentence={activeSentence}
|
||||||
|
activeWordIndex={activeWordIndex}
|
||||||
|
centered
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,7 +115,13 @@ export function MediaParticleView({
|
|||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{captionOverlay}
|
{transcript && (
|
||||||
|
<TranscriptOverlay
|
||||||
|
transcript={transcript}
|
||||||
|
activeSentence={activeSentence}
|
||||||
|
activeWordIndex={activeWordIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,6 +240,12 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen flex-col bg-black text-white">
|
<div className="relative flex h-screen flex-col bg-black text-white">
|
||||||
|
{/* Top gradient safe zone — keeps progress bar & breadcrumbs readable */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
|
||||||
|
|
||||||
|
{/* Bottom gradient safe zone — keeps captions & controls readable */}
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-48 bg-gradient-to-t from-black/60 to-transparent" />
|
||||||
|
|
||||||
{/* Progress indicator */}
|
{/* Progress indicator */}
|
||||||
<div className="z-10 absolute left-0 right-0">
|
<div className="z-10 absolute left-0 right-0">
|
||||||
<PlaybackPageIndicator
|
<PlaybackPageIndicator
|
||||||
@@ -273,21 +279,22 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|||||||
onParticleCreated={onLocalParticleCreated}
|
onParticleCreated={onLocalParticleCreated}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bottom overlay: stream info + reply */}
|
{/* Bottom bar: metadata left, controls right */}
|
||||||
<div className="absolute right-0 bottom-0 z-10">
|
<div className="absolute inset-x-0 bottom-0 z-10 flex items-end justify-between px-2 pb-2">
|
||||||
<ControlsIndicator type={"reply"}>
|
{/* Left: seen indicator + exit countdown */}
|
||||||
<div className="flex items-center gap-1 text-xs text-white/70">
|
<div className="flex items-center gap-2 text-xs text-white/70">
|
||||||
{currentParticle && (
|
{currentParticle && (
|
||||||
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
|
<SeenIndicator stream={streamParticle} currentParticle={currentParticle} networkId={networkId} />
|
||||||
)}
|
)}
|
||||||
{/* Exit countdown */}
|
{exitRemainingMs !== null && (
|
||||||
{exitRemainingMs !== null && (
|
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
|
||||||
<span>
|
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
||||||
Closing in {Math.ceil(exitRemainingMs / 1000)}s
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
</ControlsIndicator>
|
{/* Right: recording controls */}
|
||||||
|
<ControlsIndicator type="reply" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,35 +1,66 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { Transcript } from "@/api/types";
|
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 {
|
interface TranscriptOverlayProps {
|
||||||
transcript: Transcript;
|
transcript: Transcript;
|
||||||
activeParagraph: Transcript["paragraphs"][number] | null;
|
activeSentence: Sentence | null;
|
||||||
activeWordIndex: number | null;
|
activeWordIndex: number | null;
|
||||||
|
/** Center captions vertically (e.g. for audio-only playback) */
|
||||||
|
centered?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TranscriptOverlay({
|
export function TranscriptOverlay({
|
||||||
transcript,
|
transcript,
|
||||||
activeParagraph,
|
activeSentence,
|
||||||
activeWordIndex,
|
activeWordIndex,
|
||||||
|
centered = false,
|
||||||
}: TranscriptOverlayProps) {
|
}: TranscriptOverlayProps) {
|
||||||
// Find the words that belong to the active paragraph by time range
|
const sentenceWords = useMemo(() => {
|
||||||
const paragraphWords = useMemo(() => {
|
if (!activeSentence) return [];
|
||||||
if (!activeParagraph) return [];
|
|
||||||
return transcript.words.filter(
|
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 =
|
const activeWord =
|
||||||
activeWordIndex !== null ? transcript.words[activeWordIndex] : null;
|
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 (
|
return (
|
||||||
<div className="absolute bottom-10 left-0 right-0 flex justify-center px-6 py-4 max-w-md">
|
<div className={centered
|
||||||
<p className="rounded-lg bg-black/20 px-5 py-3 text-base leading-relaxed backdrop-blur-sm text-lg text-left">
|
? "absolute inset-0 flex items-center justify-center px-6"
|
||||||
{paragraphWords.map((word, i) => {
|
: "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 =
|
const isSpoken =
|
||||||
activeWord !== null && word.start <= activeWord.end;
|
activeWord !== null && word.start <= activeWord.end;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { Transcript } from "@/api/types";
|
import type { Transcript } from "@/api/types";
|
||||||
|
|
||||||
|
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||||
|
|
||||||
interface TranscriptPlaybackState {
|
interface TranscriptPlaybackState {
|
||||||
/** The paragraph currently being spoken, or null if before/after speech */
|
/** The sentence currently being spoken, or null if between sentences */
|
||||||
activeParagraph: Transcript["paragraphs"][number] | null;
|
activeSentence: Sentence | null;
|
||||||
/** Index of the active word within the transcript's flat words array */
|
/** Index of the active word within the transcript's flat words array */
|
||||||
activeWordIndex: number | null;
|
activeWordIndex: number | null;
|
||||||
}
|
}
|
||||||
@@ -13,12 +15,19 @@ export function useTranscriptPlayback(
|
|||||||
currentTime: number,
|
currentTime: number,
|
||||||
): TranscriptPlaybackState {
|
): TranscriptPlaybackState {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!transcript) return { activeParagraph: null, activeWordIndex: null };
|
if (!transcript) return { activeSentence: null, activeWordIndex: null };
|
||||||
|
|
||||||
const activeParagraph =
|
// Find the active sentence across all paragraphs
|
||||||
transcript.paragraphs.find(
|
let activeSentence: Sentence | null = null;
|
||||||
(p) => currentTime >= p.start && currentTime <= p.end,
|
for (const paragraph of transcript.paragraphs) {
|
||||||
) ?? null;
|
const sentence = paragraph.sentences.find(
|
||||||
|
(s) => currentTime >= s.start && currentTime <= s.end,
|
||||||
|
);
|
||||||
|
if (sentence) {
|
||||||
|
activeSentence = sentence;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Binary search for active word
|
// Binary search for active word
|
||||||
const words = transcript.words;
|
const words = transcript.words;
|
||||||
@@ -35,6 +44,6 @@ export function useTranscriptPlayback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { activeParagraph, activeWordIndex };
|
return { activeSentence, activeWordIndex };
|
||||||
}, [transcript, currentTime]);
|
}, [transcript, currentTime]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user