513 lines
16 KiB
TypeScript
513 lines
16 KiB
TypeScript
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, type ParticlePath } from "@/lib/particle-path";
|
|
import { ComposeOverlay } from "@/features/compose/compose-overlay";
|
|
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
|
|
import { MediaParticleView } 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 ControlsIndicator from "@/features/compose/controls-indicator";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useNetwork, useNetworks } from "@/hooks/use-networks";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Settings } from "lucide-react";
|
|
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";
|
|
|
|
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;
|
|
}
|
|
|
|
// --- StreamView ---
|
|
|
|
interface StreamViewProps {
|
|
streamParticle: Particle & { type: "stream" };
|
|
path: ParticlePath;
|
|
}
|
|
|
|
export function StreamView({ path, streamParticle }: StreamViewProps) {
|
|
const { networkId } = parseParticlePath(path);
|
|
const navigate = useNavigate();
|
|
|
|
const {
|
|
children,
|
|
currentParticle,
|
|
currentIndex,
|
|
status,
|
|
paused,
|
|
next,
|
|
prev,
|
|
goTo,
|
|
goToParticle,
|
|
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 [composeActive, setComposeActive] = useState(false);
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
// 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
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (composeActive) return;
|
|
|
|
const target = e.target as HTMLElement;
|
|
if (
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable
|
|
) {
|
|
return;
|
|
}
|
|
|
|
switch (e.key) {
|
|
case "ArrowRight":
|
|
case "ArrowDown":
|
|
e.preventDefault();
|
|
next();
|
|
break;
|
|
case "ArrowLeft":
|
|
case "ArrowUp":
|
|
e.preventDefault();
|
|
prev();
|
|
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 });
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
},
|
|
[composeActive, next, prev, navigate, networkId, streamParticle.id],
|
|
);
|
|
|
|
// 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],
|
|
);
|
|
|
|
const onLocalParticleCreated = useCallback(
|
|
(particleId: string) => {
|
|
goToParticle(particleId);
|
|
}, [goToParticle]);
|
|
|
|
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>
|
|
<ControlsIndicator type="reply" showEscape={true}>
|
|
<span>
|
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
H
|
|
</kbd>{" "}
|
|
huddle
|
|
</span>
|
|
</ControlsIndicator>
|
|
<ComposeOverlay
|
|
networkId={networkId}
|
|
targetPath={path}
|
|
onActiveChange={setComposeActive}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Render particle content inline
|
|
function renderParticle(particle: Particle) {
|
|
switch (particle.type) {
|
|
case "media":
|
|
return (
|
|
<MediaParticleView
|
|
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)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<ComposeOverlay
|
|
networkId={networkId}
|
|
targetPath={path}
|
|
onActiveChange={setComposeActive}
|
|
onParticleCreated={onLocalParticleCreated}
|
|
/>
|
|
|
|
{/* BottomBar */}
|
|
<BottomBar
|
|
visible={controlsVisible}
|
|
total={children.length}
|
|
current={currentIndex}
|
|
progress={progress}
|
|
onGoTo={goTo}
|
|
presenceBySegment={presenceBySegment}
|
|
exitRemainingMs={exitRemainingMs}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BottomBar({
|
|
visible,
|
|
total,
|
|
current,
|
|
progress,
|
|
onGoTo,
|
|
presenceBySegment,
|
|
exitRemainingMs,
|
|
}: {
|
|
visible: boolean;
|
|
total: number;
|
|
current: number;
|
|
progress: number;
|
|
onGoTo: (index: number) => void;
|
|
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
|
|
exitRemainingMs: number | null;
|
|
}) {
|
|
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">
|
|
{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>
|
|
)}
|
|
<ControlsIndicator type="reply" showEscape={true}>
|
|
<span>
|
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
|
H
|
|
</kbd>{" "}
|
|
huddle
|
|
</span>
|
|
</ControlsIndicator>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
)}
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="no-drag text-muted-foreground"
|
|
onClick={() => navigate("/settings")}
|
|
>
|
|
<Settings className="size-3.5" />
|
|
</Button>
|
|
</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>
|
|
)
|
|
}
|
|
|