258ad3511d
Pass width/height as { ideal: N } constraints (not exact values) so
Chromium preserves the source's aspect ratio instead of cropping
non-matching monitors. Previously a 1920x1200 monitor under a 1080p
preset lost the bottom 120px including the Windows taskbar.
The auto-preset fallback now omits width/height entirely (both set to
undefined) so getDisplayMedia uses the screen's native resolution.
Cast through `unknown` to satisfy LiveKit's VideoResolution type, which
declares width/height as plain number but Chromium accepts the full
MediaTrackConstraints shape at runtime.
2968 lines
113 KiB
TypeScript
2968 lines
113 KiB
TypeScript
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,
|
||
// Preset dims are passed as `ideal` constraints (not fixed width/height)
|
||
// so Chromium picks the closest match preserving the source's aspect
|
||
// ratio instead of CROPPING non-matching monitors. Without this, a 16:10
|
||
// monitor (1920×1200, 2560×1600) streamed under a 16:9 preset loses its
|
||
// bottom strip — including the Windows taskbar.
|
||
//
|
||
// LiveKit's VideoResolution types declare width/height as `number`, but
|
||
// Chromium's getDisplayMedia accepts the full MediaTrackConstraints shape
|
||
// including { ideal: N } objects — we cast through `unknown` to satisfy tsc.
|
||
...(ssParams.dims
|
||
? {
|
||
resolution: {
|
||
width: { ideal: ssParams.dims.width },
|
||
height: { ideal: ssParams.dims.height },
|
||
frameRate: fps,
|
||
} as unknown as { width: number; height: number; frameRate: number },
|
||
}
|
||
: {
|
||
resolution: {
|
||
frameRate: fps,
|
||
width: undefined,
|
||
height: undefined,
|
||
} as unknown as { width: number; height: number; frameRate: number },
|
||
}),
|
||
...(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;
|
||
// Only register an OS-level shortcut if the user explicitly opted in.
|
||
// Window-scoped firing happens via the `onKey` listener above and works
|
||
// for every enabled binding regardless of the `global` flag.
|
||
const enabledAndGlobal = (b: VoiceHotkeys[HotkeyKind]) => b.enabled && b.global;
|
||
const desired: Record<HotkeyKind, string | null> = {
|
||
mute: enabledAndGlobal(settings.mute) ? bindingToTauriShortcut(settings.mute) : null,
|
||
deafen: enabledAndGlobal(settings.deafen) ? bindingToTauriShortcut(settings.deafen) : null,
|
||
hangup: enabledAndGlobal(settings.hangup) ? bindingToTauriShortcut(settings.hangup) : null,
|
||
screenShare: enabledAndGlobal(settings.screenShare) ? bindingToTauriShortcut(settings.screenShare) : null,
|
||
video: enabledAndGlobal(settings.video) ? 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();
|
||
}
|
||
}
|
||
}
|
||
|