From 5e34b39b781a92fb8e3c5a46c0b0150c5060fe63 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Mon, 23 Feb 2026 20:07:42 +0100 Subject: [PATCH] fix: broadcast camera & screen share status via WebSocket for sidebar visibility Camera and LIVE badges in the channel sidebar were only visible to users who had joined the same LiveKit room. Widen the voice_status WS event from {isMuted, isDeafened} to {isMuted, isDeafened, isCameraOn, isScreenSharing} so all server members see camera/screenshare indicators without joining voice. --- packages/server/src/ws/events.ts | 10 +++++++++- packages/server/src/ws/handler.ts | 18 +++++++++--------- packages/shared/src/types.ts | 6 +++--- .../src/components/layout/ChannelSidebar.tsx | 6 ++++-- .../web/src/components/voice/VoiceChannel.tsx | 4 ++-- .../src/components/voice/VoiceControlBar.tsx | 19 ++++++++++++++----- .../src/components/voice/VoiceControls.tsx | 14 ++++++++++++-- packages/web/src/hooks/useWebSocket.ts | 12 ++++++------ packages/web/src/stores/voiceStore.ts | 8 ++++---- packages/web/src/utils/screenShare.ts | 4 ++++ 10 files changed, 67 insertions(+), 34 deletions(-) diff --git a/packages/server/src/ws/events.ts b/packages/server/src/ws/events.ts index 5f06aa0f..ba2c289c 100644 --- a/packages/server/src/ws/events.ts +++ b/packages/server/src/ws/events.ts @@ -397,6 +397,8 @@ function handleVoiceJoin(event: Record, userId: string): void { channelId, isMuted: status.isMuted, isDeafened: status.isDeafened, + isCameraOn: status.isCameraOn, + isScreenSharing: status.isScreenSharing, }); } return; @@ -436,6 +438,8 @@ function handleVoiceJoin(event: Record, userId: string): void { channelId, isMuted: status.isMuted, isDeafened: status.isDeafened, + isCameraOn: status.isCameraOn, + isScreenSharing: status.isScreenSharing, }); } } @@ -764,6 +768,8 @@ function handleChannelAck(event: Record, userId: string): void function handleVoiceStatus(event: Record, userId: string): void { const isMuted = event.isMuted === true; const isDeafened = event.isDeafened === true; + const isCameraOn = event.isCameraOn === true; + const isScreenSharing = event.isScreenSharing === true; const channelId = connectionManager.getUserVoiceChannel(userId); if (!channelId) return; @@ -771,7 +777,7 @@ function handleVoiceStatus(event: Record, userId: string): void const serverId = getChannelServerId(channelId); if (!serverId) return; - connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened); + connectionManager.setVoiceUserStatus(userId, isMuted, isDeafened, isCameraOn, isScreenSharing); connectionManager.sendToServer(serverId, { type: 'voice_status_update', @@ -779,6 +785,8 @@ function handleVoiceStatus(event: Record, userId: string): void channelId, isMuted, isDeafened, + isCameraOn, + isScreenSharing, }); } diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 5103991e..3a4e5ede 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -44,8 +44,8 @@ class ConnectionManager { private wsToUser: Map = new Map(); // dmChannelId → { callerId, startedAt } — active DM calls private activeCalls: Map = new Map(); - // userId → { isMuted, isDeafened } — voice user status (mute/deafen state) - private voiceUserStates: Map = new Map(); + // userId → { isMuted, isDeafened, isCameraOn, isScreenSharing } — voice user status + private voiceUserStates: Map = new Map(); // userId → Timeout private pendingOfflineTimeouts: Map = new Map(); @@ -205,11 +205,11 @@ class ConnectionManager { } // Voice user status management - setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean): void { - this.voiceUserStates.set(userId, { isMuted, isDeafened }); + setVoiceUserStatus(userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean): void { + this.voiceUserStates.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing }); } - getVoiceUserStatus(userId: string): { isMuted: boolean; isDeafened: boolean } | undefined { + getVoiceUserStatus(userId: string): { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | undefined { return this.voiceUserStates.get(userId); } @@ -217,7 +217,7 @@ class ConnectionManager { this.voiceUserStates.delete(userId); } - getAllVoiceUserStates(): Map { + getAllVoiceUserStates(): Map { return this.voiceUserStates; } @@ -289,7 +289,7 @@ function buildReadyPayload(userId: string): { dmChannels: DmChannel[]; folders: ServerFolder[]; voiceStates: Record; - voiceUserStates: Record; + voiceUserStates: Record; readStates: ReadState[]; } { const db = getDb(); @@ -506,8 +506,8 @@ function buildReadyPayload(userId: string): { } } - // Build voice user states — tell the client mute/deafen status of voice users - const voiceUserStates: Record = {}; + // Build voice user states — tell the client mute/deafen/camera/screenshare status of voice users + const voiceUserStates: Record = {}; for (const chId of Object.keys(voiceStates)) { const usersInChannel = voiceStates[chId]; if (usersInChannel) { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index b2d6a29c..3ca49546 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -186,12 +186,12 @@ export type ClientEvent = | { type: 'dm_call_accept'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string } - | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean } + | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { type: 'ping' }; // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[] } + | { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[] } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } @@ -213,7 +213,7 @@ export type ServerEvent = | { type: 'dm_call_accepted'; dmChannelId: string } | { type: 'dm_call_rejected'; dmChannelId: string } | { type: 'dm_call_ended'; dmChannelId: string } - | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean } + | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { type: 'dm_channel_created'; dmChannel: DmChannel } | { type: 'dm_channel_closed'; dmChannelId: string } | { type: 'friend_removed'; userId: string } diff --git a/packages/web/src/components/layout/ChannelSidebar.tsx b/packages/web/src/components/layout/ChannelSidebar.tsx index ea84c1f1..690fc816 100644 --- a/packages/web/src/components/layout/ChannelSidebar.tsx +++ b/packages/web/src/components/layout/ChannelSidebar.tsx @@ -35,7 +35,8 @@ export function ChannelSidebar() { toggleMic(); // Broadcast mute status via WebSocket so non-joined users can see it const willBeMuted = !isMuted; - wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened }); + const { isCameraOn, isScreenSharing } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened, isCameraOn, isScreenSharing }); }; const handleDeafenToggle = async () => { @@ -47,7 +48,8 @@ export function ChannelSidebar() { 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 }); + const { isCameraOn, isScreenSharing } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: willBeMuted, isDeafened: willDeafen, isCameraOn, isScreenSharing }); if (room) { try { // Broadcast deafen state to other participants via LiveKit data channel diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index eaad53db..bbaf4b75 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -57,8 +57,8 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr const isMuted = userId === currentUserId ? localIsMuted : (participant?.isMuted ?? wsStatus?.isMuted ?? false); - const hasCamera = participant?.isCameraOn ?? false; - const isScreenSharing = participant?.isScreenSharing ?? false; + const hasCamera = participant?.isCameraOn ?? wsStatus?.isCameraOn ?? false; + const isScreenSharing = participant?.isScreenSharing ?? wsStatus?.isScreenSharing ?? false; return (
diff --git a/packages/web/src/components/voice/VoiceControlBar.tsx b/packages/web/src/components/voice/VoiceControlBar.tsx index fbaa9f0c..97efd3dd 100644 --- a/packages/web/src/components/voice/VoiceControlBar.tsx +++ b/packages/web/src/components/voice/VoiceControlBar.tsx @@ -28,8 +28,8 @@ export function VoiceControlBar() { const handleMute = React.useCallback(async () => { toggleMic(); // Broadcast via WebSocket so sidebar shows status without joining - wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened }); - }, [isMuted, isDeafened, toggleMic]); + wsSend({ type: 'voice_status', isMuted: !isMuted, isDeafened, isCameraOn, isScreenSharing }); + }, [isMuted, isDeafened, isCameraOn, isScreenSharing, toggleMic]); const handleDeafen = React.useCallback(async () => { const room = getActiveRoom(); @@ -39,7 +39,7 @@ export function VoiceControlBar() { if (willDeafen && !isMuted) toggleMic(); if (!willDeafen && isMuted) toggleMic(); // Broadcast via WebSocket - wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen }); + wsSend({ type: 'voice_status', isMuted: willDeafen, isDeafened: willDeafen, isCameraOn, isScreenSharing }); if (room) { try { // Broadcast deafen state via LiveKit data channel for in-room users @@ -52,7 +52,7 @@ export function VoiceControlBar() { console.error('[VoiceControlBar] Failed to toggle deafen:', err); } } - }, [isDeafened, isMuted, toggleDeafen, toggleMic]); + }, [isDeafened, isMuted, isCameraOn, isScreenSharing, toggleDeafen, toggleMic]); const handleCamera = async () => { const room = getActiveRoom(); @@ -77,6 +77,9 @@ export function VoiceControlBar() { await room.localParticipant.setCameraEnabled(false); } toggleCamera(); + // Broadcast camera state via WebSocket + const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }); } catch (err) { console.error('[VoiceControlBar] Failed to toggle camera:', err); } @@ -87,9 +90,15 @@ export function VoiceControlBar() { if (!room) return; try { if (!isScreenSharing) { - await startScreenShare(room); + const started = await startScreenShare(room); + if (started) { + const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }); + } } else { await stopScreenShare(room); + const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }); } } catch (err) { console.error('[VoiceControlBar] Failed to toggle screen share:', err); diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index 29d2f872..6714ac0c 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -34,8 +34,12 @@ export function VoiceControls() { const room = getActiveRoom(); if (!room) return; try { - await room.localParticipant.setCameraEnabled(!isCameraOn); + const willEnable = !isCameraOn; + await room.localParticipant.setCameraEnabled(willEnable); toggleCamera(); + // Broadcast camera state via WebSocket + const { isMuted: m, isDeafened: d, isScreenSharing: ss } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: willEnable, isScreenSharing: ss }); } catch (err) { console.error('[VoiceControls] Failed to toggle camera:', err); } @@ -46,9 +50,15 @@ export function VoiceControls() { if (!room) return; try { if (!isScreenSharing) { - await startScreenShare(room); + const started = await startScreenShare(room); + if (started) { + const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: true }); + } } else { await stopScreenShare(room); + const { isMuted: m, isDeafened: d, isCameraOn: c } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted: m, isDeafened: d, isCameraOn: c, isScreenSharing: false }); } } catch (err) { console.error('[VoiceControls] Failed to toggle screen share:', err); diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index e6bbf80d..fe4d72b5 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -45,20 +45,20 @@ function handleEvent(event: ServerEvent): void { setVoiceUsers(channelId, userIds); } } - // Populate voice user statuses (mute/deafen) from server + // Populate voice user statuses (mute/deafen/camera/screenshare) from server if (event.voiceUserStates) { for (const [uid, status] of Object.entries(event.voiceUserStates)) { - setVoiceUserStatus(uid, status.isMuted, status.isDeafened); + setVoiceUserStatus(uid, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing); } } // 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(); + const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen } = useVoiceStore.getState(); if (currentVoiceChannelId) { - console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened }); + console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened, curCamera, curScreen }); wsSend({ type: 'voice_join', channelId: currentVoiceChannelId }); - wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened }); + wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened, isCameraOn: curCamera, isScreenSharing: curScreen }); } } break; @@ -99,7 +99,7 @@ function handleEvent(event: ServerEvent): void { break; case 'voice_status_update': - setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened); + setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing); break; case 'member_joined': diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 148c83b2..30942d59 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -75,8 +75,8 @@ interface VoiceState { deafenedUserIds: Set; setUserDeafened: (userId: string, deafened: boolean) => void; // WebSocket-based voice user status (visible without joining LiveKit) - voiceUserStates: Map; - setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean) => void; + voiceUserStates: Map; + setVoiceUserStatus: (userId: string, isMuted: boolean, isDeafened: boolean, isCameraOn: boolean, isScreenSharing: boolean) => void; clearVoiceUserStatus: (userId: string) => void; getVoiceUsers: (channelId: string) => string[]; clearAllVoiceUsers: () => void; @@ -247,10 +247,10 @@ export const useVoiceStore = create()( }, voiceUserStates: new Map(), - setVoiceUserStatus: (userId, isMuted, isDeafened) => { + setVoiceUserStatus: (userId, isMuted, isDeafened, isCameraOn, isScreenSharing) => { set((state) => { const newMap = new Map(state.voiceUserStates); - newMap.set(userId, { isMuted, isDeafened }); + newMap.set(userId, { isMuted, isDeafened, isCameraOn, isScreenSharing }); return { voiceUserStates: newMap }; }); }, diff --git a/packages/web/src/utils/screenShare.ts b/packages/web/src/utils/screenShare.ts index 2d90ac28..a907b88e 100644 --- a/packages/web/src/utils/screenShare.ts +++ b/packages/web/src/utils/screenShare.ts @@ -1,6 +1,7 @@ import { Room, Track, VideoPreset } from 'livekit-client'; import { useVoiceStore } from '../stores/voiceStore'; import { AudioManager } from '../audio/AudioManager'; +import { wsSend } from '../hooks/useWebSocket'; /** * Canonical quality presets — single source of truth. @@ -172,4 +173,7 @@ export async function changeScreenShare(room: Room): Promise { export function handleScreenShareUnpublished(): void { AudioManager.getInstance().setScreenShareActive(false); useVoiceStore.setState({ isScreenSharing: false }); + // Broadcast updated state via WebSocket — OS "Stop Sharing" bypasses our UI + const { isMuted, isDeafened, isCameraOn } = useVoiceStore.getState(); + wsSend({ type: 'voice_status', isMuted, isDeafened, isCameraOn, isScreenSharing: false }); }