Files
llink/js/src/features/particles/stream-view.tsx
T
talksik 484ee9d4fb fix: longer markdown content hidden
Added scroll to longer content and padding to avoid conflicting with
stream view peripheral controls and panels
2026-04-12 10:12:06 -07:00

777 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, REACTION_EMOJIS } from "@/api/types";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay, type ComposeStep } 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 { useMediaSettingsStore } from "@/stores/media-settings-store";
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, toggleParticleReaction } from "@/lib/firestore-particles";
import { ReactionBar } from "@/features/particles/reaction-bar";
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 { StreamPresenceProvider, useStreamPresence, useStreamComposing, useStreamComposingBroadcast, type ComposingMode } from "@/features/particles/stream-presence-context";
import { ComposingIndicator } from "@/components/composing-indicator";
import { cn, getInitials } from "@/lib/utils";
import { useMount } from "react-use";
function getReactions(particle: Particle): Record<string, string[]> | undefined {
if (particle.type === "media" || particle.type === "text") return particle.reactions;
return undefined;
}
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<number | null>(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: ["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: ["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" },
],
},
];
// --- StreamView ---
interface StreamViewProps {
streamParticle: Particle & { type: "stream" };
path: ParticlePath;
}
export function StreamView({ path, streamParticle }: StreamViewProps) {
const { networkId } = parseParticlePath(path);
return (
<StreamPresenceProvider networkId={networkId} streamId={streamParticle.id}>
<StreamViewInner path={path} streamParticle={streamParticle} />
</StreamPresenceProvider>
);
}
function StreamViewInner({ 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 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<MediaParticleHandle>(null);
const handleToggleReaction = useCallback((emoji: string) => {
if (!authedUser || !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<ComposeStep>("idle");
const [progress, setProgress] = useState(0);
const [fastPlayback, setFastPlayback] = useState(false);
const [showKeybindings, setShowKeybindings] = useState(false);
// Broadcast composing state to other viewers
useEffect(() => {
const stepToMode: Record<string, ComposingMode | null> = {
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<ReturnType<typeof setTimeout>>(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();
next();
break;
case "ArrowLeft":
case "ArrowUp":
e.preventDefault();
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 "v":
e.preventDefault();
setRecordingMode(recordingMode === "video" ? "audio" : "video");
break;
case "?":
e.preventDefault();
setShowKeybindings((v) => !v);
break;
case "1": case "2": case "3": case "4": case "5": case "6": case "7":
e.preventDefault();
handleToggleReaction(REACTION_EMOJIS[parseInt(e.key) - 1]);
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, handleToggleReaction, recordingMode, setRecordingMode],
);
// 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<HTMLDivElement>) => {
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 (
<div className="flex h-full flex-col items-center justify-center gap-4 bg-black text-white">
<p className="text-muted-foreground text-sm">
No particles in this stream yet
</p>
<StreamViewControls
showEscape
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"}
/>
</div>
);
}
// Render particle content inline
function renderParticle(particle: Particle) {
switch (particle.type) {
case "media":
return (
<MediaParticleView
ref={mediaRef}
key={particle.id}
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
case "text":
return (
<TextParticleView
key={particle.id}
particle={particle}
streamPath={path}
paused={paused}
onEnded={next}
onProgress={setProgress}
/>
);
default:
return <FallbackParticleView particle={particle} />;
}
}
return (
<div
className="relative flex h-screen flex-col overflow-hidden bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:5rem]"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 mt-3">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
</div>
{/* Main playback area */}
<div className="flex-1 overflow-hidden">
{currentParticle && (
<div
className="relative flex h-full w-full cursor-pointer items-center justify-center"
onClick={handlePlaybackClick}
>
{renderParticle(currentParticle)}
{fastPlayback && (
<div className="pointer-events-none absolute right-4 top-14 z-20 rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
</div>
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && (
<div className="absolute right-4 top-1/2 -translate-y-1/2 z-10">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ""}
humans={network?.humans}
onToggle={handleToggleReaction}
/>
</div>
)}
{/* Composing indicator — left edge, always visible */}
<ComposingIndicator users={composingUsers} networkHumans={network?.humans} />
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
onStepChange={setComposeStep}
disabled={streamParticle.status === "closed"}
/>
{/* Bottom gradient safe zone for keyboard hints */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-1/4 bg-gradient-to-t from-black/60 to-transparent" />
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
exitRemainingMs={exitRemainingMs}
onOpenKeybindings={() => setShowKeybindings(true)}
/>
<KeybindingsOverlay
open={showKeybindings}
onClose={() => setShowKeybindings(false)}
groups={STREAM_VIEW_KEYBINDINGS}
title="Stream View"
/>
</div>
);
}
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<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
onlineHumanIds: Set<string>;
exitRemainingMs: number | null;
onOpenKeybindings: () => void;
}) {
return (
<div className={cn(
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
)}>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
onlineHumanIds={onlineHumanIds}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
layer="tracks"
/>
<div className="flex items-center justify-center px-3 pt-2 gap-2">
{exitRemainingMs !== null && (
<div className="flex justify-center">
<span className="rounded-full bg-black/30 px-2 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
)}
<StreamViewControls
showEscape
onOpenKeybindings={onOpenKeybindings}
/>
</div>
</div>
</div>
);
}
function StreamViewControls({
showEscape,
onOpenKeybindings,
}: {
showEscape?: boolean;
onOpenKeybindings: () => void;
}) {
return (
<div className="flex items-center gap-4 text-sm text-white/50">
{showEscape && (
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Esc
</kbd>{" "}
back
</span>
)}
<VideoAudioToggle />
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to reply
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</span>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
<kbd
role="button"
onClick={onOpenKeybindings}
className="cursor-pointer rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs transition-colors hover:text-white/80"
title="Show all shortcuts"
>
?
</kbd>
</div>
);
}
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 (
<div className="drag-region flex flex-row px-4 gap-5 items-center">
<WindowControls />
<Breadcrumb className="no-drag rounded-full bg-black/30 backdrop-blur-sm px-3 py-1 mx-auto">
<BreadcrumbList>
{streamParticle && (
<>
<BreadcrumbItem className="text-xs">
<BreadcrumbPage>{getParticleDisplayName(streamParticle)}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
{particle && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem className="text-xs">
<BreadcrumbPage><ParticleBreadcrumbContent particle={particle} networkId={networkId} /></BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
{hasActiveHuddle && (
<button
onClick={handleJoinHuddle}
className="no-drag flex items-center gap-2 rounded-full bg-red-500/20 px-3 py-1 backdrop-blur-sm transition-colors hover:bg-red-500/30"
>
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-red-500" />
</span>
<AvatarGroup>
{huddleParticipants.map((humanId) => {
const human = network?.humans?.find((h) => h.id === humanId);
const initials = human ? getInitials(human.email) : "?";
return (
<Tooltip key={humanId}>
<TooltipTrigger asChild>
<Avatar size="sm">
<AvatarFallback className="bg-red-500/30 text-[8px] text-red-200">
{initials}
</AvatarFallback>
</Avatar>
</TooltipTrigger>
<TooltipContent>{human?.email ?? humanId}</TooltipContent>
</Tooltip>
);
})}
</AvatarGroup>
<span className="text-xs font-medium text-red-200">Join</span>
</button>
)}
{streamParticle.status === "closed" && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
>
<EllipsisVertical className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
}}
>
{streamParticle.status === "open" ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate("/settings")}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
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 (
<span className="flex items-center gap-1.5">
<Avatar size="sm">
<AvatarFallback>
{initials}
</AvatarFallback>
</Avatar>
{name}
</span>
);
}
function ParticleBreadcrumbContent({ particle, networkId }: { particle: Particle; networkId: string }) {
const network = useNetwork(networkId);
const { onlineHumanIds } = useStreamPresence();
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();
const isOnline = particle.created_by_human_id ? onlineHumanIds.has(particle.created_by_human_id) : false;
return (
<span className="flex items-center gap-1.5">
<Avatar size="sm" className={isOnline ? "ring-2 ring-green-500" : ""}>
<AvatarFallback>
{initials}
</AvatarFallback>
</Avatar>
{prefix} - <RelativeTimestamp date={particle.created_at} />
</span>
)
}