Files
ChatApp/apps/desktop/src/context/CallContext.tsx
T
byGalax 1303c8e26f feat: backup/restore, user profile popover, image compress, video blur, wake lock
- backup/restore dialog + user profile popover components
- image compression, video blur, wake lock utilities
- message cache + conversation messages hook refinements
- call context, active speakers, screen share dialog tweaks
- audio + screen share settings persistence
- refreshed app icons (smaller sizes) across all platforms
2026-04-21 12:11:09 +02:00

1605 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { sendEncryptedMessage } from '@chat-app/shared/chat';
import {
type CallKind,
type CallSignal,
fetchLivekitToken,
signalTopic,
} from '@chat-app/shared/rtc';
import type { RealtimeChannel } from '@supabase/supabase-js';
import {
ConnectionState,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
Room,
RoomEvent,
Track,
} from 'livekit-client';
import {
createContext,
type ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useAuth } from './AuthContext';
import { useConversationsContext } from './ConversationsContext';
import { playEndBeep, playJoinBeep, playLeaveBeep } from '../lib/callSounds';
import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify';
import {
isTauriRuntime,
registerPttShortcut,
unregisterPttShortcut,
} from '../lib/globalShortcut';
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
applyBackgroundBlurToLocal,
removeBackgroundBlurFromLocal,
} from '../lib/videoBlur';
import {
createCallE2EE,
getCallE2EESettings,
isE2EESupported,
} from '../lib/callE2EE';
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
import { getParticipantVolume } from '../lib/participantVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playEntry } from '../lib/soundboardPlayback';
import {
getPrefs as getSoundboardPrefs,
listSounds as listSoundboard,
updatePrefs as updateSoundboardPrefs,
} from '../lib/soundboardStorage';
import {
type DisplaySurfaceHint,
getPresetParams,
getScreenShareSettings,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
export type CallState =
| { kind: 'idle' }
| {
kind: 'outgoing';
callId: string;
conversationId: string;
mediaKind: CallKind;
ringingSince: string;
}
| {
kind: 'incoming';
callId: string;
conversationId: string;
fromUserId: string;
mediaKind: CallKind;
}
| {
kind: 'connecting';
callId: string;
conversationId: string;
mediaKind: CallKind;
}
| {
kind: 'connected';
callId: string;
conversationId: string;
mediaKind: CallKind;
startedAt: string;
}
| { kind: 'error'; message: string };
export interface RemoteScreenShare {
track: RemoteTrack;
participantId: string;
participantName: string;
}
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue {
state: CallState;
room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean;
isE2EEActive: boolean;
isScreenSharing: boolean;
isCameraEnabled: boolean;
isDeafened: boolean;
/** identity -> their deafen state, received via data channel. */
remoteDeafen: Record<string, boolean>;
/** 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;
}>,
) => 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>;
}
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, device, 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 [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 signalChannelRef = useRef<RealtimeChannel | null>(null);
const presenceChannelRef = useRef<RealtimeChannel | null>(null);
const ringTimerRef = useRef<number | null>(null);
const soloTimerRef = useRef<number | 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);
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 || !device?.id) return;
try {
const priv = await loadDevicePrivateKey(
devLocalSecretStore,
session.user.id,
device.id,
);
if (!priv) return;
const payload = JSON.stringify({
v: 1,
type: 'call_event',
status,
mediaKind,
durationSec,
});
await sendEncryptedMessage({
client: supabase,
conversationId,
plaintext: payload,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.id, device?.id],
);
const clearRingTimer = useCallback(() => {
if (ringTimerRef.current !== null) {
window.clearTimeout(ringTimerRef.current);
ringTimerRef.current = null;
}
}, []);
const clearSoloTimer = useCallback(() => {
if (soloTimerRef.current !== null) {
window.clearTimeout(soloTimerRef.current);
soloTimerRef.current = null;
}
}, []);
const disconnectRoom = useCallback(async () => {
const r = roomRef.current;
if (r) {
try {
await r.disconnect();
} catch {
/* ignore */
}
}
roomRef.current = null;
setRoom(null);
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
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);
},
[],
);
const peerUserIdsFor = useCallback(
(conversationId: string): string[] => {
const conv = conversations.find((c) => c.id === conversationId);
if (!conv || !myId) return [];
return conv.members.filter((m) => m.userId !== myId && m.accepted).map((m) => m.userId);
},
[conversations, myId],
);
// Transitions to 'connected' once at least one remote participant is in
// the room. Also cancels the solo-timeout. No-op if already connected.
const markConnectedIfReady = useCallback(
(r: Room, conversationId: string, mediaKind: CallKind, callId: string) => {
const cur = stateRef.current;
if (cur.kind === 'connected') return;
if (r.remoteParticipants.size === 0) return;
clearRingTimer();
clearSoloTimer();
everConnectedRef.current = true;
setState({
kind: 'connected',
callId,
conversationId,
mediaKind,
startedAt: new Date().toISOString(),
});
},
[clearRingTimer, clearSoloTimer],
);
// --- LiveKit join/leave ------------------------------------------------
const joinRoom = useCallback(
async (conversationId: string, mediaKind: CallKind, callId: string) => {
const { token, url } = await fetchLivekitToken(supabase, conversationId);
// Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
// 3 temporal layers from a single encode. LiveKit's congestion control
// drops temporal/spatial layers per-subscriber when uplink degrades,
// so a 4K60 publisher downshifts smoothly to 720p30 on weak links
// without a full renegotiate. `adaptiveStream` pauses video downlink
// off-screen; `dynacast` stops publishing layers nobody subscribes to;
// `degradationPreference: balanced` tells WebRTC to trade framerate vs
// resolution dynamically based on the encoder's CPU + bandwidth.
// Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
// 3 temporal layers from a single encode. LiveKit's congestion control
// drops temporal/spatial layers per-subscriber when uplink degrades,
// so a 4K60 publisher downshifts smoothly to 720p30 on weak links
// without a full renegotiate. `adaptiveStream` pauses video downlink
// off-screen; `dynacast` stops publishing layers nobody subscribes to;
// `degradationPreference: balanced` tells WebRTC to trade framerate vs
// resolution dynamically based on the encoder's CPU + bandwidth.
// Audio: `red` doubles Opus packets for packet-loss concealment;
// `dtx` is off in hifi so music doesn't get zeroed out between notes;
// `forceStereo` publishes stereo when the hifi preset captures stereo.
const ssCfg = getScreenShareSettings();
const ssParams = getPresetParams(ssCfg.preset);
const aParams = getAudioQualityParams(getAudioSettings().quality);
const e2eeCfg = getCallE2EESettings();
const e2eeUsable = e2eeCfg.enabled && isE2EESupported();
const e2eeBundle = e2eeUsable ? await createCallE2EE(conversationId) : null;
const r = new Room({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
scalabilityMode: 'L3T3_KEY',
degradationPreference: 'balanced',
backupCodec: true,
audioPreset: {
maxBitrate: aParams.bitrateKbps * 1000,
priority: 'high',
},
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
screenShareEncoding: {
maxBitrate: ssParams.bitrateKbps * 1000,
maxFramerate: ssParams.framerate,
priority: 'high',
},
},
...(e2eeBundle
? {
e2ee: {
keyProvider: e2eeBundle.keyProvider,
worker: e2eeBundle.worker,
},
}
: {}),
});
roomRef.current = r;
setRoom(r);
r.on(RoomEvent.ConnectionStateChanged, (cs) => {
if (cs === ConnectionState.Disconnected) {
// Server / network tore us out — reset state cleanly. Remember the
// conversation so the sidebar "still live — rejoin" widget stays
// visible: observers will poll presence and detect any remaining
// peers, which is the only cue this side gets that a call is live.
clearRingTimer();
clearSoloTimer();
const wasInCall =
stateRef.current.kind === 'connected' ||
stateRef.current.kind === 'connecting' ||
stateRef.current.kind === 'outgoing';
if (wasInCall) {
setLastCallConversationId(conversationId);
}
// Untrack our presence entry so observers see us leave. Keep the
// channel alive — observers share it via Supabase topic dedupe.
const pres = presenceChannelRef.current;
if (pres) {
try {
void pres.untrack();
} catch {
/* ignore */
}
presenceChannelRef.current = null;
}
if (stateRef.current.kind !== 'idle' && stateRef.current.kind !== 'error') {
setState({ kind: 'idle' });
}
roomRef.current = null;
setRoom(null);
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
setIsE2EEActive(false);
}
});
r.on(RoomEvent.ParticipantConnected, () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
if (presenceRef.current !== 'dnd') void playJoinBeep();
markConnectedIfReady(r, conversationId, mediaKind, callId);
});
r.on(RoomEvent.ParticipantDisconnected, () => {
const remaining = Array.from(r.remoteParticipants.values());
setRemoteParticipants(remaining);
if (presenceRef.current !== 'dnd') void playLeaveBeep();
// Alone in the room while connected — start the solo-timeout.
if (
stateRef.current.kind === 'connected' &&
remaining.length === 0 &&
soloTimerRef.current === null
) {
soloTimerRef.current = window.setTimeout(() => {
void (async () => {
soloTimerRef.current = null;
const cur = stateRef.current;
if (cur.kind === 'connected') {
const durationSec = Math.max(
0,
Math.floor((Date.now() - new Date(cur.startedAt).getTime()) / 1000),
);
void emitCallEvent(cur.conversationId, 'ended', cur.mediaKind, durationSec);
}
await disconnectRoom();
void playEndBeep();
// Room was empty by timeout — nothing to rejoin into.
setLastCallConversationId(null);
setState({ kind: 'idle' });
})();
}, SOLO_TIMEOUT_MS);
}
});
r.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
attachTrack(track, publication, participant);
if (
track.kind === Track.Kind.Video &&
(track.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShare)
) {
const identity = participant.identity;
const name = participant.name || identity;
setRemoteScreenShares((prev) => {
if (prev.some((s) => s.track.sid === track.sid)) return prev;
return [...prev, { track, participantId: identity, participantName: name }];
});
}
});
r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
detachTrack(track, publication, participant);
if (track.kind === Track.Kind.Video) {
setRemoteScreenShares((prev) => prev.filter((s) => s.track.sid !== track.sid));
}
});
// Mute/unmute a camera doesn't publish or unpublish — the publication
// stays, just its `muted` flag flips. Without this listener, remote
// participants who toggle video mid-call appear as a frozen last frame
// or (worse) a black tile on every other client. Bumping the
// remoteParticipants state reference forces buildTiles to re-read
// `isCameraEnabled` and swap to the avatar placeholder.
const bumpParticipants = () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
};
r.on(RoomEvent.TrackMuted, bumpParticipants);
r.on(RoomEvent.TrackUnmuted, bumpParticipants);
// Remote deafen state is broadcast via the LiveKit data channel. We
// store incoming states in `remoteDeafenMapRef` and bump participants
// so buildTiles re-reads it.
r.on(
RoomEvent.DataReceived,
(payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
if (!participant?.identity) return;
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text) as {
type?: string;
deafened?: boolean;
muted?: boolean;
};
if (msg.type !== 'presence') return;
const id: string = participant.identity;
if (typeof msg.deafened === 'boolean') {
const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened };
});
}
if (typeof msg.muted === 'boolean') {
const muted: boolean = msg.muted;
setRemoteMute((prev) => {
if (prev[id] === muted) return prev;
return { ...prev, [id]: muted };
});
}
} catch {
/* ignore malformed */
}
},
);
// When someone joins, re-send our current presence (deafen + mute) so
// they know immediately instead of waiting for the next toggle.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive, mutedRef.current);
});
// Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setIsScreenSharing(true);
}
});
r.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setIsScreenSharing(false);
}
});
await r.connect(url, token);
if (e2eeBundle) {
try {
await r.setE2EEEnabled(true);
setIsE2EEActive(true);
} catch (e2eErr: unknown) {
console.error('setE2EEEnabled failed — falling back to clear-media', e2eErr);
setIsE2EEActive(false);
}
} else {
setIsE2EEActive(false);
}
try {
const audioPrefs = getAudioSettings();
const inputId = audioPrefs.inputDeviceId;
// Noise suppression: user-preference wins over the quality preset so
// hifi-mode users can still enable NS when they need to cut room hum.
const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
// Grab the raw mic ourselves instead of going through LiveKit's
// setMicrophoneEnabled. The resulting MediaStreamTrack is routed
// through createMicPipeline, which mixes in soundboard buffers and
// exposes a single output track we hand to publishTrack. Mute / PTT
// are gain-based from here on, never track.enabled or device stop.
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');
const pipeline = createMicPipeline(rawTrack);
pipelineRef.current = pipeline;
// Pull the user's last-saved soundboard gains onto the live pipeline
// before the first sound ever plays so nothing blasts at 100%.
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,
});
} catch (micErr: unknown) {
console.error('mic pipeline setup failed', micErr);
}
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,
],
);
// --- Public actions ----------------------------------------------------
const startCall = useCallback(
async (conversationId: string, mediaKind: CallKind = 'audio') => {
if (!myId) return;
if (stateRef.current.kind !== 'idle') return;
const callId = newCallId();
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({
kind: 'outgoing',
callId,
conversationId,
mediaKind,
ringingSince: new Date().toISOString(),
});
const peers = peerUserIdsFor(conversationId);
pendingPeersRef.current = new Set(peers);
await Promise.all(
peers.map((to) =>
sendSignal(to, {
type: 'invite',
callId,
conversationId,
fromUserId: myId,
kind: mediaKind,
sentAt: new Date().toISOString(),
}),
),
);
// Caller joins the room immediately so acceptors hop straight into a
// live room. Transition to 'connected' happens in ParticipantConnected.
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
return;
}
clearRingTimer();
ringTimerRef.current = window.setTimeout(() => {
void (async () => {
// Only trigger if still alone (nobody joined the room yet).
const cur = stateRef.current;
if (cur.kind !== 'outgoing' || cur.callId !== callId) return;
const peers2 = peerUserIdsFor(conversationId);
await Promise.all(
peers2.map((to) =>
sendSignal(to, { type: 'cancel', callId, byUserId: myId }),
),
);
void emitCallEvent(conversationId, 'missed', mediaKind, 0);
await disconnectRoom();
setState({ kind: 'idle' });
})();
}, RING_TIMEOUT_MS);
},
[myId, peerUserIdsFor, sendSignal, clearRingTimer, emitCallEvent, joinRoom, disconnectRoom],
);
// Re-join an already-running call without ringing peers.
const joinActiveCall = useCallback(
async (conversationId: string, mediaKind: CallKind = 'audio') => {
if (!myId) return;
if (stateRef.current.kind !== 'idle') return;
const callId = newCallId();
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom],
);
const acceptIncoming = useCallback(
async (override?: CallKind) => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId } = s;
// Caller's `mediaKind` is the INVITE kind (what they started with). The
// receiver can accept with audio even if the caller rang as video, or
// upgrade an audio invite to video on accept. `override` picks the
// receiver's choice.
const mediaKind: CallKind = override ?? s.mediaKind;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom],
);
const rejectIncoming = useCallback(() => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
void sendSignal(s.fromUserId, { type: 'reject', callId: s.callId, byUserId: myId });
setState({ kind: 'idle' });
}, [myId, sendSignal]);
const hangup = useCallback(async () => {
const s = stateRef.current;
clearRingTimer();
clearSoloTimer();
if (s.kind === 'outgoing' && myId) {
// Caller cancelled before anyone picked up — dismiss other sides' rings.
const peers = peerUserIdsFor(s.conversationId);
void Promise.all(
peers.map((to) =>
sendSignal(to, { type: 'cancel', callId: s.callId, byUserId: myId }),
),
);
if (!everConnectedRef.current) {
void emitCallEvent(s.conversationId, 'missed', s.mediaKind, 0);
}
}
let nextLastCallId: string | null = null;
if (s.kind === 'connected') {
const r = roomRef.current;
const amLastOut = !r || r.remoteParticipants.size === 0;
if (amLastOut) {
const durationSec = Math.max(
0,
Math.floor((Date.now() - new Date(s.startedAt).getTime()) / 1000),
);
void emitCallEvent(s.conversationId, 'ended', s.mediaKind, durationSec);
} else {
// Peers still in room — keep sidebar rejoin affordance.
nextLastCallId = s.conversationId;
}
}
// Await disconnect BEFORE dropping state so the tracker presence channel
// is removed before any RejoinCallBar observer tries to subscribe to the
// same topic (Supabase realtime dedupes channels by topic).
await disconnectRoom();
void playEndBeep();
setLastCallConversationId(nextLastCallId);
setState({ kind: 'idle' });
}, [
myId,
peerUserIdsFor,
sendSignal,
disconnectRoom,
clearRingTimer,
clearSoloTimer,
emitCallEvent,
]);
const dismissLastCall = useCallback(() => {
setLastCallConversationId(null);
}, []);
const toggleMute = useCallback(() => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
setIsMuted((prev) => {
const nextMuted = !prev;
pipeline.setMicGain(nextMuted ? 0 : 1);
mutedRef.current = nextMuted;
const r = roomRef.current;
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
return nextMuted;
});
}, []);
const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (lp.isScreenShareEnabled) return;
// Persist the user's choice so subsequent shares use the same config
// without re-opening the picker unless they want to change something.
const settings = getScreenShareSettings();
const preset = overrides?.preset ?? settings.preset;
const displaySurface =
overrides?.displaySurface !== undefined
? overrides.displaySurface
: settings.displaySurface;
const framerateOverride =
overrides?.framerate !== undefined
? overrides.framerate
: settings.framerateOverride;
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
try {
await lp.setScreenShareEnabled(true, {
// "Go live" mode — capture system audio alongside the screen when
// the user opted in. On hosts that can't fulfil the request the
// browser quietly drops it; peers just get video-only, no error.
audio: settings.includeSystemAudio,
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: fps,
},
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
// Hints the OS picker to pre-filter by source kind. `null` = no
// filter (show both). Cast because TS lib.dom doesn't know the
// field yet on all branches.
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}),
contentHint: 'detail',
});
setIsScreenSharing(true);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
}
},
[],
);
const stopScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isScreenShareEnabled) return;
try {
await lp.setScreenShareEnabled(false);
setIsScreenSharing(false);
} catch (err: unknown) {
console.error('stopScreenShare failed', err);
}
}, []);
// Legacy toggle kept for convenience elsewhere — opens/closes with the
// last-persisted settings and no picker UI.
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
if (r.localParticipant.isScreenShareEnabled) {
await stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
const toggleDeafen = useCallback(() => {
setIsDeafened((prev) => {
const next = !prev;
deafenedActive = next;
// Apply to every currently-attached remote-audio element. Fresh tracks
// that attach during a deafened session are muted in attachTrack above.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
els.forEach((el) => {
el.muted = next;
});
// 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, mutedRef.current);
return next;
});
}, []);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
if (nextOn && getAudioSettings().videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
}
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
// publication state so the button doesn't lie.
setIsCameraEnabled(lp.isCameraEnabled);
}
}, []);
// Live-toggle the background-blur processor when the settings flag flips.
// Acquiring the MediaPipe model is deferred until first activation to
// avoid the 1.5MB download on users who never enable blur.
useEffect(() => {
return subscribeAudioSettings((s) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isCameraEnabled) return;
if (s.videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
} else {
void removeBackgroundBlurFromLocal(lp);
}
});
}, []);
// --- 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]);
// --- Outgoing-channel: listen for accept/reject on our own invite ------
// and incoming invites from peers.
useEffect(() => {
if (!myId) return;
const me: string = myId; // narrowed capture for the inner closure
const topic = signalTopic(me);
const channel = supabase
.channel(topic, { config: { broadcast: { self: false } } })
.on('broadcast', { event: 'signal' }, (msg) => {
const payload = msg.payload as CallSignal;
handleSignal(payload);
});
signalChannelRef.current = channel;
void channel.subscribe();
return () => {
signalChannelRef.current = null;
void supabase.removeChannel(channel);
};
function handleSignal(p: CallSignal) {
const cur = stateRef.current;
switch (p.type) {
case 'invite': {
if (cur.kind !== 'idle') {
// Busy — auto-reject.
void sendSignal(p.fromUserId, {
type: 'reject',
callId: p.callId,
byUserId: me,
});
return;
}
setState({
kind: 'incoming',
callId: p.callId,
conversationId: p.conversationId,
fromUserId: p.fromUserId,
mediaKind: p.kind,
});
const conv = conversationsRef.current.find((c) => c.id === p.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === p.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
const isGroup = conv?.type === 'group';
if (presenceRef.current !== 'dnd') {
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
: 'Eingehender Anruf',
body: isGroup
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
}
break;
}
case 'accept':
case 'end': {
// No-op. Participant join/leave is tracked via LiveKit events so
// calls stay active while at least one person remains in the room.
break;
}
case 'reject': {
// Only relevant while I'm still the outgoing-caller who hasn't
// reached the room. Once someone joined (state = connected), a late
// reject from another ringing peer doesn't end the call.
if (
cur.kind !== 'outgoing' ||
cur.callId !== p.callId ||
everConnectedRef.current
) {
break;
}
// Remove the rejecter from the pending set. For group calls, keep
// ringing as long as at least one peer hasn't answered — only the
// last reject collapses the call to `declined`.
pendingPeersRef.current.delete(p.byUserId);
if (pendingPeersRef.current.size > 0) break;
clearRingTimer();
clearSoloTimer();
void emitCallEvent(cur.conversationId, 'declined', cur.mediaKind, 0);
void disconnectRoom();
setState({ kind: 'idle' });
break;
}
case 'cancel': {
// Caller cancelled before anyone picked up — receiver dismisses the
// incoming toast. Only hits when we're still in 'incoming' state.
if (cur.kind === 'incoming' && cur.callId === p.callId) {
const conv = conversationsRef.current.find((c) => c.id === cur.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
if (presenceRef.current !== 'dnd') {
void notify({
title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt',
});
}
setState({ kind: 'idle' });
}
break;
}
}
}
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
const markActive = useCallback((id: string, on: boolean) => {
setActiveSoundboardIds((prev) => {
const has = prev.has(id);
if (on && has) return prev;
if (!on && !has) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const playSoundboard = useCallback(
async (id: string, opts?: { overlap?: boolean }) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const handle = await playEntry(pipeline, entry, {
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
onEnded: () => markActive(id, false),
});
if (handle) markActive(id, true);
},
[markActive],
);
const stopSoundboard = useCallback(
(id?: string) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.stopAll(id);
if (id) {
markActive(id, false);
} else {
setActiveSoundboardIds(new Set<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 setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
updateAudioSettings({ outputDeviceId: deviceId });
const sinkId = deviceId ?? '';
// Apply to every <audio> element we've attached to the body. LiveKit's
// switchActiveDevice only tracks elements it attached itself; our custom
// appendChild path bypasses that, so we iterate and setSinkId manually.
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 === 'outgoing';
void setCallWakeLock(callActive);
}, [state.kind]);
// Global Esc: drop out of fullscreen cinema back to grid while in an active
// call. Doesn't hangup and doesn't fire when other dialogs would consume it.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== 'Escape') return;
if (callMode !== 'fullscreen') return;
if (state.kind !== 'connected' && state.kind !== 'connecting') return;
setCallModeState('grid');
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [callMode, state.kind]);
const value = useMemo<CallContextValue>(
() => ({
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
rejectIncoming,
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
}),
[
state,
room,
remoteParticipants,
isMuted,
isE2EEActive,
isScreenSharing,
isCameraEnabled,
isDeafened,
remoteDeafen,
remoteMute,
remoteScreenShares,
lastCallConversationId,
callMode,
focusedId,
startCall,
joinActiveCall,
acceptIncoming,
rejectIncoming,
hangup,
toggleMute,
toggleScreenShare,
startScreenShare,
stopScreenShare,
toggleCamera,
toggleDeafen,
dismissLastCall,
setCallMode,
setFocusedId,
setAudioInputDevice,
setAudioOutputDevice,
playSoundboard,
stopSoundboard,
activeSoundboardIds,
setSoundboardMasterGain,
setSoundboardMonitorGain,
],
);
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;
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 audio = track.attach();
if (audio instanceof HTMLAudioElement) {
audio.autoplay = true;
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
if (participant.identity) {
audio.setAttribute('data-participant', participant.identity);
audio.volume = getParticipantVolume(participant.identity);
}
if (deafenedActive) audio.muted = true;
document.body.appendChild(audio);
// Apply persisted sinkId so the element routes to the user's chosen
// speaker/headphone from the start (LiveKit's own `switchActiveDevice`
// doesn't track custom-appended elements).
const sinkId = getAudioSettings().outputDeviceId;
if (sinkId && typeof audio.setSinkId === 'function') {
void audio.setSinkId(sinkId).catch((err: unknown) => {
console.warn('setSinkId on attach failed', err);
});
}
}
}
// Video is handled later in M2.6/M3 by a dedicated <video> element.
}
function detachTrack(
track: RemoteTrack,
_publication: RemoteTrackPublication,
_participant: RemoteParticipant,
): void {
if (track.kind === Track.Kind.Audio) {
const els = track.detach();
for (const el of els) {
el.remove();
}
}
}