@@ -7,6 +7,9 @@ import type { Particle } from "@/api/types";
|
|||||||
import { apiClient } from "@/api/client";
|
import { apiClient } from "@/api/client";
|
||||||
import { logError } from "@/lib/errors";
|
import { logError } from "@/lib/errors";
|
||||||
import { useEvent } from "@/hooks/use-event";
|
import { useEvent } from "@/hooks/use-event";
|
||||||
|
import { useTranscriptPlayback } from "@/hooks/use-transcript-playback";
|
||||||
|
import { TranscriptOverlay } from "./TranscriptOverlay";
|
||||||
|
import { useStreamSafeArea } from "./stream-safe-area";
|
||||||
|
|
||||||
type MediaParticle = Extract<Particle, { type: "media" }>;
|
type MediaParticle = Extract<Particle, { type: "media" }>;
|
||||||
|
|
||||||
@@ -90,6 +93,7 @@ function PlayableMediaView({
|
|||||||
}) {
|
}) {
|
||||||
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
const [sourceUri, setSourceUri] = useState<string | null>(null);
|
||||||
const [resolveError, setResolveError] = useState<Error | null>(null);
|
const [resolveError, setResolveError] = useState<Error | null>(null);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
|
||||||
// Fetch the signed download URL once per active object. Orion URLs are
|
// Fetch the signed download URL once per active object. Orion URLs are
|
||||||
// time-limited — we treat the URL as one-shot for this view's lifetime.
|
// time-limited — we treat the URL as one-shot for this view's lifetime.
|
||||||
@@ -99,6 +103,7 @@ function PlayableMediaView({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSourceUri(null);
|
setSourceUri(null);
|
||||||
setResolveError(null);
|
setResolveError(null);
|
||||||
|
setCurrentTime(0);
|
||||||
apiClient
|
apiClient
|
||||||
.getParticleDownloadUrl(activeObjectId)
|
.getParticleDownloadUrl(activeObjectId)
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
@@ -143,6 +148,24 @@ function PlayableMediaView({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drive caption highlighting from the player's own timeUpdate cadence
|
||||||
|
// (timeUpdateEventInterval = 0.15s above). Pausing halts the events, which
|
||||||
|
// naturally freezes the active word/sentence — no extra plumbing needed.
|
||||||
|
useEventListener(player, "timeUpdate", ({ currentTime: t }) => {
|
||||||
|
setCurrentTime(t);
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcript = particle.properties.transcript;
|
||||||
|
const { activeSentence, activeWordIndex } = useTranscriptPlayback(
|
||||||
|
transcript,
|
||||||
|
currentTime,
|
||||||
|
);
|
||||||
|
const safeArea = useStreamSafeArea();
|
||||||
|
// Sit just above the compose dock. Pills moved to the top of the screen so
|
||||||
|
// the only thing this offset has to clear is the dock itself plus a small
|
||||||
|
// gap — captions can ride lower than they did before.
|
||||||
|
const captionBottomOffset = safeArea.bottom;
|
||||||
|
|
||||||
const onEndedStable = useEvent(onEnded);
|
const onEndedStable = useEvent(onEnded);
|
||||||
const onProgressStable = useEvent(onProgress);
|
const onProgressStable = useEvent(onProgress);
|
||||||
|
|
||||||
@@ -208,6 +231,14 @@ function PlayableMediaView({
|
|||||||
<Text className="text-white mt-6 text-lg font-medium">
|
<Text className="text-white mt-6 text-lg font-medium">
|
||||||
Voice message
|
Voice message
|
||||||
</Text>
|
</Text>
|
||||||
|
{transcript ? (
|
||||||
|
<TranscriptOverlay
|
||||||
|
transcript={transcript}
|
||||||
|
activeSentence={activeSentence}
|
||||||
|
activeWordIndex={activeWordIndex}
|
||||||
|
bottomOffset={captionBottomOffset}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -225,6 +256,14 @@ function PlayableMediaView({
|
|||||||
allowsFullscreen={false}
|
allowsFullscreen={false}
|
||||||
allowsPictureInPicture={false}
|
allowsPictureInPicture={false}
|
||||||
/>
|
/>
|
||||||
|
{transcript ? (
|
||||||
|
<TranscriptOverlay
|
||||||
|
transcript={transcript}
|
||||||
|
activeSentence={activeSentence}
|
||||||
|
activeWordIndex={activeWordIndex}
|
||||||
|
bottomOffset={captionBottomOffset}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,10 +370,11 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
const exitRemainingMs = useExitCountdown(status, paused, exit);
|
||||||
|
|
||||||
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
|
// Chrome reservations: top = safe-area + segmented bar (3) + gap (12) +
|
||||||
// metadata row (~38) + breathing room (12). Bottom = safe-area + room for
|
// metadata row (~38) + breathing room (12). Bottom = safe-area + compose
|
||||||
// pause / countdown pills + the compose dock that lands in this same step.
|
// dock + breathing room. Pause / countdown pills moved to the top so the
|
||||||
|
// bottom only reserves space for the compose dock now.
|
||||||
const chromeTop = insets.top + 65;
|
const chromeTop = insets.top + 65;
|
||||||
const chromeBottom = insets.bottom + 96;
|
const chromeBottom = insets.bottom + COMPOSE_DOCK_HEIGHT + 14;
|
||||||
|
|
||||||
// --- Render the active particle ---
|
// --- Render the active particle ---
|
||||||
const renderParticle = (particle: Particle) => {
|
const renderParticle = (particle: Particle) => {
|
||||||
@@ -499,12 +500,14 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Bottom chrome: paused pill + exit countdown. Sit above the
|
{/* Top status pills: paused + exit countdown. Anchored just below
|
||||||
compose dock so the record button doesn't cover them. */}
|
the metadata row (avatar + name ≈ 40px tall, starts at
|
||||||
|
insets.top + 32) so they share the top chrome real estate
|
||||||
|
instead of competing with captions at the bottom. */}
|
||||||
<View
|
<View
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
className="absolute inset-x-0 bottom-0 items-center"
|
className="absolute inset-x-0 items-center"
|
||||||
style={{ paddingBottom: insets.bottom + COMPOSE_DOCK_HEIGHT }}
|
style={{ top: insets.top + 88 }}
|
||||||
>
|
>
|
||||||
{paused ? (
|
{paused ? (
|
||||||
<View className="bg-white/15 rounded-full px-3 py-1">
|
<View className="bg-white/15 rounded-full px-3 py-1">
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useMemo, useRef } from "react";
|
||||||
|
import { Text, View } from "react-native";
|
||||||
|
import type { Transcript } from "@/api/types";
|
||||||
|
|
||||||
|
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||||
|
type Word = Transcript["words"][number];
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 9;
|
||||||
|
|
||||||
|
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;
|
||||||
|
/** Distance from the bottom of the parent container, in px. */
|
||||||
|
bottomOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptOverlay({
|
||||||
|
transcript,
|
||||||
|
activeSentence,
|
||||||
|
activeWordIndex,
|
||||||
|
bottomOffset,
|
||||||
|
}: 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;
|
||||||
|
|
||||||
|
const lastSpokenWordRef = useRef<Word | null>(null);
|
||||||
|
if (activeWord) {
|
||||||
|
lastSpokenWordRef.current = activeWord;
|
||||||
|
}
|
||||||
|
const highlightWord = activeWord ?? lastSpokenWordRef.current;
|
||||||
|
|
||||||
|
const lastChunkRef = useRef<Word[] | null>(null);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lastChunkRef.current && chunks.some((c) => c === lastChunkRef.current)) {
|
||||||
|
return lastChunkRef.current;
|
||||||
|
}
|
||||||
|
const fallback = chunks[0] ?? null;
|
||||||
|
lastChunkRef.current = fallback;
|
||||||
|
return fallback;
|
||||||
|
}, [chunks, activeWord]);
|
||||||
|
|
||||||
|
if (!activeSentence || !activeChunk || activeChunk.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
pointerEvents="none"
|
||||||
|
className="absolute left-0 right-0 items-center px-6"
|
||||||
|
style={{ bottom: bottomOffset }}
|
||||||
|
>
|
||||||
|
<View className="rounded-lg px-5 py-3 max-w-[480px]">
|
||||||
|
<Text className="text-[24px] leading-[44px] text-center">
|
||||||
|
{activeChunk.map((word, i) => {
|
||||||
|
const isSpoken =
|
||||||
|
highlightWord !== null && word.start <= highlightWord.end;
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
key={`${word.start}-${i}`}
|
||||||
|
className={
|
||||||
|
isSpoken ? "text-white font-medium" : "text-white/40"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{i > 0 ? " " : ""}
|
||||||
|
{word.word}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { Transcript } from "@/api/types";
|
||||||
|
|
||||||
|
type Sentence = Transcript["paragraphs"][number]["sentences"][number];
|
||||||
|
|
||||||
|
interface TranscriptPlaybackState {
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTranscriptPlayback(
|
||||||
|
transcript: Transcript | undefined,
|
||||||
|
currentTime: number,
|
||||||
|
): TranscriptPlaybackState {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (!transcript) return { activeSentence: null, activeWordIndex: 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;
|
||||||
|
let activeWordIndex: number | null = null;
|
||||||
|
let lo = 0;
|
||||||
|
let hi = words.length - 1;
|
||||||
|
while (lo <= hi) {
|
||||||
|
const mid = (lo + hi) >>> 1;
|
||||||
|
if (currentTime < words[mid].start) hi = mid - 1;
|
||||||
|
else if (currentTime > words[mid].end) lo = mid + 1;
|
||||||
|
else {
|
||||||
|
activeWordIndex = mid;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeSentence, activeWordIndex };
|
||||||
|
}, [transcript, currentTime]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user