From f9376460d4e5e111c10b3523cd5d62a4d79b4cf9 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 02:45:03 +0100 Subject: [PATCH] feat: add camera watch/unwatch with click propagation fix and correct badge color Add ability to unsubscribe from remote camera tracks via context menu, with unwatched state tracked in voiceStore. Fix click propagation through React portals by adding onClick stopPropagation alongside onMouseDown on both the main context menu and MoveToSubmenu flyout portal containers. Use rose badge color for unwatched cameras (user choice) instead of amber (reserved for server-enforced states). --- .../web/src/components/voice/VoiceUser.tsx | 11 +++++ .../components/voice/VoiceUserContextMenu.tsx | 45 +++++++++++++++++++ packages/web/src/hooks/useLiveKit.ts | 16 ++++++- packages/web/src/stores/voiceStore.ts | 22 +++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 616f6c20..75df4b54 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -19,6 +19,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity)); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); + const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const [, forceUpdate] = useState(0); @@ -153,6 +154,16 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { )} + {!isLocal && participant.isCameraOn && ( +
+ + + {unwatchedCameras.has(participant.userId) && ( + + )} + +
+ )} ); })()} diff --git a/packages/web/src/components/voice/VoiceUserContextMenu.tsx b/packages/web/src/components/voice/VoiceUserContextMenu.tsx index 5d762813..23e98d1b 100644 --- a/packages/web/src/components/voice/VoiceUserContextMenu.tsx +++ b/packages/web/src/components/voice/VoiceUserContextMenu.tsx @@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { wsSend } from '../../hooks/useWebSocket'; import { hasPermissionBit, PermissionBits } from '../../utils/permissions'; +import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit'; interface VoiceModMenuItemsProps { targetUserId: string; @@ -196,6 +197,7 @@ function MoveToSubmenu({ channels, onMove, btnClass, btnStyle }: MoveToSubmenuPr onMouseEnter={cancelCloseTimer} onMouseLeave={startCloseTimer} onMouseDown={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} > {channels.map((ch) => ( + +
+ + )}
User Volume diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index 8a87421c..5bdd778a 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -111,6 +111,17 @@ export function setStreamSubscription(room: Room | null, targetIdentity: string, }); } +export function setCameraSubscription(room: Room | null, targetIdentity: string, subscribed: boolean) { + if (!room) return; + const rp = room.remoteParticipants.get(targetIdentity); + if (!rp) return; + rp.trackPublications.forEach((pub) => { + if (pub.source === Track.Source.Camera) { + (pub as RemoteTrackPublication).setSubscribed(subscribed); + } + }); +} + function parseIdentity(identity: string): { userId: string; username: string } { const parts = identity.split(':'); return { userId: parts[0] ?? identity, username: parts[1] ?? identity }; @@ -167,9 +178,12 @@ export function useLiveKit() { let lkVideoTrack: Track | null = null; let lkScreenTrack: Track | null = null; let hasScreenSharePublication = false; + let hasCameraPublication = false; p.trackPublications.forEach((pub) => { // Detect screen share publication even if unsubscribed if (pub.source === Track.Source.ScreenShare) hasScreenSharePublication = true; + // Detect camera publication even if unsubscribed (for unwatched cameras) + if (pub.source === Track.Source.Camera) hasCameraPublication = true; const track = pub.track; if (!track) return; @@ -212,7 +226,7 @@ export function useLiveKit() { homeUserId, isMuted: isPartMuted, isDeafened: isPartDeafened, - isCameraOn: !!videoTrack, + isCameraOn: hasCameraPublication && p.isCameraEnabled, // True even when unsubscribed isScreenSharing: hasScreenSharePublication, // True even when unsubscribed isLocal, audioTrack, diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index ac6c2a05..89b59994 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -39,12 +39,15 @@ interface VoiceState { streamVolumes: Map; // userId → 0-200 (100 default) streamMutes: Map; // userId → muted? watchingStreams: Set; // userIds we're watching + unwatchedCameras: Set; // userIds whose cameras we've opted out of streamAttenuationEnabled: boolean; // global toggle, default true streamAttenuationStrength: number; // 0-100, default 50 setStreamVolume: (userId: string, volume: number) => void; setStreamMute: (userId: string, muted: boolean) => void; watchStream: (userId: string) => void; unwatchStream: (userId: string) => void; + unwatchCamera: (userId: string) => void; + rewatchCamera: (userId: string) => void; clearStreamVolume: (userId: string) => void; clearStreamMute: (userId: string) => void; setStreamAttenuationEnabled: (enabled: boolean) => void; @@ -135,6 +138,7 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), + unwatchedCameras: new Set(), streamAttenuationEnabled: false, streamAttenuationStrength: 50, @@ -166,6 +170,20 @@ export const useVoiceStore = create()( return { watchingStreams: newSet }; }); }, + unwatchCamera: (userId) => { + set((state) => { + const newSet = new Set(state.unwatchedCameras); + newSet.add(userId); + return { unwatchedCameras: newSet }; + }); + }, + rewatchCamera: (userId) => { + set((state) => { + const newSet = new Set(state.unwatchedCameras); + newSet.delete(userId); + return { unwatchedCameras: newSet }; + }); + }, clearStreamVolume: (userId) => { set((state) => { const newMap = new Map(state.streamVolumes); @@ -366,6 +384,7 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), + unwatchedCameras: new Set(), voiceUsers, }; }); @@ -391,6 +410,7 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), + unwatchedCameras: new Set(), }); }, @@ -420,6 +440,7 @@ export const useVoiceStore = create()( streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), + unwatchedCameras: new Set(), serverMutedUserIds: new Set(), serverDeafenedUserIds: new Set(), }), @@ -494,6 +515,7 @@ export const useVoiceStore = create()( merged.streamVolumes = currentState.streamVolumes; merged.streamMutes = currentState.streamMutes; merged.watchingStreams = currentState.watchingStreams; + merged.unwatchedCameras = currentState.unwatchedCameras; return merged; }, }