import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react"; import { useNavigate } from "react-router-dom"; import { useAuthStore } from "@/stores/auth-store"; import { apiClient } from "@/api/client"; import { isParticleDeleted, type Particle } from "@/api/types"; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay, type ComposeStep } from "@/features/compose/compose-overlay"; import { useComposeIntentStore } from "@/stores/compose-intent-store"; import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator"; import { MediaParticleView, type MediaParticleHandle } from "@/features/particles/media-particle-view"; import { TextParticleView } from "@/features/particles/text-particle-view"; import { FallbackParticleView } from "@/features/particles/fallback-particle-view"; import { DeletedParticleView } from "@/features/particles/deleted-particle-view"; import { VideoAudioToggle } from "@/components/video-audio-toggle"; import { useMediaSettingsStore } from "@/stores/media-settings-store"; import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay"; import { useNetwork } from "@/hooks/use-networks"; import { toggleParticleReaction } from "@/lib/firestore-particles"; import { ReactionBar } from "@/features/particles/reaction-bar"; import { TextReactionInput } from "@/features/particles/text-reaction-input"; import { TopBar } from "@/features/particles/stream-top-bar"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; import { usePresencePositions } from "@/hooks/use-presence-positions"; import { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context"; import { ComposingIndicator } from "@/components/composing-indicator"; import { cn } from "@/lib/utils"; import { useMount } from "react-use"; import { usePlaybackPauseStore, selectIsPaused } from "@/stores/playback-pause-store"; import { usePlaybackKeys } from "@/hooks/use-playback-keys"; import { useStreamNavigationKeys } from "@/hooks/use-stream-navigation-keys"; import { useStreamActionKeys } from "@/hooks/use-stream-action-keys"; import { platform } from "@/lib/platform"; import { requireDesktop } from "@/lib/platform/desktop-only"; function getReactions(particle: Particle): Record | undefined { if (isParticleDeleted(particle)) return undefined; if (particle.type === "media" || particle.type === "text") return particle.reactions; return undefined; } // --- Exit countdown hook --- const EXIT_DELAY_MS = 5000; const EXIT_TICK_MS = 100; type PlaybackStatus = "idle" | "playing" | "ended"; function useExitCountdown( status: PlaybackStatus, disabled: boolean, onExit: () => void, ) { const [remainingMs, setRemainingMs] = useState(null); const handleExit = useEffectEvent(() => { onExit(); }); // Start/cancel countdown based on playback status useEffect(() => { if (status === "ended") { setRemainingMs(EXIT_DELAY_MS); } else { setRemainingMs(null); } }, [status]); // Tick the countdown down (pauses when compose is active) useEffect(() => { if (remainingMs === null || remainingMs <= 0 || disabled) return; const interval = setInterval(() => { setRemainingMs((prev) => { if (prev === null) return null; const next = prev - EXIT_TICK_MS; return next <= 0 ? 0 : next; }); }, EXIT_TICK_MS); return () => clearInterval(interval); }, [remainingMs !== null && remainingMs > 0, disabled]); // Navigate once countdown hits zero useEffect(() => { if (remainingMs !== null && remainingMs <= 0) { handleExit(); } }, [remainingMs]); return remainingMs; } // --- Keybindings --- const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [ { label: "Navigation", bindings: [ { keys: ["←", "→", "↑", "↓"], description: "Previous / next particle" }, { keys: ["Esc"], description: "Back to network" }, ], }, { label: "Playback", bindings: [ { keys: ["Space"], description: "Toggle pause" }, { keys: ["Hold", "Space"], description: "Pause while held" }, { keys: ["Hold", "Shift"], description: "1.5× speed" }, { keys: ["Shift", "←", "→"], description: "Seek ±5s" }, ], }, { label: "Compose", bindings: [ { keys: ["Hold", "`"], description: "Reply" }, { keys: ["S"], description: "Screen record" }, { keys: ["T"], description: "Text compose" }, { keys: ["V"], description: "Toggle video / audio" }, { keys: ["H"], description: "Join huddle" }, ], }, { label: "Reactions", bindings: [ { keys: ["1-7"], description: "Toggle emoji reaction" }, { keys: ["R"], description: "Quick text reply" }, ], }, ]; // --- StreamView --- interface StreamViewProps { streamParticle: Particle & { type: "stream" }; path: ParticlePath; } export function StreamView({ path, streamParticle }: StreamViewProps) { const { networkId } = parseParticlePath(path); return ( ); } function StreamViewInner({ path, streamParticle }: StreamViewProps) { const { networkId } = parseParticlePath(path); const navigate = useNavigate(); useMount(() => { platform.autoplay.dismiss(); }); const { children, currentParticle, currentIndex, status, next, prev, goTo, goToParticle } = useStreamPlayback(streamParticle, path); usePrefetchAdjacentMedia(children, currentIndex); const authedUser = useAuthStore((s) => s.user); const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode); const network = useNetwork(networkId); const presenceBySegment = usePresencePositions( streamParticle.playback_markers, children, network?.humans, authedUser?.id, ); // --- Stream presence (realtime via pusher) --- const { onlineHumanIds } = useStreamPresence(); const { composingUsers } = useStreamComposing(); const { startComposing, stopComposing } = useStreamComposingBroadcast(); const mediaRef = useRef(null); const handleToggleReaction = useCallback((emoji: string) => { if (!authedUser || !currentParticle) return; if (isParticleDeleted(currentParticle)) return; const currentParticleDocPath = currentParticle ? toFirestoreDocPath(particlePath(networkId, [streamParticle.id, currentParticle.id])) : null; if (!currentParticleDocPath) return; const reactions = getReactions(currentParticle); toggleParticleReaction(currentParticleDocPath, emoji, authedUser.id, reactions); }, [authedUser, currentParticle]); const [composeActive, setComposeActive] = useState(false); const [composeStep, setComposeStep] = useState("idle"); const paused = usePlaybackPauseStore(selectIsPaused); const [progress, setProgress] = useState(0); const [showKeybindings, setShowKeybindings] = useState(false); const [textReactionOpen, setTextReactionOpen] = useState(false); const handleSubmitTextReaction = useCallback((text: string) => { handleToggleReaction(text); }, [handleToggleReaction]); const { fastPlayback } = usePlaybackKeys({ mediaRef }); useStreamNavigationKeys({ next, prev, currentIndex, childrenLength: children.length, mediaRef, }); const handleOpenHuddle = useCallback(() => { if (!requireDesktop("Huddle")) return; apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { platform.huddle.open({ token, serverUrl: server_url }); }); navigate(`/${networkId}`); }, [networkId, streamParticle.id, navigate]); const handleToggleRecordingMode = useCallback(() => { setRecordingMode(recordingMode === "video" ? "audio" : "video"); }, [recordingMode, setRecordingMode]); const handleToggleKeybindings = useCallback(() => { setShowKeybindings((v) => !v); }, []); useStreamActionKeys({ onToggleReaction: handleToggleReaction, onOpenHuddle: handleOpenHuddle, onToggleRecordingMode: handleToggleRecordingMode, onToggleKeybindings: handleToggleKeybindings, onOpenTextReaction: () => setTextReactionOpen(true), }); // Broadcast composing state to other viewers useEffect(() => { const stepToMode: Record = { idle: null, submitting: null, recording: "recording", typing: "typing", reviewing: "typing", configuring: "typing", picking: "screen", }; const mode = stepToMode[composeStep] ?? null; if (mode) { startComposing(mode); } else { stopComposing(); } }, [composeStep, startComposing, stopComposing]); // Show/hide chrome on mouse activity (YouTube-style) const [showControls, setShowControls] = useState(true); const idleTimerRef = useRef>(undefined); const handleMouseActivity = useCallback(() => { setShowControls(true); clearTimeout(idleTimerRef.current); idleTimerRef.current = setTimeout(() => setShowControls(false), 3000); }, []); useEffect(() => () => clearTimeout(idleTimerRef.current), []); // Always show controls when compose is active or exit countdown is visible const controlsVisible = showControls || composeActive || status === "ended"; const handleExitNavigate = useCallback(() => { navigate(`/${networkId}`); }, [navigate, networkId]); const exitRemainingMs = useExitCountdown( status, paused, handleExitNavigate, ); // Reset progress when particle changes useEffect(() => { setProgress(0); }, [currentParticle?.id]); const handleParticleCreated = useCallback((particleId: string) => { if (currentIndex === -1) return; // When local user is at children.length - 1, and they send a new particle, // we want to navigate to the new particle immediately so the user is considered caught up in the stream. // In other cases (e.g. when user is in the middle of the stream and new particles are added), // we don't want to disrupt their current position by jumping them to the end of the stream. // NOTE: at this point, `children` contains stale data from the time when compose was sending, so it doesn't include the new particle yet. if (currentIndex === children.length - 1) { goToParticle(particleId); } }, [children, goToParticle, currentIndex]); if (children.length === 0) { return (

