fix: resolve LiveKit voice/video stability issues
- Guard Disconnected/ConnectionStateChanged event handlers against stale rooms: old room events no longer nuke the new room's state (root cause of buttons failing, mute getting stuck, DUPLICATE_IDENTITY cascades) - Reset media state (isMuted/isCameraOn/isScreenSharing) on connect to prevent desync after reconnects - Add voiceStates to WS ready payload so users see who's in voice on page load - Wire VoiceControls buttons to check getActiveRoom() before SDK calls - Guard ChannelSidebar against re-joining the same voice channel - Switch LIVEKIT_URL to wss://nova.ddns.net/livekit for HTTPS secure context (required for getUserMedia in Safari)
This commit is contained in:
@@ -2,6 +2,12 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Room, RoomEvent, Track, ConnectionState, } from 'livekit-client';
|
||||
import { api } from '../api/client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
// Module-level reference so other components (e.g. VoiceControls)
|
||||
// can call LiveKit SDK methods directly without prop drilling.
|
||||
let _activeRoom = null;
|
||||
export function getActiveRoom() {
|
||||
return _activeRoom;
|
||||
}
|
||||
function parseIdentity(identity) {
|
||||
const parts = identity.split(':');
|
||||
return {
|
||||
@@ -9,12 +15,16 @@ function parseIdentity(identity) {
|
||||
username: parts[1] ?? identity,
|
||||
};
|
||||
}
|
||||
// Connection lock to prevent concurrent connect() calls from racing
|
||||
let _connectGeneration = 0;
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState(null);
|
||||
const [participants, setParticipants] = useState([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState(null);
|
||||
const roomRef = useRef(null);
|
||||
const connectedChannelRef = useRef(null);
|
||||
const isMuted = useVoiceStore((s) => s.isMuted);
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
@@ -61,51 +71,136 @@ export function useLiveKit() {
|
||||
setParticipants(allParticipants);
|
||||
}, []);
|
||||
const connect = useCallback(async (channelId) => {
|
||||
// Don't reconnect if already connected to this channel
|
||||
if (connectedChannelRef.current === channelId && roomRef.current) {
|
||||
console.log('[LiveKit] Already connected to channel:', channelId);
|
||||
return;
|
||||
}
|
||||
// Bump generation — any in-flight connect with an older generation
|
||||
// will bail out after its async gaps.
|
||||
const gen = ++_connectGeneration;
|
||||
console.log('[LiveKit] connect() gen=%d channel=%s', gen, channelId);
|
||||
// Tear down any existing room synchronously
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
try {
|
||||
roomRef.current.disconnect();
|
||||
}
|
||||
catch { }
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
connectedChannelRef.current = null;
|
||||
}
|
||||
setIsConnecting(true);
|
||||
setConnectionError(null);
|
||||
useVoiceStore.getState().setConnectionError(null);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
try {
|
||||
console.log('[LiveKit] Fetching token for channel:', channelId);
|
||||
const { token, url } = await api.livekit.token(channelId);
|
||||
// Abort if a newer connect() was called while we were fetching the token
|
||||
if (gen !== _connectGeneration) {
|
||||
console.log('[LiveKit] gen=%d aborted (superseded by gen=%d)', gen, _connectGeneration);
|
||||
return;
|
||||
}
|
||||
console.log('[LiveKit] Got token, connecting to:', url);
|
||||
const newRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
newRoom.on(RoomEvent.ParticipantConnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackMuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, updateParticipants);
|
||||
// Guard all event handlers: only update state if this room is still current.
|
||||
// Without this, stale events from old rooms corrupt the new room's state.
|
||||
const guardedUpdate = () => {
|
||||
if (roomRef.current === newRoom) updateParticipants();
|
||||
};
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
setIsConnected(state === ConnectionState.Connected);
|
||||
console.log('[LiveKit] ConnectionStateChanged:', state, 'isCurrentRoom:', roomRef.current === newRoom);
|
||||
// Only update state if this room is still the active one
|
||||
if (roomRef.current === newRoom) {
|
||||
setIsConnected(state === ConnectionState.Connected);
|
||||
}
|
||||
});
|
||||
newRoom.on(RoomEvent.Disconnected, () => {
|
||||
console.log('[LiveKit] Disconnected event fired, isCurrentRoom:', roomRef.current === newRoom);
|
||||
// CRITICAL: Only clear state if this room is still the active one.
|
||||
// If a newer connect() has already replaced us, don't nuke its state.
|
||||
if (roomRef.current !== newRoom) {
|
||||
console.log('[LiveKit] Ignoring stale Disconnected event from old room');
|
||||
return;
|
||||
}
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
connectedChannelRef.current = null;
|
||||
setIsConnected(false);
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
useVoiceStore.getState().setConnectionError('Disconnected from voice');
|
||||
});
|
||||
await newRoom.connect(url, token);
|
||||
await newRoom.localParticipant.enableCameraAndMicrophone();
|
||||
// Abort if a newer connect() was called while we were connecting
|
||||
if (gen !== _connectGeneration) {
|
||||
console.log('[LiveKit] gen=%d aborted after connect (superseded)', gen);
|
||||
newRoom.disconnect();
|
||||
return;
|
||||
}
|
||||
console.log('[LiveKit] Connected successfully! gen=%d', gen);
|
||||
roomRef.current = newRoom;
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = channelId;
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
useVoiceStore.getState().setConnectionError(null);
|
||||
updateParticipants();
|
||||
// Enable mic only (not camera) by default.
|
||||
// Reset media state in store to match SDK state — prevents desync after reconnects.
|
||||
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
|
||||
try {
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true);
|
||||
console.log('[LiveKit] Microphone enabled');
|
||||
updateParticipants();
|
||||
}
|
||||
catch (mediaErr) {
|
||||
console.warn('[LiveKit] Could not enable microphone:', mediaErr);
|
||||
// Mic failed to enable — mark as muted in store
|
||||
useVoiceStore.setState({ isMuted: true });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Failed to connect to LiveKit:', err);
|
||||
// Only set error if this is still the active generation
|
||||
if (gen === _connectGeneration) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect to voice';
|
||||
console.error('[LiveKit] Connection failed:', err);
|
||||
connectedChannelRef.current = null;
|
||||
setConnectionError(message);
|
||||
useVoiceStore.getState().setConnectionError(message);
|
||||
}
|
||||
}
|
||||
finally {
|
||||
setIsConnecting(false);
|
||||
if (gen === _connectGeneration) {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [updateParticipants]);
|
||||
const disconnect = useCallback(async () => {
|
||||
// Bump generation so any in-flight connect aborts
|
||||
_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);
|
||||
}
|
||||
}, []);
|
||||
const toggleMic = useCallback(async () => {
|
||||
@@ -128,8 +223,12 @@ export function useLiveKit() {
|
||||
}, [isScreenSharing, updateParticipants]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
_connectGeneration++;
|
||||
if (roomRef.current) {
|
||||
roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
connectedChannelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -138,6 +237,7 @@ export function useLiveKit() {
|
||||
participants,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionError,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
|
||||
@@ -13,6 +13,14 @@ import {
|
||||
import { api } from '../api/client';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
|
||||
// Module-level reference so other components (e.g. VoiceControls)
|
||||
// can call LiveKit SDK methods directly without prop drilling.
|
||||
let _activeRoom: Room | null = null;
|
||||
|
||||
export function getActiveRoom(): Room | null {
|
||||
return _activeRoom;
|
||||
}
|
||||
|
||||
export interface ParticipantInfo {
|
||||
identity: string;
|
||||
userId: string;
|
||||
@@ -35,12 +43,17 @@ function parseIdentity(identity: string): { userId: string; username: string } {
|
||||
};
|
||||
}
|
||||
|
||||
// Connection lock to prevent concurrent connect() calls from racing
|
||||
let _connectGeneration = 0;
|
||||
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState<Room | null>(null);
|
||||
const [participants, setParticipants] = useState<ParticipantInfo[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
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 isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
@@ -91,55 +104,142 @@ export function useLiveKit() {
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(async (channelId: string) => {
|
||||
// Don't reconnect if already connected to this channel
|
||||
if (connectedChannelRef.current === channelId && roomRef.current) {
|
||||
console.log('[LiveKit] Already connected to channel:', channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bump generation — any in-flight connect with an older generation
|
||||
// will bail out after its async gaps.
|
||||
const gen = ++_connectGeneration;
|
||||
console.log('[LiveKit] connect() gen=%d channel=%s', gen, channelId);
|
||||
|
||||
// Tear down any existing room synchronously
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
try { roomRef.current.disconnect(); } catch {}
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
connectedChannelRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
setConnectionError(null);
|
||||
useVoiceStore.getState().setConnectionError(null);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
|
||||
try {
|
||||
console.log('[LiveKit] Fetching token for channel:', channelId);
|
||||
const { token, url } = await api.livekit.token(channelId);
|
||||
|
||||
// Abort if a newer connect() was called while we were fetching the token
|
||||
if (gen !== _connectGeneration) {
|
||||
console.log('[LiveKit] gen=%d aborted (superseded by gen=%d)', gen, _connectGeneration);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[LiveKit] Got token, connecting to:', url);
|
||||
const newRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
|
||||
newRoom.on(RoomEvent.ParticipantConnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackMuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, updateParticipants);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, updateParticipants);
|
||||
// Guard all event handlers: only update state if this room is still current.
|
||||
// Without this, stale events from old rooms corrupt the new room's state.
|
||||
const guardedUpdate = () => {
|
||||
if (roomRef.current === newRoom) updateParticipants();
|
||||
};
|
||||
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
|
||||
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
|
||||
setIsConnected(state === ConnectionState.Connected);
|
||||
console.log('[LiveKit] ConnectionStateChanged:', state, 'isCurrentRoom:', roomRef.current === newRoom);
|
||||
// Only update state if this room is still the active one
|
||||
if (roomRef.current === newRoom) {
|
||||
setIsConnected(state === ConnectionState.Connected);
|
||||
}
|
||||
});
|
||||
newRoom.on(RoomEvent.Disconnected, () => {
|
||||
console.log('[LiveKit] Disconnected event fired, isCurrentRoom:', roomRef.current === newRoom);
|
||||
// CRITICAL: Only clear state if this room is still the active one.
|
||||
// If a newer connect() has already replaced us, don't nuke its state.
|
||||
if (roomRef.current !== newRoom) {
|
||||
console.log('[LiveKit] Ignoring stale Disconnected event from old room');
|
||||
return;
|
||||
}
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
connectedChannelRef.current = null;
|
||||
setIsConnected(false);
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
useVoiceStore.getState().setConnectionError('Disconnected from voice');
|
||||
});
|
||||
|
||||
await newRoom.connect(url, token);
|
||||
await newRoom.localParticipant.enableCameraAndMicrophone();
|
||||
|
||||
// Abort if a newer connect() was called while we were connecting
|
||||
if (gen !== _connectGeneration) {
|
||||
console.log('[LiveKit] gen=%d aborted after connect (superseded)', gen);
|
||||
newRoom.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[LiveKit] Connected successfully! gen=%d', gen);
|
||||
roomRef.current = newRoom;
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = channelId;
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
useVoiceStore.getState().setConnectionError(null);
|
||||
updateParticipants();
|
||||
|
||||
// Enable mic only (not camera) by default.
|
||||
// Reset media state in store to match SDK state — prevents desync after reconnects.
|
||||
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
|
||||
try {
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true);
|
||||
console.log('[LiveKit] Microphone enabled');
|
||||
updateParticipants();
|
||||
} catch (mediaErr) {
|
||||
console.warn('[LiveKit] Could not enable microphone:', mediaErr);
|
||||
// Mic failed to enable — mark as muted in store
|
||||
useVoiceStore.setState({ isMuted: true });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to LiveKit:', err);
|
||||
// Only set error if this is still the active generation
|
||||
if (gen === _connectGeneration) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect to voice';
|
||||
console.error('[LiveKit] Connection failed:', err);
|
||||
connectedChannelRef.current = null;
|
||||
setConnectionError(message);
|
||||
useVoiceStore.getState().setConnectionError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
if (gen === _connectGeneration) {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}
|
||||
}, [updateParticipants]);
|
||||
|
||||
const disconnect = useCallback(async () => {
|
||||
// Bump generation so any in-flight connect aborts
|
||||
_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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -166,8 +266,12 @@ export function useLiveKit() {
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
_connectGeneration++;
|
||||
if (roomRef.current) {
|
||||
roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
connectedChannelRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
@@ -177,6 +281,7 @@ export function useLiveKit() {
|
||||
participants,
|
||||
isConnected,
|
||||
isConnecting,
|
||||
connectionError,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useAuthStore } from '../stores/authStore';
|
||||
import { useServerStore } from '../stores/serverStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { useSocialStore } from '../stores/socialStore';
|
||||
let globalWs = null;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer;
|
||||
@@ -20,6 +21,14 @@ function handleEvent(event) {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Populate voice channel state so users see who's in voice on load
|
||||
if (event.voiceStates) {
|
||||
const vs = event.voiceStates;
|
||||
const { setVoiceUsers } = useVoiceStore.getState();
|
||||
for (const [channelId, userIds] of Object.entries(vs)) {
|
||||
setVoiceUsers(channelId, userIds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message_created':
|
||||
addMessage(event.message.channelId, event.message);
|
||||
@@ -59,6 +68,16 @@ function handleEvent(event) {
|
||||
case 'reaction_removed':
|
||||
onReactionRemoved(event.messageId, event.userId, event.emoji);
|
||||
break;
|
||||
case 'friend_request_received': {
|
||||
const { addIncomingRequest } = useSocialStore.getState();
|
||||
addIncomingRequest(event.request);
|
||||
break;
|
||||
}
|
||||
case 'friend_request_accepted': {
|
||||
const { addFriendFromAccepted } = useSocialStore.getState();
|
||||
addFriendFromAccepted(event.friend, event.requestId);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
|
||||
@@ -25,6 +25,14 @@ function handleEvent(event: ServerEvent): void {
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Populate voice channel state so users see who's in voice on load
|
||||
if ((event as any).voiceStates) {
|
||||
const vs = (event as any).voiceStates as Record<string, string[]>;
|
||||
const { setVoiceUsers } = useVoiceStore.getState();
|
||||
for (const [channelId, userIds] of Object.entries(vs)) {
|
||||
setVoiceUsers(channelId, userIds);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_created':
|
||||
|
||||
Reference in New Issue
Block a user