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"; import { logError } from "@/lib/errors"; import { useEvent } from "@/hooks/use-event"; // --- Playback reducer (ID-based) --- type PlaybackStatus = "idle" | "playing" | "ended"; interface PlaybackState { currentParticleId: string | null; status: PlaybackStatus; initialized: boolean; } type PlaybackAction = | { type: "INIT"; particleId: string } | { type: "SET_PARTICLE"; particleId: string } | { type: "END" } | { type: "PARTICLE_ADDED"; particleId: string } | { type: "PARTICLE_REMOVED"; removedParticleId: string; fallbackParticleId: string | null; }; const initialState: PlaybackState = { currentParticleId: null, status: "idle", initialized: false, }; function playbackReducer( state: PlaybackState, action: PlaybackAction, ): PlaybackState { switch (action.type) { case "INIT": return { currentParticleId: action.particleId, status: "playing", initialized: true, }; case "SET_PARTICLE": return { ...state, currentParticleId: action.particleId, status: "playing", }; case "END": return { ...state, status: "ended" }; case "PARTICLE_ADDED": if (state.status === "ended") { return { ...state, currentParticleId: action.particleId, status: "playing", }; } return state; case "PARTICLE_REMOVED": if (action.removedParticleId !== state.currentParticleId) return state; if (action.fallbackParticleId) { return { ...state, currentParticleId: action.fallbackParticleId, status: "playing", }; } return { ...state, currentParticleId: null, status: "idle" }; } } const INIT_FALLBACK_TIMEOUT_MS = 5000; interface UseStreamPlaybackResult { children: Particle[]; currentParticle: Particle | null; currentIndex: number; status: PlaybackStatus; initialized: boolean; next: () => void; prev: () => void; goTo: (index: number) => void; goToParticle: (particleId: string) => void; } export function useStreamPlayback( streamParticle: Particle & { type: "stream" }, path: ParticlePath, ): UseStreamPlaybackResult { const userId = useAuthStore((s) => s.user?.id); const [state, dispatch] = useReducer(playbackReducer, initialState); // Track which stream we initialized for, so navigating to a sibling resets cleanly. const initializedForRef = useRef(null); const onParticleAdded = useCallback((particle: Particle) => { dispatch({ type: "PARTICLE_ADDED", particleId: particle.id }); }, []); const onParticleRemoved = useEvent( (removed: Particle, updatedChildren: Particle[]) => { const fallbackIndex = Math.min(currentIndex, updatedChildren.length - 1); const fallback = updatedChildren[Math.max(0, fallbackIndex)]; dispatch({ type: "PARTICLE_REMOVED", removedParticleId: removed.id, fallbackParticleId: fallback?.id ?? null, }); }, ); const { children } = useLiveParticleChildren(path, { orderByField: "created_at", orderDirection: "asc", onAdded: onParticleAdded, onRemoved: onParticleRemoved, }); // 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; const initFallback = useEvent(() => { if (state.initialized || children.length === 0) return; initializedForRef.current = streamParticle.id; dispatch({ type: "INIT", particleId: children[0].id }); }); // --- Init logic: runs on every children change until initialized --- useEffect(() => { if ( initializedForRef.current !== null && initializedForRef.current !== streamParticle.id ) { initializedForRef.current = null; } if (state.initialized && initializedForRef.current === streamParticle.id) return; if (children.length === 0) return; const playbackPosition = streamParticle.playback_markers?.[userId ?? ""]; if (!playbackPosition) { initializedForRef.current = streamParticle.id; dispatch({ type: "INIT", particleId: children[0].id }); return; } const found = children.find( (c) => c.created_at.getTime() > playbackPosition.getTime(), ); if (found) { initializedForRef.current = streamParticle.id; dispatch({ type: "INIT", particleId: found.id }); return; } else { initializedForRef.current = streamParticle.id; dispatch({ type: "INIT", particleId: children[children.length - 1].id, }); } const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS); return () => clearTimeout(timeout); }, [ children, streamParticle.id, streamParticle.playback_markers, userId, state.initialized, initFallback, ]); // --- Persist playback marker (only advance forward, never backwards) --- const lastPersistedMarkerRef = useRef(null); useEffect(() => { if (!userId || !state.initialized || !currentParticle) return; const currentTime = currentParticle.created_at; const existingMarker = lastPersistedMarkerRef.current ?? streamParticle.playback_markers?.[userId]; if (existingMarker && currentTime.getTime() <= existingMarker.getTime()) return; lastPersistedMarkerRef.current = currentTime; const streamDocPath = toFirestoreDocPath(path); updateStreamPlaybackMarker(streamDocPath, userId, currentTime).catch( (err) => logError(err, { scope: "playback.marker", path }), ); // streamParticle.playback_markers is read at effect time; not in deps to // avoid double-writes when the snapshot we just persisted echoes back. // eslint-disable-next-line react-hooks/exhaustive-deps }, [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], ); // If the particle isn't in `children` yet (e.g. just-created), the live // query will resolve it shortly and the derived index/particle will catch up. const goToParticle = useCallback((particleId: string) => { dispatch({ type: "SET_PARTICLE", particleId }); }, []); return { children, currentParticle, currentIndex, status: state.status, initialized: state.initialized, next, prev, goTo, goToParticle, }; }