Mirrors the desktop huddle window: tap the headphones button on a stream to fetch a LiveKit token from Orion and join a video room. Active huddles show a red badge on the stream card (participant count) and a red Join pill in the stream top bar — both driven by huddle_active_participants synced from the LiveKit webhook. The HuddleScreen lives at its own route (not a modal/window): video tile grid with placeholders for camera-off, mic/camera toggles, and a leave button. Adds the LiveKit RN SDK plus the WebRTC + LiveKit Expo config plugins for prebuild. https://claude.ai/code/session_01Po5tAD17MXhnqGQ9hKh6PC
152 lines
4.7 KiB
TypeScript
152 lines
4.7 KiB
TypeScript
import { ActivityIndicator, Pressable, Text, View } from "react-native";
|
|
import {
|
|
EllipsisVertical,
|
|
Globe,
|
|
Headphones,
|
|
Maximize2,
|
|
Minimize2,
|
|
} from "lucide-react-native";
|
|
import type { Human, Particle } from "@/api/types";
|
|
import { parseVisibleTo } from "@/lib/stream-visibility";
|
|
import { cn } from "@/lib/utils";
|
|
import { Avatar } from "@/components/Avatar";
|
|
import { useOpenHuddle } from "@/features/huddle/use-open-huddle";
|
|
import { useStreamPresence } from "./stream-presence-context";
|
|
|
|
interface StreamTopActionsProps {
|
|
networkId: string;
|
|
streamParticle: Particle & { type: "stream" };
|
|
humans: Human[];
|
|
videoFit: "cover" | "contain";
|
|
onToggleVideoFit: () => void;
|
|
onOpenMembers: () => void;
|
|
onOpenActions: () => void;
|
|
/** True when current particle is a video — fit toggle hidden otherwise. */
|
|
showFitToggle: boolean;
|
|
}
|
|
|
|
const MAX_AVATARS = 3;
|
|
|
|
/**
|
|
* Top-right cluster on StreamView: visibility avatars (with presence ring),
|
|
* fit/fill toggle, and actions menu trigger. Mirrors desktop's stream-top-bar
|
|
* but compact for the mobile chrome.
|
|
*/
|
|
export function StreamTopActions({
|
|
networkId,
|
|
streamParticle,
|
|
humans,
|
|
videoFit,
|
|
onToggleVideoFit,
|
|
onOpenMembers,
|
|
onOpenActions,
|
|
showFitToggle,
|
|
}: StreamTopActionsProps) {
|
|
const { onlineHumanIds } = useStreamPresence();
|
|
const { open: openHuddle, loading: huddleLoading } = useOpenHuddle();
|
|
const huddleCount = streamParticle.huddle_active_participants?.length ?? 0;
|
|
const huddleActive = huddleCount > 0;
|
|
const visibility = parseVisibleTo(streamParticle.visible_to, networkId);
|
|
const memberIds =
|
|
visibility.mode === "network"
|
|
? humans.map((h) => h.id)
|
|
: visibility.humanIds;
|
|
const shown = memberIds.slice(0, MAX_AVATARS);
|
|
const overflow = memberIds.length - shown.length;
|
|
|
|
return (
|
|
<View className="flex-row items-center gap-1.5">
|
|
<Pressable
|
|
onPress={onOpenMembers}
|
|
accessibilityLabel="Stream members"
|
|
className="bg-white/10 active:bg-white/20 rounded-full px-2 py-1 flex-row items-center gap-1"
|
|
>
|
|
{visibility.mode === "network" && memberIds.length === 0 ? (
|
|
<Globe color="rgba(255,255,255,0.85)" size={14} />
|
|
) : (
|
|
<View className="flex-row">
|
|
{shown.map((id, idx) => (
|
|
<View
|
|
key={id}
|
|
style={{ marginLeft: idx === 0 ? 0 : -8 }}
|
|
>
|
|
{/* The stack ring matches the chrome's translucent bg so it
|
|
reads as a separator without painting hard black halos. */}
|
|
<Avatar
|
|
humanId={id}
|
|
humans={humans}
|
|
size="xs"
|
|
online={onlineHumanIds.has(id)}
|
|
/>
|
|
</View>
|
|
))}
|
|
</View>
|
|
)}
|
|
{overflow > 0 ? (
|
|
<Text className="text-white/70 text-[10px] font-medium ml-0.5">
|
|
+{overflow}
|
|
</Text>
|
|
) : null}
|
|
</Pressable>
|
|
|
|
<Pressable
|
|
onPress={() =>
|
|
void openHuddle(
|
|
networkId,
|
|
streamParticle.id,
|
|
streamParticle.properties.name,
|
|
)
|
|
}
|
|
disabled={huddleLoading}
|
|
accessibilityLabel={huddleActive ? "Join huddle" : "Start huddle"}
|
|
className={cn(
|
|
"h-8 items-center justify-center rounded-full px-2.5 flex-row gap-1",
|
|
huddleActive
|
|
? "bg-red-500/90 active:bg-red-600"
|
|
: "bg-white/10 active:bg-white/20",
|
|
)}
|
|
>
|
|
{huddleLoading ? (
|
|
<ActivityIndicator color="white" size="small" />
|
|
) : (
|
|
<>
|
|
<Headphones color="white" size={14} strokeWidth={2} />
|
|
{huddleActive ? (
|
|
<Text className="text-white text-xs font-semibold">
|
|
{huddleCount}
|
|
</Text>
|
|
) : null}
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
|
|
{showFitToggle ? (
|
|
<Pressable
|
|
onPress={onToggleVideoFit}
|
|
accessibilityLabel={
|
|
videoFit === "cover" ? "Fit video to screen" : "Fill screen with video"
|
|
}
|
|
className={cn(
|
|
"h-8 w-8 items-center justify-center rounded-full",
|
|
"bg-white/10 active:bg-white/20",
|
|
)}
|
|
>
|
|
{videoFit === "cover" ? (
|
|
<Minimize2 color="white" size={15} strokeWidth={1.8} />
|
|
) : (
|
|
<Maximize2 color="white" size={15} strokeWidth={1.8} />
|
|
)}
|
|
</Pressable>
|
|
) : null}
|
|
|
|
<Pressable
|
|
onPress={onOpenActions}
|
|
accessibilityLabel="More actions"
|
|
className="h-8 w-8 items-center justify-center rounded-full bg-white/10 active:bg-white/20"
|
|
>
|
|
<EllipsisVertical color="white" size={16} strokeWidth={1.8} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|