feat(mobile): CallProvider with state machine + LiveKit room lifecycle
This commit is contained in:
@@ -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 };
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user