refactor: organize desktop vs. mobile into separate folders
This commit is contained in:
@@ -0,0 +1,568 @@
|
||||
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 { 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 { c } from "vite/dist/node/types.d-aGj9QkWt";
|
||||
|
||||
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(() => {
|
||||
window.electronAutoplay.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(() => {
|
||||
apiClient.getLivekitToken(networkId, streamParticle.id).then(({ token, server_url }) => {
|
||||
window.electronWindow.openHuddle({ 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;
|
||||
}) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user