import { loadDevicePrivateKey } from '@chat-app/shared/auth'; import { sendEncryptedMessage } from '@chat-app/shared/chat'; import { type CallKind, type CallSignal, fetchLivekitToken, signalTopic, } from '@chat-app/shared/rtc'; import type { RealtimeChannel } from '@supabase/supabase-js'; import { ConnectionState, type RemoteParticipant, type RemoteTrack, type RemoteTrackPublication, Room, RoomEvent, Track, } from 'livekit-client'; import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { useAuth } from './AuthContext'; import { useConversationsContext } from './ConversationsContext'; import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds'; import { notify } from '../lib/osNotify'; import { isTauriRuntime, registerPttShortcut, unregisterPttShortcut, } from '../lib/globalShortcut'; import { getPttSettings, subscribePttSettings } from '../lib/pttSettings'; import { getAudioQualityParams, getAudioSettings, } from '../lib/audioSettings'; import { createCallE2EE, getCallE2EESettings, isE2EESupported, } from '../lib/callE2EE'; import { getPresetParams, getScreenShareSettings, } from '../lib/screenShareSettings'; import { devLocalSecretStore } from '../lib/secretStore'; import { supabase } from '../lib/supabase'; export type CallState = | { kind: 'idle' } | { kind: 'outgoing'; callId: string; conversationId: string; mediaKind: CallKind; ringingSince: string; } | { kind: 'incoming'; callId: string; conversationId: string; fromUserId: string; mediaKind: CallKind; } | { kind: 'connecting'; callId: string; conversationId: string; mediaKind: CallKind; } | { kind: 'connected'; callId: string; conversationId: string; mediaKind: CallKind; startedAt: string; } | { kind: 'error'; message: string }; export interface RemoteScreenShare { track: RemoteTrack; participantId: string; participantName: string; } // Visual call modes (Discord-style): grid shows all tiles equally, focus pins // one speaker with others in a strip, fullscreen is cinema mode. export type CallMode = 'grid' | 'focus' | 'fullscreen'; interface CallContextValue { state: CallState; room: Room | null; remoteParticipants: RemoteParticipant[]; isMuted: boolean; isE2EEActive: boolean; isScreenSharing: boolean; // Zero or more remote screen shares (LiveKit supports multiple simultaneous). remoteScreenShares: RemoteScreenShare[]; // Remembers the conversation of the last call we left so a sidebar widget // can show "still live — rejoin" while peers stay in the room. lastCallConversationId: string | null; // Clean-Rail UI state — display mode + focused participant id. callMode: CallMode; focusedId: string | null; // Actions: startCall: (conversationId: string, mediaKind?: CallKind) => Promise; joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise; acceptIncoming: () => Promise; rejectIncoming: () => void; hangup: () => Promise; toggleMute: () => void; toggleScreenShare: () => Promise; dismissLastCall: () => void; setCallMode: (mode: CallMode) => void; setFocusedId: (id: string | null) => void; } const CallContext = createContext(null); const RING_TIMEOUT_MS = 45_000; const SOLO_TIMEOUT_MS = 5 * 60_000; function newCallId(): string { return crypto.randomUUID(); } export function CallProvider({ children }: { children: ReactNode }) { const { session, device } = useAuth(); const { conversations } = useConversationsContext(); const myId = session?.user.id; const [state, setState] = useState({ kind: 'idle' }); const [room, setRoom] = useState(null); const [remoteParticipants, setRemoteParticipants] = useState([]); const [isMuted, setIsMuted] = useState(false); const [isE2EEActive, setIsE2EEActive] = useState(false); const [isScreenSharing, setIsScreenSharing] = useState(false); const [remoteScreenShares, setRemoteScreenShares] = useState([]); const [lastCallConversationId, setLastCallConversationId] = useState(null); const [callMode, setCallModeState] = useState('grid'); const [focusedId, setFocusedIdState] = useState(null); const signalChannelRef = useRef(null); const presenceChannelRef = useRef(null); const ringTimerRef = useRef(null); const soloTimerRef = useRef(null); const roomRef = useRef(null); // Tracks whether the current call was ever in the connected state — needed // so hangup/solo-timeout can emit a real duration message vs. "missed". const everConnectedRef = useRef(false); // Peers we invited who haven't answered yet. The outgoing call only goes // `declined → idle` once *every* ringer has rejected (not just the first). // For 1:1 calls the set has one entry so behaviour is unchanged; for // groups, a single reject doesn't terminate the call while others ring. const pendingPeersRef = useRef>(new Set()); const stateRef = useRef(state); stateRef.current = state; // Keep latest conversations accessible from signal-channel closures without // re-subscribing the channel on every conversations update. const conversationsRef = useRef(conversations); conversationsRef.current = conversations; // --- Helpers ----------------------------------------------------------- // Emit a structured call-event message into the conversation so call // history shows up in the chat. Caller-side only (to dedupe). const emitCallEvent = useCallback( async ( conversationId: string, status: 'ended' | 'missed' | 'declined', mediaKind: CallKind, durationSec: number, ) => { if (!session?.user.id || !device?.id) return; try { const priv = await loadDevicePrivateKey( devLocalSecretStore, session.user.id, device.id, ); if (!priv) return; const payload = JSON.stringify({ v: 1, type: 'call_event', status, mediaKind, durationSec, }); await sendEncryptedMessage({ client: supabase, conversationId, plaintext: payload, senderUserId: session.user.id, senderDeviceId: device.id, senderPrivateKey: priv, }); } catch (err: unknown) { console.error('emitCallEvent failed', err); } }, [session?.user.id, device?.id], ); const clearRingTimer = useCallback(() => { if (ringTimerRef.current !== null) { window.clearTimeout(ringTimerRef.current); ringTimerRef.current = null; } }, []); const clearSoloTimer = useCallback(() => { if (soloTimerRef.current !== null) { window.clearTimeout(soloTimerRef.current); soloTimerRef.current = null; } }, []); const disconnectRoom = useCallback(async () => { const r = roomRef.current; if (r) { try { await r.disconnect(); } catch { /* ignore */ } } roomRef.current = null; setRoom(null); setRemoteParticipants([]); setRemoteScreenShares([]); setIsScreenSharing(false); setIsE2EEActive(false); const pres = presenceChannelRef.current; if (pres) { try { await pres.untrack(); } catch { /* ignore */ } // Don't removeChannel — observers (e.g. ActiveCallBanner, RejoinCallBar) // on the same topic share this instance via Supabase's topic dedupe. // Removing it would break their presence subscription. Untracking is // enough: the server drops our presence entry; observers see us leave. presenceChannelRef.current = null; } }, []); const sendSignal = useCallback( async (toUserId: string, payload: CallSignal) => { const ch = supabase.channel(signalTopic(toUserId)); await ch.subscribe(); await ch.send({ type: 'broadcast', event: 'signal', payload }); window.setTimeout(() => { void supabase.removeChannel(ch); }, 400); }, [], ); const peerUserIdsFor = useCallback( (conversationId: string): string[] => { const conv = conversations.find((c) => c.id === conversationId); if (!conv || !myId) return []; return conv.members.filter((m) => m.userId !== myId && m.accepted).map((m) => m.userId); }, [conversations, myId], ); // Transitions to 'connected' once at least one remote participant is in // the room. Also cancels the solo-timeout. No-op if already connected. const markConnectedIfReady = useCallback( (r: Room, conversationId: string, mediaKind: CallKind, callId: string) => { const cur = stateRef.current; if (cur.kind === 'connected') return; if (r.remoteParticipants.size === 0) return; clearRingTimer(); clearSoloTimer(); everConnectedRef.current = true; setState({ kind: 'connected', callId, conversationId, mediaKind, startedAt: new Date().toISOString(), }); }, [clearRingTimer, clearSoloTimer], ); // --- LiveKit join/leave ------------------------------------------------ const joinRoom = useCallback( async (conversationId: string, mediaKind: CallKind, callId: string) => { const { token, url } = await fetchLivekitToken(supabase, conversationId); // Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial × // 3 temporal layers from a single encode. LiveKit's congestion control // drops temporal/spatial layers per-subscriber when uplink degrades, // so a 4K60 publisher downshifts smoothly to 720p30 on weak links // without a full renegotiate. `adaptiveStream` pauses video downlink // off-screen; `dynacast` stops publishing layers nobody subscribes to; // `degradationPreference: balanced` tells WebRTC to trade framerate vs // resolution dynamically based on the encoder's CPU + bandwidth. // Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial × // 3 temporal layers from a single encode. LiveKit's congestion control // drops temporal/spatial layers per-subscriber when uplink degrades, // so a 4K60 publisher downshifts smoothly to 720p30 on weak links // without a full renegotiate. `adaptiveStream` pauses video downlink // off-screen; `dynacast` stops publishing layers nobody subscribes to; // `degradationPreference: balanced` tells WebRTC to trade framerate vs // resolution dynamically based on the encoder's CPU + bandwidth. // Audio: `red` doubles Opus packets for packet-loss concealment; // `dtx` is off in hifi so music doesn't get zeroed out between notes; // `forceStereo` publishes stereo when the hifi preset captures stereo. const ssCfg = getScreenShareSettings(); const ssParams = getPresetParams(ssCfg.preset); const aParams = getAudioQualityParams(getAudioSettings().quality); const e2eeCfg = getCallE2EESettings(); const e2eeUsable = e2eeCfg.enabled && isE2EESupported(); const e2eeBundle = e2eeUsable ? await createCallE2EE(conversationId) : null; const r = new Room({ adaptiveStream: true, dynacast: true, publishDefaults: { videoCodec: 'vp9', scalabilityMode: 'L3T3_KEY', degradationPreference: 'balanced', backupCodec: true, audioPreset: { maxBitrate: aParams.bitrateKbps * 1000, priority: 'high', }, red: true, dtx: aParams.stereo ? false : true, forceStereo: aParams.stereo, screenShareEncoding: { maxBitrate: ssParams.bitrateKbps * 1000, maxFramerate: ssParams.framerate, priority: 'high', }, }, ...(e2eeBundle ? { e2ee: { keyProvider: e2eeBundle.keyProvider, worker: e2eeBundle.worker, }, } : {}), }); roomRef.current = r; setRoom(r); r.on(RoomEvent.ConnectionStateChanged, (cs) => { if (cs === ConnectionState.Disconnected) { // Server / network tore us out — reset state cleanly. Remember the // conversation so the sidebar "still live — rejoin" widget stays // visible: observers will poll presence and detect any remaining // peers, which is the only cue this side gets that a call is live. clearRingTimer(); clearSoloTimer(); const wasInCall = stateRef.current.kind === 'connected' || stateRef.current.kind === 'connecting' || stateRef.current.kind === 'outgoing'; if (wasInCall) { setLastCallConversationId(conversationId); } // Untrack our presence entry so observers see us leave. Keep the // channel alive — observers share it via Supabase topic dedupe. const pres = presenceChannelRef.current; if (pres) { try { void pres.untrack(); } catch { /* ignore */ } presenceChannelRef.current = null; } if (stateRef.current.kind !== 'idle' && stateRef.current.kind !== 'error') { setState({ kind: 'idle' }); } roomRef.current = null; setRoom(null); setRemoteParticipants([]); setRemoteScreenShares([]); setIsScreenSharing(false); setIsE2EEActive(false); } }); r.on(RoomEvent.ParticipantConnected, () => { setRemoteParticipants(Array.from(r.remoteParticipants.values())); void playJoinBeep(); markConnectedIfReady(r, conversationId, mediaKind, callId); }); r.on(RoomEvent.ParticipantDisconnected, () => { const remaining = Array.from(r.remoteParticipants.values()); setRemoteParticipants(remaining); void playLeaveBeep(); // Alone in the room while connected — start the solo-timeout. if ( stateRef.current.kind === 'connected' && remaining.length === 0 && soloTimerRef.current === null ) { soloTimerRef.current = window.setTimeout(() => { void (async () => { soloTimerRef.current = null; const cur = stateRef.current; if (cur.kind === 'connected') { const durationSec = Math.max( 0, Math.floor((Date.now() - new Date(cur.startedAt).getTime()) / 1000), ); void emitCallEvent(cur.conversationId, 'ended', cur.mediaKind, durationSec); } await disconnectRoom(); void playEndBeep(); // Room was empty by timeout — nothing to rejoin into. setLastCallConversationId(null); setState({ kind: 'idle' }); })(); }, SOLO_TIMEOUT_MS); } }); r.on(RoomEvent.TrackSubscribed, (track, publication, participant) => { attachTrack(track, publication, participant); if ( track.kind === Track.Kind.Video && (track.source === Track.Source.ScreenShare || publication.source === Track.Source.ScreenShare) ) { const identity = participant.identity; const name = participant.name || identity; setRemoteScreenShares((prev) => { if (prev.some((s) => s.track.sid === track.sid)) return prev; return [...prev, { track, participantId: identity, participantName: name }]; }); } }); r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => { detachTrack(track, publication, participant); if (track.kind === Track.Kind.Video) { setRemoteScreenShares((prev) => prev.filter((s) => s.track.sid !== track.sid)); } }); // Track my own screen-share state via LocalTrack events so the toggle // stays in sync if the user stops sharing via the browser's native UI. r.on(RoomEvent.LocalTrackPublished, (publication) => { if (publication.source === Track.Source.ScreenShare) { setIsScreenSharing(true); } }); r.on(RoomEvent.LocalTrackUnpublished, (publication) => { if (publication.source === Track.Source.ScreenShare) { setIsScreenSharing(false); } }); await r.connect(url, token); if (e2eeBundle) { try { await r.setE2EEEnabled(true); setIsE2EEActive(true); } catch (e2eErr: unknown) { console.error('setE2EEEnabled failed — falling back to clear-media', e2eErr); setIsE2EEActive(false); } } else { setIsE2EEActive(false); } try { await r.localParticipant.setMicrophoneEnabled(true, { echoCancellation: aParams.echoCancellation, noiseSuppression: aParams.noiseSuppression, autoGainControl: aParams.autoGainControl, channelCount: aParams.stereo ? 2 : 1, sampleRate: aParams.sampleRateHz, }); } catch (micErr: unknown) { console.error('setMicrophoneEnabled failed', micErr); } if (mediaKind === 'video') { try { await r.localParticipant.setCameraEnabled(true); } catch (camErr: unknown) { console.error('setCameraEnabled failed', camErr); } } setIsMuted(false); setRemoteParticipants(Array.from(r.remoteParticipants.values())); // If peers are already in the room, transition immediately — otherwise // stay in outgoing/connecting and wait for ParticipantConnected. // Runs BEFORE presence setup so a slow/failing presence channel can't // leave the acceptor stuck in "connecting…". markConnectedIfReady(r, conversationId, mediaKind, callId); // Advertise "we're in this call" on the presence channel so other // conversation members can show a Join-button even after leaving. // Supabase realtime dedupes channels by topic: if any component (e.g. // an observer banner) already subscribed to this topic, we get the // existing channel back. In that case .subscribe() would throw — so we // branch on channel.state and just track(). if (myId) { const pres = supabase.channel('call-presence:' + conversationId, { config: { presence: { key: myId, enabled: true } }, }); presenceChannelRef.current = pres; const track = () => { void pres.track({ userId: myId, joinedAt: new Date().toISOString() }); }; if (pres.state === 'joined') { track(); } else if (pres.state === 'closed') { pres.subscribe((status) => { if (status === 'SUBSCRIBED') track(); }); } else { // 'joining' — wait briefly for join to complete before tracking. const waitId = window.setInterval(() => { if (pres.state === 'joined') { window.clearInterval(waitId); track(); } }, 100); window.setTimeout(() => window.clearInterval(waitId), 5000); } } }, [ markConnectedIfReady, clearRingTimer, clearSoloTimer, emitCallEvent, disconnectRoom, myId, ], ); // --- Public actions ---------------------------------------------------- const startCall = useCallback( async (conversationId: string, mediaKind: CallKind = 'audio') => { if (!myId) return; if (stateRef.current.kind !== 'idle') return; const callId = newCallId(); everConnectedRef.current = false; setLastCallConversationId(null); setState({ kind: 'outgoing', callId, conversationId, mediaKind, ringingSince: new Date().toISOString(), }); const peers = peerUserIdsFor(conversationId); pendingPeersRef.current = new Set(peers); await Promise.all( peers.map((to) => sendSignal(to, { type: 'invite', callId, conversationId, fromUserId: myId, kind: mediaKind, sentAt: new Date().toISOString(), }), ), ); // Caller joins the room immediately so acceptors hop straight into a // live room. Transition to 'connected' happens in ParticipantConnected. try { await joinRoom(conversationId, mediaKind, callId); } catch (err: unknown) { setState({ kind: 'error', message: err instanceof Error ? err.message : 'join failed', }); await disconnectRoom(); return; } clearRingTimer(); ringTimerRef.current = window.setTimeout(() => { void (async () => { // Only trigger if still alone (nobody joined the room yet). const cur = stateRef.current; if (cur.kind !== 'outgoing' || cur.callId !== callId) return; const peers2 = peerUserIdsFor(conversationId); await Promise.all( peers2.map((to) => sendSignal(to, { type: 'cancel', callId, byUserId: myId }), ), ); void emitCallEvent(conversationId, 'missed', mediaKind, 0); await disconnectRoom(); setState({ kind: 'idle' }); })(); }, RING_TIMEOUT_MS); }, [myId, peerUserIdsFor, sendSignal, clearRingTimer, emitCallEvent, joinRoom, disconnectRoom], ); // Re-join an already-running call without ringing peers. const joinActiveCall = useCallback( async (conversationId: string, mediaKind: CallKind = 'audio') => { if (!myId) return; if (stateRef.current.kind !== 'idle') return; const callId = newCallId(); everConnectedRef.current = false; setLastCallConversationId(null); setState({ kind: 'connecting', callId, conversationId, mediaKind }); try { await joinRoom(conversationId, mediaKind, callId); } catch (err: unknown) { setState({ kind: 'error', message: err instanceof Error ? err.message : 'join failed', }); await disconnectRoom(); } }, [myId, joinRoom, disconnectRoom], ); const acceptIncoming = useCallback(async () => { const s = stateRef.current; if (s.kind !== 'incoming' || !myId) return; const { callId, conversationId, mediaKind } = s; everConnectedRef.current = false; setLastCallConversationId(null); setState({ kind: 'connecting', callId, conversationId, mediaKind }); try { await joinRoom(conversationId, mediaKind, callId); } catch (err: unknown) { setState({ kind: 'error', message: err instanceof Error ? err.message : 'join failed', }); await disconnectRoom(); } }, [myId, joinRoom, disconnectRoom]); const rejectIncoming = useCallback(() => { const s = stateRef.current; if (s.kind !== 'incoming' || !myId) return; void sendSignal(s.fromUserId, { type: 'reject', callId: s.callId, byUserId: myId }); setState({ kind: 'idle' }); }, [myId, sendSignal]); const hangup = useCallback(async () => { const s = stateRef.current; clearRingTimer(); clearSoloTimer(); if (s.kind === 'outgoing' && myId) { // Caller cancelled before anyone picked up — dismiss other sides' rings. const peers = peerUserIdsFor(s.conversationId); void Promise.all( peers.map((to) => sendSignal(to, { type: 'cancel', callId: s.callId, byUserId: myId }), ), ); if (!everConnectedRef.current) { void emitCallEvent(s.conversationId, 'missed', s.mediaKind, 0); } } let nextLastCallId: string | null = null; if (s.kind === 'connected') { const r = roomRef.current; const amLastOut = !r || r.remoteParticipants.size === 0; if (amLastOut) { const durationSec = Math.max( 0, Math.floor((Date.now() - new Date(s.startedAt).getTime()) / 1000), ); void emitCallEvent(s.conversationId, 'ended', s.mediaKind, durationSec); } else { // Peers still in room — keep sidebar rejoin affordance. nextLastCallId = s.conversationId; } } // Await disconnect BEFORE dropping state so the tracker presence channel // is removed before any RejoinCallBar observer tries to subscribe to the // same topic (Supabase realtime dedupes channels by topic). await disconnectRoom(); void playEndBeep(); setLastCallConversationId(nextLastCallId); setState({ kind: 'idle' }); }, [ myId, peerUserIdsFor, sendSignal, disconnectRoom, clearRingTimer, clearSoloTimer, emitCallEvent, ]); const dismissLastCall = useCallback(() => { setLastCallConversationId(null); }, []); const toggleMute = useCallback(() => { const r = roomRef.current; if (!r) return; const lp = r.localParticipant; const shouldEnable = !lp.isMicrophoneEnabled; void lp.setMicrophoneEnabled(shouldEnable).then(() => { setIsMuted(!shouldEnable); }); }, []); const toggleScreenShare = useCallback(async () => { const r = roomRef.current; if (!r) return; const lp = r.localParticipant; const nextOn = !lp.isScreenShareEnabled; try { const ssParams = getPresetParams(getScreenShareSettings().preset); await lp.setScreenShareEnabled(nextOn, { audio: false, // Omitting `resolution` lets the browser return native source size — // best possible input quality. Fixed presets pass explicit dims so // the encoder has a predictable target. ...(ssParams.dims ? { resolution: { width: ssParams.dims.width, height: ssParams.dims.height, frameRate: ssParams.framerate, }, } : {}), contentHint: 'detail', }); setIsScreenSharing(nextOn); } catch (err: unknown) { console.error('setScreenShareEnabled failed', err); // User cancelled or permission denied — leave state as-is. } }, []); // --- Push-to-talk ------------------------------------------------------ // While PTT is active + we're in a connected call, the mic is held off // except while the configured key is pressed. Under Tauri we also register // the key as an OS-level global shortcut so PTT keeps working while the // user is focused on another window (Discord-style). The window listener // stays as a fallback for the web build and for the case where the global // shortcut registration fails (collision with another app). useEffect(() => { if (state.kind !== 'connected') return; let settings = getPttSettings(); let activeKeyDown = false; let globalRegisteredFor: string | null = null; const setMic = (on: boolean) => { const r = roomRef.current; if (!r) return; void r.localParticipant.setMicrophoneEnabled(on).then(() => setIsMuted(!on)); }; const pressPtt = () => { if (activeKeyDown) return; activeKeyDown = true; setMic(true); }; const releasePtt = () => { if (!activeKeyDown) return; activeKeyDown = false; setMic(false); }; const isTypingTarget = (el: EventTarget | null): boolean => { if (!(el instanceof HTMLElement)) return false; const tag = el.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return true; if (el.isContentEditable) return true; return false; }; const onKeyDown = (e: KeyboardEvent) => { if (!settings.enabled) return; if (e.code !== settings.key) return; if (isTypingTarget(e.target)) return; if (e.repeat) return; pressPtt(); }; const onKeyUp = (e: KeyboardEvent) => { if (!settings.enabled) return; if (e.code !== settings.key) return; releasePtt(); }; const ensureGlobalShortcut = () => { if (!isTauriRuntime()) return; if (!settings.enabled) { if (globalRegisteredFor) { const key = globalRegisteredFor; globalRegisteredFor = null; void unregisterPttShortcut(key); } return; } if (globalRegisteredFor === settings.key) return; // Hotkey changed — unregister old, register new. const oldKey = globalRegisteredFor; globalRegisteredFor = settings.key; if (oldKey) void unregisterPttShortcut(oldKey); void registerPttShortcut(settings.key, pressPtt, releasePtt); }; const applyPttState = () => { if (settings.enabled) { activeKeyDown = false; setMic(false); } else { setMic(true); } ensureGlobalShortcut(); }; applyPttState(); const unsubSettings = subscribePttSettings((next) => { settings = next; applyPttState(); }); window.addEventListener('keydown', onKeyDown); window.addEventListener('keyup', onKeyUp); return () => { unsubSettings(); window.removeEventListener('keydown', onKeyDown); window.removeEventListener('keyup', onKeyUp); if (globalRegisteredFor) { void unregisterPttShortcut(globalRegisteredFor); globalRegisteredFor = null; } }; }, [state.kind]); // --- Outgoing-channel: listen for accept/reject on our own invite ------ // and incoming invites from peers. useEffect(() => { if (!myId) return; const me: string = myId; // narrowed capture for the inner closure const topic = signalTopic(me); const channel = supabase .channel(topic, { config: { broadcast: { self: false } } }) .on('broadcast', { event: 'signal' }, (msg) => { const payload = msg.payload as CallSignal; handleSignal(payload); }); signalChannelRef.current = channel; void channel.subscribe(); return () => { signalChannelRef.current = null; void supabase.removeChannel(channel); }; function handleSignal(p: CallSignal) { const cur = stateRef.current; switch (p.type) { case 'invite': { if (cur.kind !== 'idle') { // Busy — auto-reject. void sendSignal(p.fromUserId, { type: 'reject', callId: p.callId, byUserId: me, }); return; } setState({ kind: 'incoming', callId: p.callId, conversationId: p.conversationId, fromUserId: p.fromUserId, mediaKind: p.kind, }); const conv = conversationsRef.current.find((c) => c.id === p.conversationId) ?? null; const callerName = conv?.members.find((m) => m.userId === p.fromUserId)?.profile?.displayName ?? conv?.peer?.displayName ?? '…'; const isGroup = conv?.type === 'group'; void notify({ title: isGroup ? (conv?.name ?? 'Gruppenanruf') : 'Eingehender Anruf', body: isGroup ? callerName + ' ruft die Gruppe' : callerName + ' ruft dich an', }); break; } case 'accept': case 'end': { // No-op. Participant join/leave is tracked via LiveKit events so // calls stay active while at least one person remains in the room. break; } case 'reject': { // Only relevant while I'm still the outgoing-caller who hasn't // reached the room. Once someone joined (state = connected), a late // reject from another ringing peer doesn't end the call. if ( cur.kind !== 'outgoing' || cur.callId !== p.callId || everConnectedRef.current ) { break; } // Remove the rejecter from the pending set. For group calls, keep // ringing as long as at least one peer hasn't answered — only the // last reject collapses the call to `declined`. pendingPeersRef.current.delete(p.byUserId); if (pendingPeersRef.current.size > 0) break; clearRingTimer(); clearSoloTimer(); void emitCallEvent(cur.conversationId, 'declined', cur.mediaKind, 0); void disconnectRoom(); setState({ kind: 'idle' }); break; } case 'cancel': { // Caller cancelled before anyone picked up — receiver dismisses the // incoming toast. Only hits when we're still in 'incoming' state. if (cur.kind === 'incoming' && cur.callId === p.callId) { const conv = conversationsRef.current.find((c) => c.id === cur.conversationId) ?? null; const callerName = conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ?? conv?.peer?.displayName ?? '…'; void notify({ title: 'Verpasster Anruf', body: callerName + ' hat aufgelegt', }); setState({ kind: 'idle' }); } break; } } } }, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]); const setCallMode = useCallback((mode: CallMode) => { setCallModeState(mode); }, []); const setFocusedId = useCallback((id: string | null) => { setFocusedIdState(id); }, []); // Reset UI call-mode state when the call leaves any active phase so the next // call starts fresh at grid/unfocused. useEffect(() => { if (state.kind === 'idle' || state.kind === 'error') { setCallModeState('grid'); setFocusedIdState(null); } }, [state.kind]); // Global Esc: drop out of fullscreen cinema back to grid while in an active // call. Doesn't hangup and doesn't fire when other dialogs would consume it. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return; if (callMode !== 'fullscreen') return; if (state.kind !== 'connected' && state.kind !== 'connecting') return; setCallModeState('grid'); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [callMode, state.kind]); const value = useMemo( () => ({ state, room, remoteParticipants, isMuted, isE2EEActive, isScreenSharing, remoteScreenShares, lastCallConversationId, callMode, focusedId, startCall, joinActiveCall, acceptIncoming, rejectIncoming, hangup, toggleMute, toggleScreenShare, dismissLastCall, setCallMode, setFocusedId, }), [ state, room, remoteParticipants, isMuted, isE2EEActive, isScreenSharing, remoteScreenShares, lastCallConversationId, callMode, focusedId, startCall, joinActiveCall, acceptIncoming, rejectIncoming, hangup, toggleMute, toggleScreenShare, dismissLastCall, setCallMode, setFocusedId, ], ); return {children}; } export function useCall(): CallContextValue { const ctx = useContext(CallContext); if (!ctx) throw new Error('useCall must be used inside '); return ctx; } function attachTrack( track: RemoteTrack, _publication: RemoteTrackPublication, _participant: RemoteParticipant, ): void { if (track.kind === Track.Kind.Audio) { const audio = track.attach(); if (audio instanceof HTMLAudioElement) { audio.autoplay = true; audio.setAttribute('playsinline', 'true'); audio.setAttribute('data-livekit-track', track.sid ?? ''); document.body.appendChild(audio); } } // Video is handled later in M2.6/M3 by a dedicated