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