5a3dc15704
The 2500ms timer that drifts CallState from `ended` back to `idle` was an inline magic number. Promote to a module-level constant with a comment explaining why the value isn't arbitrary — picked from the post-Phase-3 quality review. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
390 lines
12 KiB
TypeScript
390 lines
12 KiB
TypeScript
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);
|
|
}
|
|
|
|
// Time the call state lingers in `ended` so the UI can render a transient
|
|
// status pill ("Anruf abgelehnt", "Anruf beendet") before snapping back
|
|
// to idle. Keep this comfortably above the user's reaction time but short
|
|
// enough that returning to a chat feels snappy.
|
|
const ENDED_STATE_LINGER_MS = 2500;
|
|
|
|
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 the linger window so the UI can
|
|
// show a brief status pill before snapping back.
|
|
useEffect(() => {
|
|
if (state.kind !== 'ended') return;
|
|
const t = setTimeout(() => {
|
|
setState({ kind: 'idle' });
|
|
}, ENDED_STATE_LINGER_MS);
|
|
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 };
|