import { useCallback, useEffect, useEffectEvent, 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; 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' }; } } // --- Init timeout --- const INIT_FALLBACK_TIMEOUT_MS = 5000; // --- Hook --- 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; } interface UseStreamPlaybackOptions { /** * When false, newly arriving particles don't pull playback forward after * it has ended (list mode browses; selection must stay put). Default true. */ autoAdvanceOnNew?: boolean; } export function useStreamPlayback( streamParticle: Particle & { type: 'stream' }, path: ParticlePath, { autoAdvanceOnNew = true }: UseStreamPlaybackOptions = {}, ): UseStreamPlaybackResult { const userId = useAuthStore((s) => s.user?.id); const [state, dispatch] = useReducer(playbackReducer, initialState); // Read via ref so the onAdded subscription callback stays stable. const autoAdvanceOnNewRef = useRef(autoAdvanceOnNew); useEffect(() => { autoAdvanceOnNewRef.current = autoAdvanceOnNew; }, [autoAdvanceOnNew]); // Track the stream ID we've initialized for, to reset when navigating between streams const initializedForRef = useRef(null); // Latest currentIndex for onParticleRemoved, which is passed into // useLiveParticleChildren. Reading it through a ref keeps the callback stable // (no re-subscription) and breaks the declaration cycle // children -> currentIndex -> callback -> children. useEffectEvent can't be // used here — Effect Events may not be passed to another hook. const currentIndexRef = useRef(0); // --- Firestore change callbacks --- const onParticleAdded = useCallback((particle: Particle) => { if (!autoAdvanceOnNewRef.current) return; dispatch({ type: 'PARTICLE_ADDED', particleId: particle.id }); }, []); const onParticleRemoved = useCallback( (removed: Particle, updatedChildren: Particle[]) => { const fallbackIndex = Math.min( currentIndexRef.current, 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; // Keep the ref read by onParticleRemoved in sync with the derived index. useEffect(() => { currentIndexRef.current = currentIndex; }, [currentIndex]); // 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 (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]; // Only update if advancing beyond the current marker if (existingMarker && currentTime.getTime() <= existingMarker.getTime()) return; lastPersistedMarkerRef.current = currentTime; const streamDocPath = toFirestoreDocPath(path); updateStreamPlaybackMarker(streamDocPath, userId, currentTime); }, [ currentParticle, streamParticle.playback_markers, 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 }); }, []); return { children, currentParticle, currentIndex, status: state.status, initialized: state.initialized, next, prev, goTo, goToParticle, }; }