feat(mobile): add support for huddles to mobile app (#219)
* 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 * fix(mobile): exiting from huddle * fix: pause stream during huddle --------- Co-authored-by: Claude <[email protected]>
This commit was merged in pull request #219.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user