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-secure-store": "~14.0.0",
|
||||||
"expo-sqlite": "~15.0.0",
|
"expo-sqlite": "~15.0.0",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
|
"livekit-client": "^2.7.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.0",
|
"react-native": "0.76.0",
|
||||||
"react-native-gesture-handler": "^2.31.2",
|
"react-native-gesture-handler": "^2.31.2",
|
||||||
|
|||||||
Generated
+3
@@ -198,6 +198,9 @@ importers:
|
|||||||
expo-status-bar:
|
expo-status-bar:
|
||||||
specifier: ~2.0.0
|
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)
|
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:
|
react:
|
||||||
specifier: 18.3.1
|
specifier: 18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
|||||||
Reference in New Issue
Block a user