600 lines
18 KiB
TypeScript
600 lines
18 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 { isParticleDeleted, 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 { 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 { 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 { usePlaybackSuspenderStore } from "@/stores/playback-suspender-store";
|
||
|
||
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;
|
||
const SEEK_DELTA_SEC = 5;
|
||
|
||
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: ["Hold", "Space"], description: "Pause" },
|
||
{ 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" },
|
||
],
|
||
},
|
||
];
|
||
|
||
// --- 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;
|
||
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 playbackSuspended = usePlaybackSuspenderStore((s) => s.suspendCount > 0);
|
||
const playbackBlocked = composeActive || playbackSuspended;
|
||
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,
|
||
playbackBlocked,
|
||
handleExitNavigate,
|
||
);
|
||
|
||
// Reset progress when particle changes
|
||
useEffect(() => {
|
||
setProgress(0);
|
||
}, [currentParticle?.id]);
|
||
|
||
// Pause/resume playback when compose overlay or lightbox is open.
|
||
useEffect(() => {
|
||
if (playbackBlocked) pause();
|
||
else resume();
|
||
}, [playbackBlocked, 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 (playbackBlocked) return;
|
||
if (isInputTarget(e)) return;
|
||
|
||
switch (e.key) {
|
||
case "ArrowRight":
|
||
e.preventDefault();
|
||
if (!e.shiftKey || !mediaRef.current?.seek(SEEK_DELTA_SEC)) next();
|
||
break;
|
||
case "ArrowDown":
|
||
e.preventDefault();
|
||
next();
|
||
break;
|
||
case "ArrowLeft":
|
||
e.preventDefault();
|
||
if (!e.shiftKey || !mediaRef.current?.seek(-SEEK_DELTA_SEC)) prev();
|
||
break;
|
||
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(-1);
|
||
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 (playbackBlocked) 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);
|
||
};
|
||
},
|
||
[playbackBlocked, next, prev, pause, resume, navigate, networkId, streamParticle.id, setShowKeybindings, handleToggleReaction, recordingMode, setRecordingMode],
|
||
);
|
||
|
||
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) {
|
||
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} />;
|
||
}
|
||
}
|
||
|
||
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)}
|
||
|
||
{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 && !isParticleDeleted(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>
|
||
);
|
||
}
|