feat(mobile): CallProvider with state machine + LiveKit room lifecycle

This commit is contained in:
byGalax
2026-05-14 06:11:00 +02:00
parent 68cd4108dd
commit e83c23d698
3 changed files with 387 additions and 0 deletions
+383
View File
@@ -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<void>;
acceptIncoming: () => Promise<void>;
rejectIncoming: () => Promise<void>;
cancelOutgoing: () => Promise<void>;
endCall: () => Promise<void>;
toggleMute: () => Promise<void>;
toggleSpeaker: () => Promise<void>;
}
const Ctx = createContext<CallContextValue | null>(null);
export function useCall(): CallContextValue {
const v = useContext(Ctx);
if (!v) throw new Error('useCall() called outside <CallProvider>');
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<CallState>({ kind: 'idle' });
const roomRef = useRef<Room | null>(null);
const participantsRef = useRef<Map<Identity, RemoteParticipantSummary>>(new Map());
const peerIdsFor = useCallback(
async (conversationId: string): Promise<Identity[]> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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 <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export { Track };
+1
View File
@@ -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",
+3
View File
@@ -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