From 3a441d5abd732d386395b54ecf97d08a5d011b8d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:57:16 +0100 Subject: [PATCH] Fix room switching and audio consistency issues --- .../web/src/components/layout/AppLayout.tsx | 78 +++++++++--- .../web/src/components/voice/VoiceUser.tsx | 8 ++ packages/web/src/hooks/useLiveKit.ts | 112 +++++++++++++++--- packages/web/src/stores/voiceStore.ts | 5 +- 4 files changed, 172 insertions(+), 31 deletions(-) diff --git a/packages/web/src/components/layout/AppLayout.tsx b/packages/web/src/components/layout/AppLayout.tsx index b38c2267..f054783c 100644 --- a/packages/web/src/components/layout/AppLayout.tsx +++ b/packages/web/src/components/layout/AppLayout.tsx @@ -75,6 +75,9 @@ export function AppLayout() { connectDm: connectDmVoice, disconnect: disconnectVoice, participants: voiceParticipants, + isConnected: isVoiceConnected, + isConnecting: isVoiceConnecting, + connectedChannelId, } = useLiveKit(); // Initialize WebSocket @@ -85,24 +88,67 @@ export function AppLayout() { setParticipants(voiceParticipants); }, [voiceParticipants, setParticipants]); - // Manage voice connection (server voice channels) - useEffect(() => { - if (!isLoading && user && isWsConnected && currentVoiceChannelId) { - console.log('[AppLayout] Auto-rejoining voice channel:', currentVoiceChannelId); - connectVoice(currentVoiceChannelId); - } else if (!isLoading && user && !currentVoiceChannelId && !activeDmCall) { - disconnectVoice(); - } - }, [currentVoiceChannelId, connectVoice, disconnectVoice, activeDmCall, isLoading, user, isWsConnected]); + // Track the last channel we attempted to connect to, to prevent effect loops + const lastAttemptedRef = React.useRef(null); - // Manage DM call connection + // Manage voice connection useEffect(() => { - if (activeDmCall) { - connectDmVoice(activeDmCall.dmChannelId); - } else if (!currentVoiceChannelId) { - disconnectVoice(); - } - }, [activeDmCall, connectDmVoice, disconnectVoice, currentVoiceChannelId]); + if (isLoading || !user || !isWsConnected) return; + + const manageConnection = async () => { + // Determine what we SHOULD be connected to + const targetChannelId = activeDmCall + ? `dm-${activeDmCall.dmChannelId}` + : currentVoiceChannelId; + + // 1. If we have a target + if (targetChannelId) { + // If we're not connected to the RIGHT place, trigger connect. + // We IGNORE isVoiceConnecting here to allow "interrupting" a connection + // or switching rooms immediately. + if (connectedChannelId !== targetChannelId) { + // Prevent spamming the same connection attempt if React re-renders + if (lastAttemptedRef.current === targetChannelId && isVoiceConnecting) { + return; + } + + console.log(`[AppLayout] Switching/Connecting to: ${targetChannelId}`); + lastAttemptedRef.current = targetChannelId; + + if (activeDmCall) { + await connectDmVoice(activeDmCall.dmChannelId); + } else { + await connectVoice(targetChannelId); + } + } else { + // We are connected to the right place. Reset ref. + lastAttemptedRef.current = null; + } + return; + } + + // 2. No target — ensure disconnected + if (connectedChannelId !== null || isVoiceConnected || isVoiceConnecting) { + console.log('[AppLayout] Leaving voice (no target)'); + lastAttemptedRef.current = null; + await disconnectVoice(); + } + }; + + manageConnection(); + }, [ + currentVoiceChannelId, + activeDmCall, + connectedChannelId, + isVoiceConnected, + isVoiceConnecting, + isWsConnected, + isLoading, + user, + connectVoice, + connectDmVoice, + disconnectVoice + ]); // Responsive detection useEffect(() => { diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 4c45248f..bb0bb861 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -117,6 +117,14 @@ export function VoiceUser({ participant, large }: VoiceUserProps) { audioEl.play().catch(() => {}); } } + + return () => { + if (boostSourceRef.current) { + boostSourceRef.current.disconnect(); + boostSourceRef.current = null; + boostGainRef.current = null; + } + }; }, [outputVolume, perUserVolume, isDeafened, isLocal, participant.audioTrack]); diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 9ecb34d3..ea6e301c 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -89,6 +89,8 @@ export function useLiveKit() { const [participants, setParticipants] = useState([]); const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); + const [connectionState, setConnectionState] = useState(ConnectionState.Disconnected); + const [connectedChannelId, setConnectedChannelId] = useState(null); const [connectionError, setConnectionError] = useState(null); const roomRef = useRef(null); const connectedChannelRef = useRef(null); @@ -212,14 +214,38 @@ export function useLiveKit() { return () => { unsubscribe(); }; - }, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, room]); + }, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected]); const connect = useCallback(async (channelId: string) => { - if (connectedChannelRef.current === channelId && roomRef.current) return; + if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected) return; const gen = ++_connectGeneration; - if (roomRef.current) { try { roomRef.current.disconnect(); } catch { } roomRef.current = null; } + + // 1. Reset state immediately to reflect "Loading/Switching" in UI + setRoom(null); + setParticipants([]); + setIsConnected(false); setIsConnecting(true); + setConnectionState(ConnectionState.Connecting); + setConnectionError(null); + setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning + useVoiceStore.getState().setIsLiveKitConnected(false); + + // 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] Disconnecting previous room:', roomToDisconnect.name); + await roomToDisconnect.disconnect(); + } catch (err) { + console.warn('Error disconnecting from previous room:', err); + } + roomRef.current = null; + _activeRoom = null; + } + try { const { token, url } = await api.livekit.token(channelId); if (gen !== _connectGeneration) return; @@ -227,6 +253,7 @@ export function useLiveKit() { roomRef.current = newRoom; const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; + // ... existing event listeners ... newRoom.on(RoomEvent.ParticipantConnected, (participant) => { guardedUpdate(); if (useVoiceStore.getState().isDeafened) { @@ -249,20 +276,35 @@ export function useLiveKit() { newRoom.on(RoomEvent.DataReceived, handleDataReceived); 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) { + updateParticipants(); + } } }); newRoom.on(RoomEvent.Disconnected, () => { if (roomRef.current !== newRoom) return; + setConnectionState(ConnectionState.Disconnected); + setConnectedChannelId(null); roomRef.current = null; _activeRoom = null; setIsConnected(false); setRoom(null); setParticipants([]); useVoiceStore.getState().setIsLiveKitConnected(false); }); await newRoom.connect(url, token); if (gen !== _connectGeneration) { newRoom.disconnect(); return; } - _activeRoom = newRoom; connectedChannelRef.current = channelId; setRoom(newRoom); setIsConnected(true); + _activeRoom = newRoom; + connectedChannelRef.current = channelId; + setConnectedChannelId(channelId); + setRoom(newRoom); + setIsConnected(true); useVoiceStore.getState().setIsLiveKitConnected(true); updateParticipants(); @@ -271,8 +313,6 @@ export function useLiveKit() { const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState(); useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false }); - // Mic handling is now done by useEffect - if (wasDeafened) { newRoom.remoteParticipants.forEach((p) => p.setVolume(0)); } @@ -284,8 +324,30 @@ export function useLiveKit() { const connectDm = useCallback(async (dmChannelId: string) => { const gen = ++_connectGeneration; - if (roomRef.current) { try { roomRef.current.disconnect(); } catch { } roomRef.current = null; } + + // 1. Reset state immediately + setRoom(null); + setParticipants([]); + setIsConnected(false); setIsConnecting(true); + setConnectionState(ConnectionState.Connecting); + setConnectionError(null); + setConnectedChannelId(null); + + // 2. Strictly disconnect previous room (Local Ref OR Global Ref) + const roomToDisconnect = roomRef.current || _activeRoom; + + if (roomToDisconnect) { + try { + console.log('[LiveKit] Disconnecting previous room (DM):', roomToDisconnect.name); + await roomToDisconnect.disconnect(); + } catch (err) { + console.warn('Error disconnecting from previous room:', err); + } + roomRef.current = null; + _activeRoom = null; + } + try { const { token, url } = await api.livekit.dmToken(dmChannelId); if (gen !== _connectGeneration) return; @@ -303,21 +365,33 @@ export function useLiveKit() { newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); 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) { + updateParticipants(); + } } }); await newRoom.connect(url, token); if (gen !== _connectGeneration) { newRoom.disconnect(); return; } - _activeRoom = newRoom; connectedChannelRef.current = `dm-${dmChannelId}`; setRoom(newRoom); setIsConnected(true); + const fullId = `dm-${dmChannelId}`; + _activeRoom = newRoom; + connectedChannelRef.current = fullId; + setConnectedChannelId(fullId); + setRoom(newRoom); + setIsConnected(true); useVoiceStore.getState().setIsLiveKitConnected(true); updateParticipants(); const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState(); useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false }); - // Mic handling is now done by useEffect - if (wasDeafened) { newRoom.remoteParticipants.forEach((p) => p.setVolume(0)); } @@ -328,12 +402,22 @@ export function useLiveKit() { const disconnect = useCallback(async () => { _connectGeneration++; - if (roomRef.current) { await roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; connectedChannelRef.current = null; setRoom(null); setIsConnected(false); setParticipants([]); useVoiceStore.getState().setIsLiveKitConnected(false); } + connectedChannelRef.current = null; + setConnectedChannelId(null); + if (roomRef.current) { + await roomRef.current.disconnect(); + roomRef.current = null; + _activeRoom = null; + setRoom(null); + setIsConnected(false); + setIsConnecting(false); + setConnectionState(ConnectionState.Disconnected); + setParticipants([]); + useVoiceStore.getState().setIsLiveKitConnected(false); + } }, []); const toggleMic = useCallback(async () => { - // This is now purely a UI helper, actual toggling logic is in the store and useEffect - // But we might want to manually trigger resume here just in case await AudioManager.getInstance().resumeContext(); useVoiceStore.getState().toggleMic(); }, []); @@ -432,5 +516,5 @@ export function useLiveKit() { return () => { _connectGeneration++; if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; _activeRoom = null; } }; }, []); - return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare }; + return { room, participants, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare }; } diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 5fcfe330..f4be4126 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -125,7 +125,10 @@ export const useVoiceStore = create()( }); }, - setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }), + setCurrentVoiceChannel: (channelId) => set({ + currentVoiceChannelId: channelId, + activeDmCall: null // Clear active DM call when joining a server channel + }), setParticipants: (participants) => set({ participants }), setConnectionError: (error) => set({ connectionError: error }),