feat: voice status visibility + sidebar persistence fixes

- Add WebSocket voice_status/voice_status_update events so mute/deafen
  icons are visible in the sidebar without joining the voice channel
- Server tracks voiceUserStates and includes them in the ready payload
- Re-register voice channel on WebSocket reconnect to prevent sidebar
  users from disappearing after idle timeout
- Re-broadcast deafen state to late joiners via LiveKit data channel
- Fix black grid tile when video stops (enabled-flag guards)
- Remove duplicate mute/deafen from VoiceControls (replaced with
  Video Quality + Noise Suppression)
- Fix missing users in sidebar voice list (identity matching + fallback)
This commit is contained in:
Jannis Braun
2026-02-19 07:58:50 +01:00
parent 503e483b82
commit 0da4a530d6
10 changed files with 307 additions and 102 deletions
@@ -1,5 +1,6 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore';
@@ -15,6 +16,10 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const currentUserId = useAuthStore((s) => s.user?.id);
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
@@ -39,22 +44,29 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
<div className="ml-6 mt-0.5 space-y-0.5">
{voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId);
if (!member) return null;
const displayName = member.user.displayName ?? member.user.username;
// Find participant for status badges
const participant = participants.find(p => p.identity === userId || p.username === member.user.username);
const isMuted = participant ? !participant.audioTrack : false;
const hasCamera = participant ? participant.videoTrack !== null : false;
const isScreenSharing = participant ? participant.screenTrack !== null : false;
const participant = participants.find(p => p.userId === userId);
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
const avatar = member?.user.avatar ?? null;
const status = member?.user.status;
// Resolve status: for local user use store directly, for remote users
// try LiveKit participant first, then fall back to WebSocket voiceUserStates
const wsStatus = voiceUserStates.get(userId);
const isParticipantDeafened = userId === currentUserId
? localIsDeafened
: (participant?.isDeafened ?? wsStatus?.isDeafened ?? false);
const isMuted = userId === currentUserId
? localIsMuted
: (participant?.isMuted ?? wsStatus?.isMuted ?? false);
const hasCamera = participant?.isCameraOn ?? false;
const isScreenSharing = participant?.isScreenSharing ?? false;
return (
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors">
<Avatar
src={member.user.avatar}
src={avatar}
name={displayName}
size={20}
status={member.user.status}
status={status}
/>
<span className="text-[13px] text-discord-text-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */}
@@ -66,6 +78,12 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
{isParticipantDeafened && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-discord-red">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg>
)}
{hasCamera && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted">
<path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
@@ -1,8 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { VideoQualityPopover } from './VideoQualityPopover';
/**
* VoiceControls renders the voice status + button rows.
@@ -10,62 +11,22 @@ import { wsSend } from '../../hooks/useWebSocket';
*/
export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
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 toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const toggleMic = useVoiceStore((s) => s.toggleMic);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const toggleNoiseSuppression = useVoiceStore((s) => s.toggleNoiseSuppression);
const connectionError = useVoiceStore((s) => s.connectionError);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const channels = useServerStore((s) => s.channels);
const [showVideoQuality, setShowVideoQuality] = useState(false);
if (!currentVoiceChannelId) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel';
const handleMute = async () => {
const room = getActiveRoom();
if (room) {
try {
await room.localParticipant.setMicrophoneEnabled(isMuted);
} catch (err) {
console.error('[VoiceControls] Failed to toggle mic:', err);
}
}
toggleMic();
};
const handleDeafen = async () => {
const room = getActiveRoom();
if (room) {
try {
const willDeafen = !isDeafened;
if (willDeafen) {
await room.localParticipant.setMicrophoneEnabled(false);
room.remoteParticipants.forEach((p) => {
p.setVolume(0);
});
if (!isMuted) toggleMic();
} else {
const outputVolume = useVoiceStore.getState().outputVolume;
const scaled = outputVolume / 100;
room.remoteParticipants.forEach((p) => {
p.setVolume(scaled);
});
await room.localParticipant.setMicrophoneEnabled(true);
if (isMuted) toggleMic();
}
} catch (err) {
console.error('[VoiceControls] Failed to toggle deafen:', err);
}
}
toggleDeafen();
};
const handleCamera = async () => {
const room = getActiveRoom();
if (!room) return;
@@ -88,6 +49,28 @@ export function VoiceControls() {
}
};
const handleNoiseSuppression = async () => {
const room = getActiveRoom();
if (room) {
try {
const micPub = room.localParticipant.getTrackPublications().find(
p => p.source === 'microphone'
);
const mediaTrack = micPub?.track?.mediaStreamTrack;
if (mediaTrack) {
await mediaTrack.applyConstraints({
noiseSuppression: !noiseSuppression,
echoCancellation: true,
autoGainControl: true,
});
}
} catch (err) {
console.error('[VoiceControls] Failed to toggle noise suppression:', err);
}
}
toggleNoiseSuppression();
};
const handleDisconnect = () => {
wsSend({ type: 'voice_leave' });
useVoiceStore.getState().leaveVoice();
@@ -145,39 +128,8 @@ export function VoiceControls() {
</div>
</div>
{/* Row 2: Mute, Deafen, Camera, Screen Share */}
<div className="flex items-center gap-1 px-3 pb-2 pt-1">
<button
onClick={handleMute}
className={`${btnBase} ${
isMuted || isDeafened
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: btnDefaultStyle
}`}
title={isMuted ? 'Unmute' : 'Mute'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
{(isMuted || isDeafened) && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
<button
onClick={handleDeafen}
className={`${btnBase} ${
isDeafened
? 'bg-discord-red/20 text-discord-red hover:bg-discord-red/30'
: btnDefaultStyle
}`}
title={isDeafened ? 'Undeafen' : 'Deafen'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
{isDeafened && <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />}
</svg>
</button>
{/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
<div className="relative flex items-center gap-1 px-3 pb-2 pt-1">
<button
onClick={handleCamera}
className={`${btnBase} ${
@@ -213,6 +165,51 @@ export function VoiceControls() {
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</svg>
</button>
{/* Video Quality */}
<button
onClick={() => setShowVideoQuality(!showVideoQuality)}
className={`${btnBase} ${
showVideoQuality
? 'bg-[#111214] text-discord-blurple hover:bg-[#1a1b1e]'
: btnDefaultStyle
}`}
title="Video Quality"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5v14h18V5H3zm16 12H5V7h14v10z" />
<path d="M8 15l2.5-3.21L13 15l2-2.5L18 17H6z" />
</svg>
</button>
{/* Noise Suppression */}
<button
onClick={handleNoiseSuppression}
className={`${btnBase} ${
noiseSuppression
? 'bg-[#111214] text-discord-green hover:bg-[#1a1b1e]'
: btnDefaultStyle
}`}
title={noiseSuppression ? 'Disable Noise Suppression' : 'Enable Noise Suppression'}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 9v6h4l5 5V4l-5 5H7z" />
{noiseSuppression ? (
<>
<path d="M19 12c0-1.66-.68-3.16-1.76-4.24l-1.42 1.42C16.55 9.9 17 10.9 17 12c0 1.1-.45 2.1-1.18 2.82l1.42 1.42C18.32 15.16 19 13.66 19 12z" />
<path d="M21 12c0-2.76-1.12-5.26-2.93-7.07l-1.42 1.42C18.2 7.9 19 9.85 19 12c0 2.15-.8 4.1-2.35 5.65l1.42 1.42C19.88 17.26 21 14.76 21 12z" opacity="0.6" />
</>
) : (
<line x1="19" y1="5" x2="19" y2="19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" opacity="0.4" />
)}
</svg>
</button>
{/* Video Quality Popover */}
<VideoQualityPopover
open={showVideoQuality}
onClose={() => setShowVideoQuality(false)}
/>
</div>
</>
);
@@ -19,9 +19,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal;
// Determine active video track — prioritize screen share, check readyState
const liveScreen = participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
// Determine active video track — prioritize screen share, check both enabled flag and readyState
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
const activeVideoTrack = liveScreen ?? liveCamera;
const hasVideo = activeVideoTrack !== null;
const isScreenShare = liveScreen !== null;
@@ -154,6 +154,14 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
</svg>
</div>
)}
{(isLocal ? isDeafened : participant.isDeafened) && (
<div className="w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
<path d="M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" />
<line x1="3" y1="3" x2="21" y2="21" stroke="white" strokeWidth="2" />
</svg>
</div>
)}
{participant.isScreenSharing && !isScreenShare && (
<div className="w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center">
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">