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 type { Particle } from "@/api/types"; import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path"; import { ComposeOverlay } from "@/features/compose/compose-overlay"; 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 { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"; import { VideoAudioToggle } from "@/components/video-audio-toggle"; import { KeybindingsOverlay, type KeybindingGroup } from "@/components/keybindings-overlay"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useNetwork, useNetworks } from "@/hooks/use-networks"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react"; import { updateStreamStatus } from "@/lib/firestore-particles"; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"; import { WindowControls } from "@/components/window-controls"; import { RelativeTimestamp } from "@/components/relative-timestamp"; import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; import { usePresencePositions } from "@/hooks/use-presence-positions"; import { cn, getInitials } from "@/lib/utils"; import { useMount } from "react-use"; function getParticleDisplayName(particle: Particle): string { switch (particle.type) { case "stream": case "folder": return particle.properties.name; case "quest": return particle.properties.title; case "paper": return particle.properties.title; case "file": return particle.properties.filename; case "text": return particle.properties.content.slice(0, 30); case "media": return particle.type; } } // --- Exit countdown hook --- const EXIT_DELAY_MS = 5000; const EXIT_TICK_MS = 100; type PlaybackStatus = "idle" | "playing" | "ended"; function useExitCountdown( status: PlaybackStatus, composeActive: 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 || composeActive) 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, composeActive]); // 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: ["↑", "↓"], description: "Previous / next particle" }, { keys: ["Esc"], description: "Back to network" }, ], }, { label: "Playback", bindings: [ { keys: ["Hold", "Space"], description: "Pause" }, { keys: ["Hold", "Shift"], description: "1.5× speed" }, ], }, { label: "Compose", bindings: [ { keys: ["Hold", "`"], description: "Reply" }, { keys: ["T"], description: "Text compose" }, { keys: ["H"], description: "Join huddle" }, ], }, ]; // --- StreamView --- interface StreamViewProps { streamParticle: Particle & { type: "stream" }; path: ParticlePath; } export function StreamView({ path, streamParticle }: StreamViewProps) { const { networkId } = parseParticlePath(path); const navigate = useNavigate(); useMount(() => { window.electronAutoplay.dismiss(); }); const { children, currentParticle, currentIndex, status, paused, next, prev, goTo, pause, resume, } = useStreamPlayback(streamParticle, path); usePrefetchAdjacentMedia(children, currentIndex); const authedUser = useAuthStore((s) => s.user); const network = useNetwork(networkId); const presenceBySegment = usePresencePositions( streamParticle.playback_markers, children, network?.humans, authedUser?.id, ); const mediaRef = useRef(null); const [composeActive, setComposeActive] = useState(false); const [progress, setProgress] = useState(0); const [fastPlayback, setFastPlayback] = useState(false); const [showKeybindings, setShowKeybindings] = useState(false); // 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, composeActive, handleExitNavigate, ); // Reset progress when particle changes useEffect(() => { setProgress(0); }, [currentParticle?.id]); // Pause/resume playback when compose overlay opens/closes useEffect(() => { if (composeActive) pause(); else resume(); }, [composeActive, pause, resume]); // Playback keyboard: arrows, escape, hold-space-to-pause useEffect(() => { function isInputTarget(e: KeyboardEvent) { const target = e.target as HTMLElement; return ( target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable ); } const handleKeyDown = (e: KeyboardEvent) => { if (composeActive) return; if (isInputTarget(e)) return; switch (e.key) { case "ArrowRight": case "ArrowDown": e.preventDefault(); if (!mediaRef.current?.seek(10)) next(); break; case "ArrowLeft": case "ArrowUp": e.preventDefault(); if (!mediaRef.current?.seek(-10)) prev(); break; case " ": e.preventDefault(); if (!e.repeat) pause(); break; case "Shift": if (!e.repeat) { mediaRef.current?.setPlaybackRate(1.5); setFastPlayback(true); } break; case "Escape": e.preventDefault(); navigate(`/${networkId}`); break; case "h": { e.preventDefault(); apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { window.electronWindow.openHuddle({ token, serverUrl: server_url }); }); navigate(`/${networkId}`); break; } case "?": e.preventDefault(); setShowKeybindings((v) => !v); break; } }; const handleKeyUp = (e: KeyboardEvent) => { if (composeActive) return; if (isInputTarget(e)) return; if (e.key === " ") { e.preventDefault(); resume(); } if (e.key === "Shift") { mediaRef.current?.setPlaybackRate(1); setFastPlayback(false); } }; window.addEventListener("keydown", handleKeyDown); window.addEventListener("keyup", handleKeyUp); return () => { window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("keyup", handleKeyUp); }; }, [composeActive, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings], ); // Click-to-navigate: left 30% = prev, right 70% = next // Suppressed when the user has selected text (drag-to-select) const handlePlaybackClick = useCallback( (e: React.MouseEvent) => { const selection = window.getSelection(); if (selection && selection.toString().length > 0) return; const rect = e.currentTarget.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; if (x < 0.3) prev(); else if (x > 0.7) next(); }, [prev, next], ); if (children.length === 0) { return (

No particles in this stream yet

setShowKeybindings(true)} />
); } // Render particle content inline function renderParticle(particle: Particle) { 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
)}
)}
{/* BottomBar */} setShowKeybindings(true)} /> setShowKeybindings(false)} groups={STREAM_VIEW_KEYBINDINGS} title="Stream View" />
); } function BottomBar({ visible, total, current, progress, onGoTo, presenceBySegment, exitRemainingMs, onOpenKeybindings, }: { visible: boolean; total: number; current: number; progress: number; onGoTo: (index: number) => void; presenceBySegment: Map; 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; }) { return (
{showEscape && ( Esc {" "} back )} Hold ` {" "} to reply T {" "} text H {" "} huddle ?
); } function TopBar({ networkId, particle, streamParticle }: { networkId: string; particle: Particle | null; streamParticle: Particle & { type: "stream" } }) { const navigate = useNavigate(); const network = useNetwork(networkId); const huddleParticipants = streamParticle.huddle_active_participants ?? []; const hasActiveHuddle = huddleParticipants.length > 0; const handleJoinHuddle = () => { apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => { window.electronWindow.openHuddle({ token, serverUrl: server_url }); }); }; return (
{streamParticle && ( <> {getParticleDisplayName(streamParticle)} )} {particle && ( <> )} {hasActiveHuddle && ( )} {streamParticle.status === "closed" && ( Closed )} { const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id])); await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open"); }} > {streamParticle.status === "open" ? ( <> Close stream ) : ( <> Open stream )} navigate("/settings")}> Settings
); } function NetworkBreadcrumbContent({ networkId }: { networkId: string }) { const { data: networks } = useNetworks(); const network = networks?.find((n) => n.id === networkId); const name = network?.name ?? networkId; const initials = name.slice(0, 2).toUpperCase(); return ( {initials} {name} ); } function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) { const network = useNetwork(networkId); const creator = network?.humans?.find((h) => h.id === particle.created_by_human_id); const prefix = creator?.email_prefix ?? particle.created_by_human_id; const initials = prefix.slice(0, 2).toUpperCase(); return ( {initials} {prefix} - ) }