Fix voice status persistence and global visibility across channels

This commit is contained in:
Jannis Braun
2026-02-19 17:37:30 +01:00
parent c7608e0655
commit a18e087e24
6 changed files with 38 additions and 10 deletions
+12
View File
@@ -408,6 +408,18 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string): void {
userId, userId,
action: 'join', action: 'join',
}); });
// Also broadcast current voice status if it exists (persisted during moves)
const status = connectionManager.getVoiceUserStatus(userId);
if (status) {
connectionManager.sendToServer(serverId, {
type: 'voice_status_update',
userId,
channelId,
isMuted: status.isMuted,
isDeafened: status.isDeafened,
});
}
} }
function handleVoiceLeave(userId: string): void { function handleVoiceLeave(userId: string): void {
+1 -2
View File
@@ -112,11 +112,9 @@ 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);
@@ -560,6 +558,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
// Leave voice if in one // Leave voice if in one
const leftChannel = connectionManager.leaveAllVoice(removedUserId); const leftChannel = connectionManager.leaveAllVoice(removedUserId);
connectionManager.clearVoiceUserStatus(removedUserId);
if (leftChannel) { if (leftChannel) {
// Get channel's server to broadcast // Get channel's server to broadcast
const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get();
@@ -40,6 +40,8 @@ export function ChannelSidebar() {
} }
} }
toggleMic(); toggleMic();
// Broadcast via WebSocket
wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened });
}; };
const handleDeafenToggle = async () => { const handleDeafenToggle = async () => {
const room = getActiveRoom(); const room = getActiveRoom();
@@ -52,8 +54,6 @@ export function ChannelSidebar() {
room.remoteParticipants.forEach((p) => p.setVolume(0)); room.remoteParticipants.forEach((p) => p.setVolume(0));
if (!isMuted) if (!isMuted)
toggleMic(); toggleMic();
// Broadcast deafened state via metadata
await room.localParticipant.setMetadata(JSON.stringify({ deafened: true }));
} }
else { else {
// Undeafen: restore mic + restore remote audio // Undeafen: restore mic + restore remote audio
@@ -63,7 +63,6 @@ export function ChannelSidebar() {
await room.localParticipant.setMicrophoneEnabled(true); await room.localParticipant.setMicrophoneEnabled(true);
if (isMuted) if (isMuted)
toggleMic(); toggleMic();
await room.localParticipant.setMetadata(JSON.stringify({ deafened: false }));
} }
} }
catch (err) { catch (err) {
@@ -71,6 +70,8 @@ export function ChannelSidebar() {
} }
} }
toggleDeafen(); toggleDeafen();
// Broadcast via WebSocket
wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen });
}; };
const server = servers.find(s => s.id === currentServerId); const server = servers.find(s => s.id === currentServerId);
const currentMember = members.find(m => m.userId === user?.id); const currentMember = members.find(m => m.userId === user?.id);
@@ -93,6 +94,8 @@ export function ChannelSidebar() {
} }
setCurrentVoiceChannel(channelId); setCurrentVoiceChannel(channelId);
wsSend({ type: 'voice_join', channelId }); wsSend({ type: 'voice_join', channelId });
// Also broadcast current status immediately after joining
wsSend({ type: 'voice_status', isMuted: useVoiceStore.getState().isMuted, isDeafened: useVoiceStore.getState().isDeafened });
navigate(`/channels/${currentServerId}/${channelId}`); navigate(`/channels/${currentServerId}/${channelId}`);
}; };
// Floating bottom panel — shared between DM view and server view // Floating bottom panel — shared between DM view and server view
@@ -7,6 +7,7 @@ export function VoiceChannel({ channelId, channelName, onClick }) {
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 voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const members = useServerStore((s) => s.members); const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId; const isActive = currentVoiceChannel === channelId;
return (_jsxs("div", { children: [_jsxs("button", { onClick: onClick, className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${isActive return (_jsxs("div", { children: [_jsxs("button", { onClick: onClick, className: `w-full flex items-center gap-1.5 px-2 h-8 rounded-[4px] group transition-colors ${isActive
@@ -14,11 +15,12 @@ export function VoiceChannel({ channelId, channelName, onClick }) {
: 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("span", { className: "truncate text-[15px] font-medium", children: channelName })] }), voiceUsers.length > 0 && (_jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: voiceUsers.map((userId) => { : 'text-discord-text-muted hover:text-discord-text-secondary hover:bg-discord-modifier-hover'}`, children: [_jsx("svg", { width: "20", height: "20", viewBox: "0 0 24 24", fill: "currentColor", className: "flex-shrink-0 opacity-60", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46ZM19.07 4.93C20.91 6.77 22 9.28 22 12C22 14.72 20.91 17.23 19.07 19.07L17.66 17.66C19.11 16.21 20 14.21 20 12C20 9.79 19.11 7.79 17.66 6.34L19.07 4.93Z" }) }), _jsx("span", { className: "truncate text-[15px] font-medium", children: channelName })] }), voiceUsers.length > 0 && (_jsx("div", { className: "ml-6 mt-0.5 space-y-0.5", children: voiceUsers.map((userId) => {
const member = members.find(m => m.userId === userId); const member = members.find(m => m.userId === userId);
const participant = participants.find(p => p.userId === userId); const participant = participants.find(p => p.userId === userId);
const voiceState = voiceUserStates.get(userId);
const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId; const displayName = member?.user.displayName ?? member?.user.username ?? participant?.username ?? userId;
const avatar = member?.user.avatar ?? null; const avatar = member?.user.avatar ?? null;
const status = member?.user.status; const status = member?.user.status;
const isParticipantDeafened = participant?.isDeafened ?? false; const isParticipantDeafened = voiceState?.isDeafened ?? participant?.isDeafened ?? false;
const isMuted = participant?.isMuted ?? false; const isMuted = voiceState?.isMuted ?? participant?.isMuted ?? false;
const hasCamera = participant?.isCameraOn ?? false; const hasCamera = participant?.isCameraOn ?? false;
const isScreenSharing = participant?.isScreenSharing ?? false; const isScreenSharing = participant?.isScreenSharing ?? false;
return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors", children: [_jsx(Avatar, { src: avatar, name: displayName, size: 20, status: status }), _jsx("span", { className: "text-[13px] text-discord-text-secondary truncate flex-1 min-w-0", children: displayName }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [isParticipantDeafened ? (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", children: [_jsx("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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) : isMuted ? (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", children: [_jsx("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" }), _jsx("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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) : null, hasCamera && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("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" }) })), isScreenSharing && (_jsx("span", { className: "bg-discord-green text-white text-[9px] font-bold px-1 rounded leading-[14px]", children: "LIVE" }))] })] }, userId)); return (_jsxs("div", { className: "flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors", children: [_jsx(Avatar, { src: avatar, name: displayName, size: 20, status: status }), _jsx("span", { className: "text-[13px] text-discord-text-secondary truncate flex-1 min-w-0", children: displayName }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [isParticipantDeafened ? (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", children: [_jsx("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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) : isMuted ? (_jsxs("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-red", children: [_jsx("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" }), _jsx("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" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] })) : null, hasCamera && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted", children: _jsx("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" }) })), isScreenSharing && (_jsx("span", { className: "bg-discord-green text-white text-[9px] font-bold px-1 rounded leading-[14px]", children: "LIVE" }))] })] }, userId));
+15 -2
View File
@@ -103,6 +103,7 @@ export function useLiveKit() {
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 videoQuality = useVoiceStore((s) => s.videoQuality); const videoQuality = useVoiceStore((s) => s.videoQuality);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const updateParticipants = useCallback(() => { const updateParticipants = useCallback(() => {
const r = roomRef.current; const r = roomRef.current;
@@ -122,13 +123,21 @@ export function useLiveKit() {
else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt; else if (pub.source === Track.Source.Camera && p.isCameraEnabled) videoTrack = mt;
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt; else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled) screenTrack = mt;
}); });
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
let isDeafened = false; let isDeafened = false;
let isMuted = !p.isMicrophoneEnabled;
if (isLocal) { if (isLocal) {
isDeafened = useVoiceStore.getState().isDeafened; isDeafened = useVoiceStore.getState().isDeafened;
isMuted = useVoiceStore.getState().isMuted;
} else { } else {
isDeafened = useVoiceStore.getState().deafenedUserIds.has(userId); isDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState) {
isMuted = userState.isMuted;
}
} }
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted, 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));
@@ -318,6 +327,10 @@ export function useLiveKit() {
} }
}, [isScreenSharing, videoQuality, updateParticipants]); }, [isScreenSharing, videoQuality, updateParticipants]);
useEffect(() => {
updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
// Sync quality changes // Sync quality changes
useEffect(() => { useEffect(() => {
if (!room) return; if (!room) return;
-1
View File
@@ -93,7 +93,6 @@ 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;