Files
llink/js/src/features/particles/stream-view.tsx
T
talksik 7e11d66dc2 enhance keyboard experience
Now we have network root and stream-view defining their own hints
instead of trying to re-use one component. And we also introduce an
overlay for additional commands for people.
2026-04-08 08:40:07 -07:00

679 lines
21 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 } 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<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: ["↑", "↓"], 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<MediaParticleHandle>(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<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();
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<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"
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>
<ComposeOverlay
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"}
/>
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
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,
exitRemainingMs,
onOpenKeybindings,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
onGoTo: (index: number) => void;
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
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}
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 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 (
<span className="flex
items-center gap-1.5">
<Avatar size="sm">
<AvatarFallback>
{initials}
</AvatarFallback>
</Avatar>
{prefix} - <RelativeTimestamp date={particle.created_at} />
</span>
)
}