No particles in this stream yet

setShowKeybindings(true)} />
); } // Render particle content inline function renderParticle(particle: Particle) { if (isParticleDeleted(particle)) { return ( ); } switch (particle.type) { case "media": return ( ); case "text": return ( ); default: return ; } } return (
setShowControls(false)} > {/* Top gradient safe zone */}
{/* TopBar — always visible */}
{/* Main playback area */}
{currentParticle && (
{renderParticle(currentParticle)}
{fastPlayback && (
1.5x
)} {paused && (
Paused
)}
)}
{/* Reaction bar — always visible */} {currentParticle && !isParticleDeleted(currentParticle) && (
setTextReactionOpen(true)} /> setTextReactionOpen(false)} />
)} {/* Composing indicator — left edge, always visible */} {/* Bottom gradient safe zone for keyboard hints */}
{/* BottomBar */} setShowKeybindings(true)} /> setShowKeybindings(false)} groups={STREAM_VIEW_KEYBINDINGS} title="Stream View" />
); } function BottomBar({ visible, total, current, progress, onGoTo, presenceBySegment, onlineHumanIds, exitRemainingMs, onOpenKeybindings, }: { visible: boolean; total: number; current: number; progress: number; onGoTo: (index: number) => void; presenceBySegment: Map; onlineHumanIds: Set; exitRemainingMs: number | null; onOpenKeybindings: () => void; }) { return (
{/* Presence avatars — above the blurred background */} {/* Blurred background container — tracks + controls */}
{exitRemainingMs !== null && (
Closing in {Math.ceil(exitRemainingMs / 1000)}s
)}
); } function StreamViewControls({ showEscape, onOpenKeybindings, }: { showEscape?: boolean; onOpenKeybindings: () => void; }) { const requestIntent = useComposeIntentStore((s) => s.request); return (
{showEscape && ( Esc {" "} back )} H {" "} huddle ?
); }