import { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef, } from 'react'; import { useAuthStore } from '@/stores/auth-store'; import type { Particle } from '@/api/types'; import { useWindowedStreamParticles } from '@/hooks/use-windowed-stream-particles'; 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; /** Whether older particles exist before the loaded window (list scroll-up). */ hasMoreOlder: boolean; /** Extend the loaded window backward. */ loadOlder: () => void; isLoadingOlder: 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); const marker = userId ? (streamParticle.playback_markers?.[userId] ?? null) : null; // Windowed source: only the tail (plus enough history to cover the marker) // is loaded, instead of every particle in the stream. const { children, hasMoreOlder, loadOlder, isLoadingOlder } = useWindowedStreamParticles(path, { marker }); // Read via ref so the new-particle effect stays cheap to reason about. const autoAdvanceOnNewRef = useRef(autoAdvanceOnNew); useEffect(() => { autoAdvanceOnNewRef.current = autoAdvanceOnNew; }, [autoAdvanceOnNew]); // Track the stream ID we've initialized for, to reset when navigating 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; // Remember the last index the current particle was actually found at, so a // removal can fall back to a sensible neighbour even though `currentIndex` // has already gone to -1 by the time we notice. const lastValidIndexRef = useRef(0); useEffect(() => { if (currentIndex >= 0) lastValidIndexRef.current = currentIndex; }, [currentIndex]); // --- New tail particle → resume from end --- // Derive arrivals from the children tail rather than Firestore change events, // which can't distinguish a genuine new particle from pagination backfill. const prevNewestIdRef = useRef(null); useEffect(() => { if (children.length === 0) { prevNewestIdRef.current = null; return; } const newestId = children[children.length - 1].id; const prevNewestId = prevNewestIdRef.current; prevNewestIdRef.current = newestId; if (prevNewestId === null || newestId === prevNewestId) return; if (!autoAdvanceOnNewRef.current) return; // Resume at the first particle added after where playback ended. const prevIndex = children.findIndex((c) => c.id === prevNewestId); const firstNew = prevIndex >= 0 ? (children[prevIndex + 1] ?? children[children.length - 1]) : children[children.length - 1]; dispatch({ type: 'PARTICLE_ADDED', particleId: firstNew.id }); }, [children]); // --- Current particle removed (deletion) → fall back to a neighbour --- const prevIdsRef = useRef>(new Set()); useEffect(() => { const id = state.currentParticleId; const prevIds = prevIdsRef.current; const currIds = new Set(children.map((c) => c.id)); prevIdsRef.current = currIds; if (!id || children.length === 0) return; if (currIds.has(id)) return; // Only treat as a removal if it was present before — a not-yet-arrived id // (e.g. optimistic goToParticle) should wait, not fall back. if (!prevIds.has(id)) return; const fallbackIndex = Math.min( lastValidIndexRef.current, children.length - 1, ); const fallback = children[Math.max(0, fallbackIndex)]; dispatch({ type: 'PARTICLE_REMOVED', removedParticleId: id, fallbackParticleId: fallback?.id ?? null, }); }, [children, state.currentParticleId]); // 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 start of the loaded window. initializedForRef.current = streamParticle.id; dispatch({ type: 'INIT', particleId: children[0].id }); return; } // Resume at the first particle after the marker. const found = children.find( (c) => c.created_at.getTime() > playbackPosition.getTime(), ); if (found) { initializedForRef.current = streamParticle.id; dispatch({ type: 'INIT', particleId: found.id }); return; } // Marker is older than everything loaded so far. If the window is still // growing backward to reach it, wait for more particles to arrive. if (hasMoreOlder) { const timeout = setTimeout(initFallback, INIT_FALLBACK_TIMEOUT_MS); return () => clearTimeout(timeout); } // Reached the start with no particle after the marker → caught up. initializedForRef.current = streamParticle.id; dispatch({ type: 'INIT', particleId: children[children.length - 1].id }); }, [ children, streamParticle.id, streamParticle.playback_markers, userId, state.initialized, hasMoreOlder, ]); // --- 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; if (currentIndex === 0) { // At the start of the loaded window — pull in older history so the user // can keep going back. if (hasMoreOlder) loadOlder(); return; } dispatch({ type: 'SET_PARTICLE', particleId: children[currentIndex - 1].id, }); }, [children, currentIndex, hasMoreOlder, loadOlder]); 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, hasMoreOlder, loadOlder, isLoadingOlder, next, prev, goTo, goToParticle, }; }