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.
This commit is contained in:
Jannis Braun
2026-02-23 20:07:42 +01:00
parent 77c5bda1fd
commit 5e34b39b78
10 changed files with 67 additions and 34 deletions
+9 -1
View File
@@ -397,6 +397,8 @@ function handleVoiceJoin(event: Record<string, unknown>, 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<string, unknown>, userId: string): void {
channelId,
isMuted: status.isMuted,
isDeafened: status.isDeafened,
isCameraOn: status.isCameraOn,
isScreenSharing: status.isScreenSharing,
});
}
}
@@ -764,6 +768,8 @@ 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 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<string, unknown>, 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<string, unknown>, userId: string): void
channelId,
isMuted,
isDeafened,
isCameraOn,
isScreenSharing,
});
}
+9 -9
View File
@@ -44,8 +44,8 @@ class ConnectionManager {
private wsToUser: Map<WebSocket, string> = new Map();
// dmChannelId → { callerId, startedAt } — active DM calls
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();
// userId → { isMuted, isDeafened, isCameraOn, isScreenSharing } — voice user status
private voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = new Map();
// userId → Timeout
private pendingOfflineTimeouts: Map<string, NodeJS.Timeout> = 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<string, { isMuted: boolean; isDeafened: boolean }> {
getAllVoiceUserStates(): Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> {
return this.voiceUserStates;
}
@@ -289,7 +289,7 @@ function buildReadyPayload(userId: string): {
dmChannels: DmChannel[];
folders: ServerFolder[];
voiceStates: Record<string, string[]>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean }>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
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<string, { isMuted: boolean; isDeafened: boolean }> = {};
// Build voice user states — tell the client mute/deafen/camera/screenshare status of voice users
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
for (const chId of Object.keys(voiceStates)) {
const usersInChannel = voiceStates[chId];
if (usersInChannel) {
+3 -3
View File
@@ -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<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean }>; readStates?: ReadState[] }
| { type: 'ready'; user: User; servers: ServerWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: ServerFolder[]; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; 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 }
@@ -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
@@ -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 (
<div key={userId} className="flex items-center gap-2 px-2 py-0.5 rounded hover:bg-discord-modifier-hover transition-colors">
@@ -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);
@@ -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);
+6 -6
View File
@@ -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':
+4 -4
View File
@@ -75,8 +75,8 @@ interface VoiceState {
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;
voiceUserStates: Map<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
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<VoiceState>()(
},
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 };
});
},
+4
View File
@@ -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<void> {
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 });
}