Files
llink/js/mobile/src/features/huddle/HuddleScreen.tsx
T
Arjun PatelandGitHub a8a0b7db1b infra: add linting and formatting for js projects (#230)
* wip

* wip

* wip

* format

* wip(mobile): lint and format

* cleanup

* nits

* nits

* idiomatic react

* nit

* format all root files
2026-06-02 07:44:24 -07:00

297 lines
8.8 KiB
TypeScript

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>
);
}