Files
ChatApp/apps/desktop/src/context/CallContext.tsx
T
byGalax 20216b37c6 refactor(desktop): AuthContext exposes userKeyState instead of device record
Replaces the per-device DeviceRecord lookup with a per-user discriminated
union (loading | needs-setup | needs-unlock | unlocked). Heartbeat block
deleted (telemetry no longer device-bound); webPush keyed by install-id.
2026-05-15 22:52:41 +02:00

2957 lines
112 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
ConnectionQuality,
ConnectionState,
type Participant,
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 {
playDeafenBeep,
playEndBeep,
playJoinBeep,
playLeaveBeep,
playMuteBeep,
playUndeafenBeep,
playUnmuteBeep,
} from '../lib/callSounds';
import { useLiveCaptions } from '../lib/useLiveCaptions';
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,
subscribeParticipantVolumes,
} from '../lib/participantVolumes';
import {
allPipelines,
createPipeline,
destroyPipeline,
type RemoteAudioPipeline,
setAllPipelinesSinkId,
setPipelineGain,
} from '../lib/remoteAudioPipelines';
import {
type LoopbackTrackHandle,
startLoopbackTrack,
} from '../lib/loopbackAudio';
import {
type SystemAudioHandle,
startSystemAudioCapture,
} from '../lib/screenAudio';
import {
clearScreenShareVolumes,
getScreenShareVolume,
subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes';
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,
subscribeScreenShareSettings,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { ensureInstallId } from '../lib/installId';
import { supabase } from '../lib/supabase';
import { cachedUserKey } from '../lib/userIdentity';
import { setWindowFullscreen } from '../lib/windowFullscreen';
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;
}
// Discord-style "second-call" ringer — a peer invites us while we're
// already in another call. Lives alongside CallState so the active call
// keeps running while we decide. Only one slot: a third caller arriving
// while this is set still gets an automatic busy-reject.
export interface PendingIncoming {
callId: string;
conversationId: string;
fromUserId: string;
mediaKind: CallKind;
}
// 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;
/** Participants whose screen share the local user has actively clicked
* "Bildschirm anschauen" on. Session-only (cleared on call end). Used to
* gate both the <video> rendering and the ScreenShareAudio playback so
* sound only plays after an explicit opt-in. */
watchingShareUserIds: ReadonlySet<string>;
/** Participants whose share has been right-click dismissed ("Zuschauen
* beenden"). Filters their screen-tile out of the grid until they stop
* + restart sharing (track-unsubscribe clears the entry). */
dismissedShareUserIds: ReadonlySet<string>;
/** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>;
/** identity -> LiveKit connectionQuality. Includes the local participant.
* Drives the Wifi-quality badge on each tile. */
connectionQualities: Record<string, ConnectionQuality>;
/** Discord-style host marker — the userId of whoever initiated the call.
* Set when starting an outgoing call (myId) or accepting an incoming
* invite (fromUserId). Cleared on disconnect. Drives the crown badge,
* but only in group calls. Null while idle or in 1:1 contexts. */
callHostId: string | null;
/** identity -> latest live-caption fragment received via data channel.
* Includes own captions for self-overlay. Receivers prune entries whose
* timestamp is older than ~5s so stale lines fade out. */
captions: Record<string, { text: string; final: boolean; timestamp: number }>;
/** Surface a caption for the local user — the live-captions hook calls
* this on every interim/final SpeechRecognition result so the overlay
* shows our own line without going through the SFU round-trip. */
pushLocalCaption: (text: string, final: boolean) => void;
/** 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<string, 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<void>;
joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
acceptIncoming: (override?: CallKind) => Promise<void>;
rejectIncoming: () => void;
hangup: () => Promise<void>;
toggleMute: () => void;
toggleScreenShare: () => Promise<void>;
startScreenShare: (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
/** Source id from desktopCapturer (e.g. `window:<HWND>:0` or
* `screen:<id>:0`). When this is a window source AND we're on
* Windows with the native loopback addon, the system-audio
* capture switches to INCLUDE_TARGET_PROCESS_TREE so only the
* picked app's audio goes out (Discord parity). */
pickedSourceId: string;
}>,
) => Promise<void>;
stopScreenShare: () => Promise<void>;
toggleCamera: () => Promise<void>;
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<void>;
/** 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<string>;
/** Apply new sb master/monitor gains to the live pipeline. Persists via
* updatePrefs in the storage module. */
setSoundboardMasterGain: (value: number) => Promise<void>;
setSoundboardMonitorGain: (value: number) => Promise<void>;
// 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<void>;
// Runtime speaker/headphone switch. Persists + applies setSinkId to all
// currently-attached remote-audio elements.
setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
// Runtime camera switch. Persists choice so subsequent publishes pick
// it up; hot-swaps the active publication via LiveKit's switchActiveDevice.
setVideoInputDevice: (deviceId: string | null) => Promise<void>;
/** 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<void>;
/** Flip a user's screen share into the watching state (video plays + audio
* unmutes). */
watchShare: (userId: string) => void;
/** Flip out of watching state (video pauses / placeholder + audio mutes).
* Does NOT dismiss the tile — use dismissShare to hide it entirely. */
stopWatchingShare: (userId: string) => void;
/** Remove a share tile from view for the rest of this session (or until
* the sharer stops + restarts). Also clears watching if applicable. */
dismissShare: (userId: string) => void;
/** Per-share-audio manual mute flag (in addition to the watching gate).
* When true, the ScreenShareAudio stays muted even when watching is on
* — lets the user watch the video without the audio track. */
screenShareAudioMutedIds: ReadonlySet<string>;
setScreenShareAudioMuted: (userId: string, muted: boolean) => void;
/** Sender-side mute on our own outgoing ScreenShareAudio publication.
* When true, the LocalTrackPublication is muted at the LiveKit layer so
* peers receive silence without us having to stop + restart the share. */
outgoingShareAudioMuted: boolean;
toggleOutgoingShareAudioMute: () => Promise<void>;
/** Discord-style second-call ringer. Set when an invite arrives while
* the user is already in a connected call. Shown as a top-center
* toast; accepting hangs up the active call cleanly and joins the new
* one. Null at all other times. */
pendingIncoming: PendingIncoming | null;
acceptPendingIncoming: () => Promise<void>;
rejectPendingIncoming: () => void;
}
const CallContext = createContext<CallContextValue | null>(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, profile } = useAuth();
const { conversations } = useConversationsContext();
const myId = session?.user.id;
const [state, setState] = useState<CallState>({ kind: 'idle' });
const [room, setRoom] = useState<Room | null>(null);
const [remoteParticipants, setRemoteParticipants] = useState<RemoteParticipant[]>([]);
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<RemoteScreenShare[]>([]);
const [connectionQualities, setConnectionQualities] = useState<
Record<string, ConnectionQuality>
>({});
const [callHostId, setCallHostId] = useState<string | null>(null);
// Toggled when the loopback path is actively capturing system audio for a
// share. Drives the auto-duck of remote call audio (see effective-gain
// useEffect) so peers don't hear themselves echoed back when the OS-level
// process-tree exclusion isn't watertight.
const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
const [captions, setCaptions] = useState<
Record<string, { text: string; final: boolean; timestamp: number }>
>({});
const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
const [callMode, setCallModeState] = useState<CallMode>('grid');
const [focusedId, setFocusedIdState] = useState<string | null>(null);
const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [micError, setMicError] = useState<string | null>(null);
const [watchingShareUserIds, setWatchingShareUserIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [dismissedShareUserIds, setDismissedShareUserIds] = useState<ReadonlySet<string>>(
() => new Set<string>(),
);
const [screenShareAudioMutedIds, setScreenShareAudioMutedIds] = useState<
ReadonlySet<string>
>(() => new Set<string>());
// Sender-side mute for our own outgoing ScreenShareAudio publication. Lets
// the local sharer silence the system-audio half of their share without
// tearing down the video. Reset whenever a fresh share starts.
const [outgoingShareAudioMuted, setOutgoingShareAudioMutedState] = useState(false);
// Second-call ringer state. The ref mirror is only needed inside event
// callbacks (signal handler, presence sync) where the closure captured
// the initial state value.
const [pendingIncoming, setPendingIncomingState] = useState<PendingIncoming | null>(null);
const pendingIncomingRef = useRef<PendingIncoming | null>(null);
pendingIncomingRef.current = pendingIncoming;
const pendingRingTimerRef = useRef<number | null>(null);
const signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
const ringTimerRef = useRef<number | null>(null);
const soloTimerRef = useRef<number | null>(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<number | null>(null);
// Handle for the Windows-only WASAPI system-audio capture. Lives in
// lockstep with the ScreenShare video track published by
// setScreenShareEnabled; teardown is chained to the video track's
// `ended` event so stale audio can never outlive the video share.
const nativeAudioCaptureRef = useRef<SystemAudioHandle | null>(null);
// Handle for the napi-rs WASAPI process-loopback addon. Preferred on
// Windows over Chromium's `audio: 'loopback'` source because the
// addon excludes our own process tree — peers don't hear themselves
// echoed back. Mutually exclusive with `nativeAudioCaptureRef`: if
// the addon path fails or isn't available, the existing fallback
// populates `nativeAudioCaptureRef` instead.
const nativeLoopbackRef = useRef<LoopbackTrackHandle | null>(null);
const roomRef = useRef<Room | null>(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<MicPipeline | null>(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<boolean>(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<Set<string>>(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<Record<string, boolean>>({});
const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
// Mirror of isMuted for use inside LiveKit-event callbacks that run outside
// the React component (ParticipantConnected rebroadcast etc).
const mutedRef = useRef<boolean>(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<boolean | null>(null);
const stateRef = useRef<CallState>(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) return;
try {
const priv = await cachedUserKey(session.user.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: ensureInstallId(),
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.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<void> => {
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 */
}
}
if (nativeAudioCaptureRef.current) {
const h = nativeAudioCaptureRef.current;
nativeAudioCaptureRef.current = null;
setIsCapturingSystemAudio(false);
await h.stop().catch(() => undefined);
}
if (nativeLoopbackRef.current) {
const h = nativeLoopbackRef.current;
nativeLoopbackRef.current = null;
setIsCapturingSystemAudio(false);
await h.stop().catch(() => undefined);
}
roomRef.current = null;
setRoom(null);
setRemoteParticipants([]);
setRemoteScreenShares([]);
// Drop any deferred screen-share audio entries left from this call so
// a fresh call doesn't auto-attach stale tracks if the same peer joins
// again before watchShare clears them.
deferredScreenAudio.clear();
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<string>());
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);
},
[],
);
// Pending-incoming helpers. Setting the state via the helper keeps the
// ref mirror, ringtone, and timer in sync — handlers in the signal
// listener should always go through these instead of touching state
// directly.
const clearPendingRingTimer = useCallback(() => {
if (pendingRingTimerRef.current !== null) {
window.clearTimeout(pendingRingTimerRef.current);
pendingRingTimerRef.current = null;
}
}, []);
const setPendingIncoming = useCallback(
(next: PendingIncoming | null) => {
pendingIncomingRef.current = next;
setPendingIncomingState(next);
if (next === null) clearPendingRingTimer();
},
[clearPendingRingTimer],
);
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([]);
setConnectionQualities({});
setCallHostId(null);
setCaptions({});
setIsScreenSharing(false);
setIsE2EEActive(false);
}
});
// Discord-style Wifi-Badge: LiveKit emits this whenever the SFU-measured
// quality of any participant (incl. local) changes. Map identity ->
// quality and feed every tile so we can show poor/lost glyphs.
r.on(
RoomEvent.ConnectionQualityChanged,
(quality: ConnectionQuality, participant: Participant) => {
setConnectionQualities((prev) => {
if (prev[participant.identity] === quality) return prev;
return { ...prev, [participant.identity]: quality };
});
},
);
r.on(RoomEvent.ParticipantConnected, () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
if (presenceRef.current !== 'dnd') void playJoinBeep();
// Rejoin während wir schon `connected` sind: markConnectedIfReady
// returnt früh und würde den Solo-Timer nicht cancellen. Hier
// unbedingt clearen, sonst kickt der 5-Minuten-Timer obwohl der
// andere Peer längst wieder im Raum ist.
clearSoloTimer();
markConnectedIfReady(r, conversationId, mediaKind, callId);
});
r.on(RoomEvent.ParticipantDisconnected, (participant) => {
const remaining = Array.from(r.remoteParticipants.values());
setRemoteParticipants(remaining);
// Drop the leaver's quality entry so a stale "poor" badge doesn't
// linger after they disconnect.
setConnectionQualities((prev) => {
if (!(participant.identity in prev)) return prev;
const next = { ...prev };
delete next[participant.identity];
return next;
});
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) {
const wasShare =
track.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShare;
setRemoteScreenShares((prev) => prev.filter((s) => s.track.sid !== track.sid));
// Clean up watching / dismissed state for the sharer so a fresh
// restart from the same user shows the overlay again (Discord
// resets dismiss when a new stream begins).
if (wasShare && participant.identity) {
const identity = participant.identity;
setWatchingShareUserIds((prev) => {
if (!prev.has(identity)) return prev;
const next = new Set(prev);
next.delete(identity);
return next;
});
setDismissedShareUserIds((prev) => {
if (!prev.has(identity)) return prev;
const next = new Set(prev);
next.delete(identity);
return next;
});
setScreenShareAudioMutedIds((prev) => {
if (!prev.has(identity)) return prev;
const next = new Set(prev);
next.delete(identity);
return next;
});
}
}
});
// 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;
captionText?: string;
captionFinal?: boolean;
};
const id: string = participant.identity;
if (msg.type === 'presence') {
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 };
});
}
return;
}
if (msg.type === 'caption' && typeof msg.captionText === 'string') {
const text2 = msg.captionText;
const final = msg.captionFinal === true;
setCaptions((prev) => ({
...prev,
[id]: { text: text2, final, timestamp: Date.now() },
}));
return;
}
} 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);
// Discord-style host marker — caller is the host of an outgoing call.
setCallHostId(myId);
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);
// Re-join doesn't carry an inviter — leave host unset. The crown
// simply won't render in this case, which matches Discord's
// "no clear host" rejoin behaviour.
setCallHostId(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, fromUserId } = 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);
// Discord-style host marker — original caller (the inviter) is host.
setCallHostId(fromUserId);
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]);
// Decline a Discord-style second-call ringer. Keeps the active call
// running; only the pending invite is cleared and the caller notified.
const rejectPendingIncoming = useCallback(() => {
const pi = pendingIncomingRef.current;
if (!pi || !myId) return;
void sendSignal(pi.fromUserId, {
type: 'reject',
callId: pi.callId,
byUserId: myId,
});
setPendingIncoming(null);
}, [myId, sendSignal, setPendingIncoming]);
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);
}, []);
// Accept a Discord-style second-call ringer. Hangs up the currently
// active call cleanly first (so the other side gets the proper end
// signal + duration event) then joins the new room. Routing to the
// new conversation is the toast's responsibility — it has access to
// react-router's `navigate` from a component context.
const acceptPendingIncoming = useCallback(async () => {
const pi = pendingIncomingRef.current;
if (!pi || !myId) return;
setPendingIncoming(null);
// Tear down whatever's currently active. hangup() already covers
// outgoing/connected/reconnecting paths and resets state to idle, so
// this is the same teardown a manual hangup would run.
try {
await hangup();
} catch (err: unknown) {
console.warn('hangup before pending-incoming accept failed', err);
}
everConnectedRef.current = false;
setLastCallConversationId(null);
// Discord-style host marker — original caller of the pending invite.
setCallHostId(pi.fromUserId);
setState({
kind: 'connecting',
callId: pi.callId,
conversationId: pi.conversationId,
mediaKind: pi.mediaKind,
});
try {
await joinRoom(pi.conversationId, pi.mediaKind, pi.callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
}, [myId, hangup, joinRoom, disconnectRoom, setPendingIncoming]);
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);
// Discord-style self-feedback blip. Always plays (even in DND) since
// it's confirmation of the user's own action, not a notification.
void (nextMuted ? playMuteBeep() : playUnmuteBeep());
return nextMuted;
});
}, []);
const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
pickedSourceId: string;
}>,
) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (lp.isScreenShareEnabled) return;
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;
// Under Electron: setScreenShareEnabled(true) calls getDisplayMedia
// internally; Chromium defers to the display-media-request-handler
// configured in the main process (see electron/main.ts) which
// silently grants the first screen source.
//
// Audio path:
// 1. On Windows we *prefer* the native WASAPI process-loopback
// addon (lib/loopbackAudio.ts) — it's the only path that
// excludes our own PID tree, so peers never hear themselves
// echoed back. We pass `audio: false` to LiveKit so Chromium
// doesn't grant its own un-filtered 'loopback' on top of the
// native track. The native track is published as its own
// ScreenShareAudio publication AFTER setScreenShareEnabled
// returns.
// 2. If the addon isn't available (dev build without
// pnpm build:native, packaged build missing the .node, or a
// runtime error) we fall through to passing
// `audio: settings.includeSystemAudio` to LiveKit on the
// retry — the existing Chromium-loopback path still works,
// just without process-tree exclusion.
// 3. On macOS/Linux the addon throws "Windows only" so the
// same fallthrough lands on the LiveKit path, which tries
// Chromium's loopback and ultimately the manual
// startSystemAudioCapture path below.
const wantAudio = settings.includeSystemAudio;
const preferNativeLoopback =
wantAudio &&
typeof window !== 'undefined' &&
window.electronAPI?.osPlatform === 'win32' &&
!!window.electronAPI?.audioLoopback;
try {
await lp.setScreenShareEnabled(true, {
// When the native addon path is available we suppress
// Chromium's loopback grant entirely — otherwise we'd publish
// the same system audio twice and Chromium's version would
// include our own renderer playback.
audio: preferNativeLoopback ? false : wantAudio,
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: fps,
},
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}),
contentHint: 'detail',
});
setIsScreenSharing(true);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
return;
}
if (!wantAudio) return;
// Step 1: try the native WASAPI process-loopback addon. This is
// the preferred path on Windows because it excludes our own PID
// tree — Chromium's 'loopback' source captures the entire OS
// mixer including our renderer's playback, which echoes peers
// back at themselves. We only attempt this when `preferNativeLoopback`
// was true (we already passed `audio: false` to LiveKit, so no
// double-publish risk).
if (preferNativeLoopback) {
try {
// For window-shares we want only the picked app's audio
// (Discord parity) — INCLUDE_TARGET_PROCESS_TREE on the HWND's
// owning process. desktopCapturer hands us source ids shaped
// `window:<HWND>:0`; the regex extracts the decimal HWND so
// the addon can resolve it via GetWindowThreadProcessId. For
// screen-shares (or unparseable ids) we fall through to the
// default EXCLUDE-self path (whole OS mixer minus our PID).
const pickedId = overrides?.pickedSourceId;
let windowHwnd: number | undefined;
if (typeof pickedId === 'string') {
const m = /^window:(\d+):/.exec(pickedId);
if (m && m[1]) {
const parsed = Number(m[1]);
if (Number.isFinite(parsed) && parsed > 0) {
windowHwnd = parsed;
}
}
}
const handle = await startLoopbackTrack(
windowHwnd !== undefined ? { windowHwnd } : undefined,
);
nativeLoopbackRef.current = handle;
setIsCapturingSystemAudio(true);
const audioPub = await lp.publishTrack(handle.track, {
source: Track.Source.ScreenShareAudio,
});
let videoMst: MediaStreamTrack | null = null;
for (const pub of lp.videoTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShare && pub.track) {
videoMst = pub.track.mediaStreamTrack;
break;
}
}
const teardown = () => {
void (async () => {
try {
if (audioPub.track) await lp.unpublishTrack(audioPub.track);
} catch {
/* already unpublished */
}
const active = nativeLoopbackRef.current;
if (active === handle) {
nativeLoopbackRef.current = null;
setIsCapturingSystemAudio(false);
await active.stop().catch(() => undefined);
}
})();
};
handle.track.addEventListener('ended', teardown);
if (videoMst) videoMst.addEventListener('ended', teardown);
return;
} catch (err: unknown) {
console.warn(
'screen-share: native loopback addon failed, falling back',
err instanceof Error ? err.message : err,
);
// Fall through to the existing fallback paths below.
}
}
// If Chromium's native path already landed a ScreenShareAudio
// track (display-media handler granted 'loopback' and the OS
// produced an audio track), we're done — spinning up the manual
// capture on top would double-publish audio.
for (const pub of lp.audioTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShareAudio) {
// Native loopback is live → enable the JS-level auto-duck of
// remote call audio so peers don't echo back through the
// capture. Cleared in stopScreenShare/disconnectRoom.
setIsCapturingSystemAudio(true);
return;
}
}
// Fallback: manual loopback via screenAudio.ts for hosts where
// the native path produced no audio track (window-share, macOS
// without loopback grant). Publishes as its own ScreenShareAudio
// track and chains teardown to the ScreenShare video track's
// `ended` event so the "Stop sharing" overlay kills both sides
// together.
try {
const audioHandle = await startSystemAudioCapture();
nativeAudioCaptureRef.current = audioHandle;
setIsCapturingSystemAudio(true);
const audioMst = audioHandle.stream.getAudioTracks()[0];
if (!audioMst) {
await audioHandle.stop();
nativeAudioCaptureRef.current = null;
setIsCapturingSystemAudio(false);
return;
}
const audioPub = await lp.publishTrack(audioMst, {
source: Track.Source.ScreenShareAudio,
});
let videoMst: MediaStreamTrack | null = null;
for (const pub of lp.videoTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShare && pub.track) {
videoMst = pub.track.mediaStreamTrack;
break;
}
}
const teardown = () => {
void (async () => {
try {
if (audioPub.track) await lp.unpublishTrack(audioPub.track);
} catch {
/* already unpublished */
}
const active = nativeAudioCaptureRef.current;
if (active && active.captureId === audioHandle.captureId) {
nativeAudioCaptureRef.current = null;
setIsCapturingSystemAudio(false);
await active.stop().catch(() => undefined);
}
})();
};
audioMst.addEventListener('ended', teardown);
if (videoMst) videoMst.addEventListener('ended', teardown);
} catch (err: unknown) {
console.warn(
'screen-share: native system-audio unavailable, sharing video only',
err instanceof Error ? err.message : err,
);
if (nativeAudioCaptureRef.current) {
await nativeAudioCaptureRef.current.stop().catch(() => undefined);
nativeAudioCaptureRef.current = null;
setIsCapturingSystemAudio(false);
}
}
},
[],
);
const stopScreenShare = useCallback(async () => {
if (nativeAudioCaptureRef.current) {
const h = nativeAudioCaptureRef.current;
nativeAudioCaptureRef.current = null;
setIsCapturingSystemAudio(false);
try {
await h.stop();
} catch (err: unknown) {
console.warn('native audio capture stop failed', err);
}
}
if (nativeLoopbackRef.current) {
const h = nativeLoopbackRef.current;
nativeLoopbackRef.current = null;
setIsCapturingSystemAudio(false);
try {
await h.stop();
} catch (err: unknown) {
console.warn('native loopback addon stop failed', err);
}
}
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
// Unpublish any ScreenShareAudio track we manually published alongside
// setScreenShareEnabled. LiveKit's setScreenShareEnabled(false) only
// drops the video track it captured itself.
for (const pub of lp.audioTrackPublications.values()) {
if (pub.source === Track.Source.ScreenShareAudio && pub.track) {
await lp.unpublishTrack(pub.track).catch(() => undefined);
}
}
if (lp.isScreenShareEnabled) {
try {
await lp.setScreenShareEnabled(false);
} catch (err: unknown) {
console.error('stopScreenShare failed', err);
}
}
// Clear the auto-duck flag for the Chromium-native path too, where
// nativeAudioCaptureRef was never populated but the flag was set when
// the loopback ScreenShareAudio publication landed.
setIsCapturingSystemAudio(false);
setIsScreenSharing(false);
setOutgoingShareAudioMutedState(false);
}, []);
// Sender-side mute on our own outgoing ScreenShareAudio. Iterates the
// local audio publications instead of holding a ref because both the
// Chromium-native path and the WASAPI fallback path can publish this
// source — we just want whichever publication exists right now. mute() /
// unmute() on a LocalTrackPublication signals LiveKit to stop sending
// packets without unpublishing, which is exactly what we want here:
// peers see the muted flag and our network use drops to ~zero, but the
// video track + capture pipeline are untouched so toggling back is
// instant.
const toggleOutgoingShareAudioMute = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const next = !outgoingShareAudioMuted;
let touched = false;
for (const pub of lp.audioTrackPublications.values()) {
if (pub.source !== Track.Source.ScreenShareAudio) continue;
const track = pub.track;
if (!track) continue;
try {
if (next) await track.mute();
else await track.unmute();
touched = true;
} catch (err: unknown) {
console.warn('outgoing share-audio mute toggle failed', err);
}
}
if (touched) setOutgoingShareAudioMutedState(next);
}, [outgoingShareAudioMuted]);
// 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;
// Remote-audio gain is recomputed by the effective-gain useEffect
// as soon as React picks up the new `isDeafened` state — no DOM
// iteration needed here.
// 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);
// Discord-style deeper self-feedback blip — distinct tone from mute so
// the user can tell the two states apart by ear alone.
void (next ? playDeafenBeep() : playUndeafenBeep());
return next;
});
}, []);
const pushLocalCaption = useCallback(
(text: string, final: boolean) => {
if (!myId) return;
setCaptions((prev) => ({
...prev,
[myId]: { text, final, timestamp: Date.now() },
}));
},
[myId],
);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
// Discord-parity: honor the user's preferred camera deviceId from
// settings. LiveKit picks up `deviceId` here on first publish; runtime
// switching is handled by setVideoInputDevice below.
const settings = getAudioSettings();
const captureOpts = settings.videoInputDeviceId
? { deviceId: settings.videoInputDeviceId }
: undefined;
await lp.setCameraEnabled(nextOn, captureOpts);
setIsCameraEnabled(nextOn);
if (nextOn && settings.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);
}
}, []);
// Hot-swap the camera device on an active publication. Mirrors
// setAudioInputDevice for symmetry. Persists the choice so future
// publishes pick it up automatically.
const setVideoInputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ videoInputDeviceId: deviceId });
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isCameraEnabled) return;
try {
// LiveKit's switchActiveDevice swaps the underlying source without
// republishing. Empty string falls back to default.
await r.switchActiveDevice('videoinput', deviceId ?? '');
} catch (err: unknown) {
console.warn('switchActiveDevice videoinput failed', err);
}
}, []);
// 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();
type HotkeyKind = 'mute' | 'deafen' | 'hangup' | 'screenShare' | 'video';
const KINDS: readonly HotkeyKind[] = [
'mute',
'deafen',
'hangup',
'screenShare',
'video',
];
let registered: Record<HotkeyKind, string | null> = {
mute: null,
deafen: null,
hangup: null,
screenShare: null,
video: null,
};
const fire = (kind: HotkeyKind) => {
switch (kind) {
case 'mute':
toggleMute();
return;
case 'deafen':
toggleDeafen();
return;
case 'hangup':
void hangup();
return;
case 'screenShare':
void toggleScreenShare();
return;
case 'video':
void toggleCamera();
return;
}
};
const onKey = (e: KeyboardEvent) => {
for (const kind of KINDS) {
if (eventMatchesBinding(settings[kind], e)) {
e.preventDefault();
fire(kind);
return;
}
}
};
window.addEventListener('keydown', onKey);
const syncGlobalShortcuts = () => {
if (!isTauriRuntime()) return;
const desired: Record<HotkeyKind, string | null> = {
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
screenShare: settings.screenShare.enabled
? bindingToTauriShortcut(settings.screenShare)
: null,
video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
};
for (const kind of KINDS) {
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);
for (const kind of KINDS) {
const have = registered[kind];
if (have) void unregisterGlobalShortcut(have);
}
};
}, [state.kind, toggleMute, toggleDeafen, hangup, toggleScreenShare, toggleCamera]);
// --- 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': {
// Discord-style second-call ringer: if we're already in an
// active call (connected or temporarily reconnecting), surface
// the new invite as a pendingIncoming toast instead of
// auto-rejecting. The active call keeps running until the user
// either accepts (which hangs it up cleanly) or rejects/lets
// it ring out.
const inActiveCall =
cur.kind === 'connected' || cur.kind === 'reconnecting';
if (inActiveCall) {
// Only one slot — second pending invite while one is already
// queued still gets a busy-reject.
if (pendingIncomingRef.current) {
void sendSignal(p.fromUserId, {
type: 'reject',
callId: p.callId,
byUserId: me,
});
return;
}
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';
setPendingIncoming({
callId: p.callId,
conversationId: p.conversationId,
fromUserId: p.fromUserId,
mediaKind: p.kind,
});
// Same 45s ring-out as the normal incoming flow. On expiry we
// send a reject so the caller's UI clears.
pendingRingTimerRef.current = window.setTimeout(() => {
const pi = pendingIncomingRef.current;
if (!pi || pi.callId !== p.callId) return;
void sendSignal(pi.fromUserId, {
type: 'reject',
callId: pi.callId,
byUserId: me,
});
setPendingIncoming(null);
}, RING_TIMEOUT_MS);
if (presenceRef.current !== 'dnd') {
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
: 'Eingehender Anruf',
body: isGroup
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
}
return;
}
if (cur.kind !== 'idle') {
// Outgoing / connecting / already-incoming — busy.
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. May be the regular
// incoming ringer or — when the receiver was already in another
// call — a pendingIncoming toast.
const pi = pendingIncomingRef.current;
if (pi && pi.callId === p.callId) {
setPendingIncoming(null);
// No notification spam here — the toast just disappears,
// matching Discord's behaviour for a withdrawn second call.
}
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, setPendingIncoming]);
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<string>());
}
},
[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 watchShare = useCallback((userId: string) => {
setWatchingShareUserIds((prev) => {
if (prev.has(userId)) return prev;
const next = new Set(prev);
next.add(userId);
// Sync the mirror immediately so the flush below sees this user as
// "watching" — without this, attachTrack inside flush would re-defer
// the same entry because the React-effect mirror update hasn't
// run yet.
watchingShareUserIdsMirror = next;
return next;
});
// Un-dismiss in case the user had dismissed earlier in the session and
// now wants to watch again (Discord also lets you re-subscribe).
setDismissedShareUserIds((prev) => {
if (!prev.has(userId)) return prev;
const next = new Set(prev);
next.delete(userId);
return next;
});
// Now perform the actual track.attach() for any ScreenShareAudio
// publications we deferred while the user wasn't watching. Video is
// already gated on the `watching` boolean inside ScreenShareViewer
// so it just re-renders on the state flip.
flushDeferredScreenAudio(userId);
}, []);
const stopWatchingShare = useCallback((userId: string) => {
setWatchingShareUserIds((prev) => {
if (!prev.has(userId)) return prev;
const next = new Set(prev);
next.delete(userId);
return next;
});
}, []);
const dismissShare = useCallback((userId: string) => {
setWatchingShareUserIds((prev) => {
if (!prev.has(userId)) return prev;
const next = new Set(prev);
next.delete(userId);
return next;
});
setDismissedShareUserIds((prev) => {
if (prev.has(userId)) return prev;
const next = new Set(prev);
next.add(userId);
return next;
});
}, []);
const setScreenShareAudioMuted = useCallback(
(userId: string, muted: boolean) => {
setScreenShareAudioMutedIds((prev) => {
const has = prev.has(userId);
if (muted === has) return prev;
const next = new Set(prev);
if (muted) next.add(userId);
else next.delete(userId);
return next;
});
},
[],
);
const retryMic = useCallback(async () => {
const r = roomRef.current;
if (!r) {
setMicError(null);
return;
}
await setupMicPipeline(r);
}, [setupMicPipeline]);
// Clear the stale mic-error + screen-share session 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);
setWatchingShareUserIds(new Set<string>());
setDismissedShareUserIds(new Set<string>());
setScreenShareAudioMutedIds(new Set<string>());
clearScreenShareVolumes();
}
}, [state.kind]);
// Mirror the watching / manual-mute sets into module-level variables so
// attachTrack (which runs outside the React render cycle) can read them
// when a ScreenShareAudio track lands. The runtime recompute for already-
// attached tracks happens in the effective-gain useEffect below.
useEffect(() => {
watchingShareUserIdsMirror = watchingShareUserIds;
screenShareAudioMutedIdsMirror = screenShareAudioMutedIds;
}, [watchingShareUserIds, screenShareAudioMutedIds]);
// Single source of truth for remote-audio output gain. Runs every time a
// state that influences the effective-gain formula flips, plus on every
// participantVolumes / screenShareVolumes subscriber ping. Keeps deafen,
// watching, manual mute, and user volume all in one pass per pipeline.
useEffect(() => {
const recompute = () => {
// JS-side auto-duck retired. The native audio-loopback addon
// (modules/audio-loopback.ts on Windows) excludes our process tree
// from the captured stream at OS level via WASAPI's
// EXCLUDE_TARGET_PROCESS_TREE, so peers can't hear themselves
// echoed back even when call audio is playing locally. Muting
// remote-mic <audio> elements while capturing was the wrong fix —
// it killed the user's ability to hear callers during a share.
// Constant kept inline so the existing `else if (duck)` branch
// below stays dead and `el.muted = isDeafened || duck` collapses
// to just `isDeafened`.
const duck = false;
for (const pipeline of allPipelines()) {
let g: number;
if (isDeafened) {
g = 0;
} else if (pipeline.trackSource === 'screenshare') {
const watching = watchingShareUserIds.has(pipeline.participantId);
const manualMuted = screenShareAudioMutedIds.has(pipeline.participantId);
g = watching && !manualMuted ? getScreenShareVolume(pipeline.participantId) : 0;
} else if (duck) {
g = 0;
} else {
g = getParticipantVolume(pipeline.participantId);
}
setPipelineGain(pipeline, g);
}
// Mirror the mute decision onto the HTMLAudioElement itself. When the
// user has selected a custom output device, `audio.setSinkId(...)`
// causes the element to play through that sink in PARALLEL to the
// WebAudio graph (Chromium quirk) — gain=0 silences the WebAudio
// path but the element keeps going. Setting `el.muted` covers that
// direct-playback path. Volume slider already mirrors via
// applyToAttachedElements in screenShareVolumes.ts; this is the
// mute-equivalent.
const shareEls = document.querySelectorAll<HTMLAudioElement>(
'audio[data-track-source="screenshare"]',
);
shareEls.forEach((el) => {
const pid = el.getAttribute('data-participant');
if (!pid) return;
const watching = watchingShareUserIds.has(pid);
const manualMuted = screenShareAudioMutedIds.has(pid);
el.muted = isDeafened || !watching || manualMuted;
});
// Same parallel-playback risk for microphone elements, but only
// surfaces while we're capturing system audio for an outgoing share —
// otherwise muting peers' voices locally would silence the call. The
// duck-while-sharing flag ensures this only kicks in when we're
// actively capturing system audio for publication.
const micEls = document.querySelectorAll<HTMLAudioElement>(
'audio[data-track-source="microphone"]',
);
micEls.forEach((el) => {
el.muted = isDeafened || duck;
});
};
recompute();
const unsubP = subscribeParticipantVolumes(recompute);
const unsubS = subscribeScreenShareVolumes(recompute);
// Toggle of the duck-while-sharing checkbox needs to re-run gain
// immediately, otherwise the user has to flip share off/on for the
// change to land.
const unsubSettings = subscribeScreenShareSettings(recompute);
return () => {
unsubP();
unsubS();
unsubSettings();
};
}, [
isDeafened,
watchingShareUserIds,
screenShareAudioMutedIds,
isCapturingSystemAudio,
// `remoteParticipants` is a dep so the initial gain gets applied when a
// brand new pipeline lands — attachTrack creates it asynchronously,
// state flips, effect re-runs, gain updates.
remoteParticipants,
]);
const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ outputDeviceId: deviceId });
const sinkId = deviceId ?? '';
// WebAudio path: route every pipeline's AudioContext at the chosen
// sink. Feature-detects AudioContext.setSinkId (Chrome 115+) — older
// runtimes silently keep the default sink.
await setAllPipelinesSinkId(sinkId);
// HTMLAudioElement fallback path: elements stay in the DOM for track
// lifetime even when WebAudio takes over their output; keep setSinkId
// in sync there so a runtime that didn't support createMediaElement-
// Source still routes to the right device.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
for (const el of Array.from(els)) {
if (typeof el.setSinkId !== 'function') continue;
try {
await el.setSinkId(sinkId);
} catch (err: unknown) {
console.warn('setSinkId on audio element failed', err);
}
}
const r = roomRef.current;
if (r) {
try {
await r.switchActiveDevice('audiooutput', sinkId || 'default');
} catch (err: unknown) {
console.warn('switchActiveDevice(audiooutput) failed', err);
}
}
}, []);
// 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]);
// Hold the screen awake while a call is live so long sessions don't get
// dropped by display-sleep / OS power-save. Released the moment the call
// ends or errors out.
useEffect(() => {
const callActive =
state.kind === 'connected' ||
state.kind === 'connecting' ||
state.kind === 'reconnecting' ||
state.kind === 'outgoing';
void setCallWakeLock(callActive);
}, [state.kind]);
// Cinema mode = real OS fullscreen. The CSS overlay alone leaves the
// Windows taskbar drawn on top, which kills the immersive feel; flipping
// the OS-level fullscreen flag (via the Electron preload bridge) covers
// it. Effect runs on every mode change so exit paths (Esc,
// hangup-resets-to-grid, mode toggle) all restore the windowed state
// automatically. Errors are swallowed inside setWindowFullscreen — the
// call rejects on minimised / unfocused windows, both recoverable noise.
useEffect(() => {
void setWindowFullscreen(callMode === 'fullscreen');
}, [callMode]);
// 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' &&
state.kind !== 'reconnecting'
) {
return;
}
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
// Discord-style live-captions broadcaster — runs on the local mic while
// we're connected, and ships interim/final transcripts on the LiveKit
// DataChannel so peers can render them.
useLiveCaptions({
room,
active: state.kind === 'connected' || state.kind === 'reconnecting',
onLocalCaption: pushLocalCaption,
});
const value = useMemo<CallContextValue>(
() => ({
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
connectionQualities,
callHostId,
captions,
pushLocalCaption,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
rejectIncoming,
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
setVideoInputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
micError,
clearMicError,
retryMic,
watchingShareUserIds,
dismissedShareUserIds,
watchShare,
stopWatchingShare,
dismissShare,
screenShareAudioMutedIds,
setScreenShareAudioMuted,
outgoingShareAudioMuted,
toggleOutgoingShareAudioMute,
pendingIncoming,
acceptPendingIncoming,
rejectPendingIncoming,
}),
[
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
connectionQualities,
callHostId,
captions,
pushLocalCaption,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
rejectIncoming,
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
setVideoInputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
micError,
clearMicError,
retryMic,
watchingShareUserIds,
dismissedShareUserIds,
watchShare,
stopWatchingShare,
dismissShare,
screenShareAudioMutedIds,
setScreenShareAudioMuted,
outgoingShareAudioMuted,
toggleOutgoingShareAudioMute,
pendingIncoming,
acceptPendingIncoming,
rejectPendingIncoming,
],
);
return <CallContext.Provider value={value}>{children}</CallContext.Provider>;
}
export function useCall(): CallContextValue {
const ctx = useContext(CallContext);
if (!ctx) throw new Error('useCall must be used inside <CallProvider>');
return ctx;
}
// Shared flag so attachTrack (called from LiveKit event listeners, outside the
// React component) can apply the current deafen state to freshly-attached
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;
// Same pattern for the "which shares is the user actively watching" set —
// used by attachTrack to decide whether a freshly-landed ScreenShareAudio
// track should start muted. Synced from React via a useEffect inside
// CallProvider.
let watchingShareUserIdsMirror: ReadonlySet<string> = new Set<string>();
// Discord-style: ScreenShareAudio tracks that arrived while the user
// hadn't clicked "Bildschirm anschauen" yet. We don't run track.attach()
// until they do — keeping these audio elements out of the DOM is more
// reliable than gating with WebAudio gain=0 (autoplay-suspended contexts
// + WebView2 quirks have leaked through gain=0 in the past). Keyed by
// participantId since one share publishes one audio track at a time.
interface DeferredShareAudio {
track: RemoteTrack;
publication: RemoteTrackPublication;
participant: RemoteParticipant;
}
const deferredScreenAudio = new Map<string, Map<string, DeferredShareAudio>>();
function recordDeferredScreenAudio(
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void {
const pid = participant.identity;
const sid = track.sid;
if (!pid || !sid) return;
let inner = deferredScreenAudio.get(pid);
if (!inner) {
inner = new Map();
deferredScreenAudio.set(pid, inner);
}
inner.set(sid, { track, publication, participant });
}
function removeDeferredScreenAudio(pid: string, sid?: string): void {
const inner = deferredScreenAudio.get(pid);
if (!inner) return;
if (sid) {
inner.delete(sid);
if (inner.size === 0) deferredScreenAudio.delete(pid);
} else {
deferredScreenAudio.delete(pid);
}
}
function flushDeferredScreenAudio(pid: string): void {
const inner = deferredScreenAudio.get(pid);
if (!inner) return;
// Snapshot before iterating so attachTrack's defer check (which now
// reads the freshly-updated mirror as "watching") doesn't try to
// re-record the same entry mid-loop.
const entries = [...inner.values()];
deferredScreenAudio.delete(pid);
for (const entry of entries) {
attachTrack(entry.track, entry.publication, entry.participant);
}
}
// Manual mute flags for screen-share audio, independent of the watching
// state. When a userId sits in here, their share-audio stays muted even
// after the user clicked "Bildschirm anschauen".
let screenShareAudioMutedIdsMirror: ReadonlySet<string> = new Set<string>();
async function broadcastPresence(
room: Room,
deafened: boolean,
muted: boolean,
): Promise<void> {
try {
const payload = new TextEncoder().encode(
JSON.stringify({ type: 'presence', deafened, muted }),
);
await room.localParticipant.publishData(payload, { reliable: true });
} catch (err: unknown) {
console.warn('broadcastPresence failed', err);
}
}
function attachTrack(
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
const isScreenShareAudio =
track.source === Track.Source.ScreenShareAudio ||
publication.source === Track.Source.ScreenShareAudio;
// Defer screen-share audio until the user explicitly opts in by
// clicking "Bildschirm anschauen". Without this gate the audio
// element is created on TrackSubscribed and — under autoplay-
// suspended AudioContexts — can leak through the gain=0 silencer.
// The track gets attached later inside watchShare via
// flushDeferredScreenAudio.
if (isScreenShareAudio && participant.identity) {
if (!watchingShareUserIdsMirror.has(participant.identity)) {
recordDeferredScreenAudio(track, publication, participant);
return;
}
}
const audio = track.attach();
if (audio instanceof HTMLAudioElement) {
audio.autoplay = true;
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
// Always tag the kind so the gain-recompute can find every element of
// either source. Without "microphone" tagged, the duck-while-sharing
// logic can't reach the parallel direct-playback path that WebView2
// keeps alive when audio.setSinkId is set on the element.
audio.setAttribute(
'data-track-source',
isScreenShareAudio ? 'screenshare' : 'microphone',
);
// Diagnostic — surfaces source-tag mismatches between SDK versions.
// If a remote participant publishes system-audio but the tag never
// reaches us, `isScreenShareAudio` flips false and the watching
// gate is bypassed; seeing this in the console tells us whether
// the unwanted playback is a gating bug or a tagging mismatch.
console.info('attachTrack:audio', {
participant: participant.identity,
trackSource: track.source,
pubSource: publication.source,
isScreenShareAudio,
watching: participant.identity
? watchingShareUserIdsMirror.has(participant.identity)
: null,
});
if (participant.identity) {
audio.setAttribute('data-participant', participant.identity);
}
document.body.appendChild(audio);
// Build the WebAudio pipeline so we have a single GainNode we can
// drive past 100% (up to 200%). Setting `audio.volume` / `muted`
// directly from here on is a no-op once the MediaElementSource
// diverts the samples through the graph — every gating decision
// flows through `setPipelineGain(effectiveGainFor(...))`.
const trackSid = track.sid;
if (trackSid && participant.identity) {
const pipeline = createPipeline(audio, {
trackSid,
participantId: participant.identity,
trackSource: isScreenShareAudio ? 'screenshare' : 'microphone',
});
if (pipeline) {
setPipelineGain(pipeline, computeInitialEffectiveGain(pipeline));
} else {
// WebAudio unavailable — fall back to element-level volume so
// the user at least hears something, even without 200% boost.
audio.muted = false;
audio.volume = isScreenShareAudio
? getScreenShareVolume(participant.identity)
: getParticipantVolume(participant.identity);
if (deafenedActive) audio.muted = true;
else if (isScreenShareAudio) {
const pid = participant.identity;
const watching = watchingShareUserIdsMirror.has(pid);
const manualMuted = screenShareAudioMutedIdsMirror.has(pid);
audio.muted = !watching || manualMuted;
}
}
}
// Persist the user's chosen output device on the HTMLAudioElement
// as a redundant guard — some WebView2 builds route the element
// directly despite createMediaElementSource. When AudioContext
// .setSinkId is available (see setAudioOutputDevice), that picks
// up the same preference for the WebAudio graph.
const sinkId = getAudioSettings().outputDeviceId;
if (sinkId && typeof audio.setSinkId === 'function') {
void audio.setSinkId(sinkId).catch((err: unknown) => {
console.warn('setSinkId on attach failed', err);
});
}
// Earlier builds muted mid-share peer joins here ("if we're capturing
// system audio + duck flag → mute new mic <audio>"). Removed because
// the native audio-loopback addon now does process-tree exclusion at
// OS level and JS-side ducking only made the local user unable to
// hear callers. Mid-share joins now just play normally.
}
}
// Video is handled later in M2.6/M3 by a dedicated <video> element.
}
// Effective-gain formula in one place so both the initial attach and the
// runtime recompute stay consistent. Read the comment chain in attachTrack
// for the priority order.
function computeInitialEffectiveGain(pipeline: RemoteAudioPipeline): number {
if (deafenedActive) return 0;
if (pipeline.trackSource === 'screenshare') {
const watching = watchingShareUserIdsMirror.has(pipeline.participantId);
const manualMuted = screenShareAudioMutedIdsMirror.has(pipeline.participantId);
if (!watching || manualMuted) return 0;
return getScreenShareVolume(pipeline.participantId);
}
return getParticipantVolume(pipeline.participantId);
}
function detachTrack(
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
// Drop the deferred slot if this track was never attached. Safe to
// call even on tracks that did get attached — the map lookup just
// misses for those.
const isScreenShareAudio =
track.source === Track.Source.ScreenShareAudio ||
publication.source === Track.Source.ScreenShareAudio;
if (isScreenShareAudio && participant.identity) {
removeDeferredScreenAudio(participant.identity, track.sid);
}
if (track.sid) destroyPipeline(track.sid);
const els = track.detach();
for (const el of els) {
el.remove();
}
}
}