diff --git a/js/src/features/particles/stream-view.tsx b/js/src/features/particles/stream-view.tsx index 4524b9f..7a7e08b 100644 --- a/js/src/features/particles/stream-view.tsx +++ b/js/src/features/particles/stream-view.tsx @@ -1,9 +1,8 @@ -import { useState, useEffect, useEffectEvent, useCallback, useReducer, useRef } from "react"; +import { useState, useEffect, useEffectEvent, useCallback } from "react"; import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import type { Particle } from "@/api/types"; -import { useLiveParticleChildren } from "@/hooks/use-particle"; -import { parseParticlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; +import { parseParticlePath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; import { MediaParticleView } from "@/features/particles/media-particle-view"; @@ -11,7 +10,6 @@ import { TextParticleView } from "@/features/particles/text-particle-view"; import { FallbackParticleView } from "@/features/particles/fallback-particle-view"; import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import ControlsIndicator from "@/features/compose/controls-indicator"; -import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useNetwork, useNetworks } from "@/hooks/use-networks"; import { Button } from "@/components/ui/button"; @@ -19,6 +17,7 @@ import { Home, Settings } from "lucide-react"; import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import { WindowControls } from "@/components/window-controls"; import { formatDistanceToNow } from "@/lib/time-utils"; +import { useStreamPlayback } from "@/hooks/use-stream-playback"; function getParticleDisplayName(particle: Particle): string { switch (particle.type) { @@ -38,92 +37,13 @@ function getParticleDisplayName(particle: Particle): string { } } -// --- Playback reducer --- - -type PlaybackStatus = "idle" | "playing" | "ended"; - -interface PlaybackState { - currentIndex: number; - status: PlaybackStatus; - paused: boolean; -} - -type PlaybackAction = - | { type: "INIT"; particleCount: number, initialIndex?: 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: action.initialIndex ?? 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.status === "ended" && state.currentIndex < action.particleCount - 1) { - // New particle appended — resume and advance to it - return { ...state, currentIndex: state.currentIndex + 1, status: "playing", paused: false }; - } - if (state.currentIndex >= action.particleCount) { - return { ...state, currentIndex: action.particleCount - 1 }; - } - return state; - } -} - -const initialState: PlaybackState = { - currentIndex: 0, - status: "idle", - paused: false, -}; - // --- Exit countdown hook --- const EXIT_DELAY_MS = 5000; const EXIT_TICK_MS = 100; +type PlaybackStatus = "idle" | "playing" | "ended"; + function useExitCountdown( status: PlaybackStatus, composeActive: boolean, @@ -179,88 +99,43 @@ interface StreamViewProps { export function StreamView({ path, streamParticle }: StreamViewProps) { const { networkId } = parseParticlePath(path); const navigate = useNavigate(); - const { children } = useLiveParticleChildren(path, "created_at", "asc"); - const [state, dispatch] = useReducer(playbackReducer, initialState); + const { + children, + currentParticle, + currentIndex, + status, + paused, + next, + prev, + goTo, + pause, + resume, + } = useStreamPlayback(streamParticle, path); + const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); - const hasInitializedRef = useRef(null); const handleExitNavigate = useCallback(() => { navigate(`/${networkId}`); }, [navigate, networkId]); const exitRemainingMs = useExitCountdown( - state.status, + status, composeActive, handleExitNavigate, ); - const userId = useAuthStore((s) => s.user?.id); - // Reset progress when particle changes useEffect(() => { setProgress(0); - }, [state.currentIndex]); - - // Init playback once per stream entry, only after children have loaded - useEffect(() => { - if (children.length === 0) return; - if (hasInitializedRef.current === streamParticle.id) return; - hasInitializedRef.current = streamParticle.id; - - const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; - let initialIndex = 0; - - if (playbackPosition) { - const foundIndex = children.findIndex( - (c) => c.created_at.getTime() === playbackPosition.getTime(), - ); - if (foundIndex !== -1) { - initialIndex = foundIndex; - } - } - - dispatch({ type: "INIT", particleCount: children.length, initialIndex }); - }, [streamParticle.id, userId, children]); - - // Sync on subsequent changes (new particle appended, removed, etc.) - useEffect(() => { - if (hasInitializedRef.current !== streamParticle.id) return; - dispatch({ type: "SYNC_PARTICLES", particleCount: children.length }); - }, [children.length, streamParticle.id]); + }, [currentParticle?.id]); // Pause/resume playback when compose overlay opens/closes useEffect(() => { - 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], - ); - - // Click-to-navigate: left 30% = prev, right 70% = next - const handlePlaybackClick = useCallback( - (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect(); - const x = (e.clientX - rect.left) / rect.width; - if (x < 0.3) prev(); - else if (x > 0.7) next(); - }, - [prev, next], - ); + if (composeActive) pause(); + else resume(); + }, [composeActive, pause, resume]); // Playback keyboard: arrows, escape useEffect(() => { @@ -299,18 +174,16 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { [composeActive, next, prev, navigate, networkId], ); - const currentParticle = children[state.currentIndex] ?? null; - - useEffect(() => { - if (!userId || !currentParticle) return; - - const streamDocPath = toFirestoreDocPath(path); - updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at); - }, [currentParticle?.id, path]) - - // Author info from current particle - const authorEmail = currentParticle?.created_by_email ?? ""; - const authorInitials = authorEmail.split("@")[0]?.slice(0, 2).toUpperCase() ?? ""; + // Click-to-navigate: left 30% = prev, right 70% = next + const handlePlaybackClick = useCallback( + (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const x = (e.clientX - rect.left) / rect.width; + if (x < 0.3) prev(); + else if (x > 0.7) next(); + }, + [prev, next], + ); if (children.length === 0) { return ( @@ -328,7 +201,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { ); } - // Render particle content inline (replaces ParticleRenderer) + // Render particle content inline function renderParticle(particle: Particle) { switch (particle.type) { case "media": @@ -336,7 +209,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { @@ -346,7 +219,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { @@ -362,7 +235,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
@@ -394,7 +267,9 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
- + {currentParticle && ( + + )} {/* Exit countdown */} {exitRemainingMs !== null && ( @@ -408,7 +283,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) { ); } -function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle; streamParticle: Particle & { type: "stream" } }) { +function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) { const navigate = useNavigate(); return ( diff --git a/js/src/hooks/use-stream-playback.ts b/js/src/hooks/use-stream-playback.ts new file mode 100644 index 0000000..bddf19c --- /dev/null +++ b/js/src/hooks/use-stream-playback.ts @@ -0,0 +1,240 @@ +import { useCallback, useEffect, useMemo, useReducer, useRef } from "react"; +import { useAuthStore } from "@/stores/auth-store"; +import type { Particle } from "@/api/types"; +import { useLiveParticleChildren } from "@/hooks/use-particle"; +import { toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; +import { updateStreamPlaybackMarker } from "@/lib/firestore-particles"; + +// --- Playback reducer (ID-based) --- + +type PlaybackStatus = "idle" | "playing" | "ended"; + +interface PlaybackState { + currentParticleId: string | null; + status: PlaybackStatus; + paused: boolean; + initialized: boolean; +} + +type PlaybackAction = + | { type: "INIT"; particleId: string } + | { type: "SET_PARTICLE"; particleId: string } + | { type: "END" } + | { type: "PARTICLE_REMOVED"; fallbackParticleId: string | null } + | { type: "PAUSE" } + | { type: "RESUME" }; + +const initialState: PlaybackState = { + currentParticleId: null, + status: "idle", + paused: false, + initialized: false, +}; + +function playbackReducer(state: PlaybackState, action: PlaybackAction): PlaybackState { + switch (action.type) { + case "INIT": + return { + currentParticleId: action.particleId, + status: "playing", + paused: false, + initialized: true, + }; + case "SET_PARTICLE": + return { + ...state, + currentParticleId: action.particleId, + status: "playing", + paused: false, + }; + case "END": + return { ...state, status: "ended", paused: false }; + case "PARTICLE_REMOVED": + if (action.fallbackParticleId) { + return { ...state, currentParticleId: action.fallbackParticleId, status: "playing" }; + } + return { ...state, currentParticleId: null, status: "idle" }; + case "PAUSE": + return { ...state, paused: true }; + case "RESUME": + return { ...state, paused: false }; + } +} + +// --- Init timeout --- + +const INIT_FALLBACK_TIMEOUT_MS = 5000; + +// --- Hook --- + +interface UseStreamPlaybackResult { + children: Particle[]; + currentParticle: Particle | null; + currentIndex: number; + status: PlaybackStatus; + paused: boolean; + initialized: boolean; + next: () => void; + prev: () => void; + goTo: (index: number) => void; + pause: () => void; + resume: () => void; +} + +export function useStreamPlayback( + streamParticle: Particle & { type: "stream" }, + path: ParticlePath, +): UseStreamPlaybackResult { + const userId = useAuthStore((s) => s.user?.id); + const { children } = useLiveParticleChildren(path, "created_at", "asc"); + + const [state, dispatch] = useReducer(playbackReducer, initialState); + const initTimeoutRef = useRef | null>(null); + // Track the stream ID we've initialized for, to reset when navigating between streams + const initializedForRef = useRef(null); + + // Derive current index and particle from ID + const currentIndex = useMemo(() => { + if (!state.currentParticleId) return -1; + return children.findIndex((c) => c.id === state.currentParticleId); + }, [children, state.currentParticleId]); + + const currentParticle = currentIndex !== -1 ? children[currentIndex] : null; + + // --- Init logic: runs on every children change until initialized --- + useEffect(() => { + // Reset if we navigated to a different stream + if (initializedForRef.current !== null && initializedForRef.current !== streamParticle.id) { + initializedForRef.current = null; + if (initTimeoutRef.current) { + clearTimeout(initTimeoutRef.current); + initTimeoutRef.current = null; + } + } + + // Already initialized for this stream + if (state.initialized && initializedForRef.current === streamParticle.id) return; + + if (children.length === 0) return; + + const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; + + if (!playbackPosition) { + // No marker — start from the beginning + initializedForRef.current = streamParticle.id; + dispatch({ type: "INIT", particleId: children[0].id }); + return; + } + + // Try to find the marker's target particle + const found = children.find( + (c) => c.created_at.getTime() === playbackPosition.getTime(), + ); + + if (found) { + // Found it — init immediately + initializedForRef.current = streamParticle.id; + if (initTimeoutRef.current) { + clearTimeout(initTimeoutRef.current); + initTimeoutRef.current = null; + } + dispatch({ type: "INIT", particleId: found.id }); + return; + } + + // Marker target not found yet — start timeout if not already running + if (!initTimeoutRef.current) { + initTimeoutRef.current = setTimeout(() => { + initTimeoutRef.current = null; + // Fallback: find nearest particle by timestamp, or first child + initializedForRef.current = streamParticle.id; + dispatch({ type: "INIT", particleId: children[0].id }); + }, INIT_FALLBACK_TIMEOUT_MS); + } + + return () => { + if (initTimeoutRef.current) { + clearTimeout(initTimeoutRef.current); + initTimeoutRef.current = null; + } + }; + }, [children, streamParticle.id, streamParticle.playback_markers, userId, state.initialized]); + + // --- Handle current particle disappearing (deletion) --- + useEffect(() => { + if (!state.initialized || !state.currentParticleId) return; + if (children.length === 0) { + dispatch({ type: "PARTICLE_REMOVED", fallbackParticleId: null }); + return; + } + + const stillExists = children.some((c) => c.id === state.currentParticleId); + if (stillExists) return; + + // Current particle was removed — find nearest neighbor + // Use the previous index position, clamped to the new array bounds + const fallbackIndex = Math.min(currentIndex, children.length - 1); + const fallback = children[Math.max(0, fallbackIndex)]; + dispatch({ type: "PARTICLE_REMOVED", fallbackParticleId: fallback?.id ?? null }); + }, [children, state.initialized, state.currentParticleId, currentIndex]); + + // --- Handle new particles appended while at "ended" --- + useEffect(() => { + if (state.status !== "ended" || !state.currentParticleId) return; + + const idx = children.findIndex((c) => c.id === state.currentParticleId); + if (idx !== -1 && idx < children.length - 1) { + // New particle after current — advance to it + dispatch({ type: "SET_PARTICLE", particleId: children[idx + 1].id }); + } + }, [children, state.status, state.currentParticleId]); + + // --- Persist playback marker --- + useEffect(() => { + if (!userId || !state.initialized || !currentParticle) return; + + const streamDocPath = toFirestoreDocPath(path); + updateStreamPlaybackMarker(streamDocPath, userId, currentParticle.created_at); + }, [currentParticle?.id, state.initialized, userId, path]); + + // --- Navigation callbacks --- + const next = useCallback(() => { + if (currentIndex === -1) return; + if (currentIndex < children.length - 1) { + dispatch({ type: "SET_PARTICLE", particleId: children[currentIndex + 1].id }); + } else { + dispatch({ type: "END" }); + } + }, [children, currentIndex]); + + const prev = useCallback(() => { + if (currentIndex <= 0) return; + dispatch({ type: "SET_PARTICLE", particleId: children[currentIndex - 1].id }); + }, [children, currentIndex]); + + const goTo = useCallback( + (index: number) => { + if (index >= 0 && index < children.length) { + dispatch({ type: "SET_PARTICLE", particleId: children[index].id }); + } + }, + [children], + ); + + const pause = useCallback(() => dispatch({ type: "PAUSE" }), []); + const resume = useCallback(() => dispatch({ type: "RESUME" }), []); + + return { + children, + currentParticle, + currentIndex, + status: state.status, + paused: state.paused, + initialized: state.initialized, + next, + prev, + goTo, + pause, + resume, + }; +} diff --git a/js/src/lib/firestore-particles.ts b/js/src/lib/firestore-particles.ts index 628d561..4ba0408 100644 --- a/js/src/lib/firestore-particles.ts +++ b/js/src/lib/firestore-particles.ts @@ -143,6 +143,10 @@ export function subscribeToParticleChildren( return onSnapshot( q, (snap) => { + // onNext(snap.docChanges().map((change) => { + // const data = change.doc.data(); + // return { type: change.type, data }; + // })); onData(snap.docs.map((d) => d.data())); }, onError,