feat(mobile): add huddles with LiveKit

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
This commit is contained in:
Claude
2026-05-26 19:20:00 +00:00
parent 173c63508a
commit 5b4643bcb3
10 changed files with 1298 additions and 15 deletions
@@ -1,9 +1,16 @@
import { Pressable, Text, View } from "react-native";
import { EllipsisVertical, Globe, Maximize2, Minimize2 } from "lucide-react-native";
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 {
@@ -36,6 +43,9 @@ export function StreamTopActions({
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"
@@ -79,6 +89,37 @@ export function StreamTopActions({
) : 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}