From 69d6ec2ca2d527375e619c095acf1189510ddfeb Mon Sep 17 00:00:00 2001 From: talksik Date: Wed, 18 Mar 2026 17:13:41 -0700 Subject: [PATCH] fix: implement stream playback cleaner structure --- js/src/api/client.ts | 11 +- js/src/features/compose/compose-overlay.tsx | 249 ++++++++++++++++-- .../features/compose/controls-indicator.tsx | 7 +- js/src/features/compose/recording-overlay.tsx | 4 +- .../features/compose/use-compose-keyboard.ts | 110 -------- js/src/features/compose/use-recorder.ts | 6 +- js/src/features/layoutwithpath.tsx | 2 - js/src/features/network-root.tsx | 10 +- js/src/features/particles/folder-view.tsx | 7 +- .../features/particles/particle-list-view.tsx | 11 - js/src/features/particles/stream-view.tsx | 185 +++++++++---- .../features/playback/media-particle-view.tsx | 48 +--- .../features/playback/particle-renderer.tsx | 35 ++- js/src/hooks/use-download-url.ts | 9 + js/src/hooks/use-recording-mode.ts | 19 ++ js/src/lib/firestore-particles.ts | 1 + js/src/stores/compose-store.ts | 106 -------- js/src/stores/playback-store.ts | 88 ------- 18 files changed, 461 insertions(+), 447 deletions(-) delete mode 100644 js/src/features/compose/use-compose-keyboard.ts create mode 100644 js/src/hooks/use-download-url.ts create mode 100644 js/src/hooks/use-recording-mode.ts delete mode 100644 js/src/stores/compose-store.ts delete mode 100644 js/src/stores/playback-store.ts diff --git a/js/src/api/client.ts b/js/src/api/client.ts index d27afa0..9794b76 100644 --- a/js/src/api/client.ts +++ b/js/src/api/client.ts @@ -44,9 +44,11 @@ class ApiClient { path: string, body?: unknown, ): Promise { - const headers: Record = { - "Content-Type": "application/json", - }; + const headers: Record = {}; + + if (body) { + headers["Content-Type"] = "application/json"; + } const token = this.config.getToken(); if (token) { @@ -115,7 +117,8 @@ class ApiClient { "GET", `/particles/${objectId}/download`, ); - return response.url; + const data = await response.json(); + return data.url; } // --- Depot --- diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index e3f979d..e6b23b1 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -1,38 +1,164 @@ -import { useComposeStore } from "@/stores/compose-store"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useAuthStore } from "@/stores/auth-store"; import { useCreateParticle } from "@/hooks/use-create-particle"; +import { useRecordingMode } from "@/hooks/use-recording-mode"; +import { useRecorder } from "@/features/compose/use-recorder"; import { particlePath, toFirestoreChildrenPath } from "@/lib/particle-path"; +import type { ParticlePath } from "@/lib/particle-path"; import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { TextComposeStep } from "@/features/compose/text-compose-step"; import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; -import { useCallback } from "react"; +import { apiClient } from "@/api/client"; + +type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring"; + +interface ComposeOverlayProps { + networkId: string; + targetPath?: ParticlePath; + onActiveChange?: (active: boolean) => void; +} /** - * Renders the current compose step as a fullscreen overlay. - * Returns null when idle — zero cost when not composing. + * Self-contained compose overlay. Each consumer renders its own instance + * with props that determine the mode (new stream vs. reply). */ -export function ComposeOverlay() { - const step = useComposeStore((s) => s.step); - const cancel = useComposeStore((s) => s.cancel); - const textContent = useComposeStore((s) => s.textContent); - const setTextContent = useComposeStore((s) => s.setTextContent); - const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure); - const networkId = useComposeStore((s) => s.networkId); - const mediaStream = useComposeStore((s) => s.mediaStream); - const recordingMode = useComposeStore((s) => s.recordingMode); - const reviewBlob = useComposeStore((s) => s.reviewBlob); - const error = useComposeStore((s) => s.error); +export function ComposeOverlay({ + networkId, + targetPath, + onActiveChange, +}: ComposeOverlayProps) { + const [step, setStep] = useState("idle"); + const [error, setError] = useState(null); + const [textContent, setTextContent] = useState(""); + const [mediaStream, setMediaStream] = useState(null); + const [reviewBlob, setReviewBlob] = useState(null); + const [reviewDurationMs, setReviewDurationMs] = useState(0); + const [reviewMimeType, setReviewMimeType] = useState(null); + const [recordingMode] = useRecordingMode(); const userEmail = useAuthStore((s) => s.user?.email); const createParticle = useCreateParticle(); + // Refs to avoid stale closures in keyboard handler + const stepRef = useRef(step); + stepRef.current = step; + + // Notify parent when active state changes + useEffect(() => { + onActiveChange?.(step !== "idle"); + }, [step, onActiveChange]); + + const cancel = useCallback(() => { + setStep("idle"); + setError(null); + setTextContent(""); + setMediaStream(null); + setReviewBlob(null); + setReviewDurationMs(0); + setReviewMimeType(null); + }, []); + + const { startRecording, stopRecording, cancelRecording } = useRecorder({ + mode: recordingMode, + onStreamReady: (stream) => setMediaStream(stream), + onStreamCleanup: () => setMediaStream(null), + onFinish: (blob, durationMs, mimeType) => { + setStep("reviewing"); + setReviewBlob(blob); + setReviewDurationMs(durationMs); + setReviewMimeType(mimeType); + }, + onError: (message) => setError(message), + }); + + // --- Submission --- + + const uploadMedia = useCallback( + async (blob: Blob, mimeType: string) => { + const ext = "webm"; + const fileName = `recording-${Date.now()}.${ext}`; + + const { object_id, upload_url, upload_headers } = + await apiClient.prepareUpload({ + network_id: networkId, + name: fileName, + content_type: mimeType, + content_length: blob.size, + }); + + await fetch(upload_url, { + method: "PUT", + headers: upload_headers, + body: blob, + }); + + await apiClient.confirmUpload(object_id); + + return { object_id, size_bytes: blob.size }; + }, + [networkId], + ); + + const createChildParticle = useCallback( + async (collectionPath: string) => { + if (!userEmail) return; + + if (textContent.trim()) { + await createParticle.mutateAsync({ + collectionPath, + type: "text", + properties: { content: textContent }, + createdByEmail: userEmail, + }); + } else if (reviewBlob && reviewMimeType) { + const { object_id, size_bytes } = await uploadMedia( + reviewBlob, + reviewMimeType, + ); + + await createParticle.mutateAsync({ + collectionPath, + type: "media", + properties: { + object_id, + mime_type: reviewMimeType, + duration_ms: reviewDurationMs, + size_bytes, + }, + createdByEmail: userEmail, + }); + } + }, + [ + userEmail, + textContent, + reviewBlob, + reviewMimeType, + reviewDurationMs, + createParticle, + uploadMedia, + ], + ); + + // Reply mode: create particle directly under targetPath + const submitReply = useCallback(async () => { + if (!targetPath || !userEmail) return; + const collectionPath = toFirestoreChildrenPath(targetPath); + await createChildParticle(collectionPath); + cancel(); + }, [targetPath, userEmail, createChildParticle, cancel]); + + const submitReplyRef = useRef(submitReply); + submitReplyRef.current = submitReply; + + // New stream mode: create stream + first child const handleStreamSubmit = useCallback( async (streamName: string, visibleTo: string[]) => { - if (!networkId || !userEmail) return; + if (!userEmail) return; const collectionPath = toFirestoreChildrenPath(particlePath(networkId)); - await createParticle.mutateAsync({ + const streamId = await createParticle.mutateAsync({ collectionPath, type: "stream", properties: { @@ -43,13 +169,96 @@ export function ComposeOverlay() { createdByEmail: userEmail, }); + const streamChildrenPath = toFirestoreChildrenPath( + particlePath(networkId, [streamId]), + ); + + await createChildParticle(streamChildrenPath); cancel(); }, - [networkId, userEmail, createParticle, cancel], + [networkId, userEmail, createParticle, createChildParticle, cancel], ); + // --- Keyboard handling --- + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + const currentStep = stepRef.current; + + if (currentStep === "typing" || currentStep === "configuring") return; + + const target = e.target as HTMLElement; + if ( + target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable + ) { + return; + } + + switch (currentStep) { + case "idle": { + if (e.key === "`" && !e.repeat) { + e.preventDefault(); + setStep("recording"); + startRecording(); + } else if (e.key === "t" || e.key === "T") { + e.preventDefault(); + setStep("typing"); + } + break; + } + + case "recording": { + if (e.key === "q" || e.key === "Q" || e.key === "Escape") { + e.preventDefault(); + cancelRecording(); + cancel(); + } + break; + } + + case "reviewing": { + if (e.key === "q" || e.key === "Q" || e.key === "Escape") { + e.preventDefault(); + cancelRecording(); + cancel(); + } else if (e.key === "Enter") { + e.preventDefault(); + if (targetPath) { + submitReplyRef.current(); + } else { + setStep("configuring"); + } + } + break; + } + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (stepRef.current === "recording" && e.key === "`") { + e.preventDefault(); + stopRecording(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [targetPath, startRecording, stopRecording, cancelRecording, cancel]); + + // --- Render --- + if (step === "idle") return null; + const handleTextAdvance = targetPath + ? submitReply + : () => setStep("configuring"); + return ( <> {(step === "recording" || step === "reviewing") && ( @@ -66,11 +275,11 @@ export function ComposeOverlay() { )} - {step === "configuring" && ( + {!targetPath && step === "configuring" && ( s.recordingMode); - const setRecordingMode = useComposeStore((s) => s.setRecordingMode); + const [recordingMode, setRecordingMode] = useRecordingMode(); return (
@@ -23,7 +22,7 @@ export default function ControlsIndicator({ type }: ControlsIndicatorProps) { } variant="secondary" className={cn( - "text-xs", + "text-xs rounded-full", "text-muted-foreground hover:text-white/90", )} > diff --git a/js/src/features/compose/recording-overlay.tsx b/js/src/features/compose/recording-overlay.tsx index 0cb6c06..aec6b37 100644 --- a/js/src/features/compose/recording-overlay.tsx +++ b/js/src/features/compose/recording-overlay.tsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import type { ComposeStep, RecordingMode } from "@/stores/compose-store"; +import type { RecordingMode } from "@/hooks/use-recording-mode"; import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { useAudioSource } from "@/components/audio/use-audio-source"; interface RecordingOverlayProps { - step: ComposeStep; + step: "recording" | "reviewing"; mediaStream: MediaStream | null; recordingMode: RecordingMode; reviewBlob: Blob | null; diff --git a/js/src/features/compose/use-compose-keyboard.ts b/js/src/features/compose/use-compose-keyboard.ts deleted file mode 100644 index 384d31c..0000000 --- a/js/src/features/compose/use-compose-keyboard.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { useEffect, useCallback } from "react"; -import { useComposeStore } from "@/stores/compose-store"; -import { useRecorder } from "@/features/compose/use-recorder"; -import { useParams } from "react-router-dom"; - -/** - * Global keyboard handler for the compose flow. - * - * Handles keys for idle, recording, and reviewing steps. - * The typing and configuring steps handle their own keyboard - * events via focused elements — this hook ignores those steps. - */ -export function useComposeKeyboard() { - const networkId = useParams()["networkId"]; - const recordingMode = useComposeStore((s) => s.recordingMode); - const setMediaStream = useComposeStore((s) => s.setMediaStream); - const finishRecording = useComposeStore((s) => s.finishRecording); - const setError = useComposeStore((s) => s.setError); - const beginRecording = useComposeStore((s) => s.startRecording); - const beginTyping = useComposeStore((s) => s.startTyping); - const cancel = useComposeStore((s) => s.cancel); - const advanceToConfigure = useComposeStore((s) => s.advanceToConfigure); - - const { startRecording, stopRecording, cancelRecording } = useRecorder({ - mode: recordingMode, - onStreamReady: (stream) => setMediaStream(stream), - onStreamCleanup: () => setMediaStream(null), - onFinish: (blob, durationMs) => finishRecording(blob, durationMs), - onError: (message) => setError(message), - }); - - const handleKeyDown = useCallback( - (e: KeyboardEvent) => { - const { step } = useComposeStore.getState(); - - // Typing and configuring steps own their focused keyboard events - if (step === "typing" || step === "configuring") return; - - // Don't intercept if the user is typing in an unrelated input - const target = e.target as HTMLElement; - if ( - target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable - ) { - return; - } - - switch (step) { - case "idle": { - if (!networkId) return; - if (e.key === "`" && !e.repeat) { - e.preventDefault(); - beginRecording(networkId); - startRecording(); - } else if (e.key === "t" || e.key === "T") { - e.preventDefault(); - beginTyping(networkId); - } - break; - } - - case "recording": { - if (e.key === "q" || e.key === "Q" || e.key === "Escape") { - e.preventDefault(); - cancelRecording(); - cancel(); - } - break; - } - - case "reviewing": { - if (e.key === "q" || e.key === "Q" || e.key === "Escape") { - e.preventDefault(); - cancelRecording(); - cancel(); - } else if (e.key === "Enter") { - e.preventDefault(); - advanceToConfigure(); - } - break; - } - } - }, - [networkId, startRecording, cancelRecording, beginRecording, beginTyping, cancel, advanceToConfigure], - ); - - const handleKeyUp = useCallback( - (e: KeyboardEvent) => { - const { step } = useComposeStore.getState(); - - if (step === "recording" && e.key === "`") { - e.preventDefault(); - stopRecording(); - // finishRecording is called by the MediaRecorder onstop handler - // once the blob is ready — no need to call it here. - } - }, - [stopRecording], - ); - - useEffect(() => { - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - return () => { - window.removeEventListener("keydown", handleKeyDown); - window.removeEventListener("keyup", handleKeyUp); - }; - }, [handleKeyDown, handleKeyUp]); -} diff --git a/js/src/features/compose/use-recorder.ts b/js/src/features/compose/use-recorder.ts index 4ee4b22..f0e0e0f 100644 --- a/js/src/features/compose/use-recorder.ts +++ b/js/src/features/compose/use-recorder.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import type { RecordingMode } from "@/stores/compose-store"; +import type { RecordingMode } from "@/hooks/use-recording-mode"; const VIDEO_PREFERRED_MIME = "video/webm;codecs=vp9,opus"; const VIDEO_FALLBACK_MIME = "video/webm"; @@ -21,7 +21,7 @@ interface UseRecorderOptions { mode: RecordingMode; onStreamReady: (stream: MediaStream) => void; onStreamCleanup: () => void; - onFinish: (blob: Blob, durationMs: number) => void; + onFinish: (blob: Blob, durationMs: number, mimeType: string) => void; onError: (message: string) => void; } @@ -86,7 +86,7 @@ export function useRecorder({ stopTracks(); if (blob.size > 0) { - onFinishRef.current(blob, durationMs); + onFinishRef.current(blob, durationMs, mime); } }; diff --git a/js/src/features/layoutwithpath.tsx b/js/src/features/layoutwithpath.tsx index c8c7ea7..3b78dcb 100644 --- a/js/src/features/layoutwithpath.tsx +++ b/js/src/features/layoutwithpath.tsx @@ -12,7 +12,6 @@ import { } from "@/components/ui/breadcrumb"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { useNetworks } from "@/hooks/use-networks"; -import { ComposeOverlay } from "@/features/compose/compose-overlay"; function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { const { data: networks } = useNetworks(); @@ -123,7 +122,6 @@ export default function LayoutWithPath() {
-
); diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index ce33d8e..0ecc103 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,7 +1,8 @@ import { useParams } from "react-router-dom"; import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; -import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; +import ControlsIndicator from "@/features/compose/controls-indicator"; +import { ComposeOverlay } from "./compose/compose-overlay"; /** * Route-level component for /:networkId (index). @@ -10,9 +11,12 @@ import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; export default function NetworkRoot() { const { networkId } = useParams(); const path = particlePath(networkId!, []); - useComposeKeyboard(); return ( - + <> + + + + ); } diff --git a/js/src/features/particles/folder-view.tsx b/js/src/features/particles/folder-view.tsx index dc0791a..34bcaed 100644 --- a/js/src/features/particles/folder-view.tsx +++ b/js/src/features/particles/folder-view.tsx @@ -1,7 +1,7 @@ import { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; -import type { ParticlePath } from "@/lib/particle-path"; -import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; +import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; +import { ComposeOverlay } from "@/features/compose/compose-overlay"; interface FolderViewProps { folderParticle: Particle; @@ -9,14 +9,15 @@ interface FolderViewProps { } export function FolderView({ path, folderParticle }: FolderViewProps) { - useComposeKeyboard(); const { children, error, isLoading } = useLiveParticleChildren(path); + const { networkId } = parseParticlePath(path); return (

Folder view — {folderParticle.id}

+
); } diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 5ee8c98..8e5d362 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -1,5 +1,4 @@ import { useMemo } from "react"; -import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; import { useNavigate } from "react-router-dom"; import { Radio } from "lucide-react"; import { useLiveParticleChildren } from "@/hooks/use-particle"; @@ -9,7 +8,6 @@ import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; -import ControlsIndicator from "@/features/compose/controls-indicator"; import type { Particle, StreamProperties } from "@/api/types"; function StreamRow({ @@ -55,7 +53,6 @@ interface ParticleListViewProps { * List of stream particles for a container (network root, folder, etc.). */ export function ParticleListView({ path }: ParticleListViewProps) { - useComposeKeyboard(); const { children, isLoading } = useLiveParticleChildren(path); const { networkId } = parseParticlePath(path); const navigate = useNavigate(); @@ -69,14 +66,6 @@ export function ParticleListView({ path }: ParticleListViewProps) { return ; } - if (streams.length === 0) { - return ( -
- -
- ); - } - return (
diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index f682bd4..847d3c3 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -1,16 +1,93 @@ -import { useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useReducer } from "react"; import { useNavigate, useParams } from "react-router-dom"; import type { Particle } from "@/api/types"; import { useLiveParticleChildren } from "@/hooks/use-particle"; import type { ParticlePath } from "@/lib/particle-path"; -import { usePlaybackStore } from "@/stores/playback-store"; -import { useComposeStore } from "@/stores/compose-store"; -import { useComposeKeyboard } from "@/features/compose/use-compose-keyboard"; +import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/playback/playback-page-indicator"; import { ParticleRenderer } from "@/features/playback/particle-renderer"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import ControlsIndicator from "@/features/compose/controls-indicator"; +// --- Playback reducer --- + +type PlaybackStatus = "idle" | "playing" | "ended"; + +interface PlaybackState { + currentIndex: number; + status: PlaybackStatus; + paused: boolean; +} + +type PlaybackAction = + | { type: "INIT"; particleCount: number } + | { type: "NEXT"; particleCount: number } + | { type: "PREV" } + | { type: "GO_TO"; index: number; particleCount: number } + | { type: "PAUSE" } + | { type: "RESUME" } + | { type: "SYNC_PARTICLES"; particleCount: number }; + +function playbackReducer( + state: PlaybackState, + action: PlaybackAction, +): PlaybackState { + switch (action.type) { + case "INIT": + return { + currentIndex: 0, + status: action.particleCount > 0 ? "playing" : "idle", + paused: false, + }; + case "NEXT": + if (state.currentIndex < action.particleCount - 1) { + return { ...state, currentIndex: state.currentIndex + 1, paused: false }; + } + return { ...state, status: "ended", paused: false }; + case "PREV": + if (state.currentIndex > 0) { + return { + ...state, + currentIndex: state.currentIndex - 1, + status: "playing", + paused: false, + }; + } + return state; + case "GO_TO": + if (action.index >= 0 && action.index < action.particleCount) { + return { + ...state, + currentIndex: action.index, + status: "playing", + paused: false, + }; + } + return state; + case "PAUSE": + return { ...state, paused: true }; + case "RESUME": + return { ...state, paused: false }; + case "SYNC_PARTICLES": + // Clamp index if particles were removed; don't reset position + if (action.particleCount === 0) { + return { currentIndex: 0, status: "idle", paused: state.paused }; + } + if (state.currentIndex >= action.particleCount) { + return { ...state, currentIndex: action.particleCount - 1 }; + } + return state; + } +} + +const initialState: PlaybackState = { + currentIndex: 0, + status: "idle", + paused: false, +}; + +// --- StreamView --- + interface StreamViewProps { streamParticle: Particle; path: ParticlePath; @@ -21,44 +98,44 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { const navigate = useNavigate(); const { children } = useLiveParticleChildren(path); - const status = usePlaybackStore((s) => s.status); - const currentIndex = usePlaybackStore((s) => s.currentIndex); - const particles = usePlaybackStore((s) => s.particles); - const initStream = usePlaybackStore((s) => s.initStream); - const goTo = usePlaybackStore((s) => s.goTo); - const next = usePlaybackStore((s) => s.next); - const prev = usePlaybackStore((s) => s.prev); - const pause = usePlaybackStore((s) => s.pause); - const resume = usePlaybackStore((s) => s.resume); - const reset = usePlaybackStore((s) => s.reset); + const [state, dispatch] = useReducer(playbackReducer, initialState); + const [composeActive, setComposeActive] = useState(false); - // Compose keyboard (backtick, t, q, escape-during-compose) - useComposeKeyboard(); - - // Init playback when children change + // Init playback when the stream particle changes useEffect(() => { - if (children.length > 0) { - initStream(streamParticle.id, children, 0); - } - return () => reset(); - }, [children, streamParticle.id, initStream, reset]); + dispatch({ type: "INIT", particleCount: children.length }); + }, [streamParticle.id]); + + // Sync when children list changes (e.g. new particle appended via Firestore) + useEffect(() => { + dispatch({ type: "SYNC_PARTICLES", particleCount: children.length }); + }, [children.length]); // Pause/resume playback when compose overlay opens/closes useEffect(() => { - return useComposeStore.subscribe((state) => { - if (state.step !== "idle") { - pause(); - } else { - resume(); - } - }); - }, [pause, resume]); + if (composeActive) dispatch({ type: "PAUSE" }); + else dispatch({ type: "RESUME" }); + }, [composeActive]); + + const next = useCallback(() => { + dispatch({ type: "NEXT", particleCount: children.length }); + }, [children.length]); + + const prev = useCallback(() => { + dispatch({ type: "PREV" }); + }, []); + + const goTo = useCallback( + (index: number) => { + dispatch({ type: "GO_TO", index, particleCount: children.length }); + }, + [children.length], + ); // Playback keyboard: arrows, escape const handleKeyDown = useCallback( (e: KeyboardEvent) => { - const composeStep = useComposeStore.getState().step; - if (composeStep !== "idle") return; + if (composeActive) return; const target = e.target as HTMLElement; if ( @@ -86,7 +163,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { break; } }, - [next, prev, navigate, networkId], + [composeActive, next, prev, navigate, networkId], ); useEffect(() => { @@ -94,7 +171,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { return () => window.removeEventListener("keydown", handleKeyDown); }, [handleKeyDown]); - const currentParticle = particles[currentIndex] ?? null; + const currentParticle = children[state.currentIndex] ?? null; // Stream name from properties (narrowed to stream type) const streamName = @@ -113,6 +190,11 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { No particles in this stream yet

+
); } @@ -120,10 +202,10 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { return (
{/* Progress indicator */} -
+
@@ -144,18 +226,27 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { {/* Main playback area */}
- {currentParticle && status !== "ended" ? ( - - ) : ( -
-

End of stream

-
+ {currentParticle && ( + )}
- {/* Stream name overlay */} -
- {streamName} + + + {/* Bottom overlay: stream info + reply */} +
+
+ +
); diff --git a/js/src/features/playback/media-particle-view.tsx b/js/src/features/playback/media-particle-view.tsx index a7d92db..86ed0b5 100644 --- a/js/src/features/playback/media-particle-view.tsx +++ b/js/src/features/playback/media-particle-view.tsx @@ -1,13 +1,14 @@ import { useEffect, useRef, useState } from "react"; import type { Particle } from "@/api/types"; -import { apiClient } from "@/api/client"; -import { usePlaybackStore } from "@/stores/playback-store"; +import { useDownloadUrl } from "@/hooks/use-download-url"; import { Skeleton } from "@/components/ui/skeleton"; type MediaParticle = Extract; interface MediaParticleViewProps { particle: MediaParticle; + paused: boolean; + onEnded: () => void; } function formatTime(ms: number): string { @@ -35,14 +36,10 @@ function DurationPill({ export function MediaParticleView({ particle, + paused, + onEnded, }: MediaParticleViewProps) { - const cachedUrl = usePlaybackStore( - (s) => s.downloadUrlCache[particle.id], - ); - const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl); - const next = usePlaybackStore((s) => s.next); - const paused = usePlaybackStore((s) => s.paused); - const [error, setError] = useState(null); + const { data: url, error } = useDownloadUrl(particle.properties.object_id); const videoRef = useRef(null); const audioRef = useRef(null); @@ -50,25 +47,6 @@ export function MediaParticleView({ const isAudio = particle.properties.mime_type?.startsWith("audio/"); - useEffect(() => { - if (cachedUrl) return; - - let cancelled = false; - apiClient - .getParticleDownloadUrl(particle.properties.object_id) - .then((downloadUrl) => { - if (cancelled) return; - cacheDownloadUrl(particle.id, downloadUrl); - }) - .catch(() => { - if (!cancelled) setError("Failed to load media"); - }); - - return () => { - cancelled = true; - }; - }, [particle.id, particle.properties.object_id, cacheDownloadUrl]); - useEffect(() => { const el = isAudio ? audioRef.current : videoRef.current; if (!el) return; @@ -80,17 +58,17 @@ export function MediaParticleView({ console.warn("Playback failed", { particleId: particle.id }); }); } - }, [paused]); + }, [paused, isAudio, particle.id]); if (error) { return (
- {error} + Failed to load media
); } - if (!cachedUrl) { + if (!url) { return ; } @@ -100,9 +78,9 @@ export function MediaParticleView({