Files
ChatApp/apps/desktop/src/context/CallContext.tsx
T
byGalax 331b1298f8 feat(call): Discord-style screen-share UX
- Watch-gate lifted into CallContext. watchingShareUserIds /
  dismissedShareUserIds / screenShareAudioMutedIds as session-only state,
  cleared on CallState.idle and on TrackUnsubscribed for each sharer.
  Survives layout changes (grid <-> focus <-> fullscreen) without
  resetting which the old local-state viewer dropped on remount.
- ScreenShareAudio tracks tagged via data-track-source="screenshare" at
  attach-time; initial muted follows watching + manual mute mirrors so
  audio never plays before the user clicks "Bildschirm anschauen". Deafen
  still wins at the top of the priority chain.
- New screenShareVolumes store (session-only, keyed by participantId).
  attachTrack pulls the initial volume from this store for screenshare
  audio elements so the context-menu slider takes effect immediately.
- Screen shares are no longer auto-promoted to focus. They render as
  equal-size grid tiles like everyone else; user clicks to focus. The
  "Bildschirm anschauen" overlay replaces auto-play as the opt-in.
- Dismissed sharer-ids filter out of buildTiles, so "Zuschauen beenden"
  really hides the tile until the sharer stops + restarts.
- New ScreenShareContextMenu (portal, Esc / outside-click to close):
  volume slider + audio mute toggle when the share has audio + a
  destructive "Zuschauen beenden" row. Wired via a dispatcher in
  InCallPanel that picks between participant-volume and share-menu
  based on tile.kind.
- Fullscreen cinema gets a "Hide participant strip" toggle (top-right,
  session-only) so focused content reaches the full viewport when the
  bottom thumbnail row would otherwise steal 160px. Fades with the
  auto-hide controls; only surfaces when there's a focus + peers to hide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:42:26 +02:00

