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 ( { Alert.alert('Huddle error', err.message ?? 'Failed to connect.'); leave(); }} > ); } 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 ( {streamName} {tracks.length === 1 ? '1 participant' : `${tracks.length} participants`} ) : ( ) } /> ) : ( ) } /> void leave()} icon={} /> ); } 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 ( {tiles.map((tile) => ( ))} ); } 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 ( {hasVideo ? ( ) : ( {initials} )} {muted ? ( ) : ( )} {name} ); } 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 ( {icon} ); }