Files
backspace/packages/web/src/hooks/useLiveKit.ts
T
Jannis Braun bfecb41c66 fix: derive voice sidebar from LiveKit participants to eliminate desync
The channel sidebar voice user list was maintained by a separate
voiceUsers Map (fed by WS events + fragile hydration code) that diverged
from reality after server restarts — users shown in wrong channels,
duplicated across channels. The VoiceGrid was always correct because it
reads LiveKit participants directly.

Now VoiceChannel.tsx derives its user list from LiveKit participants for
the connected channel (single source of truth) and only falls back to
server-provided voiceUsers for channels the user is not connected to.

Removed all hydration band-aids that tried to sync the two systems:
- useLiveKit ParticipantDisconnected → removeVoiceUser
- useLiveKit ConnectionStateChanged → addVoiceUser hydration loop
- useWebSocket ready handler → dynamic import LiveKit hydration

Also includes: voice channel settings gear icon on hover, persist
per-user volume/mute prefs across sessions, default screen share
audio off on Electron (no system audio capture support).
2026-03-16 22:10:53 +01:00

632 lines
26 KiB
TypeScript

import { useState, useCallback, useRef, useEffect } from 'react';
import {
Room,
RoomEvent,
Track,
Participant,
RemoteParticipant,
RemoteTrackPublication,
RemoteAudioTrack,
ConnectionState,
ConnectionQuality,
LocalAudioTrack,
LocalTrackPublication,
DisconnectReason,
} from 'livekit-client';
import { getApiForOrigin, getChannelOrigin, getMyUserIdForOrigin, useSpaceStore } from '../stores/spaceStore';
import { wsSend } from './useWebSocket';
import { useVoiceStore } from '../stores/voiceStore';
import { broadcastVoiceStatus } from '../utils/voice';
import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector';
import {
CAMERA_PRESET,
CAMERA_OVERDRIVE,
buildScreenShareOptions,
applyOverdrive,
startScreenShare,
stopScreenShare,
handleScreenShareUnpublished,
} from '../utils/screenShare';
import { getMediaStreamTrack } from '../utils/livekitInternals';
let _activeRoom: Room | null = null;
export function getActiveRoom(): Room | null {
return _activeRoom;
}
export interface ParticipantInfo {
identity: string;
userId: string;
username: string;
homeUserId: string | null;
isMuted: boolean;
isDeafened: boolean;
isCameraOn: boolean;
isScreenSharing: boolean;
isLocal: boolean;
audioTrack: MediaStreamTrack | null;
videoTrack: MediaStreamTrack | null;
screenTrack: MediaStreamTrack | null;
screenAudioTrack: MediaStreamTrack | null;
lkVideoTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream)
lkScreenTrack: Track | null; // LiveKit Track for attach/detach (adaptive stream)
}
export interface UserTile {
kind: 'user';
key: string; // participant.identity
participant: ParticipantInfo;
videoTrack: MediaStreamTrack | null; // camera only
audioTrack: MediaStreamTrack | null; // mic
lkVideoTrack: Track | null; // LiveKit Track for attach/detach
}
export interface StreamTile {
kind: 'stream';
key: string; // `${identity}:stream`
participant: ParticipantInfo;
screenTrack: MediaStreamTrack | null;
screenAudioTrack: MediaStreamTrack | null;
lkScreenTrack: Track | null; // LiveKit Track for attach/detach
}
export type GridTile = UserTile | StreamTile;
export function deriveGridTiles(participants: ParticipantInfo[]): GridTile[] {
const tiles: GridTile[] = [];
for (const p of participants) {
const hasLiveVideo = p.isCameraOn && p.videoTrack?.readyState === 'live';
tiles.push({
kind: 'user',
key: p.identity,
participant: p,
videoTrack: hasLiveVideo ? p.videoTrack : null,
audioTrack: p.audioTrack,
lkVideoTrack: hasLiveVideo ? p.lkVideoTrack : null,
});
if (p.isScreenSharing) {
tiles.push({
kind: 'stream',
key: `${p.identity}:stream`,
participant: p,
screenTrack: p.screenTrack,
screenAudioTrack: p.screenAudioTrack,
lkScreenTrack: p.lkScreenTrack,
});
}
}
return tiles;
}
export function setStreamSubscription(room: Room | null, targetIdentity: string, subscribed: boolean) {
if (!room) return;
const rp = room.remoteParticipants.get(targetIdentity);
if (!rp) return;
rp.trackPublications.forEach((pub) => {
if (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) {
(pub as RemoteTrackPublication).setSubscribed(subscribed);
}
});
}
export function setCameraSubscription(room: Room | null, targetIdentity: string, subscribed: boolean) {
if (!room) return;
const rp = room.remoteParticipants.get(targetIdentity);
if (!rp) return;
rp.trackPublications.forEach((pub) => {
if (pub.source === Track.Source.Camera) {
(pub as RemoteTrackPublication).setSubscribed(subscribed);
}
});
}
export function parseIdentity(identity: string): { userId: string; username: string } {
const parts = identity.split(':');
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
}
let _connectGeneration = 0;
/** Null-safe Room.disconnect() wrapper — lets the SDK tear down its own internals cleanly. */
function destroyRoom(room: Room | null): Promise<void> | void {
if (!room) return;
return room.disconnect();
}
export function useLiveKit() {
const [room, setRoom] = useState<Room | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionState, setConnectionState] = useState<ConnectionState>(ConnectionState.Disconnected);
const [connectedChannelId, setConnectedChannelId] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState<string | null>(null);
const roomRef = useRef<Room | null>(null);
const connectedChannelRef = useRef<string | null>(null);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const screenShareConfig = useVoiceStore((s) => s.screenShareConfig);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const spaceMutedUserIds = useVoiceStore((s) => s.spaceMutedUserIds);
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const permissionMutedUserIds = useVoiceStore((s) => s.permissionMutedUserIds);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
const lastMicGenRef = useRef(0);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r) return;
const allParticipants: ParticipantInfo[] = [];
const processParticipant = (p: Participant, isLocal: boolean) => {
if (!p.identity) return;
const { userId, username } = parseIdentity(p.identity);
const memberMatch = useSpaceStore.getState().members.find(m => m.userId === userId);
const homeUserId = memberMatch?.user.homeUserId ?? null;
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null;
let screenAudioTrack: MediaStreamTrack | null = null;
let lkVideoTrack: Track | null = null;
let lkScreenTrack: Track | null = null;
let hasScreenSharePublication = false;
let hasCameraPublication = false;
p.trackPublications.forEach((pub) => {
// Detect screen share publication even if unsubscribed
if (pub.source === Track.Source.ScreenShare) hasScreenSharePublication = true;
// Detect camera publication even if unsubscribed (for unwatched cameras)
if (pub.source === Track.Source.Camera) hasCameraPublication = true;
const track = pub.track;
if (!track) return;
// Strict check: Track must be subscribed AND not muted to be considered "active"
if (pub.isMuted) return;
if (!isLocal && !pub.isSubscribed) return;
const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live') return;
if (pub.source === Track.Source.Microphone) audioTrack = mt;
else if (pub.source === Track.Source.Camera && p.isCameraEnabled) { videoTrack = mt; lkVideoTrack = track; }
else if (pub.source === Track.Source.ScreenShare) { screenTrack = mt; lkScreenTrack = track; }
else if (pub.source === Track.Source.ScreenShareAudio) screenAudioTrack = mt;
});
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
let isPartDeafened = false;
let isPartMuted = !p.isMicrophoneEnabled;
if (isLocal) {
// Compute effective state: user intent || server enforcement
const vs = useVoiceStore.getState();
const cvId = vs.currentVoiceChannelId;
const localOrigin = cvId ? getChannelOrigin(cvId) : '';
const localMyId = cvId ? getMyUserIdForOrigin(localOrigin) : undefined;
const localSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
const localKey = (localSpaceId && localMyId) ? `${localSpaceId}:${localMyId}` : '';
isPartMuted = vs.isMuted || vs.spaceMutedUserIds.has(localKey) || vs.permissionMutedUserIds.has(localKey);
isPartDeafened = vs.isDeafened || vs.spaceDeafenedUserIds.has(localKey);
} else {
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState) isPartMuted = userState.isMuted;
}
allParticipants.push({
identity: p.identity,
userId,
username,
homeUserId,
isMuted: isPartMuted,
isDeafened: isPartDeafened,
isCameraOn: hasCameraPublication && p.isCameraEnabled, // True even when unsubscribed
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
isLocal,
audioTrack,
videoTrack,
screenTrack,
screenAudioTrack,
lkVideoTrack,
lkScreenTrack,
});
};
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
useVoiceStore.getState().setParticipants(allParticipants);
SpeakingDetector.getInstance().syncTracks(allParticipants);
}, []);
const handleDataReceived = useCallback((payload: Uint8Array, participant?: RemoteParticipant) => {
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text);
if (msg.type === 'deafen' && participant) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
updateParticipants();
}
} catch { }
}, [updateParticipants]);
// Handle Input Device & Mute Logic via AudioManager
// Mute uses setMicrophoneEnabled(false) to keep the track published (silence frames)
// instead of unpublishTrack() which tears down the WebRTC transport.
// This preserves the Web Audio pipeline for future AudioWorklet nodes (e.g. RNNoise).
useEffect(() => {
const r = roomRef.current;
if (!r || !isConnected) return;
// Compute effective mute/deafen: user intent || server enforcement
const vs = useVoiceStore.getState();
const cvId = vs.currentVoiceChannelId;
const effOrigin = cvId ? getChannelOrigin(cvId) : '';
const effMyId = cvId ? getMyUserIdForOrigin(effOrigin) : undefined;
const effSpaceId = cvId ? useSpaceStore.getState().channelToSpaceMap.get(cvId) : null;
const effKey = (effSpaceId && effMyId) ? `${effSpaceId}:${effMyId}` : '';
const effectiveMuted = isMuted || spaceMutedUserIds.has(effKey) || permissionMutedUserIds.has(effKey);
const effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(effKey);
const syncMic = async () => {
try {
const audioManager = AudioManager.getInstance();
// Sync voice processing settings to AudioManager
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
await audioManager.setRnnoiseEnabled(rnnoiseEnabled);
const micPub = r.localParticipant.getTrackPublications()
.find(p => p.source === Track.Source.Microphone);
// If effectively muted or deafened, mute the track in-place (keep it published)
if (effectiveMuted || effectiveDeafened) {
if (micPub?.track && !micPub.isMuted) {
await r.localParticipant.setMicrophoneEnabled(false);
}
return;
}
// Not muted — ensure mic is published and live
await audioManager.setInputDevice(inputDeviceId);
audioManager.setInputVolume(inputVolume);
const currentGen = audioManager.getStreamGeneration();
if (micPub?.track) {
// Track already published — check if it's still current
if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
// Current and live — just unmute if needed
if (micPub.isMuted) {
await r.localParticipant.setMicrophoneEnabled(true);
}
return;
}
// Track is stale (device or constraint change) — replace it
await r.localParticipant.unpublishTrack(micPub.track as LocalAudioTrack);
}
// Publish fresh track from AudioManager pipeline
const audioTrack = audioManager.getFreshTrack();
if (!audioTrack) return;
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
await r.localParticipant.publishTrack(audioTrack, {
name: 'microphone',
source: Track.Source.Microphone,
});
lastMicGenRef.current = currentGen;
} catch (err) {
console.error('[LiveKit] Failed to sync mic state:', err);
}
};
syncMic();
// Re-sync when AudioManager resumes
const unsubscribe = AudioManager.getInstance().onResumed(() => {
syncMic();
});
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled]);
const connect = useCallback(async (channelId: string, isDm?: boolean) => {
const storedId = isDm ? `dm-${channelId}` : channelId;
if (connectedChannelRef.current === storedId && roomRef.current?.state === ConnectionState.Connected) return;
// Register voice state with the WS server after LiveKit connects (not for DM calls)
const registerWithServer = () => {
if (isDm) return;
const origin = getChannelOrigin(channelId);
wsSend({ type: 'voice_join', channelId }, origin);
broadcastVoiceStatus(origin);
};
const gen = ++_connectGeneration;
// Ensure AudioContext is created and resumed before tracks arrive
await AudioManager.getInstance().resumeContext();
// 1. Reset state immediately to reflect "Loading/Switching" in UI
SpeakingDetector.getInstance().clear();
setRoom(null);
useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setSpeakingParticipants(new Set());
setIsConnected(false);
setIsConnecting(true);
setConnectionState(ConnectionState.Connecting);
setConnectionError(null);
setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning
useVoiceStore.getState().setConnectionError(null);
useVoiceStore.getState().setIsLiveKitConnected(false);
useVoiceStore.getState().setConnectionQuality('unknown');
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
// This handles cases where AppLayout might have remounted, losing roomRef but leaving _activeRoom alive.
const roomToDisconnect = roomRef.current || _activeRoom;
if (roomToDisconnect) {
try {
console.log('[LiveKit] Destroying previous room:', roomToDisconnect.name);
await destroyRoom(roomToDisconnect);
} catch (err) {
console.warn('Error disconnecting from previous room:', err);
}
roomRef.current = null;
_activeRoom = null;
}
try {
const client = getApiForOrigin(getChannelOrigin(channelId));
const { token, url } = isDm ? await client.livekit.dmToken(channelId) : await client.livekit.token(channelId);
if (gen !== _connectGeneration) return;
const newRoom = new Room({ adaptiveStream: true, dynacast: true, publishDefaults: { videoCodec: 'h264', simulcast: true } });
roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
guardedUpdate();
// Notify new participant of our effective deafen state
const vsConn = useVoiceStore.getState();
const cvIdConn = vsConn.currentVoiceChannelId;
const connOrigin = cvIdConn ? getChannelOrigin(cvIdConn) : '';
const connMyId = cvIdConn ? getMyUserIdForOrigin(connOrigin) : undefined;
const connSpaceId = cvIdConn ? useSpaceStore.getState().channelToSpaceMap.get(cvIdConn) : null;
const connKey = (connSpaceId && connMyId) ? `${connSpaceId}:${connMyId}` : '';
const effDeaf = vsConn.isDeafened || vsConn.spaceDeafenedUserIds.has(connKey);
if (effDeaf) {
const encoder = new TextEncoder();
newRoom.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
{ reliable: true }
).catch(() => { });
}
});
newRoom.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
guardedUpdate();
// Clean up stale WS-based voice status for the departed participant
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().clearVoiceUserStatus(userId);
});
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
// GlobalAudioRenderer is the sole audio playback path with volume/attenuation/boost.
// Detach LiveKit's internal element to prevent double-playback.
if (track.kind === Track.Kind.Audio) {
(track as RemoteAudioTrack).detach();
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
if (track.kind === Track.Kind.Audio) {
(track as RemoteAudioTrack).detach();
}
guardedUpdate();
});
newRoom.on(RoomEvent.LocalTrackPublished, (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(newRoom.localParticipant.identity);
useVoiceStore.getState().unwatchStream(userId);
// OS-level "Stop sharing" fires this without going through stopScreenShare
handleScreenShareUnpublished();
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (
publication.source !== Track.Source.ScreenShare &&
publication.source !== Track.Source.ScreenShareAudio
) {
publication.setSubscribed(true);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionQualityChanged, (quality: ConnectionQuality, participant: Participant) => {
if (participant.identity === newRoom.localParticipant.identity) {
useVoiceStore.getState().setConnectionQuality(quality as any);
}
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected);
setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) {
// On LiveKit reconnect, re-register with WS server (server may have restarted)
if (connectedChannelRef.current) {
registerWithServer();
}
updateParticipants();
}
}
});
newRoom.on(RoomEvent.Disconnected, (reason?: DisconnectReason) => {
if (roomRef.current !== newRoom) return;
SpeakingDetector.getInstance().clear();
setConnectionState(ConnectionState.Disconnected);
setConnectedChannelId(null);
roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null);
useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setSpeakingParticipants(new Set());
useVoiceStore.getState().setIsLiveKitConnected(false);
// Non-client disconnect (identity collision, server shutdown, kicked, etc.)
// → clear voice intent so AppLayout doesn't auto-retry into an infinite loop.
// Client-initiated disconnects already clear this via leaveVoice() / VoiceControlBar.
if (reason !== undefined && reason !== DisconnectReason.CLIENT_INITIATED) {
useVoiceStore.getState().handleForceDisconnect();
}
});
await newRoom.connect(url, token, { autoSubscribe: false });
if (gen !== _connectGeneration) { destroyRoom(newRoom); return; }
_activeRoom = newRoom;
connectedChannelRef.current = storedId;
setConnectedChannelId(storedId);
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
// Tell WS server we're in the voice channel now that LiveKit is connected
registerWithServer();
updateParticipants();
// Subscribe to non-screen-share tracks from existing participants (safety net)
newRoom.remoteParticipants.forEach((rp) => {
rp.trackPublications.forEach((pub) => {
if (
pub.source !== Track.Source.ScreenShare &&
pub.source !== Track.Source.ScreenShareAudio
) {
(pub as RemoteTrackPublication).setSubscribed(true);
}
});
});
// Initial mute state check
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
} catch (err) { if (gen === _connectGeneration) { setConnectionError('Failed to connect'); useVoiceStore.getState().setConnectionError('Failed to connect'); useVoiceStore.getState().leaveVoice(); } }
finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants, handleDataReceived]);
const disconnect = useCallback(async () => {
_connectGeneration++;
SpeakingDetector.getInstance().clear();
connectedChannelRef.current = null;
setConnectedChannelId(null);
if (roomRef.current) {
await destroyRoom(roomRef.current);
roomRef.current = null;
_activeRoom = null;
setRoom(null);
setIsConnected(false);
setIsConnecting(false);
setConnectionState(ConnectionState.Disconnected);
useVoiceStore.getState().setParticipants([]);
useVoiceStore.getState().setSpeakingParticipants(new Set());
useVoiceStore.getState().setIsLiveKitConnected(false);
}
}, []);
const toggleMic = useCallback(async () => {
await AudioManager.getInstance().resumeContext();
useVoiceStore.getState().toggleMic();
}, []);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
if (!isCameraOn) {
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: CAMERA_PRESET.resolution, frameRate: CAMERA_PRESET.encoding.maxFramerate }, { videoCodec: CAMERA_PRESET.codec, videoEncoding: CAMERA_PRESET.encoding, simulcast: true });
setTimeout(() => { if (roomRef.current) applyOverdrive(roomRef.current, Track.Source.Camera, CAMERA_OVERDRIVE); }, 2000);
} else { await roomRef.current.localParticipant.setCameraEnabled(false); }
updateParticipants();
}
}, [isCameraOn, updateParticipants]);
const toggleScreenShare = useCallback(async () => {
if (!roomRef.current) return;
if (!useVoiceStore.getState().isScreenSharing) {
await startScreenShare(roomRef.current);
} else {
await stopScreenShare(roomRef.current);
}
updateParticipants();
}, [updateParticipants]);
useEffect(() => {
updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, spaceMutedUserIds, spaceDeafenedUserIds, permissionMutedUserIds, updateParticipants]);
useEffect(() => {
if (!room) return;
const updateActiveTracks = async () => {
if (isScreenSharing) {
const opts = buildScreenShareOptions(screenShareConfig);
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.videoTrack) {
const mediaTrack = getMediaStreamTrack(screenPub.videoTrack);
if (mediaTrack) {
await mediaTrack.applyConstraints({ width: { ideal: opts.capture.width }, height: { ideal: opts.capture.height }, frameRate: { ideal: opts.capture.frameRate } });
mediaTrack.contentHint = opts.contentHint;
}
await applyOverdrive(room, Track.Source.ScreenShare, opts.overdrive);
}
}
if (isCameraOn) { await applyOverdrive(room, Track.Source.Camera, CAMERA_OVERDRIVE); }
};
updateActiveTracks().catch(() => {});
}, [room, screenShareConfig, isScreenSharing, isCameraOn]);
useEffect(() => {
return () => { _connectGeneration++; SpeakingDetector.getInstance().clear(); if (roomRef.current) { destroyRoom(roomRef.current); roomRef.current = null; _activeRoom = null; } };
}, []);
return { room, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, disconnect, toggleMic, toggleCamera, toggleScreenShare };
}