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, }; }