2104 lines
75 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,
registerGlobalShortcutPress,
registerPttShortcut,
unregisterGlobalShortcut,
unregisterPttShortcut,
} from '../lib/globalShortcut';
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
bindingToTauriShortcut,
eventMatchesBinding,
getVoiceHotkeys,
subscribeVoiceHotkeys,
type VoiceHotkeys,
} from '../lib/voiceHotkeys';
import {
getAudioQualityParams,
getAudioSettings,
subscribeAudioSettings,
updateAudioSettings,
} from '../lib/audioSettings';
import {
applyBackgroundBlurToLocal,
removeBackgroundBlurFromLocal,
} from '../lib/videoBlur';
import {
createCallE2EE,
getCallE2EESettings,
isE2EESupported,
} from '../lib/callE2EE';
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
import { getParticipantVolume } from '../lib/participantVolumes';
import {
clearScreenShareVolumes,
getScreenShareVolume,
} 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,
type ScreenSharePreset,
updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
export type CallState =
| { kind: 'idle' }
| {
kind: 'outgoing';
callId: string;
conversationId: string;
mediaKind: CallKind;
ringingSince: string;
}
| {
kind: 'incoming';
callId: string;
conversationId: string;
fromUserId: string;
mediaKind: CallKind;
}
| {
kind: 'connecting';
callId: string;
conversationId: string;
mediaKind: CallKind;
}
| {
kind: 'connected';
callId: string;
conversationId: string;
mediaKind: CallKind;
startedAt: string;
}
| {
// LiveKit dropped the signaling socket but is actively retrying. The
// room + tracks stay alive — the user's mic + speakers keep working —
// they just can't reach peers until we're back. Distinct from
// `connecting` so the UI can show "Verbinde neu…" vs "Verbinde…".
kind: 'reconnecting';
callId: string;
conversationId: string;
mediaKind: CallKind;
startedAt: string;
}
| { kind: 'error'; message: string };
export interface RemoteScreenShare {
track: RemoteTrack;
participantId: string;
participantName: string;
}
// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';
interface CallContextValue {
state: CallState;
room: Room | null;
remoteParticipants: RemoteParticipant[];
isMuted: boolean;
isE2EEActive: boolean;
isScreenSharing: boolean;
isCameraEnabled: boolean;
isDeafened: boolean;
/** 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 -> 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>;
/** 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;
}
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 [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>());
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);
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 || !device?.id) return;
try {
const priv = await loadDevicePrivateKey(
devLocalSecretStore,
session.user.id,
device.id,
);
if (!priv) return;
const payload = JSON.stringify({
v: 1,
type: 'call_event',
status,
mediaKind,
durationSec,
});
await sendEncryptedMessage({
client: supabase,
conversationId,
plaintext: payload,
senderUserId: session.user.id,
senderDeviceId: device.id,
senderPrivateKey: priv,
});
} catch (err: unknown) {
console.error('emitCallEvent failed', err);
}
},
[session?.user.id, device?.id],
);
const clearRingTimer = useCallback(() => {
if (ringTimerRef.current !== null) {
window.clearTimeout(ringTimerRef.current);
ringTimerRef.current = null;
}
}, []);
const clearSoloTimer = useCallback(() => {
if (soloTimerRef.current !== null) {
window.clearTimeout(soloTimerRef.current);
soloTimerRef.current = null;
}
}, []);
const clearJoinFallbackTimer = useCallback(() => {
if (joinFallbackTimerRef.current !== null) {
window.clearTimeout(joinFallbackTimerRef.current);
joinFallbackTimerRef.current = null;
}
}, []);
// Extracted so retryMic can call it after the user grants permission from
// OS settings. Reads audioSettings fresh every call so NS/input-device
// flips take effect without rejoining the room.
const setupMicPipeline = useCallback(async (r: Room): Promise<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 */
}
}
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();
clearJoinFallbackTimer();
everConnectedRef.current = true;
setState({
kind: 'connected',
callId,
conversationId,
mediaKind,
startedAt: new Date().toISOString(),
});
},
[clearRingTimer, clearSoloTimer, clearJoinFallbackTimer],
);
// --- LiveKit join/leave ------------------------------------------------
const joinRoom = useCallback(
async (conversationId: string, mediaKind: CallKind, callId: string) => {
const { token, url } = await fetchLivekitToken(supabase, conversationId);
// Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
// 3 temporal layers from a single encode. LiveKit's congestion control
// drops temporal/spatial layers per-subscriber when uplink degrades,
// so a 4K60 publisher downshifts smoothly to 720p30 on weak links
// without a full renegotiate. `adaptiveStream` pauses video downlink
// off-screen; `dynacast` stops publishing layers nobody subscribes to;
// `degradationPreference: balanced` tells WebRTC to trade framerate vs
// resolution dynamically based on the encoder's CPU + bandwidth.
// Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
// 3 temporal layers from a single encode. LiveKit's congestion control
// drops temporal/spatial layers per-subscriber when uplink degrades,
// so a 4K60 publisher downshifts smoothly to 720p30 on weak links
// without a full renegotiate. `adaptiveStream` pauses video downlink
// off-screen; `dynacast` stops publishing layers nobody subscribes to;
// `degradationPreference: balanced` tells WebRTC to trade framerate vs
// resolution dynamically based on the encoder's CPU + bandwidth.
// Audio: `red` doubles Opus packets for packet-loss concealment;
// `dtx` is off in hifi so music doesn't get zeroed out between notes;
// `forceStereo` publishes stereo when the hifi preset captures stereo.
const ssCfg = getScreenShareSettings();
const ssParams = getPresetParams(ssCfg.preset);
const aParams = getAudioQualityParams(getAudioSettings().quality);
const e2eeCfg = getCallE2EESettings();
const e2eeUsable = e2eeCfg.enabled && isE2EESupported();
const e2eeBundle = e2eeUsable ? await createCallE2EE(conversationId) : null;
const r = new Room({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: 'vp9',
scalabilityMode: 'L3T3_KEY',
degradationPreference: 'balanced',
backupCodec: true,
audioPreset: {
maxBitrate: aParams.bitrateKbps * 1000,
priority: 'high',
},
red: true,
dtx: aParams.stereo ? false : true,
forceStereo: aParams.stereo,
screenShareEncoding: {
maxBitrate: ssParams.bitrateKbps * 1000,
maxFramerate: ssParams.framerate,
priority: 'high',
},
},
...(e2eeBundle
? {
e2ee: {
keyProvider: e2eeBundle.keyProvider,
worker: e2eeBundle.worker,
},
}
: {}),
});
roomRef.current = r;
setRoom(r);
r.on(RoomEvent.ConnectionStateChanged, (cs) => {
if (cs === ConnectionState.Reconnecting) {
// LiveKit lost the signaling socket and is retrying. Hold the
// connected state visually — the track publications stay live,
// so the user's mic + speakers keep working once the socket is
// back. Only transition from `connected`; if we were still in
// `connecting`/`outgoing`, LK will sort itself out on its own.
const cur = stateRef.current;
if (cur.kind === 'connected') {
setState({
kind: 'reconnecting',
callId: cur.callId,
conversationId: cur.conversationId,
mediaKind: cur.mediaKind,
startedAt: cur.startedAt,
});
}
return;
}
if (cs === ConnectionState.Connected) {
// Flip back from `reconnecting` when LK re-establishes the socket.
// Preserves startedAt so the duration counter doesn't reset.
const cur = stateRef.current;
if (cur.kind === 'reconnecting') {
setState({
kind: 'connected',
callId: cur.callId,
conversationId: cur.conversationId,
mediaKind: cur.mediaKind,
startedAt: cur.startedAt,
});
}
return;
}
if (cs === ConnectionState.Disconnected) {
// Server / network tore us out — reset state cleanly. Remember the
// conversation so the sidebar "still live — rejoin" widget stays
// visible: observers will poll presence and detect any remaining
// peers, which is the only cue this side gets that a call is live.
clearRingTimer();
clearSoloTimer();
if (joinFallbackTimerRef.current !== null) {
window.clearTimeout(joinFallbackTimerRef.current);
joinFallbackTimerRef.current = null;
}
const wasInCall =
stateRef.current.kind === 'connected' ||
stateRef.current.kind === 'connecting' ||
stateRef.current.kind === 'reconnecting' ||
stateRef.current.kind === 'outgoing';
if (wasInCall) {
setLastCallConversationId(conversationId);
}
// Untrack our presence entry so observers see us leave. Keep the
// channel alive — observers share it via Supabase topic dedupe.
const pres = presenceChannelRef.current;
if (pres) {
try {
void pres.untrack();
} catch {
/* ignore */
}
presenceChannelRef.current = null;
}
if (stateRef.current.kind !== 'idle' && stateRef.current.kind !== 'error') {
setState({ kind: 'idle' });
}
roomRef.current = null;
setRoom(null);
setRemoteParticipants([]);
setRemoteScreenShares([]);
setIsScreenSharing(false);
setIsE2EEActive(false);
}
});
r.on(RoomEvent.ParticipantConnected, () => {
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
if (presenceRef.current !== 'dnd') void playJoinBeep();
markConnectedIfReady(r, conversationId, mediaKind, callId);
});
r.on(RoomEvent.ParticipantDisconnected, () => {
const remaining = Array.from(r.remoteParticipants.values());
setRemoteParticipants(remaining);
if (presenceRef.current !== 'dnd') void playLeaveBeep();
// Alone in the room while connected — start the solo-timeout.
if (
stateRef.current.kind === 'connected' &&
remaining.length === 0 &&
soloTimerRef.current === null
) {
soloTimerRef.current = window.setTimeout(() => {
void (async () => {
soloTimerRef.current = null;
const cur = stateRef.current;
if (cur.kind === 'connected') {
const durationSec = Math.max(
0,
Math.floor((Date.now() - new Date(cur.startedAt).getTime()) / 1000),
);
void emitCallEvent(cur.conversationId, 'ended', cur.mediaKind, durationSec);
}
await disconnectRoom();
void playEndBeep();
// Room was empty by timeout — nothing to rejoin into.
setLastCallConversationId(null);
setState({ kind: 'idle' });
})();
}, SOLO_TIMEOUT_MS);
}
});
r.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
attachTrack(track, publication, participant);
if (
track.kind === Track.Kind.Video &&
(track.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShare)
) {
const identity = participant.identity;
const name = participant.name || identity;
setRemoteScreenShares((prev) => {
if (prev.some((s) => s.track.sid === track.sid)) return prev;
return [...prev, { track, participantId: identity, participantName: name }];
});
}
});
r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
detachTrack(track, publication, participant);
if (track.kind === Track.Kind.Video) {
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;
};
if (msg.type !== 'presence') return;
const id: string = participant.identity;
if (typeof msg.deafened === 'boolean') {
const deafened: boolean = msg.deafened;
setRemoteDeafen((prev) => {
if (prev[id] === deafened) return prev;
return { ...prev, [id]: deafened };
});
}
if (typeof msg.muted === 'boolean') {
const muted: boolean = msg.muted;
setRemoteMute((prev) => {
if (prev[id] === muted) return prev;
return { ...prev, [id]: muted };
});
}
} catch {
/* ignore malformed */
}
},
);
// When someone joins, re-send our current presence (deafen + mute) so
// they know immediately instead of waiting for the next toggle.
r.on(RoomEvent.ParticipantConnected, () => {
void broadcastPresence(r, deafenedActive, mutedRef.current);
});
// Track my own screen-share state via LocalTrack events so the toggle
// stays in sync if the user stops sharing via the browser's native UI.
r.on(RoomEvent.LocalTrackPublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setIsScreenSharing(true);
}
});
r.on(RoomEvent.LocalTrackUnpublished, (publication) => {
if (publication.source === Track.Source.ScreenShare) {
setIsScreenSharing(false);
}
});
await r.connect(url, token);
// Self-join feedback sound. `playJoinBeep` is shared with the
// ParticipantConnected path; firing it here too gives the user a clear
// "I'm in the room" cue that Discord plays on self-join.
if (presenceRef.current !== 'dnd') void playJoinBeep();
if (e2eeBundle) {
try {
await r.setE2EEEnabled(true);
setIsE2EEActive(true);
} catch (e2eErr: unknown) {
console.error('setE2EEEnabled failed — falling back to clear-media', e2eErr);
setIsE2EEActive(false);
}
} else {
setIsE2EEActive(false);
}
// Mic pipeline setup + publish. Runs asynchronously; on failure sets
// `micError` so the InCallPanel renders a retry banner without tearing
// down the whole call — the user can still hear peers meanwhile.
await setupMicPipeline(r);
if (mediaKind === 'video') {
try {
await r.localParticipant.setCameraEnabled(true);
setIsCameraEnabled(true);
} catch (camErr: unknown) {
console.error('setCameraEnabled failed', camErr);
}
}
setIsMuted(false);
setRemoteParticipants(Array.from(r.remoteParticipants.values()));
// If peers are already in the room, transition immediately — otherwise
// stay in outgoing/connecting and wait for ParticipantConnected.
// Runs BEFORE presence setup so a slow/failing presence channel can't
// leave the acceptor stuck in "connecting…".
markConnectedIfReady(r, conversationId, mediaKind, callId);
// Advertise "we're in this call" on the presence channel so other
// conversation members can show a Join-button even after leaving.
// Supabase realtime dedupes channels by topic: if any component (e.g.
// an observer banner) already subscribed to this topic, we get the
// existing channel back. In that case .subscribe() would throw — so we
// branch on channel.state and just track().
if (myId) {
const pres = supabase.channel('call-presence:' + conversationId, {
config: { presence: { key: myId, enabled: true } },
});
presenceChannelRef.current = pres;
const track = () => {
void pres.track({ userId: myId, joinedAt: new Date().toISOString() });
};
if (pres.state === 'joined') {
track();
} else if (pres.state === 'closed') {
pres.subscribe((status) => {
if (status === 'SUBSCRIBED') track();
});
} else {
// 'joining' — wait briefly for join to complete before tracking.
const waitId = window.setInterval(() => {
if (pres.state === 'joined') {
window.clearInterval(waitId);
track();
}
}, 100);
window.setTimeout(() => window.clearInterval(waitId), 5000);
}
}
},
[
markConnectedIfReady,
clearRingTimer,
clearSoloTimer,
emitCallEvent,
disconnectRoom,
myId,
setupMicPipeline,
],
);
// --- Public actions ----------------------------------------------------
const startCall = useCallback(
async (conversationId: string, mediaKind: CallKind = 'audio') => {
if (!myId) return;
if (stateRef.current.kind !== 'idle') return;
const callId = newCallId();
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({
kind: 'outgoing',
callId,
conversationId,
mediaKind,
ringingSince: new Date().toISOString(),
});
const peers = peerUserIdsFor(conversationId);
pendingPeersRef.current = new Set(peers);
await Promise.all(
peers.map((to) =>
sendSignal(to, {
type: 'invite',
callId,
conversationId,
fromUserId: myId,
kind: mediaKind,
sentAt: new Date().toISOString(),
}),
),
);
// Caller joins the room immediately so acceptors hop straight into a
// live room. Transition to 'connected' happens in ParticipantConnected.
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
return;
}
clearRingTimer();
ringTimerRef.current = window.setTimeout(() => {
void (async () => {
// Only trigger if still alone (nobody joined the room yet).
const cur = stateRef.current;
if (cur.kind !== 'outgoing' || cur.callId !== callId) return;
const peers2 = peerUserIdsFor(conversationId);
await Promise.all(
peers2.map((to) =>
sendSignal(to, { type: 'cancel', callId, byUserId: myId }),
),
);
void emitCallEvent(conversationId, 'missed', mediaKind, 0);
await disconnectRoom();
setState({ kind: 'idle' });
})();
}, RING_TIMEOUT_MS);
},
[myId, peerUserIdsFor, sendSignal, clearRingTimer, emitCallEvent, joinRoom, disconnectRoom],
);
// Re-join an already-running call without ringing peers.
const joinActiveCall = useCallback(
async (conversationId: string, mediaKind: CallKind = 'audio') => {
if (!myId) return;
if (stateRef.current.kind !== 'idle') return;
const callId = newCallId();
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
// Fallback: peers may have left the room right as we joined, so
// ParticipantConnected never fires. Promote to `connected` after a
// short window so the UI doesn't sit in "Verbinde…" forever. The
// solo-timeout then handles the "actually alone" case cleanly.
clearJoinFallbackTimer();
joinFallbackTimerRef.current = window.setTimeout(() => {
joinFallbackTimerRef.current = null;
const cur = stateRef.current;
if (cur.kind !== 'connecting' || cur.callId !== callId) return;
everConnectedRef.current = true;
setState({
kind: 'connected',
callId,
conversationId,
mediaKind,
startedAt: new Date().toISOString(),
});
}, 5000);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom, clearJoinFallbackTimer],
);
const acceptIncoming = useCallback(
async (override?: CallKind) => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
const { callId, conversationId } = s;
// Caller's `mediaKind` is the INVITE kind (what they started with). The
// receiver can accept with audio even if the caller rang as video, or
// upgrade an audio invite to video on accept. `override` picks the
// receiver's choice.
const mediaKind: CallKind = override ?? s.mediaKind;
everConnectedRef.current = false;
setLastCallConversationId(null);
setState({ kind: 'connecting', callId, conversationId, mediaKind });
try {
await joinRoom(conversationId, mediaKind, callId);
} catch (err: unknown) {
setState({
kind: 'error',
message: err instanceof Error ? err.message : 'join failed',
});
await disconnectRoom();
}
},
[myId, joinRoom, disconnectRoom],
);
const rejectIncoming = useCallback(() => {
const s = stateRef.current;
if (s.kind !== 'incoming' || !myId) return;
void sendSignal(s.fromUserId, { type: 'reject', callId: s.callId, byUserId: myId });
setState({ kind: 'idle' });
}, [myId, sendSignal]);
const hangup = useCallback(async () => {
const s = stateRef.current;
clearRingTimer();
clearSoloTimer();
clearJoinFallbackTimer();
if (s.kind === 'outgoing' && myId) {
// Caller cancelled before anyone picked up — dismiss other sides' rings.
const peers = peerUserIdsFor(s.conversationId);
void Promise.all(
peers.map((to) =>
sendSignal(to, { type: 'cancel', callId: s.callId, byUserId: myId }),
),
);
if (!everConnectedRef.current) {
void emitCallEvent(s.conversationId, 'missed', s.mediaKind, 0);
}
}
let nextLastCallId: string | null = null;
if (s.kind === 'connected') {
const r = roomRef.current;
const amLastOut = !r || r.remoteParticipants.size === 0;
if (amLastOut) {
const durationSec = Math.max(
0,
Math.floor((Date.now() - new Date(s.startedAt).getTime()) / 1000),
);
void emitCallEvent(s.conversationId, 'ended', s.mediaKind, durationSec);
} else {
// Peers still in room — keep sidebar rejoin affordance.
nextLastCallId = s.conversationId;
}
}
// Await disconnect BEFORE dropping state so the tracker presence channel
// is removed before any RejoinCallBar observer tries to subscribe to the
// same topic (Supabase realtime dedupes channels by topic).
await disconnectRoom();
void playEndBeep();
setLastCallConversationId(nextLastCallId);
setState({ kind: 'idle' });
}, [
myId,
peerUserIdsFor,
sendSignal,
disconnectRoom,
clearRingTimer,
clearSoloTimer,
clearJoinFallbackTimer,
emitCallEvent,
]);
const dismissLastCall = useCallback(() => {
setLastCallConversationId(null);
}, []);
const toggleMute = useCallback(() => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
setIsMuted((prev) => {
const nextMuted = !prev;
pipeline.setMicGain(nextMuted ? 0 : 1);
mutedRef.current = nextMuted;
const r = roomRef.current;
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
return nextMuted;
});
}, []);
const startScreenShare = useCallback(
async (
overrides?: Partial<{
preset: ScreenSharePreset;
displaySurface: DisplaySurfaceHint;
framerate: number | null;
}>,
) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (lp.isScreenShareEnabled) return;
// Persist the user's choice so subsequent shares use the same config
// without re-opening the picker unless they want to change something.
const settings = getScreenShareSettings();
const preset = overrides?.preset ?? settings.preset;
const displaySurface =
overrides?.displaySurface !== undefined
? overrides.displaySurface
: settings.displaySurface;
const framerateOverride =
overrides?.framerate !== undefined
? overrides.framerate
: settings.framerateOverride;
updateScreenShareSettings({ preset, displaySurface, framerateOverride });
const ssParams = getPresetParams(preset);
const fps = framerateOverride ?? ssParams.framerate;
try {
await lp.setScreenShareEnabled(true, {
// "Go live" mode — capture system audio alongside the screen when
// the user opted in. On hosts that can't fulfil the request the
// browser quietly drops it; peers just get video-only, no error.
audio: settings.includeSystemAudio,
...(ssParams.dims
? {
resolution: {
width: ssParams.dims.width,
height: ssParams.dims.height,
frameRate: fps,
},
}
: {
resolution: {
width: 3840,
height: 2160,
frameRate: fps,
},
}),
// Hints the OS picker to pre-filter by source kind. `null` = no
// filter (show both). Cast because TS lib.dom doesn't know the
// field yet on all branches.
...(displaySurface
? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
: {}),
contentHint: 'detail',
});
setIsScreenSharing(true);
} catch (err: unknown) {
console.error('setScreenShareEnabled failed', err);
}
},
[],
);
const stopScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isScreenShareEnabled) return;
try {
await lp.setScreenShareEnabled(false);
setIsScreenSharing(false);
} catch (err: unknown) {
console.error('stopScreenShare failed', err);
}
}, []);
// Legacy toggle kept for convenience elsewhere — opens/closes with the
// last-persisted settings and no picker UI.
const toggleScreenShare = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
if (r.localParticipant.isScreenShareEnabled) {
await stopScreenShare();
} else {
await startScreenShare();
}
}, [startScreenShare, stopScreenShare]);
const toggleDeafen = useCallback(() => {
setIsDeafened((prev) => {
const next = !prev;
deafenedActive = next;
// Apply to every currently-attached remote-audio element. Fresh tracks
// that attach during a deafened session are muted in attachTrack above.
// When un-deafening, screen-share-audio elements should fall back to
// the watching state (muted unless the user clicked "Bildschirm
// anschauen") rather than being blanket-unmuted like mic tracks.
const els = document.querySelectorAll<HTMLAudioElement>(
'audio[data-livekit-track]',
);
els.forEach((el) => {
if (next) {
el.muted = true;
return;
}
const source = el.getAttribute('data-track-source');
if (source === 'screenshare') {
const pid = el.getAttribute('data-participant');
el.muted = !(pid && watchingShareUserIdsMirror.has(pid));
} else {
el.muted = false;
}
});
// Discord-parity: deafen implies mute. Remember the pre-deafen mute
// state so un-deafening restores whatever the user had before.
const pipeline = pipelineRef.current;
let nextMuted = mutedRef.current;
if (next) {
// Activating deafen → snapshot current mute + force mic off.
preDeafenMutedRef.current = mutedRef.current;
if (!mutedRef.current) {
pipeline?.setMicGain(0);
mutedRef.current = true;
nextMuted = true;
setIsMuted(true);
}
} else {
// Deactivating deafen → restore pre-deafen mic state (if we have a
// snapshot). Absent snapshot (e.g. reconnect edge), unmute.
const restore = preDeafenMutedRef.current ?? false;
preDeafenMutedRef.current = null;
pipeline?.setMicGain(restore ? 0 : 1);
mutedRef.current = restore;
nextMuted = restore;
setIsMuted(restore);
}
// Broadcast via LiveKit data channel so peers' UIs can show the
// headphones-off badge. Data channel works on any LiveKit server
// version, unlike `setAttributes` which requires a newer server.
const r = roomRef.current;
if (r) void broadcastPresence(r, next, nextMuted);
return next;
});
}, []);
const toggleCamera = useCallback(async () => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
const nextOn = !lp.isCameraEnabled;
try {
await lp.setCameraEnabled(nextOn);
setIsCameraEnabled(nextOn);
if (nextOn && getAudioSettings().videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
}
} catch (err: unknown) {
console.error('setCameraEnabled failed', err);
// Permission denied / no camera — keep state in sync with actual
// publication state so the button doesn't lie.
setIsCameraEnabled(lp.isCameraEnabled);
}
}, []);
// Live-toggle the background-blur processor when the settings flag flips.
// Acquiring the MediaPipe model is deferred until first activation to
// avoid the 1.5MB download on users who never enable blur.
useEffect(() => {
return subscribeAudioSettings((s) => {
const r = roomRef.current;
if (!r) return;
const lp = r.localParticipant;
if (!lp.isCameraEnabled) return;
if (s.videoBackgroundBlur) {
void applyBackgroundBlurToLocal(lp);
} else {
void removeBackgroundBlurFromLocal(lp);
}
});
}, []);
// Hot-swap the mic track when the user flips noiseSuppression in Settings
// so the change takes effect without needing to rejoin the call. Tracks
// the previous value in a ref so we only re-acquire getUserMedia on
// actual transitions (avoid a rebuild on every unrelated settings save).
useEffect(() => {
let lastNs = getAudioSettings().noiseSuppression;
return subscribeAudioSettings((s) => {
if (s.noiseSuppression === lastNs) return;
lastNs = s.noiseSuppression;
const r = roomRef.current;
if (!r) return;
// setupMicPipeline re-reads audioSettings so the new NS constraint
// gets picked up. Same helper as the initial join + retry paths.
void setupMicPipeline(r);
});
}, [setupMicPipeline]);
// --- Push-to-talk ------------------------------------------------------
// While PTT is active + we're in a connected call, the mic is held off
// except while the configured key is pressed. Under Tauri we also register
// the key as an OS-level global shortcut so PTT keeps working while the
// user is focused on another window (Discord-style). The window listener
// stays as a fallback for the web build and for the case where the global
// shortcut registration fails (collision with another app).
useEffect(() => {
if (state.kind !== 'connected') return;
let settings = getPttSettings();
let activeKeyDown = false;
let globalRegisteredFor: string | null = null;
const setMic = (on: boolean) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.setMicGain(on ? 1 : 0);
const nextMuted = !on;
mutedRef.current = nextMuted;
setIsMuted(nextMuted);
const r = roomRef.current;
if (r) void broadcastPresence(r, deafenedActive, nextMuted);
};
const pressPtt = () => {
if (activeKeyDown) return;
activeKeyDown = true;
setMic(true);
};
const releasePtt = () => {
if (!activeKeyDown) return;
activeKeyDown = false;
setMic(false);
};
const isTypingTarget = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
if (el.isContentEditable) return true;
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (!settings.enabled) return;
if (e.code !== settings.key) return;
if (isTypingTarget(e.target)) return;
if (e.repeat) return;
pressPtt();
};
const onKeyUp = (e: KeyboardEvent) => {
if (!settings.enabled) return;
if (e.code !== settings.key) return;
releasePtt();
};
const ensureGlobalShortcut = () => {
if (!isTauriRuntime()) return;
if (!settings.enabled) {
if (globalRegisteredFor) {
const key = globalRegisteredFor;
globalRegisteredFor = null;
void unregisterPttShortcut(key);
}
return;
}
if (globalRegisteredFor === settings.key) return;
// Hotkey changed — unregister old, register new.
const oldKey = globalRegisteredFor;
globalRegisteredFor = settings.key;
if (oldKey) void unregisterPttShortcut(oldKey);
void registerPttShortcut(settings.key, pressPtt, releasePtt);
};
const applyPttState = () => {
if (settings.enabled) {
activeKeyDown = false;
setMic(false);
} else {
setMic(true);
}
ensureGlobalShortcut();
};
applyPttState();
const unsubSettings = subscribePttSettings((next) => {
settings = next;
applyPttState();
});
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
return () => {
unsubSettings();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
if (globalRegisteredFor) {
void unregisterPttShortcut(globalRegisteredFor);
globalRegisteredFor = null;
}
};
}, [state.kind]);
// --- Mute / Deafen global hotkeys --------------------------------------
// Discord-parity: both toggles respond to a user-configurable chord
// (default Ctrl+Shift+M / Ctrl+Shift+D, disabled until the user opts in).
// Registered only while a call is active so the shortcuts don't intercept
// typing outside of calls. Under Tauri we register an OS-level chord so
// mute/deafen work from any focused window; the window keydown listener
// is the fallback for the web build + when the global register fails
// (another app owns the chord).
useEffect(() => {
if (
state.kind !== 'connected' &&
state.kind !== 'reconnecting'
) {
return;
}
let settings: VoiceHotkeys = getVoiceHotkeys();
let registered: { mute: string | null; deafen: string | null } = {
mute: null,
deafen: null,
};
const fire = (kind: 'mute' | 'deafen') => {
if (kind === 'mute') toggleMute();
else toggleDeafen();
};
const onKey = (e: KeyboardEvent) => {
if (eventMatchesBinding(settings.mute, e)) {
e.preventDefault();
fire('mute');
return;
}
if (eventMatchesBinding(settings.deafen, e)) {
e.preventDefault();
fire('deafen');
return;
}
};
window.addEventListener('keydown', onKey);
const syncGlobalShortcuts = () => {
if (!isTauriRuntime()) return;
const desired = {
mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
};
for (const kind of ['mute', 'deafen'] as const) {
const want = desired[kind];
const have = registered[kind];
if (want === have) continue;
if (have) {
void unregisterGlobalShortcut(have);
registered = { ...registered, [kind]: null };
}
if (want) {
// Tag the closure so React's stale-state trap doesn't bite —
// `fire` is stable (defined above), and `kind` is captured by
// value.
const thisKind = kind;
void registerGlobalShortcutPress(want, () => fire(thisKind));
registered = { ...registered, [kind]: want };
}
}
};
syncGlobalShortcuts();
const unsub = subscribeVoiceHotkeys((next) => {
settings = next;
syncGlobalShortcuts();
});
return () => {
unsub();
window.removeEventListener('keydown', onKey);
if (registered.mute) void unregisterGlobalShortcut(registered.mute);
if (registered.deafen) void unregisterGlobalShortcut(registered.deafen);
};
}, [state.kind, toggleMute, toggleDeafen]);
// --- Outgoing-channel: listen for accept/reject on our own invite ------
// and incoming invites from peers.
useEffect(() => {
if (!myId) return;
const me: string = myId; // narrowed capture for the inner closure
const topic = signalTopic(me);
const channel = supabase
.channel(topic, { config: { broadcast: { self: false } } })
.on('broadcast', { event: 'signal' }, (msg) => {
const payload = msg.payload as CallSignal;
handleSignal(payload);
});
signalChannelRef.current = channel;
void channel.subscribe();
return () => {
signalChannelRef.current = null;
void supabase.removeChannel(channel);
};
function handleSignal(p: CallSignal) {
const cur = stateRef.current;
switch (p.type) {
case 'invite': {
if (cur.kind !== 'idle') {
// Busy — auto-reject.
void sendSignal(p.fromUserId, {
type: 'reject',
callId: p.callId,
byUserId: me,
});
return;
}
setState({
kind: 'incoming',
callId: p.callId,
conversationId: p.conversationId,
fromUserId: p.fromUserId,
mediaKind: p.kind,
});
const conv = conversationsRef.current.find((c) => c.id === p.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === p.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
const isGroup = conv?.type === 'group';
if (presenceRef.current !== 'dnd') {
void notify({
title: isGroup
? (conv?.name ?? 'Gruppenanruf')
: 'Eingehender Anruf',
body: isGroup
? callerName + ' ruft die Gruppe'
: callerName + ' ruft dich an',
});
}
break;
}
case 'accept':
case 'end': {
// No-op. Participant join/leave is tracked via LiveKit events so
// calls stay active while at least one person remains in the room.
break;
}
case 'reject': {
// Only relevant while I'm still the outgoing-caller who hasn't
// reached the room. Once someone joined (state = connected), a late
// reject from another ringing peer doesn't end the call.
if (
cur.kind !== 'outgoing' ||
cur.callId !== p.callId ||
everConnectedRef.current
) {
break;
}
// Remove the rejecter from the pending set. For group calls, keep
// ringing as long as at least one peer hasn't answered — only the
// last reject collapses the call to `declined`.
pendingPeersRef.current.delete(p.byUserId);
if (pendingPeersRef.current.size > 0) break;
clearRingTimer();
clearSoloTimer();
void emitCallEvent(cur.conversationId, 'declined', cur.mediaKind, 0);
void disconnectRoom();
setState({ kind: 'idle' });
break;
}
case 'cancel': {
// Caller cancelled before anyone picked up — receiver dismisses the
// incoming toast. Only hits when we're still in 'incoming' state.
if (cur.kind === 'incoming' && cur.callId === p.callId) {
const conv = conversationsRef.current.find((c) => c.id === cur.conversationId) ?? null;
const callerName =
conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
conv?.peer?.displayName ??
'…';
if (presenceRef.current !== 'dnd') {
void notify({
title: 'Verpasster Anruf',
body: callerName + ' hat aufgelegt',
});
}
setState({ kind: 'idle' });
}
break;
}
}
}
}, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent]);
const setCallMode = useCallback((mode: CallMode) => {
setCallModeState(mode);
}, []);
const setFocusedId = useCallback((id: string | null) => {
setFocusedIdState(id);
}, []);
const markActive = useCallback((id: string, on: boolean) => {
setActiveSoundboardIds((prev) => {
const has = prev.has(id);
if (on && has) return prev;
if (!on && !has) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
const playSoundboard = useCallback(
async (id: string, opts?: { overlap?: boolean }) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
const entries = await listSoundboard();
const entry = entries.find((e) => e.id === id);
if (!entry) return;
const handle = await playEntry(pipeline, entry, {
...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
onEnded: () => markActive(id, false),
});
if (handle) markActive(id, true);
},
[markActive],
);
const stopSoundboard = useCallback(
(id?: string) => {
const pipeline = pipelineRef.current;
if (!pipeline) return;
pipeline.stopAll(id);
if (id) {
markActive(id, false);
} else {
setActiveSoundboardIds(new Set<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);
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;
});
}, []);
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]);
// Keep the module-level mirrors in sync so attachTrack (which is defined
// outside the React component and runs from LiveKit event callbacks) can
// decide the initial muted-state for ScreenShareAudio elements. Also
// re-applies the muted state to already-attached elements on every flip
// — covers both watching changes and manual mute toggles from the
// context menu.
useEffect(() => {
watchingShareUserIdsMirror = watchingShareUserIds;
screenShareAudioMutedIdsMirror = screenShareAudioMutedIds;
const nodes = document.querySelectorAll<HTMLAudioElement>(
'audio[data-track-source="screenshare"]',
);
nodes.forEach((el) => {
if (deafenedActive) {
el.muted = true;
return;
}
const pid = el.getAttribute('data-participant');
if (!pid) return;
const watching = watchingShareUserIds.has(pid);
const manualMuted = screenShareAudioMutedIds.has(pid);
el.muted = !watching || manualMuted;
});
}, [watchingShareUserIds, screenShareAudioMutedIds]);
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 === 'reconnecting' ||
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' &&
state.kind !== 'reconnecting'
) {
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,
micError,
clearMicError,
retryMic,
watchingShareUserIds,
dismissedShareUserIds,
watchShare,
stopWatchingShare,
dismissShare,
screenShareAudioMutedIds,
setScreenShareAudioMuted,
}),
[
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,
micError,
clearMicError,
retryMic,
watchingShareUserIds,
dismissedShareUserIds,
watchShare,
stopWatchingShare,
dismissShare,
screenShareAudioMutedIds,
setScreenShareAudioMuted,
],
);
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>();
// 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 audio = track.attach();
if (audio instanceof HTMLAudioElement) {
audio.autoplay = true;
audio.setAttribute('playsinline', 'true');
audio.setAttribute('data-livekit-track', track.sid ?? '');
const isScreenShareAudio =
track.source === Track.Source.ScreenShareAudio ||
publication.source === Track.Source.ScreenShareAudio;
if (isScreenShareAudio) {
audio.setAttribute('data-track-source', 'screenshare');
}
if (participant.identity) {
audio.setAttribute('data-participant', participant.identity);
audio.volume = isScreenShareAudio
? getScreenShareVolume(participant.identity)
: getParticipantVolume(participant.identity);
}
// Mute rules, in priority order:
// 1. Deafen wins — user chose to hear nothing at all.
// 2. ScreenShareAudio is muted until the user explicitly clicks
// "Bildschirm anschauen" (watching gate).
// 3. ScreenShareAudio is also muted when the user flipped the
// manual mute toggle in the share context menu, regardless of
// watching state.
// 4. Everything else starts audible.
if (deafenedActive) {
audio.muted = true;
} else if (isScreenShareAudio) {
const pid = participant.identity ?? '';
const watching = pid !== '' && watchingShareUserIdsMirror.has(pid);
const manualMuted = pid !== '' && screenShareAudioMutedIdsMirror.has(pid);
audio.muted = !watching || manualMuted;
}
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();
}
}
}