* wip * wip * wip * format * wip(mobile): lint and format * cleanup * nits * nits * idiomatic react * nit * format all root files
297 lines
8.8 KiB
TypeScript
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>
|
|
);
|
|
}
|