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 }); }