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:
Jannis Braun
2026-02-18 07:52:25 +01:00
parent 5ef502f2e3
commit 4f65ea9c86
27 changed files with 573 additions and 143 deletions
@@ -1,15 +1,10 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
interface VoiceControlsProps {
onDisconnect: () => void;
onToggleMic: () => void;
onToggleCamera: () => void;
onToggleScreenShare: () => void;
}
export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onToggleScreenShare }: VoiceControlsProps) {
export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
@@ -19,6 +14,8 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const connectionError = useVoiceStore((s) => s.connectionError);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const channels = useServerStore((s) => s.channels);
if (!currentVoiceChannelId) return null;
@@ -26,37 +23,74 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMic = () => {
toggleMic();
onToggleMic();
const handleMic = async () => {
const room = getActiveRoom();
if (!room) {
console.warn('[VoiceControls] handleMic: no active room');
return;
}
try {
// isMuted is the pre-toggle value: if true → we want to unmute → enable mic
await room.localParticipant.setMicrophoneEnabled(isMuted);
toggleMic();
} catch (err) {
console.error('[VoiceControls] Failed to toggle mic:', err);
}
};
const handleDeafen = () => {
toggleDeafen();
};
const handleCamera = () => {
toggleCamera();
onToggleCamera();
const handleCamera = async () => {
const room = getActiveRoom();
if (!room) {
console.warn('[VoiceControls] handleCamera: no active room');
return;
}
try {
// isCameraOn is pre-toggle: if false → enable camera
await room.localParticipant.setCameraEnabled(!isCameraOn);
toggleCamera();
} catch (err) {
console.error('[VoiceControls] Failed to toggle camera:', err);
}
};
const handleScreenShare = () => {
toggleScreenShare();
onToggleScreenShare();
const handleScreenShare = async () => {
const room = getActiveRoom();
if (!room) {
console.warn('[VoiceControls] handleScreenShare: no active room');
return;
}
try {
await room.localParticipant.setScreenShareEnabled(!isScreenSharing);
toggleScreenShare();
} catch (err) {
console.error('[VoiceControls] Failed to toggle screen share:', err);
}
};
const handleDisconnect = () => {
// Send WS leave event
wsSend({ type: 'voice_leave' });
// Reset the entire voice store (sets currentVoiceChannelId to null,
// which triggers AppLayout's useEffect to call useLiveKit.disconnect)
useVoiceStore.getState().reset();
};
return (
<div className="bg-discord-bg-secondary border-t border-discord-bg-tertiary p-2">
<div className="flex items-center justify-between px-1 mb-2">
<div>
<div className="text-xs font-medium text-discord-green flex items-center gap-1">
<div className="min-w-0">
<div className={`text-xs font-medium flex items-center gap-1 ${connectionError ? 'text-discord-red' : isLiveKitConnected ? 'text-discord-green' : 'text-yellow-500'}`}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" />
</svg>
Voice Connected
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
</div>
<div className="text-xs text-discord-text-muted truncate">
{channelName}
{connectionError ? connectionError : channelName}
</div>
</div>
</div>
@@ -131,7 +165,7 @@ export function VoiceControls({ onDisconnect, onToggleMic, onToggleCamera, onTog
{/* Disconnect */}
<button
onClick={onDisconnect}
onClick={handleDisconnect}
className="p-2 rounded-full bg-discord-red/20 text-discord-red hover:bg-discord-red/30 transition-colors"
title="Disconnect"
>