diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 78f69994..d65537e5 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -408,6 +408,18 @@ function handleVoiceJoin(event: Record, userId: string): void { userId, 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 { diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index c232da87..6b495fbc 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -112,11 +112,9 @@ class ConnectionManager { this.voiceStates.delete(channelId); } } - this.voiceUserStates.delete(userId); } leaveAllVoice(userId: string): string | null { - this.voiceUserStates.delete(userId); for (const [channelId, users] of this.voiceStates) { if (users.has(userId)) { users.delete(userId); @@ -560,6 +558,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise { // Leave voice if in one const leftChannel = connectionManager.leaveAllVoice(removedUserId); + connectionManager.clearVoiceUserStatus(removedUserId); if (leftChannel) { // Get channel's server to broadcast const channel = db.select().from(schema.channels).where(eq(schema.channels.id, leftChannel)).get(); diff --git a/packages/web/src/components/layout/ChannelSidebar.js b/packages/web/src/components/layout/ChannelSidebar.js index 089dd39f..8bbc1b2b 100644 --- a/packages/web/src/components/layout/ChannelSidebar.js +++ b/packages/web/src/components/layout/ChannelSidebar.js @@ -40,6 +40,8 @@ export function ChannelSidebar() { } } toggleMic(); + // Broadcast via WebSocket + wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened }); }; const handleDeafenToggle = async () => { const room = getActiveRoom(); @@ -52,8 +54,6 @@ export function ChannelSidebar() { room.remoteParticipants.forEach((p) => p.setVolume(0)); if (!isMuted) toggleMic(); - // Broadcast deafened state via metadata - await room.localParticipant.setMetadata(JSON.stringify({ deafened: true })); } else { // Undeafen: restore mic + restore remote audio @@ -63,7 +63,6 @@ export function ChannelSidebar() { await room.localParticipant.setMicrophoneEnabled(true); if (isMuted) toggleMic(); - await room.localParticipant.setMetadata(JSON.stringify({ deafened: false })); } } catch (err) { @@ -71,6 +70,8 @@ export function ChannelSidebar() { } } toggleDeafen(); + // Broadcast via WebSocket + wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen }); }; const server = servers.find(s => s.id === currentServerId); const currentMember = members.find(m => m.userId === user?.id); @@ -93,6 +94,8 @@ export function ChannelSidebar() { } setCurrentVoiceChannel(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}`); }; // Floating bottom panel — shared between DM view and server view diff --git a/packages/web/src/components/voice/VoiceChannel.js b/packages/web/src/components/voice/VoiceChannel.js index 48dc57d5..259518a3 100644 --- a/packages/web/src/components/voice/VoiceChannel.js +++ b/packages/web/src/components/voice/VoiceChannel.js @@ -7,6 +7,7 @@ export function VoiceChannel({ channelId, channelName, onClick }) { const voiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS; const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId); const participants = useVoiceStore((s) => s.participants); + const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const members = useServerStore((s) => s.members); 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 @@ -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) => { const member = members.find(m => m.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 avatar = member?.user.avatar ?? null; const status = member?.user.status; - const isParticipantDeafened = participant?.isDeafened ?? false; - const isMuted = participant?.isMuted ?? false; + const isParticipantDeafened = voiceState?.isDeafened ?? participant?.isDeafened ?? false; + const isMuted = voiceState?.isMuted ?? participant?.isMuted ?? false; const hasCamera = participant?.isCameraOn ?? 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)); diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 14f1516a..70d306ab 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -103,6 +103,7 @@ export function useLiveKit() { const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const videoQuality = useVoiceStore((s) => s.videoQuality); + const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const updateParticipants = useCallback(() => { 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.ScreenShare && p.isScreenShareEnabled) screenTrack = mt; }); + const userState = useVoiceStore.getState().voiceUserStates.get(userId); let isDeafened = false; + let isMuted = !p.isMicrophoneEnabled; + if (isLocal) { isDeafened = useVoiceStore.getState().isDeafened; + isMuted = useVoiceStore.getState().isMuted; } 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); r.remoteParticipants.forEach((p) => processParticipant(p, false)); @@ -318,6 +327,10 @@ export function useLiveKit() { } }, [isScreenSharing, videoQuality, updateParticipants]); + useEffect(() => { + updateParticipants(); + }, [voiceUserStates, isMuted, isDeafened, updateParticipants]); + // Sync quality changes useEffect(() => { if (!room) return; diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 97c6e507..42c28dff 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -93,7 +93,6 @@ function handleEvent(event: ServerEvent): void { addVoiceUser(event.channelId, event.userId); } else { removeVoiceUser(event.channelId, event.userId); - clearVoiceUserStatus(event.userId); } break;