import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, useState } from "react"; 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 { 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_ADDED"; particleId: string } | { type: "PARTICLE_REMOVED"; removedParticleId: string; 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_ADDED": if (state.status === "ended") { return { ...state, currentParticleId: action.particleId, status: "playing", paused: false }; } 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" }; 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; goToParticle: (particleId: string) => void; pause: () => void; resume: () => 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 the stream ID we've initialized for, to reset when navigating between streams const initializedForRef = useRef(null); // --- Firestore change callbacks --- const onParticleAdded = useCallback((particle: Particle) => { dispatch({ type: "PARTICLE_ADDED", particleId: particle.id }); }, []); const onParticleRemoved = useEffectEvent((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; // Fallback init — always sees latest children/state via useEffectEvent const initFallback = useEffectEvent(() => { 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(() => { // Reset if we navigated to a different stream if (initializedForRef.current !== null && initializedForRef.current !== streamParticle.id) { initializedForRef.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) { 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 }); } // Marker target not found yet — fall back after timeout const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS); return () => clearTimeout(timeout); }, [children, streamParticle.id, streamParticle.playback_markers, userId, state.initialized]); // --- 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], ); // NOTE: if particle doesn't exist in children, this will lead to a brief moment where currentParticle is null const goToParticle = useCallback( (particleId: string) => { // Dispatch directly by ID — if the particle isn't in children yet // (e.g. just created), it will resolve once the live query delivers it. dispatch({ type: "SET_PARTICLE", particleId }); }, []); 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, goToParticle, pause, resume, }; }