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