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
+5
View File
@@ -57,6 +57,11 @@ const config: ExpoConfig = {
color: "#000000",
},
],
// LiveKit needs WebRTC native modules. The two plugins below patch the
// iOS/Android prebuild so camera/mic permissions and the webrtc pod are
// wired up correctly for huddles.
"@config-plugins/react-native-webrtc",
"@livekit/react-native-expo-plugin",
],
experiments: {
typedRoutes: false,
+6
View File
@@ -1,5 +1,11 @@
import "./global.css";
import { registerGlobals } from "@livekit/react-native";
import { registerRootComponent } from "expo";
import App from "./src/App";
// LiveKit pipes WebRTC globals (RTCPeerConnection, mediaDevices, ...) onto the
// JS global. Must run before any LiveKit client is constructed, so do it at
// app entry rather than inside the huddle screen.
registerGlobals();
registerRootComponent(App);
+5
View File
@@ -30,6 +30,11 @@
"expo-status-bar": "~3.0.9",
"expo-video": "~3.0.10",
"firebase": "^12.10.0",
"@config-plugins/react-native-webrtc": "^12.0.0",
"@livekit/react-native": "^2.7.5",
"@livekit/react-native-expo-plugin": "^1.0.2",
"@livekit/react-native-webrtc": "^144.1.0",
"livekit-client": "^2.15.2",
"lucide-react-native": "^0.575.0",
"nativewind": "^4.1.23",
"react": "19.1.0",
@@ -0,0 +1,302 @@
import { useCallback, useEffect } from "react";
import {
Alert,
Dimensions,
Pressable,
Text,
View,
} from "react-native";
import type { Human } from "@/api/types";
import { SafeAreaView } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar";
import {
AudioSession,
LiveKitRoom,
VideoTrack,
isTrackReference,
useLocalParticipant,
useRoomContext,
useTracks,
} from "@livekit/react-native";
import type { TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { Track } from "livekit-client";
import { Mic, MicOff, PhoneOff, Video, VideoOff } from "lucide-react-native";
import { useNetwork } from "@/hooks/use-networks";
import { resolveHumanDisplay } from "@/lib/humans";
import { cn } from "@/lib/utils";
import type { RootStackScreenProps } from "@/navigation/types";
/**
* Mobile huddle screen — LiveKit room with a tile grid, basic mic/camera
* controls, and a leave button. Mirrors the desktop HuddleApp but tuned for
* touch: bigger controls, grid layout instead of focus/carousel, no chat or
* screenshare (camera + audio only).
*/
export function HuddleScreen({
route,
navigation,
}: RootStackScreenProps<"Huddle">) {
const { token, serverUrl, streamName, networkId } = route.params;
// iOS in particular requires us to bracket the room session with
// start/stop calls so the AVAudioSession is configured for VoIP routing
// (earpiece → speaker, ducking, etc.). Without this, remote audio is
// routed to the receiver speaker and the user has to hold the phone to
// their ear to hear anyone.
useEffect(() => {
void AudioSession.startAudioSession().catch(() => {
// Non-fatal: room will still connect, audio routing may just be
// less ideal. Failing the whole screen here would feel worse.
});
return () => {
void AudioSession.stopAudioSession();
};
}, []);
const leave = useCallback(() => {
navigation.goBack();
}, [navigation]);
return (
<View className="flex-1 bg-black">
<StatusBar style="light" />
<LiveKitRoom
token={token}
serverUrl={serverUrl}
connect={true}
audio={true}
video={false}
options={{ adaptiveStream: { pixelDensity: "screen" } }}
onDisconnected={leave}
onError={(err) => {
Alert.alert("Huddle error", err.message ?? "Failed to connect.");
leave();
}}
>
<HuddleRoom
networkId={networkId}
streamName={streamName}
onLeave={leave}
/>
</LiveKitRoom>
</View>
);
}
interface HuddleRoomProps {
networkId: string;
streamName: string;
onLeave: () => void;
}
function HuddleRoom({ networkId, streamName, onLeave }: HuddleRoomProps) {
const room = useRoomContext();
const network = useNetwork(networkId);
const { localParticipant, isMicrophoneEnabled, isCameraEnabled } =
useLocalParticipant();
// `withPlaceholder: true` ensures we get a tile for every participant even
// when their camera is off — same pattern as desktop's HuddleContent.
const tracks = useTracks(
[{ source: Track.Source.Camera, withPlaceholder: true }],
{ onlySubscribed: false },
);
const toggleMic = useCallback(() => {
void localParticipant.setMicrophoneEnabled(!isMicrophoneEnabled);
}, [localParticipant, isMicrophoneEnabled]);
const toggleCamera = useCallback(() => {
void localParticipant.setCameraEnabled(!isCameraEnabled);
}, [localParticipant, isCameraEnabled]);
const leave = useCallback(async () => {
try {
await room.disconnect();
} finally {
onLeave();
}
}, [room, onLeave]);
return (
<SafeAreaView className="flex-1" edges={["top", "bottom"]}>
<View className="flex-row items-center justify-between px-4 pt-2 pb-3">
<View className="flex-1">
<Text className="text-white text-base font-semibold" numberOfLines={1}>
{streamName}
</Text>
<Text className="text-white/60 text-xs mt-0.5">
{tracks.length === 1
? "1 participant"
: `${tracks.length} participants`}
</Text>
</View>
</View>
<View className="flex-1 px-2">
<TileGrid tiles={tracks} humans={network?.humans ?? []} />
</View>
<View className="flex-row items-center justify-center gap-4 px-4 py-4">
<ControlButton
label={isMicrophoneEnabled ? "Mute" : "Unmute"}
active={isMicrophoneEnabled}
onPress={toggleMic}
icon={
isMicrophoneEnabled ? (
<Mic color="white" size={22} />
) : (
<MicOff color="white" size={22} />
)
}
/>
<ControlButton
label={isCameraEnabled ? "Stop video" : "Start video"}
active={isCameraEnabled}
onPress={toggleCamera}
icon={
isCameraEnabled ? (
<Video color="white" size={22} />
) : (
<VideoOff color="white" size={22} />
)
}
/>
<ControlButton
label="Leave"
tone="danger"
onPress={() => void leave()}
icon={<PhoneOff color="white" size={22} />}
/>
</View>
</SafeAreaView>
);
}
interface TileGridProps {
tiles: TrackReferenceOrPlaceholder[];
humans: Human[];
}
function TileGrid({ tiles, humans }: TileGridProps) {
// Compute a square-ish grid: 1 → 1col, 2 → 1col (stacked), 3-4 → 2col,
// 5+ → 2col with scroll. Keeps each tile big enough on a phone screen.
const columns = tiles.length <= 1 ? 1 : 2;
const { width, height } = Dimensions.get("window");
const rows = Math.max(1, Math.ceil(tiles.length / columns));
const tileWidth = (width - 16) / columns - 8;
// Subtract approx chrome height (header + control bar ≈ 200px). This is a
// simple heuristic — we don't need pixel-perfect since tiles use aspectRatio.
const availableHeight = height - 220;
const tileHeight = Math.max(140, availableHeight / rows - 8);
return (
<View className="flex-1 flex-row flex-wrap items-start justify-center">
{tiles.map((tile) => (
<View
key={trackKey(tile)}
style={{
width: tileWidth,
height: tileHeight,
margin: 4,
}}
>
<Tile tile={tile} humans={humans} />
</View>
))}
</View>
);
}
function Tile({
tile,
humans,
}: {
tile: TrackReferenceOrPlaceholder;
humans: Human[];
}) {
const identity = tile.participant.identity;
const display = resolveHumanDisplay(identity, humans);
const name = tile.participant.name || display.displayName;
const initials = display.initials;
const isSpeaking = tile.participant.isSpeaking;
const muted =
tile.participant.getTrackPublication(Track.Source.Microphone)?.isMuted ??
true;
const hasVideo = isTrackReference(tile) && !tile.publication.isMuted;
return (
<View
className={cn(
"flex-1 overflow-hidden rounded-2xl bg-neutral-900",
isSpeaking && "border-2 border-emerald-400",
)}
>
{hasVideo ? (
<VideoTrack
trackRef={tile}
style={{ flex: 1 }}
objectFit="cover"
/>
) : (
<View className="flex-1 items-center justify-center">
<View className="h-16 w-16 items-center justify-center rounded-full bg-neutral-700">
<Text className="text-white text-xl font-semibold">{initials}</Text>
</View>
</View>
)}
<View className="absolute left-2 bottom-2 flex-row items-center gap-1 rounded-full bg-black/60 px-2 py-1">
{muted ? (
<MicOff color="white" size={12} />
) : (
<Mic color="#34d399" size={12} />
)}
<Text className="text-white text-xs" numberOfLines={1}>
{name}
</Text>
</View>
</View>
);
}
function trackKey(tile: TrackReferenceOrPlaceholder): string {
const sid = isTrackReference(tile) ? tile.publication.trackSid : "placeholder";
return `${tile.participant.identity}:${tile.source}:${sid}`;
}
interface ControlButtonProps {
icon: React.ReactNode;
label: string;
onPress: () => void;
active?: boolean;
tone?: "default" | "danger";
}
function ControlButton({
icon,
label,
onPress,
active = false,
tone = "default",
}: ControlButtonProps) {
// Used purely for the visual state — destructive tone always wins so
// "Leave" is unmistakable regardless of toggle state.
return (
<Pressable
onPress={onPress}
accessibilityLabel={label}
className={cn(
"h-14 w-14 items-center justify-center rounded-full",
tone === "danger"
? "bg-red-600 active:bg-red-700"
: active
? "bg-white/20 active:bg-white/30"
: "bg-white/10 active:bg-white/20",
)}
>
{icon}
</Pressable>
);
}
@@ -0,0 +1,46 @@
import { useCallback, useState } from "react";
import { useNavigation } from "@react-navigation/native";
import { toast } from "sonner-native";
import { apiClient } from "@/api/client";
import { toUserMessage } from "@/lib/errors";
import type { RootStackParamList } from "@/navigation/types";
import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
/**
* Mirrors desktop's `handleOpenHuddle` (stream-view.tsx) — fetch a fresh
* LiveKit token from Orion for the {network, stream} pair and hand off to
* the dedicated huddle screen. Joining and starting are the same operation
* server-side: minting a token implicitly creates/joins the room.
*/
export function useOpenHuddle() {
const navigation =
useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const [loading, setLoading] = useState(false);
const open = useCallback(
async (networkId: string, streamId: string, streamName: string) => {
if (loading) return;
setLoading(true);
try {
const { token, server_url } = await apiClient.getLivekitToken(
networkId,
streamId,
);
navigation.navigate("Huddle", {
networkId,
streamId,
streamName,
token,
serverUrl: server_url,
});
} catch (err) {
toast.error(toUserMessage(err));
} finally {
setLoading(false);
}
},
[navigation, loading],
);
return { open, loading };
}
@@ -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}
@@ -1,5 +1,6 @@
import { memo, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import { Headphones } from "lucide-react-native";
import type { Particle, StreamProperties } from "@/api/types";
import { isParticleDeleted } from "@/api/types";
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
@@ -147,7 +148,14 @@ export const StreamCard = memo(function StreamCard({
)}
/>
) : null}
{isUnseen ? (
{(particle.huddle_active_participants?.length ?? 0) > 0 ? (
<View className="flex-row items-center gap-1 rounded-full bg-red-500/15 px-1.5 py-0.5">
<Headphones color="#ef4444" size={11} />
<Text className="text-red-500 text-[10px] font-semibold">
{particle.huddle_active_participants?.length}
</Text>
</View>
) : isUnseen ? (
<View className="bg-primary h-2 w-2 rounded-full" />
) : null}
</View>
@@ -6,6 +6,7 @@ import { NetworkListScreen } from "@/features/networks/NetworkListScreen";
import { StreamListScreen } from "@/features/streams/StreamListScreen";
import { NewStreamScreen } from "@/features/streams/NewStreamScreen";
import { StreamViewScreen } from "@/features/stream-view/StreamViewScreen";
import { HuddleScreen } from "@/features/huddle/HuddleScreen";
import { SettingsScreen } from "@/features/settings/SettingsScreen";
import { AccountScreen } from "@/features/settings/AccountScreen";
import type { RootStackParamList } from "./types";
@@ -43,6 +44,11 @@ export function RootNavigator() {
component={StreamViewScreen}
options={{ animation: "fade", gestureEnabled: false }}
/>
<Stack.Screen
name="Huddle"
component={HuddleScreen}
options={{ animation: "slide_from_bottom", gestureEnabled: false }}
/>
<Stack.Screen
name="NewStream"
component={NewStreamScreen}
+7
View File
@@ -7,6 +7,13 @@ export type RootStackParamList = {
NetworkList: undefined;
StreamList: { networkId: string };
StreamView: { networkId: string; streamId: string };
Huddle: {
networkId: string;
streamId: string;
streamName: string;
token: string;
serverUrl: string;
};
NewStream: { networkId: string };
Settings: undefined;
Account: undefined;
+869 -12
View File
File diff suppressed because it is too large Load Diff