Files
llink/js/desktop/src/features/particles/stream-view.tsx
T

583 lines
19 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 { 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<string, string[]> | 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<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 || 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 (
<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(() => {
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<MediaParticleHandle>(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<ComposeStep>("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<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,
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 (
<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"}
onParticleCreated={handleParticleCreated}
/>
</div>
);
}
// Render particle content inline
function renderParticle(particle: Particle) {
if (isParticleDeleted(particle)) {
return (
<DeletedParticleView
key={particle.id}
particle={particle}
networkId={networkId}
paused={paused}
onEnded={next}
/>
);
}
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} networkId={networkId} />;
}
}
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 pt-2">
<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">
{renderParticle(currentParticle)}
<div className="pointer-events-none absolute right-4 top-14 z-20 flex flex-col items-end gap-1.5">
{fastPlayback && (
<div className="rounded-full bg-black/50 px-2.5 py-1 text-xs font-medium text-white backdrop-blur-sm">
1.5x
</div>
)}
{paused && (
<div className="rounded-full bg-black/40 px-2.5 py-1 text-xs font-medium text-white/70 backdrop-blur-sm">
Paused
</div>
)}
</div>
</div>
)}
</div>
{/* Reaction bar — always visible */}
{currentParticle && !isParticleDeleted(currentParticle) && (
<div className="absolute right-4 top-1/2 z-10 flex -translate-y-1/2 flex-col items-end gap-2">
<ReactionBar
reactions={getReactions(currentParticle)}
currentHumanId={authedUser?.id ?? ""}
humans={network?.humans}
onToggle={handleToggleReaction}
onOpenTextReaction={() => setTextReactionOpen(true)}
/>
<TextReactionInput
open={textReactionOpen}
onSubmit={handleSubmitTextReaction}
onClose={() => setTextReactionOpen(false)}
/>
</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"}
onParticleCreated={handleParticleCreated}
/>
{/* 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;
}) {
const requestIntent = useComposeIntentStore((s) => s.request);
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 />
<button
type="button"
onClick={() => requestIntent("record")}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with a recording (or hold `)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
</kbd>{" "}
to reply
</button>
<button
type="button"
onClick={() => requestIntent("text")}
className="cursor-pointer rounded transition-colors hover:text-white/80"
title="Reply with text (or press T)"
>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
T
</kbd>{" "}
text
</button>
<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>
);
}