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 { setCallWakeLock } from '../lib/wakeLock'; import { notify } from '../lib/osNotify'; import { isTauriRuntime, registerGlobalShortcutPress, registerPttShortcut, unregisterGlobalShortcut, unregisterPttShortcut, } from '../lib/globalShortcut'; import { getPttSettings, subscribePttSettings } from '../lib/pttSettings'; import { bindingToTauriShortcut, eventMatchesBinding, getVoiceHotkeys, subscribeVoiceHotkeys, type VoiceHotkeys, } from '../lib/voiceHotkeys'; import { getAudioQualityParams, getAudioSettings, subscribeAudioSettings, updateAudioSettings, } from '../lib/audioSettings'; import { applyBackgroundBlurToLocal, removeBackgroundBlurFromLocal, } from '../lib/videoBlur'; import { createCallE2EE, getCallE2EESettings, isE2EESupported, } from '../lib/callE2EE'; import { createMicPipeline, type MicPipeline } from '../lib/micPipeline'; import { getParticipantVolume } from '../lib/participantVolumes'; import { startSoundboardHotkeys } from '../lib/soundboardHotkeys'; import { playEntry } from '../lib/soundboardPlayback'; import { getPrefs as getSoundboardPrefs, listSounds as listSoundboard, updatePrefs as updateSoundboardPrefs, } from '../lib/soundboardStorage'; import { type DisplaySurfaceHint, getPresetParams, getScreenShareSettings, type ScreenSharePreset, updateScreenShareSettings, } 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; } | { // LiveKit dropped the signaling socket but is actively retrying. The // room + tracks stay alive — the user's mic + speakers keep working — // they just can't reach peers until we're back. Distinct from // `connecting` so the UI can show "Verbinde neu…" vs "Verbinde…". kind: 'reconnecting'; 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; isCameraEnabled: boolean; isDeafened: boolean; /** identity -> their deafen state, received via data channel. */ remoteDeafen: Record; /** identity -> mute state. Broadcast from peer whenever mic-gain flips. * Needed because we can't rely on LiveKit's native isMicrophoneEnabled — * the mic pipeline keeps the track published with sound flowing even * while the mic path is gain-silenced, so LK never sees "muted". */ remoteMute: Record; // 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: (override?: CallKind) => Promise; rejectIncoming: () => void; hangup: () => Promise; toggleMute: () => void; toggleScreenShare: () => Promise; startScreenShare: ( overrides?: Partial<{ preset: ScreenSharePreset; displaySurface: DisplaySurfaceHint; framerate: number | null; }>, ) => Promise; stopScreenShare: () => Promise; toggleCamera: () => Promise; toggleDeafen: () => void; dismissLastCall: () => void; setCallMode: (mode: CallMode) => void; setFocusedId: (id: string | null) => void; /** Play a soundboard entry through the active call's mic pipeline. * No-op when not connected. Default single-fire per id (spamming the * hotkey cuts the previous instance); set overlap=true to layer. */ playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise; /** Stop every active sb source, or just the one matching `id` if given. */ stopSoundboard: (id?: string) => void; /** Ids of soundboard entries currently emitting audio. Updated live so * the in-call panel can show a stop icon on active pads. */ activeSoundboardIds: ReadonlySet; /** Apply new sb master/monitor gains to the live pipeline. Persists via * updatePrefs in the storage module. */ setSoundboardMasterGain: (value: number) => Promise; setSoundboardMonitorGain: (value: number) => Promise; // Runtime mic switch. Persists choice so subsequent calls reuse it, and // hot-swaps the input on an active call without a reconnect. setAudioInputDevice: (deviceId: string | null) => Promise; // Runtime speaker/headphone switch. Persists + applies setSinkId to all // currently-attached remote-audio elements. setAudioOutputDevice: (deviceId: string | null) => Promise; /** Non-fatal mic-setup error message (e.g. permission denied). Surfaced in * the in-call panel as a retry-banner so the user can stay in the call and * hear others while sorting out their mic. Null when the mic is working. */ micError: string | null; clearMicError: () => void; /** Retry mic acquisition using the current audioSettings. Safe to call * multiple times; no-op if there's no active room. */ retryMic: () => Promise; } 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, profile } = 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 [isCameraEnabled, setIsCameraEnabled] = useState(false); const [isDeafened, setIsDeafened] = useState(false); const [remoteScreenShares, setRemoteScreenShares] = useState([]); const [lastCallConversationId, setLastCallConversationId] = useState(null); const [callMode, setCallModeState] = useState('grid'); const [focusedId, setFocusedIdState] = useState(null); const [activeSoundboardIds, setActiveSoundboardIds] = useState>( () => new Set(), ); const [micError, setMicError] = useState(null); const signalChannelRef = useRef(null); const presenceChannelRef = useRef(null); const ringTimerRef = useRef(null); const soloTimerRef = useRef(null); // Fallback for joinActiveCall: if a rejoin lands in an empty room (peers // left between "call still live" and our connect), force the transition // to `connected` after a few seconds so the UI doesn't hang in "Verbinde…" // indefinitely. The solo-timeout will then cleanly close if nobody arrives. const joinFallbackTimerRef = useRef(null); const roomRef = useRef(null); // Web Audio graph that mixes live mic + soundboard sources into a single // published track. Created per call in joinRoom, destroyed in disconnectRoom. const pipelineRef = 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()); // identity -> their current deafen state. Populated via LiveKit data // channel messages ({ type: 'presence', deafened: bool }). Exposed as // state so consumer components re-render on change. const [remoteDeafen, setRemoteDeafen] = useState>({}); const [remoteMute, setRemoteMute] = useState>({}); // Mirror of isMuted for use inside LiveKit-event callbacks that run outside // the React component (ParticipantConnected rebroadcast etc). const mutedRef = useRef(false); // Remembers the pre-deafen mute state so toggling deafen off restores what // the user had before. Discord-style: deafen implies mute, and un-deafen // returns the user to whatever mute choice they had made pre-deafen. const preDeafenMutedRef = useRef(null); 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; // Mirror own presence state for use inside signal-channel callbacks. DND // suppresses incoming-call OS notifications (ringtone is handled in CallUI // which has direct access to the auth profile). const presenceRef = useRef(profile?.presenceState ?? 'offline'); presenceRef.current = profile?.presenceState ?? 'offline'; // --- 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 clearJoinFallbackTimer = useCallback(() => { if (joinFallbackTimerRef.current !== null) { window.clearTimeout(joinFallbackTimerRef.current); joinFallbackTimerRef.current = null; } }, []); // Extracted so retryMic can call it after the user grants permission from // OS settings. Reads audioSettings fresh every call so NS/input-device // flips take effect without rejoining the room. const setupMicPipeline = useCallback(async (r: Room): Promise => { const audioPrefs = getAudioSettings(); const aParams = getAudioQualityParams(audioPrefs.quality); const inputId = audioPrefs.inputDeviceId; const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression; try { const rawStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: aParams.echoCancellation, noiseSuppression: nsEffective, autoGainControl: aParams.autoGainControl, channelCount: aParams.stereo ? 2 : 1, sampleRate: aParams.sampleRateHz, ...(inputId ? { deviceId: { ideal: inputId } } : {}), }, video: false, }); const rawTrack = rawStream.getAudioTracks()[0]; if (!rawTrack) throw new Error('no audio track from getUserMedia'); // Retry path: dispose any prior pipeline so we don't leak contexts. const prev = pipelineRef.current; if (prev) { try { prev.destroy(); } catch { /* ignore */ } pipelineRef.current = null; } const pipeline = createMicPipeline(rawTrack); pipelineRef.current = pipeline; try { const prefs = await getSoundboardPrefs(); pipeline.setSoundboardGain(prefs.masterGain); pipeline.setMonitorGain(prefs.monitorGain); } catch (err: unknown) { console.warn('getSoundboardPrefs failed', err); } await r.localParticipant.publishTrack(pipeline.outputTrack, { source: Track.Source.Microphone, red: true, dtx: aParams.stereo ? false : true, forceStereo: aParams.stereo, }); setMicError(null); } catch (err: unknown) { // Categorise the error so the banner can be specific. DOMException // names are stable across Chrome/Firefox/WebKit. const name = (err as { name?: string }).name; let msg = 'Mikrofon konnte nicht gestartet werden.'; if (name === 'NotAllowedError' || name === 'SecurityError') { msg = 'Mikrofon-Zugriff blockiert. Erlaube den Zugriff in den Systemeinstellungen.'; } else if (name === 'NotFoundError' || name === 'OverconstrainedError') { msg = 'Kein Mikrofon gefunden. Schließe eines an und versuche es erneut.'; } else if (name === 'NotReadableError') { msg = 'Mikrofon ist von einer anderen App belegt. Schließe sie und versuche es erneut.'; } setMicError(msg); console.error('mic pipeline setup failed', err); } }, []); 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); setIsCameraEnabled(false); setIsDeafened(false); deafenedActive = false; setRemoteDeafen({}); setRemoteMute({}); setIsMuted(false); mutedRef.current = false; setIsE2EEActive(false); // Tear down the mic pipeline AFTER LiveKit disconnects so the published // track is unpublished cleanly first; then close AudioContext + stop // raw mic + output tracks we own. const pipeline = pipelineRef.current; if (pipeline) { try { pipeline.destroy(); } catch { /* ignore */ } pipelineRef.current = null; } setActiveSoundboardIds(new Set()); 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(); clearJoinFallbackTimer(); everConnectedRef.current = true; setState({ kind: 'connected', callId, conversationId, mediaKind, startedAt: new Date().toISOString(), }); }, [clearRingTimer, clearSoloTimer, clearJoinFallbackTimer], ); // --- 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.Reconnecting) { // LiveKit lost the signaling socket and is retrying. Hold the // connected state visually — the track publications stay live, // so the user's mic + speakers keep working once the socket is // back. Only transition from `connected`; if we were still in // `connecting`/`outgoing`, LK will sort itself out on its own. const cur = stateRef.current; if (cur.kind === 'connected') { setState({ kind: 'reconnecting', callId: cur.callId, conversationId: cur.conversationId, mediaKind: cur.mediaKind, startedAt: cur.startedAt, }); } return; } if (cs === ConnectionState.Connected) { // Flip back from `reconnecting` when LK re-establishes the socket. // Preserves startedAt so the duration counter doesn't reset. const cur = stateRef.current; if (cur.kind === 'reconnecting') { setState({ kind: 'connected', callId: cur.callId, conversationId: cur.conversationId, mediaKind: cur.mediaKind, startedAt: cur.startedAt, }); } return; } 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(); if (joinFallbackTimerRef.current !== null) { window.clearTimeout(joinFallbackTimerRef.current); joinFallbackTimerRef.current = null; } const wasInCall = stateRef.current.kind === 'connected' || stateRef.current.kind === 'connecting' || stateRef.current.kind === 'reconnecting' || 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())); if (presenceRef.current !== 'dnd') void playJoinBeep(); markConnectedIfReady(r, conversationId, mediaKind, callId); }); r.on(RoomEvent.ParticipantDisconnected, () => { const remaining = Array.from(r.remoteParticipants.values()); setRemoteParticipants(remaining); if (presenceRef.current !== 'dnd') 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)); } }); // Mute/unmute a camera doesn't publish or unpublish — the publication // stays, just its `muted` flag flips. Without this listener, remote // participants who toggle video mid-call appear as a frozen last frame // or (worse) a black tile on every other client. Bumping the // remoteParticipants state reference forces buildTiles to re-read // `isCameraEnabled` and swap to the avatar placeholder. const bumpParticipants = () => { setRemoteParticipants(Array.from(r.remoteParticipants.values())); }; r.on(RoomEvent.TrackMuted, bumpParticipants); r.on(RoomEvent.TrackUnmuted, bumpParticipants); // Remote deafen state is broadcast via the LiveKit data channel. We // store incoming states in `remoteDeafenMapRef` and bump participants // so buildTiles re-reads it. r.on( RoomEvent.DataReceived, (payload: Uint8Array, participant?: RemoteParticipant | undefined) => { if (!participant?.identity) return; try { const text = new TextDecoder().decode(payload); const msg = JSON.parse(text) as { type?: string; deafened?: boolean; muted?: boolean; }; if (msg.type !== 'presence') return; const id: string = participant.identity; if (typeof msg.deafened === 'boolean') { const deafened: boolean = msg.deafened; setRemoteDeafen((prev) => { if (prev[id] === deafened) return prev; return { ...prev, [id]: deafened }; }); } if (typeof msg.muted === 'boolean') { const muted: boolean = msg.muted; setRemoteMute((prev) => { if (prev[id] === muted) return prev; return { ...prev, [id]: muted }; }); } } catch { /* ignore malformed */ } }, ); // When someone joins, re-send our current presence (deafen + mute) so // they know immediately instead of waiting for the next toggle. r.on(RoomEvent.ParticipantConnected, () => { void broadcastPresence(r, deafenedActive, mutedRef.current); }); // 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); // Self-join feedback sound. `playJoinBeep` is shared with the // ParticipantConnected path; firing it here too gives the user a clear // "I'm in the room" cue that Discord plays on self-join. if (presenceRef.current !== 'dnd') void playJoinBeep(); 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); } // Mic pipeline setup + publish. Runs asynchronously; on failure sets // `micError` so the InCallPanel renders a retry banner without tearing // down the whole call — the user can still hear peers meanwhile. await setupMicPipeline(r); if (mediaKind === 'video') { try { await r.localParticipant.setCameraEnabled(true); setIsCameraEnabled(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, setupMicPipeline, ], ); // --- 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); // Fallback: peers may have left the room right as we joined, so // ParticipantConnected never fires. Promote to `connected` after a // short window so the UI doesn't sit in "Verbinde…" forever. The // solo-timeout then handles the "actually alone" case cleanly. clearJoinFallbackTimer(); joinFallbackTimerRef.current = window.setTimeout(() => { joinFallbackTimerRef.current = null; const cur = stateRef.current; if (cur.kind !== 'connecting' || cur.callId !== callId) return; everConnectedRef.current = true; setState({ kind: 'connected', callId, conversationId, mediaKind, startedAt: new Date().toISOString(), }); }, 5000); } catch (err: unknown) { setState({ kind: 'error', message: err instanceof Error ? err.message : 'join failed', }); await disconnectRoom(); } }, [myId, joinRoom, disconnectRoom, clearJoinFallbackTimer], ); const acceptIncoming = useCallback( async (override?: CallKind) => { const s = stateRef.current; if (s.kind !== 'incoming' || !myId) return; const { callId, conversationId } = s; // Caller's `mediaKind` is the INVITE kind (what they started with). The // receiver can accept with audio even if the caller rang as video, or // upgrade an audio invite to video on accept. `override` picks the // receiver's choice. const mediaKind: CallKind = override ?? s.mediaKind; 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(); clearJoinFallbackTimer(); 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, clearJoinFallbackTimer, emitCallEvent, ]); const dismissLastCall = useCallback(() => { setLastCallConversationId(null); }, []); const toggleMute = useCallback(() => { const pipeline = pipelineRef.current; if (!pipeline) return; setIsMuted((prev) => { const nextMuted = !prev; pipeline.setMicGain(nextMuted ? 0 : 1); mutedRef.current = nextMuted; const r = roomRef.current; if (r) void broadcastPresence(r, deafenedActive, nextMuted); return nextMuted; }); }, []); const startScreenShare = useCallback( async ( overrides?: Partial<{ preset: ScreenSharePreset; displaySurface: DisplaySurfaceHint; framerate: number | null; }>, ) => { const r = roomRef.current; if (!r) return; const lp = r.localParticipant; if (lp.isScreenShareEnabled) return; // Persist the user's choice so subsequent shares use the same config // without re-opening the picker unless they want to change something. const settings = getScreenShareSettings(); const preset = overrides?.preset ?? settings.preset; const displaySurface = overrides?.displaySurface !== undefined ? overrides.displaySurface : settings.displaySurface; const framerateOverride = overrides?.framerate !== undefined ? overrides.framerate : settings.framerateOverride; updateScreenShareSettings({ preset, displaySurface, framerateOverride }); const ssParams = getPresetParams(preset); const fps = framerateOverride ?? ssParams.framerate; try { await lp.setScreenShareEnabled(true, { // "Go live" mode — capture system audio alongside the screen when // the user opted in. On hosts that can't fulfil the request the // browser quietly drops it; peers just get video-only, no error. audio: settings.includeSystemAudio, ...(ssParams.dims ? { resolution: { width: ssParams.dims.width, height: ssParams.dims.height, frameRate: fps, }, } : { resolution: { width: 3840, height: 2160, frameRate: fps, }, }), // Hints the OS picker to pre-filter by source kind. `null` = no // filter (show both). Cast because TS lib.dom doesn't know the // field yet on all branches. ...(displaySurface ? ({ displaySurface } as { displaySurface: DisplaySurfaceHint }) : {}), contentHint: 'detail', }); setIsScreenSharing(true); } catch (err: unknown) { console.error('setScreenShareEnabled failed', err); } }, [], ); const stopScreenShare = useCallback(async () => { const r = roomRef.current; if (!r) return; const lp = r.localParticipant; if (!lp.isScreenShareEnabled) return; try { await lp.setScreenShareEnabled(false); setIsScreenSharing(false); } catch (err: unknown) { console.error('stopScreenShare failed', err); } }, []); // Legacy toggle kept for convenience elsewhere — opens/closes with the // last-persisted settings and no picker UI. const toggleScreenShare = useCallback(async () => { const r = roomRef.current; if (!r) return; if (r.localParticipant.isScreenShareEnabled) { await stopScreenShare(); } else { await startScreenShare(); } }, [startScreenShare, stopScreenShare]); const toggleDeafen = useCallback(() => { setIsDeafened((prev) => { const next = !prev; deafenedActive = next; // Apply to every currently-attached remote-audio element. Fresh tracks // that attach during a deafened session are muted in attachTrack above. const els = document.querySelectorAll( 'audio[data-livekit-track]', ); els.forEach((el) => { el.muted = next; }); // Discord-parity: deafen implies mute. Remember the pre-deafen mute // state so un-deafening restores whatever the user had before. const pipeline = pipelineRef.current; let nextMuted = mutedRef.current; if (next) { // Activating deafen → snapshot current mute + force mic off. preDeafenMutedRef.current = mutedRef.current; if (!mutedRef.current) { pipeline?.setMicGain(0); mutedRef.current = true; nextMuted = true; setIsMuted(true); } } else { // Deactivating deafen → restore pre-deafen mic state (if we have a // snapshot). Absent snapshot (e.g. reconnect edge), unmute. const restore = preDeafenMutedRef.current ?? false; preDeafenMutedRef.current = null; pipeline?.setMicGain(restore ? 0 : 1); mutedRef.current = restore; nextMuted = restore; setIsMuted(restore); } // Broadcast via LiveKit data channel so peers' UIs can show the // headphones-off badge. Data channel works on any LiveKit server // version, unlike `setAttributes` which requires a newer server. const r = roomRef.current; if (r) void broadcastPresence(r, next, nextMuted); return next; }); }, []); const toggleCamera = useCallback(async () => { const r = roomRef.current; if (!r) return; const lp = r.localParticipant; const nextOn = !lp.isCameraEnabled; try { await lp.setCameraEnabled(nextOn); setIsCameraEnabled(nextOn); if (nextOn && getAudioSettings().videoBackgroundBlur) { void applyBackgroundBlurToLocal(lp); } } catch (err: unknown) { console.error('setCameraEnabled failed', err); // Permission denied / no camera — keep state in sync with actual // publication state so the button doesn't lie. setIsCameraEnabled(lp.isCameraEnabled); } }, []); // Live-toggle the background-blur processor when the settings flag flips. // Acquiring the MediaPipe model is deferred until first activation to // avoid the 1.5MB download on users who never enable blur. useEffect(() => { return subscribeAudioSettings((s) => { const r = roomRef.current; if (!r) return; const lp = r.localParticipant; if (!lp.isCameraEnabled) return; if (s.videoBackgroundBlur) { void applyBackgroundBlurToLocal(lp); } else { void removeBackgroundBlurFromLocal(lp); } }); }, []); // Hot-swap the mic track when the user flips noiseSuppression in Settings // so the change takes effect without needing to rejoin the call. Tracks // the previous value in a ref so we only re-acquire getUserMedia on // actual transitions (avoid a rebuild on every unrelated settings save). useEffect(() => { let lastNs = getAudioSettings().noiseSuppression; return subscribeAudioSettings((s) => { if (s.noiseSuppression === lastNs) return; lastNs = s.noiseSuppression; const r = roomRef.current; if (!r) return; // setupMicPipeline re-reads audioSettings so the new NS constraint // gets picked up. Same helper as the initial join + retry paths. void setupMicPipeline(r); }); }, [setupMicPipeline]); // --- 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 pipeline = pipelineRef.current; if (!pipeline) return; pipeline.setMicGain(on ? 1 : 0); const nextMuted = !on; mutedRef.current = nextMuted; setIsMuted(nextMuted); const r = roomRef.current; if (r) void broadcastPresence(r, deafenedActive, nextMuted); }; 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]); // --- Mute / Deafen global hotkeys -------------------------------------- // Discord-parity: both toggles respond to a user-configurable chord // (default Ctrl+Shift+M / Ctrl+Shift+D, disabled until the user opts in). // Registered only while a call is active so the shortcuts don't intercept // typing outside of calls. Under Tauri we register an OS-level chord so // mute/deafen work from any focused window; the window keydown listener // is the fallback for the web build + when the global register fails // (another app owns the chord). useEffect(() => { if ( state.kind !== 'connected' && state.kind !== 'reconnecting' ) { return; } let settings: VoiceHotkeys = getVoiceHotkeys(); let registered: { mute: string | null; deafen: string | null } = { mute: null, deafen: null, }; const fire = (kind: 'mute' | 'deafen') => { if (kind === 'mute') toggleMute(); else toggleDeafen(); }; const onKey = (e: KeyboardEvent) => { if (eventMatchesBinding(settings.mute, e)) { e.preventDefault(); fire('mute'); return; } if (eventMatchesBinding(settings.deafen, e)) { e.preventDefault(); fire('deafen'); return; } }; window.addEventListener('keydown', onKey); const syncGlobalShortcuts = () => { if (!isTauriRuntime()) return; const desired = { mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null, deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null, }; for (const kind of ['mute', 'deafen'] as const) { const want = desired[kind]; const have = registered[kind]; if (want === have) continue; if (have) { void unregisterGlobalShortcut(have); registered = { ...registered, [kind]: null }; } if (want) { // Tag the closure so React's stale-state trap doesn't bite — // `fire` is stable (defined above), and `kind` is captured by // value. const thisKind = kind; void registerGlobalShortcutPress(want, () => fire(thisKind)); registered = { ...registered, [kind]: want }; } } }; syncGlobalShortcuts(); const unsub = subscribeVoiceHotkeys((next) => { settings = next; syncGlobalShortcuts(); }); return () => { unsub(); window.removeEventListener('keydown', onKey); if (registered.mute) void unregisterGlobalShortcut(registered.mute); if (registered.deafen) void unregisterGlobalShortcut(registered.deafen); }; }, [state.kind, toggleMute, toggleDeafen]); // --- 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'; if (presenceRef.current !== 'dnd') { 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 ?? '…'; if (presenceRef.current !== 'dnd') { 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); }, []); const markActive = useCallback((id: string, on: boolean) => { setActiveSoundboardIds((prev) => { const has = prev.has(id); if (on && has) return prev; if (!on && !has) return prev; const next = new Set(prev); if (on) next.add(id); else next.delete(id); return next; }); }, []); const playSoundboard = useCallback( async (id: string, opts?: { overlap?: boolean }) => { const pipeline = pipelineRef.current; if (!pipeline) return; const entries = await listSoundboard(); const entry = entries.find((e) => e.id === id); if (!entry) return; const handle = await playEntry(pipeline, entry, { ...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}), onEnded: () => markActive(id, false), }); if (handle) markActive(id, true); }, [markActive], ); const stopSoundboard = useCallback( (id?: string) => { const pipeline = pipelineRef.current; if (!pipeline) return; pipeline.stopAll(id); if (id) { markActive(id, false); } else { setActiveSoundboardIds(new Set()); } }, [markActive], ); const setSoundboardMasterGain = useCallback(async (value: number) => { const prefs = await updateSoundboardPrefs({ masterGain: value }); pipelineRef.current?.setSoundboardGain(prefs.masterGain); }, []); const setSoundboardMonitorGain = useCallback(async (value: number) => { const prefs = await updateSoundboardPrefs({ monitorGain: value }); pipelineRef.current?.setMonitorGain(prefs.monitorGain); }, []); // Global soundboard hotkey registration — runs only while connected so the // OS-level shortcuts don't fire when the user is outside of a call. useEffect(() => { if (state.kind !== 'connected') return; const teardown = startSoundboardHotkeys((id) => { void playSoundboard(id); }); return teardown; }, [state.kind, playSoundboard]); const setAudioInputDevice = useCallback(async (deviceId: string | null) => { updateAudioSettings({ inputDeviceId: deviceId }); const pipeline = pipelineRef.current; if (!pipeline) return; try { // We own the mic track (see joinRoom pipeline setup), so LiveKit's // switchActiveDevice no longer applies. Fetch a new raw track with the // updated deviceId + the same quality constraints, then hand ownership // to the pipeline. It disconnects the old source, stops the old track, // and rewires micGain onto the new source — the published track stays // stable so peers don't see a republish. const audioPrefs = getAudioSettings(); const aParams = getAudioQualityParams(audioPrefs.quality); const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression; const newStream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: aParams.echoCancellation, noiseSuppression: nsEffective, autoGainControl: aParams.autoGainControl, channelCount: aParams.stereo ? 2 : 1, sampleRate: aParams.sampleRateHz, ...(deviceId ? { deviceId: { ideal: deviceId } } : {}), }, video: false, }); const newTrack = newStream.getAudioTracks()[0]; if (!newTrack) throw new Error('no audio track for device'); pipeline.replaceMicTrack(newTrack); } catch (err: unknown) { console.error('setAudioInputDevice failed', err); } }, []); const clearMicError = useCallback(() => { setMicError(null); }, []); const retryMic = useCallback(async () => { const r = roomRef.current; if (!r) { setMicError(null); return; } await setupMicPipeline(r); }, [setupMicPipeline]); // Clear the stale mic-error state whenever a call fully tears down so the // next join starts with a clean slate. useEffect(() => { if (state.kind === 'idle' || state.kind === 'error') { setMicError(null); } }, [state.kind]); const setAudioOutputDevice = useCallback(async (deviceId: string | null) => { updateAudioSettings({ outputDeviceId: deviceId }); const sinkId = deviceId ?? ''; // Apply to every