From e83c23d69801d9b7c353565b542b2d517654636b Mon Sep 17 00:00:00 2001 From: byGalax Date: Thu, 14 May 2026 06:11:00 +0200 Subject: [PATCH] feat(mobile): CallProvider with state machine + LiveKit room lifecycle --- apps/mobile/lib/callContext.tsx | 383 ++++++++++++++++++++++++++++++++ apps/mobile/package.json | 1 + pnpm-lock.yaml | 3 + 3 files changed, 387 insertions(+) create mode 100644 apps/mobile/lib/callContext.tsx diff --git a/apps/mobile/lib/callContext.tsx b/apps/mobile/lib/callContext.tsx new file mode 100644 index 0000000..a4eba04 --- /dev/null +++ b/apps/mobile/lib/callContext.tsx @@ -0,0 +1,383 @@ +import { AudioSession } from '@livekit/react-native'; +import { Room, RoomEvent, Track } from 'livekit-client'; +import { chat, rtc } from '@chat-app/shared'; +import type { CallSignal } from '@chat-app/shared/rtc'; +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { useAuth } from './authContext'; +import { sendCallSignal, subscribeCallSignals } from './callSignal'; +import { supabase } from './supabase'; + +type Identity = string; + +export interface RemoteParticipantSummary { + identity: Identity; + name: string; + speaking: boolean; +} + +export type CallState = + | { kind: 'idle' } + | { kind: 'outgoing'; callId: string; conversationId: string; peers: Identity[] } + | { kind: 'incoming'; callId: string; conversationId: string; fromUserId: Identity } + | { kind: 'connecting'; callId: string; conversationId: string } + | { + kind: 'connected'; + callId: string; + conversationId: string; + muted: boolean; + speakerOn: boolean; + participants: RemoteParticipantSummary[]; + } + | { kind: 'ended'; reason: 'normal' | 'rejected' | 'cancelled' | 'error' }; + +interface CallContextValue { + state: CallState; + startCall: (conversationId: string) => Promise; + acceptIncoming: () => Promise; + rejectIncoming: () => Promise; + cancelOutgoing: () => Promise; + endCall: () => Promise; + toggleMute: () => Promise; + toggleSpeaker: () => Promise; +} + +const Ctx = createContext(null); + +export function useCall(): CallContextValue { + const v = useContext(Ctx); + if (!v) throw new Error('useCall() called outside '); + return v; +} + +function randomCallId(): string { + return 'call-' + Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36); +} + +export function CallProvider({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + const myUserId = user?.id ?? null; + const [state, setState] = useState({ kind: 'idle' }); + const roomRef = useRef(null); + const participantsRef = useRef>(new Map()); + + const peerIdsFor = useCallback( + async (conversationId: string): Promise => { + if (!myUserId) return []; + const all = await chat.listConversations(supabase); + const conv = all.find((c) => c.id === conversationId); + if (!conv) return []; + return conv.members.map((m) => m.userId).filter((id) => id !== myUserId); + }, + [myUserId], + ); + + const teardownRoom = useCallback(async () => { + const r = roomRef.current; + roomRef.current = null; + participantsRef.current.clear(); + if (r) { + try { + await r.disconnect(); + } catch (err) { + console.warn('[call] room.disconnect failed', err); + } + } + try { + await AudioSession.stopAudioSession(); + } catch { + /* already stopped */ + } + }, []); + + const updateParticipantsState = useCallback(() => { + setState((prev) => { + if (prev.kind !== 'connected') return prev; + return { + ...prev, + participants: Array.from(participantsRef.current.values()), + }; + }); + }, []); + + const joinRoom = useCallback( + async (conversationId: string, callId: string): Promise => { + const token = await rtc.fetchLivekitToken(supabase, conversationId); + await AudioSession.startAudioSession(); + const room = new Room(); + roomRef.current = room; + + room + .on(RoomEvent.ParticipantConnected, (p) => { + participantsRef.current.set(p.identity, { + identity: p.identity, + name: p.name || p.identity, + speaking: p.isSpeaking, + }); + updateParticipantsState(); + }) + .on(RoomEvent.ParticipantDisconnected, (p) => { + participantsRef.current.delete(p.identity); + updateParticipantsState(); + }) + .on(RoomEvent.ActiveSpeakersChanged, (speakers) => { + for (const [id, entry] of participantsRef.current) { + entry.speaking = speakers.some((sp) => sp.identity === id); + participantsRef.current.set(id, entry); + } + updateParticipantsState(); + }); + + await room.connect(token.url, token.token); + await room.localParticipant.setMicrophoneEnabled(true); + + for (const p of room.remoteParticipants.values()) { + participantsRef.current.set(p.identity, { + identity: p.identity, + name: p.name || p.identity, + speaking: p.isSpeaking, + }); + } + + setState({ + kind: 'connected', + callId, + conversationId, + muted: false, + speakerOn: false, + participants: Array.from(participantsRef.current.values()), + }); + }, + [updateParticipantsState], + ); + + // Signal subscription. Routes incoming invites / cancels / rejects to + // state transitions. eslint-disable on deps because handleIncomingSignal + // is defined inline below; re-subscribing on every render would churn + // the realtime channel. + useEffect(() => { + if (!myUserId) return; + const unsub = subscribeCallSignals(supabase, myUserId, (sig) => { + handleIncomingSignal(sig); + }); + return unsub; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [myUserId]); + + function handleIncomingSignal(sig: CallSignal) { + setState((prev) => { + switch (sig.type) { + case 'invite': + if (prev.kind === 'idle') { + return { + kind: 'incoming', + callId: sig.callId, + conversationId: sig.conversationId, + fromUserId: sig.fromUserId, + }; + } + return prev; + case 'cancel': + if ( + (prev.kind === 'incoming' || prev.kind === 'connecting') && + prev.callId === sig.callId + ) { + return { kind: 'ended', reason: 'cancelled' }; + } + return prev; + case 'reject': + if (prev.kind === 'outgoing' && prev.callId === sig.callId) { + void teardownRoom(); + return { kind: 'ended', reason: 'rejected' }; + } + return prev; + case 'accept': + return prev; + case 'end': + if ( + (prev.kind === 'connected' || prev.kind === 'connecting') && + prev.callId === sig.callId + ) { + void teardownRoom(); + return { kind: 'ended', reason: 'normal' }; + } + return prev; + } + }); + } + + const startCall = useCallback( + async (conversationId: string): Promise => { + if (!myUserId) throw new Error('not authenticated'); + const callId = randomCallId(); + const peers = await peerIdsFor(conversationId); + setState({ kind: 'outgoing', callId, conversationId, peers }); + try { + await Promise.all( + peers.map((peerId) => + sendCallSignal(supabase, peerId, { + type: 'invite', + callId, + conversationId, + fromUserId: myUserId, + kind: 'audio', + sentAt: new Date().toISOString(), + }), + ), + ); + await joinRoom(conversationId, callId); + } catch (err) { + console.warn('[call] startCall failed', err); + await teardownRoom(); + setState({ kind: 'ended', reason: 'error' }); + } + }, + [myUserId, peerIdsFor, joinRoom, teardownRoom], + ); + + const acceptIncoming = useCallback(async (): Promise => { + if (!myUserId) return; + if (state.kind !== 'incoming') return; + const { callId, conversationId, fromUserId } = state; + setState({ kind: 'connecting', callId, conversationId }); + try { + await sendCallSignal(supabase, fromUserId, { + type: 'accept', + callId, + byUserId: myUserId, + }); + await joinRoom(conversationId, callId); + } catch (err) { + console.warn('[call] acceptIncoming failed', err); + await teardownRoom(); + setState({ kind: 'ended', reason: 'error' }); + } + }, [myUserId, state, joinRoom, teardownRoom]); + + const rejectIncoming = useCallback(async (): Promise => { + if (!myUserId) return; + if (state.kind !== 'incoming') return; + const { callId, fromUserId } = state; + setState({ kind: 'ended', reason: 'rejected' }); + try { + await sendCallSignal(supabase, fromUserId, { + type: 'reject', + callId, + byUserId: myUserId, + }); + } catch (err) { + console.warn('[call] rejectIncoming send failed', err); + } + }, [myUserId, state]); + + const cancelOutgoing = useCallback(async (): Promise => { + if (!myUserId) return; + if (state.kind !== 'outgoing') return; + const { callId, peers } = state; + setState({ kind: 'ended', reason: 'cancelled' }); + try { + await Promise.all( + peers.map((peerId) => + sendCallSignal(supabase, peerId, { + type: 'cancel', + callId, + byUserId: myUserId, + }), + ), + ); + } catch (err) { + console.warn('[call] cancelOutgoing send failed', err); + } + await teardownRoom(); + }, [myUserId, state, teardownRoom]); + + const endCall = useCallback(async (): Promise => { + if (!myUserId) return; + if (state.kind !== 'connected') { + await teardownRoom(); + setState({ kind: 'idle' }); + return; + } + const { callId, conversationId } = state; + const peers = await peerIdsFor(conversationId); + setState({ kind: 'ended', reason: 'normal' }); + try { + await Promise.all( + peers.map((peerId) => + sendCallSignal(supabase, peerId, { + type: 'end', + callId, + byUserId: myUserId, + }), + ), + ); + } catch (err) { + console.warn('[call] endCall send failed', err); + } + await teardownRoom(); + }, [myUserId, state, peerIdsFor, teardownRoom]); + + const toggleMute = useCallback(async (): Promise => { + if (state.kind !== 'connected') return; + const r = roomRef.current; + if (!r) return; + const nextMuted = !state.muted; + try { + await r.localParticipant.setMicrophoneEnabled(!nextMuted); + setState((prev) => + prev.kind === 'connected' ? { ...prev, muted: nextMuted } : prev, + ); + } catch (err) { + console.warn('[call] toggleMute failed', err); + } + }, [state]); + + const toggleSpeaker = useCallback(async (): Promise => { + if (state.kind !== 'connected') return; + const nextSpeaker = !state.speakerOn; + try { + await AudioSession.selectAudioOutput(nextSpeaker ? 'speaker' : 'earpiece'); + setState((prev) => + prev.kind === 'connected' ? { ...prev, speakerOn: nextSpeaker } : prev, + ); + } catch (err) { + console.warn('[call] toggleSpeaker failed', err); + } + }, [state]); + + // Drift `ended` back to `idle` after a short pause so the UI can show + // a brief status pill ("Anruf abgelehnt") before snapping back. + useEffect(() => { + if (state.kind !== 'ended') return; + const t = setTimeout(() => { + setState({ kind: 'idle' }); + }, 2500); + return () => clearTimeout(t); + }, [state]); + + const value: CallContextValue = useMemo( + () => ({ + state, + startCall, + acceptIncoming, + rejectIncoming, + cancelOutgoing, + endCall, + toggleMute, + toggleSpeaker, + }), + [state, startCall, acceptIncoming, rejectIncoming, cancelOutgoing, endCall, toggleMute, toggleSpeaker], + ); + + return {children}; +} + +export { Track }; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 5fc39c3..baca7b9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -34,6 +34,7 @@ "expo-secure-store": "~14.0.0", "expo-sqlite": "~15.0.0", "expo-status-bar": "~2.0.0", + "livekit-client": "^2.7.0", "react": "18.3.1", "react-native": "0.76.0", "react-native-gesture-handler": "^2.31.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0fedf4c..7d8eeda 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: expo-status-bar: specifier: ~2.0.0 version: 2.0.1(react-native@0.76.0(@babel/core@7.29.0)(@babel/preset-env@7.29.2(@babel/core@7.29.0))(@types/react@18.3.28)(encoding@0.1.13)(react@18.3.1))(react@18.3.1) + livekit-client: + specifier: ^2.7.0 + version: 2.18.3(@types/dom-mediacapture-record@1.0.22) react: specifier: 18.3.1 version: 18.3.1