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
+24
View File
@@ -156,6 +156,9 @@ export function handleClientEvent(
case 'dm_call_end': case 'dm_call_end':
handleDmCallEnd(event, userId); handleDmCallEnd(event, userId);
break; break;
case 'voice_status':
handleVoiceStatus(event, userId);
break;
default: default:
connectionManager.sendToUser(userId, { connectionManager.sendToUser(userId, {
type: 'error', type: 'error',
@@ -738,6 +741,27 @@ function handleChannelAck(event: Record<string, unknown>, userId: string): void
}); });
} }
function handleVoiceStatus(event: Record<string, unknown>, userId: string): void {
const isMuted = event.isMuted === true;
const isDeafened = event.isDeafened === true;
const channelId = connectionManager.getUserVoiceChannel(userId);
if (!channelId) return;
const serverId = getChannelServerId(channelId);
if (!serverId) return;
connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened);
connectionManager.sendToServer(serverId, {
type: 'voice_status_update',
userId,
channelId,
isMuted,
isDeafened,
});
}
// ─── DM Call Handlers ────────────────────────────────────────────────────────── // ─── DM Call Handlers ──────────────────────────────────────────────────────────
function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void { function handleDmCallStart(event: Record<string, unknown>, userId: string, username: string): void {
+37 -1
View File
@@ -44,6 +44,8 @@ class ConnectionManager {
private wsToUser: Map<WebSocket, string> = new Map(); private wsToUser: Map<WebSocket, string> = new Map();
// dmChannelId → { callerId, startedAt } — active DM calls // dmChannelId → { callerId, startedAt } — active DM calls
private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map(); private activeCalls: Map<string, { callerId: string; startedAt: number }> = new Map();
// userId → { isMuted, isDeafened } — voice user status (mute/deafen state)
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean }> = new Map();
addConnection(userId: string, ws: WebSocket): void { addConnection(userId: string, ws: WebSocket): void {
if (!this.connections.has(userId)) { if (!this.connections.has(userId)) {
@@ -110,9 +112,11 @@ class ConnectionManager {
this.voiceStates.delete(channelId); this.voiceStates.delete(channelId);
} }
} }
this.voiceUserStates.delete(userId);
} }
leaveAllVoice(userId: string): string | null { leaveAllVoice(userId: string): string | null {
this.voiceUserStates.delete(userId);
for (const [channelId, users] of this.voiceStates) { for (const [channelId, users] of this.voiceStates) {
if (users.has(userId)) { if (users.has(userId)) {
users.delete(userId); users.delete(userId);
@@ -138,6 +142,23 @@ class ConnectionManager {
return null; return null;
} }
// Voice user status management
setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean): void {
this.voiceUserStates.set(userId, { isMuted, isDeafened });
}
getVoiceUserStatus(userId: string): { isMuted: boolean; isDeafened: boolean } | undefined {
return this.voiceUserStates.get(userId);
}
clearVoiceUserStatus(userId: string): void {
this.voiceUserStates.delete(userId);
}
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean }> {
return this.voiceUserStates;
}
// DM call management // DM call management
startCall(dmChannelId: string, callerId: string): boolean { startCall(dmChannelId: string, callerId: string): boolean {
if (this.activeCalls.has(dmChannelId)) return false; // Already in a call if (this.activeCalls.has(dmChannelId)) return false; // Already in a call
@@ -206,6 +227,7 @@ function buildReadyPayload(userId: string): {
dmChannels: DmChannel[]; dmChannels: DmChannel[];
folders: ServerFolder[]; folders: ServerFolder[];
voiceStates: Record<string, string[]>; voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean }>;
readStates: ReadState[]; readStates: ReadState[];
} { } {
const db = getDb(); const db = getDb();
@@ -422,6 +444,20 @@ function buildReadyPayload(userId: string): {
} }
} }
// Build voice user states — tell the client mute/deafen status of voice users
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean }> = {};
for (const chId of Object.keys(voiceStates)) {
const usersInChannel = voiceStates[chId];
if (usersInChannel) {
for (const uid of usersInChannel) {
const status = connectionManager.getVoiceUserStatus(uid);
if (status) {
voiceUserStates[uid] = status;
}
}
}
}
// Fetch read states for unread tracking // Fetch read states for unread tracking
const readStateRows = db.select() const readStateRows = db.select()
.from(schema.readStates) .from(schema.readStates)
@@ -433,7 +469,7 @@ function buildReadyPayload(userId: string): {
lastReadMessageId: rs.lastReadMessageId, lastReadMessageId: rs.lastReadMessageId,
})); }));
return { user, servers, dmChannels, folders, voiceStates, readStates }; return { user, servers, dmChannels, folders, voiceStates, voiceUserStates, readStates };
} }
export async function registerWebSocket(app: FastifyInstance): Promise<void> { export async function registerWebSocket(app: FastifyInstance): Promise<void> {
+4 -2
View File
@@ -185,11 +185,12 @@ export type ClientEvent =
| { type: 'dm_call_start'; dmChannelId: string } | { type: 'dm_call_start'; dmChannelId: string }
| { type: 'dm_call_accept'; dmChannelId: string } | { type: 'dm_call_accept'; dmChannelId: string }
| { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string }
| { type: 'dm_call_end'; dmChannelId: string }; | { type: 'dm_call_end'; dmChannelId: string }
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean };
// Server → Client Events // Server → Client Events
export type ServerEvent = export type ServerEvent =
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; readStates?: ReadState[] } | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean }>; readStates?: ReadState[] }
| { type: 'message_created'; message: MessageWithUser } | { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
@@ -211,6 +212,7 @@ export type ServerEvent =
| { type: 'dm_call_accepted'; dmChannelId: string } | { type: 'dm_call_accepted'; dmChannelId: string }
| { type: 'dm_call_rejected'; dmChannelId: string } | { type: 'dm_call_rejected'; dmChannelId: string }
| { type: 'dm_call_ended'; dmChannelId: string } | { type: 'dm_call_ended'; dmChannelId: string }
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean }
| { type: 'error'; message: string }; | { type: 'error'; message: string };
// ─── API Request/Response Types ───────────────────────────────────────────── // ─── API Request/Response Types ─────────────────────────────────────────────
@@ -40,10 +40,42 @@ export function ChannelSidebar() {
} }
} }
toggleMic(); toggleMic();
// Broadcast mute status via WebSocket so non-joined users can see it
const willBeMuted = !isMuted;
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened });
}; };
const handleDeafenToggle = () => { const handleDeafenToggle = async () => {
const room = getActiveRoom();
const willDeafen = !isDeafened;
// Update store FIRST so updateParticipants reads correct state when LiveKit events fire
toggleDeafen(); toggleDeafen();
if (willDeafen && !isMuted) toggleMic();
if (!willDeafen && isMuted) toggleMic();
// Broadcast status via WebSocket so non-joined users can see it
const willBeMuted = willDeafen ? true : false;
wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen });
if (room) {
try {
if (willDeafen) {
await room.localParticipant.setMicrophoneEnabled(false);
room.remoteParticipants.forEach((p) => p.setVolume(0));
} else {
const outputVolume = useVoiceStore.getState().outputVolume;
const scaled = outputVolume / 100;
room.remoteParticipants.forEach((p) => p.setVolume(scaled));
await room.localParticipant.setMicrophoneEnabled(true);
}
// Broadcast deafen state to other participants via LiveKit data channel
const encoder = new TextEncoder();
room.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: willDeafen })),
{ reliable: true }
).catch(() => {});
} catch (err) {
console.error('[ChannelSidebar] Failed to toggle deafen:', err);
}
}
}; };
const server = servers.find(s => s.id === currentServerId); const server = servers.find(s => s.id === currentServerId);
@@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
const EMPTY_VOICE_USERS: string[] = []; const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore'; 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 voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants); 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 members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId; 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"> <div className="ml-6 mt-0.5 space-y-0.5">
{voiceUsers.map((userId) => { {voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId); const member = members.find(m => m.userId === userId);
if (!member) return null; const participant = participants.find(p => p.userId === userId);
const displayName = member.user.displayName ?? member.user.username; const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
const avatar = member?.user.avatar ?? null;
// Find participant for status badges const status = member?.user.status;
const participant = participants.find(p => p.identity === userId || p.username === member.user.username); // Resolve status: for local user use store directly, for remote users
const isMuted = participant ? !participant.audioTrack : false; // try LiveKit participant first, then fall back to WebSocket voiceUserStates
const hasCamera = participant ? participant.videoTrack !== null : false; const wsStatus = voiceUserStates.get(userId);
const isScreenSharing = participant ? participant.screenTrack !== null : false; 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 ( return (
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors"> <div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors">
<Avatar <Avatar
src={member.user.avatar} src={avatar}
name={displayName} name={displayName}
size={20} size={20}
status={member.user.status} status={status}
/> />
<span className="text-[13px] text-discord-text-secondary truncate flex-1 min-w-0">{displayName}</span> <span className="text-[13px] text-discord-text-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */} {/* 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" /> <line x1="3" y1="3" x2="21" y2="21" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
</svg> </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 && ( {hasCamera && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="text-discord-text-muted"> <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" /> <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 { useVoiceStore } from '../../stores/voiceStore';
import { useServerStore } from '../../stores/serverStore'; import { useServerStore } from '../../stores/serverStore';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
import { VideoQualityPopover } from './VideoQualityPopover';
/** /**
* VoiceControls renders the voice status + button rows. * VoiceControls renders the voice status + button rows.
@@ -10,62 +11,22 @@ import { wsSend } from '../../hooks/useWebSocket';
*/ */
export function VoiceControls() { export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); 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 isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleCamera = useVoiceStore((s) => s.toggleCamera); const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare); const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const toggleMic = useVoiceStore((s) => s.toggleMic); const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen); const toggleNoiseSuppression = useVoiceStore((s) => s.toggleNoiseSuppression);
const connectionError = useVoiceStore((s) => s.connectionError); const connectionError = useVoiceStore((s) => s.connectionError);
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected); const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const channels = useServerStore((s) => s.channels); const channels = useServerStore((s) => s.channels);
const [showVideoQuality, setShowVideoQuality] = useState(false);
if (!currentVoiceChannelId) return null; if (!currentVoiceChannelId) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId); const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? 'Voice Channel'; 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 handleCamera = async () => {
const room = getActiveRoom(); const room = getActiveRoom();
if (!room) return; 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 = () => { const handleDisconnect = () => {
wsSend({ type: 'voice_leave' }); wsSend({ type: 'voice_leave' });
useVoiceStore.getState().leaveVoice(); useVoiceStore.getState().leaveVoice();
@@ -145,39 +128,8 @@ export function VoiceControls() {
</div> </div>
</div> </div>
{/* Row 2: Mute, Deafen, Camera, Screen Share */} {/* Row 2: Camera, Screen Share, Video Quality, Noise Suppression */}
<div className="flex items-center gap-1 px-3 pb-2 pt-1"> <div className="relative 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>
<button <button
onClick={handleCamera} onClick={handleCamera}
className={`${btnBase} ${ className={`${btnBase} ${
@@ -213,6 +165,51 @@ export function VoiceControls() {
<path d="M15 11L11 14V12H9V10H11V8L15 11Z" /> <path d="M15 11L11 14V12H9V10H11V8L15 11Z" />
</svg> </svg>
</button> </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> </div>
</> </>
); );
@@ -19,9 +19,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
const perUserVolume = participantVolumes.get(participant.userId) ?? 100; const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal; const isLocal = participant.isLocal;
// Determine active video track — prioritize screen share, check readyState // Determine active video track — prioritize screen share, check both enabled flag and readyState
const liveScreen = participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null; const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
const liveCamera = participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null; const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
const activeVideoTrack = liveScreen ?? liveCamera; const activeVideoTrack = liveScreen ?? liveCamera;
const hasVideo = activeVideoTrack !== null; const hasVideo = activeVideoTrack !== null;
const isScreenShare = liveScreen !== null; const isScreenShare = liveScreen !== null;
@@ -154,6 +154,14 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
</svg> </svg>
</div> </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 && ( {participant.isScreenSharing && !isScreenShare && (
<div className="w-5 h-5 bg-discord-blurple/90 rounded-full flex items-center justify-center"> <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"> <svg width="12" height="12" viewBox="0 0 24 24" fill="white">
+37 -6
View File
@@ -44,6 +44,7 @@ export interface ParticipantInfo {
username: string; username: string;
isSpeaking: boolean; isSpeaking: boolean;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean;
isCameraOn: boolean; isCameraOn: boolean;
isScreenSharing: boolean; isScreenSharing: boolean;
isLocal: boolean; isLocal: boolean;
@@ -118,16 +119,34 @@ export function useLiveKit() {
const mt = track.mediaStreamTrack; const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live') return; if (!mt || mt.readyState !== 'live') return;
if (pub.source === Track.Source.Microphone) audioTrack = mt; if (pub.source === Track.Source.Microphone) audioTrack = mt;
else if (pub.source === Track.Source.Camera) videoTrack = mt; else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt;
else if (pub.source === Track.Source.ScreenShare) screenTrack = mt; else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt;
}); });
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack }); let isDeafened = false;
if (isLocal) {
isDeafened = useVoiceStore.getState().isDeafened;
} else {
isDeafened = useVoiceStore.getState().deafenedUserIds.has(userId);
}
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
}; };
processParticipant(r.localParticipant, true); processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false)); r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants); setParticipants(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]);
const connect = useCallback(async (channelId: string) => { const connect = useCallback(async (channelId: string) => {
if (connectedChannelRef.current === channelId && roomRef.current) return; if (connectedChannelRef.current === channelId && roomRef.current) return;
const gen = ++_connectGeneration; const gen = ++_connectGeneration;
@@ -141,7 +160,17 @@ export function useLiveKit() {
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } }); const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom; roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); }; const guardedUpdate = () => { if (roomRef.current === newRoom) updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
guardedUpdate();
// Re-broadcast local deafen state to newly connected participant
if (useVoiceStore.getState().isDeafened) {
const encoder = new TextEncoder();
newRoom.localParticipant.publishData(
encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })),
{ reliable: true }
).catch(() => {});
}
});
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate); newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate); newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
@@ -150,6 +179,8 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackMuted, guardedUpdate); newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate); newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate); newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => { newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) { if (roomRef.current === newRoom) {
const connected = state === ConnectionState.Connected; const connected = state === ConnectionState.Connected;
@@ -171,7 +202,7 @@ export function useLiveKit() {
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); } try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } } catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
finally { if (gen === _connectGeneration) setIsConnecting(false); } finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants]); }, [updateParticipants, handleDataReceived]);
const connectDm = useCallback(async (dmChannelId: string) => { const connectDm = useCallback(async (dmChannelId: string) => {
const gen = ++_connectGeneration; const gen = ++_connectGeneration;
@@ -208,7 +239,7 @@ export function useLiveKit() {
try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); } try { await newRoom.localParticipant.setMicrophoneEnabled(true); updateParticipants(); } catch { useVoiceStore.setState({ isMuted: true }); }
} catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); } } catch (err) { if (gen === _connectGeneration) setConnectionError('Failed to connect'); }
finally { if (gen === _connectGeneration) setIsConnecting(false); } finally { if (gen === _connectGeneration) setIsConnecting(false); }
}, [updateParticipants]); }, [updateParticipants, handleDataReceived]);
const disconnect = useCallback(async () => { const disconnect = useCallback(async () => {
_connectGeneration++; _connectGeneration++;
+21 -1
View File
@@ -16,7 +16,7 @@ function handleEvent(event: ServerEvent): void {
const { setUser } = useAuthStore.getState(); const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState(); const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState(); const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState(); const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
switch (event.type) { switch (event.type) {
case 'ready': case 'ready':
@@ -45,6 +45,21 @@ function handleEvent(event: ServerEvent): void {
setVoiceUsers(channelId, userIds); setVoiceUsers(channelId, userIds);
} }
} }
// Populate voice user statuses (mute/deafen) from server
if (event.voiceUserStates) {
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
setVoiceUserStatus(uid, status.isMuted, status.isDeafened);
}
}
// Re-register in voice channel if we're still connected to LiveKit
// (WebSocket reconnect causes server to drop our voice tracking)
{
const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState();
if (currentVoiceChannelId) {
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId });
wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened });
}
}
break; break;
case 'message_created': case 'message_created':
@@ -78,9 +93,14 @@ function handleEvent(event: ServerEvent): void {
addVoiceUser(event.channelId, event.userId); addVoiceUser(event.channelId, event.userId);
} else { } else {
removeVoiceUser(event.channelId, event.userId); removeVoiceUser(event.channelId, event.userId);
clearVoiceUserStatus(event.userId);
} }
break; break;
case 'voice_status_update':
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened);
break;
case 'member_joined': case 'member_joined':
addMember(event.member); addMember(event.member);
break; break;
+38 -1
View File
@@ -41,6 +41,14 @@ interface VoiceState {
toggleDeafen: () => void; toggleDeafen: () => void;
setFocusedParticipant: (id: string | null) => void; setFocusedParticipant: (id: string | null) => void;
setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void; setVideoQuality: (quality: '1080p' | '1080p60' | '720p' | '720p60' | '540p' | '360p') => void;
noiseSuppression: boolean;
toggleNoiseSuppression: () => void;
deafenedUserIds: Set<string>;
setUserDeafened: (userId: string, deafened: boolean) => void;
// WebSocket-based voice user status (visible without joining LiveKit)
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean }>;
setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean) => void;
clearVoiceUserStatus: (userId: string) => void;
getVoiceUsers: (channelId: string) => string[]; getVoiceUsers: (channelId: string) => string[];
clearAllVoiceUsers: () => void; clearAllVoiceUsers: () => void;
leaveVoice: () => void; leaveVoice: () => void;
@@ -123,10 +131,36 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
setFocusedParticipant: (id) => set({ focusedParticipantId: id }), setFocusedParticipant: (id) => set({ focusedParticipantId: id }),
setVideoQuality: (quality) => set({ videoQuality: quality }), setVideoQuality: (quality) => set({ videoQuality: quality }),
noiseSuppression: true,
toggleNoiseSuppression: () => set((state) => ({ noiseSuppression: !state.noiseSuppression })),
deafenedUserIds: new Set(),
setUserDeafened: (userId, deafened) => {
set((state) => {
const newSet = new Set(state.deafenedUserIds);
if (deafened) newSet.add(userId); else newSet.delete(userId);
return { deafenedUserIds: newSet };
});
},
voiceUserStates: new Map(),
setVoiceUserStatus: (userId, isMuted, isDeafened) => {
set((state) => {
const newMap = new Map(state.voiceUserStates);
newMap.set(userId, { isMuted, isDeafened });
return { voiceUserStates: newMap };
});
},
clearVoiceUserStatus: (userId) => {
set((state) => {
const newMap = new Map(state.voiceUserStates);
newMap.delete(userId);
return { voiceUserStates: newMap };
});
},
getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [], getVoiceUsers: (channelId) => get().voiceUsers.get(channelId) ?? [],
clearAllVoiceUsers: () => set({ voiceUsers: new Map() }), clearAllVoiceUsers: () => set({ voiceUsers: new Map(), voiceUserStates: new Map() }),
// Leave voice without wiping the voiceUsers map (so sidebar still shows others) // Leave voice without wiping the voiceUsers map (so sidebar still shows others)
leaveVoice: () => set({ leaveVoice: () => set({
@@ -143,6 +177,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
focusedParticipantId: null, focusedParticipantId: null,
activeDmCall: null, activeDmCall: null,
outgoingCall: null, outgoingCall: null,
deafenedUserIds: new Set(),
}), }),
reset: () => set({ reset: () => set({
@@ -162,5 +197,7 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
incomingCall: null, incomingCall: null,
outgoingCall: null, outgoingCall: null,
activeDmCall: null, activeDmCall: null,
deafenedUserIds: new Set(),
voiceUserStates: new Map(),
}), }),
})); }));