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).
This commit is contained in:
@@ -19,6 +19,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
const isSpeaking = useVoiceStore((s) => s.speakingParticipantIds.has(participant.identity));
|
||||||
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds);
|
||||||
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds);
|
||||||
|
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
|
||||||
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
|
||||||
|
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
@@ -153,6 +154,16 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!isLocal && participant.isCameraOn && (
|
||||||
|
<div className={`w-5 h-5 ${unwatchedCameras.has(participant.userId) ? 'bg-accent-rose/90' : 'bg-white/20'} rounded-full flex items-center justify-center`}>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="white">
|
||||||
|
<path d="M17 10.5V7c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||||
|
{unwatchedCameras.has(participant.userId) && (
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
|||||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
|
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface VoiceModMenuItemsProps {
|
interface VoiceModMenuItemsProps {
|
||||||
targetUserId: string;
|
targetUserId: string;
|
||||||
@@ -196,6 +197,7 @@ function MoveToSubmenu({ channels, onMove, btnClass, btnStyle }: MoveToSubmenuPr
|
|||||||
onMouseEnter={cancelCloseTimer}
|
onMouseEnter={cancelCloseTimer}
|
||||||
onMouseLeave={startCloseTimer}
|
onMouseLeave={startCloseTimer}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{channels.map((ch) => (
|
{channels.map((ch) => (
|
||||||
<button
|
<button
|
||||||
@@ -248,6 +250,12 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos
|
|||||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||||
const perUserVolume = participantVolumes.get(targetUserId) ?? 100;
|
const perUserVolume = participantVolumes.get(targetUserId) ?? 100;
|
||||||
|
|
||||||
|
const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras);
|
||||||
|
const participants = useVoiceStore((s) => s.participants);
|
||||||
|
const targetParticipant = participants.find((p) => p.userId === targetUserId);
|
||||||
|
const targetHasCamera = targetParticipant?.isCameraOn ?? false;
|
||||||
|
const isCameraUnwatched = unwatchedCameras.has(targetUserId);
|
||||||
|
|
||||||
// Click-outside dismissal
|
// Click-outside dismissal
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (e: MouseEvent) => {
|
const handler = (e: MouseEvent) => {
|
||||||
@@ -281,6 +289,8 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos
|
|||||||
ref={menuRef}
|
ref={menuRef}
|
||||||
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high min-w-[200px] max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin animate-fade-in"
|
className="fixed z-[200] bg-surface-elevated rounded-md shadow-elevation-high min-w-[200px] max-h-[calc(100vh-16px)] overflow-y-auto scrollbar-thin animate-fade-in"
|
||||||
style={{ left: position.x, top: position.y }}
|
style={{ left: position.x, top: position.y }}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
{hasModPerms && (
|
{hasModPerms && (
|
||||||
<>
|
<>
|
||||||
@@ -294,6 +304,41 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos
|
|||||||
<div className="h-px bg-white/[0.06] mx-1.5" />
|
<div className="h-px bg-white/[0.06] mx-1.5" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{targetHasCamera && (
|
||||||
|
<>
|
||||||
|
<div className="py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const room = getActiveRoom();
|
||||||
|
const identity = targetParticipant?.identity;
|
||||||
|
if (isCameraUnwatched) {
|
||||||
|
useVoiceStore.getState().rewatchCamera(targetUserId);
|
||||||
|
if (identity) setCameraSubscription(room, identity, true);
|
||||||
|
} else {
|
||||||
|
useVoiceStore.getState().unwatchCamera(targetUserId);
|
||||||
|
if (identity) setCameraSubscription(room, identity, false);
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 text-txt-secondary hover:bg-accent-primary hover:text-white"
|
||||||
|
style={{ width: 'calc(100% - 12px)' }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||||
|
{isCameraUnwatched ? (
|
||||||
|
<path d="M17 10.5V7c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<path d="M17 10.5V7c0-.55-.45-1-1-1H2c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h14c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||||
|
<line x1="1" y1="1" x2="23" y2="23" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
{isCameraUnwatched ? 'Watch Camera' : 'Stop Watching Camera'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="h-px bg-white/[0.06] mx-1.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<div className="p-3">
|
<div className="p-3">
|
||||||
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
|
<div className="text-xs text-txt-tertiary mb-2 font-medium uppercase tracking-wider">
|
||||||
User Volume
|
User Volume
|
||||||
|
|||||||
@@ -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 } {
|
function parseIdentity(identity: string): { userId: string; username: string } {
|
||||||
const parts = identity.split(':');
|
const parts = identity.split(':');
|
||||||
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
|
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
|
||||||
@@ -167,9 +178,12 @@ export function useLiveKit() {
|
|||||||
let lkVideoTrack: Track | null = null;
|
let lkVideoTrack: Track | null = null;
|
||||||
let lkScreenTrack: Track | null = null;
|
let lkScreenTrack: Track | null = null;
|
||||||
let hasScreenSharePublication = false;
|
let hasScreenSharePublication = false;
|
||||||
|
let hasCameraPublication = false;
|
||||||
p.trackPublications.forEach((pub) => {
|
p.trackPublications.forEach((pub) => {
|
||||||
// Detect screen share publication even if unsubscribed
|
// Detect screen share publication even if unsubscribed
|
||||||
if (pub.source === Track.Source.ScreenShare) hasScreenSharePublication = true;
|
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;
|
const track = pub.track;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
@@ -212,7 +226,7 @@ export function useLiveKit() {
|
|||||||
homeUserId,
|
homeUserId,
|
||||||
isMuted: isPartMuted,
|
isMuted: isPartMuted,
|
||||||
isDeafened: isPartDeafened,
|
isDeafened: isPartDeafened,
|
||||||
isCameraOn: !!videoTrack,
|
isCameraOn: hasCameraPublication && p.isCameraEnabled, // True even when unsubscribed
|
||||||
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
|
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
|
||||||
isLocal,
|
isLocal,
|
||||||
audioTrack,
|
audioTrack,
|
||||||
|
|||||||
@@ -39,12 +39,15 @@ interface VoiceState {
|
|||||||
streamVolumes: Map<string, number>; // userId → 0-200 (100 default)
|
streamVolumes: Map<string, number>; // userId → 0-200 (100 default)
|
||||||
streamMutes: Map<string, boolean>; // userId → muted?
|
streamMutes: Map<string, boolean>; // userId → muted?
|
||||||
watchingStreams: Set<string>; // userIds we're watching
|
watchingStreams: Set<string>; // userIds we're watching
|
||||||
|
unwatchedCameras: Set<string>; // userIds whose cameras we've opted out of
|
||||||
streamAttenuationEnabled: boolean; // global toggle, default true
|
streamAttenuationEnabled: boolean; // global toggle, default true
|
||||||
streamAttenuationStrength: number; // 0-100, default 50
|
streamAttenuationStrength: number; // 0-100, default 50
|
||||||
setStreamVolume: (userId: string, volume: number) => void;
|
setStreamVolume: (userId: string, volume: number) => void;
|
||||||
setStreamMute: (userId: string, muted: boolean) => void;
|
setStreamMute: (userId: string, muted: boolean) => void;
|
||||||
watchStream: (userId: string) => void;
|
watchStream: (userId: string) => void;
|
||||||
unwatchStream: (userId: string) => void;
|
unwatchStream: (userId: string) => void;
|
||||||
|
unwatchCamera: (userId: string) => void;
|
||||||
|
rewatchCamera: (userId: string) => void;
|
||||||
clearStreamVolume: (userId: string) => void;
|
clearStreamVolume: (userId: string) => void;
|
||||||
clearStreamMute: (userId: string) => void;
|
clearStreamMute: (userId: string) => void;
|
||||||
setStreamAttenuationEnabled: (enabled: boolean) => void;
|
setStreamAttenuationEnabled: (enabled: boolean) => void;
|
||||||
@@ -135,6 +138,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
watchingStreams: new Set(),
|
watchingStreams: new Set(),
|
||||||
|
unwatchedCameras: new Set(),
|
||||||
streamAttenuationEnabled: false,
|
streamAttenuationEnabled: false,
|
||||||
streamAttenuationStrength: 50,
|
streamAttenuationStrength: 50,
|
||||||
|
|
||||||
@@ -166,6 +170,20 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
return { watchingStreams: newSet };
|
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) => {
|
clearStreamVolume: (userId) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newMap = new Map(state.streamVolumes);
|
const newMap = new Map(state.streamVolumes);
|
||||||
@@ -366,6 +384,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
watchingStreams: new Set(),
|
watchingStreams: new Set(),
|
||||||
|
unwatchedCameras: new Set(),
|
||||||
voiceUsers,
|
voiceUsers,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -391,6 +410,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
watchingStreams: new Set(),
|
watchingStreams: new Set(),
|
||||||
|
unwatchedCameras: new Set(),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -420,6 +440,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
streamVolumes: new Map(),
|
streamVolumes: new Map(),
|
||||||
streamMutes: new Map(),
|
streamMutes: new Map(),
|
||||||
watchingStreams: new Set(),
|
watchingStreams: new Set(),
|
||||||
|
unwatchedCameras: new Set(),
|
||||||
serverMutedUserIds: new Set(),
|
serverMutedUserIds: new Set(),
|
||||||
serverDeafenedUserIds: new Set(),
|
serverDeafenedUserIds: new Set(),
|
||||||
}),
|
}),
|
||||||
@@ -494,6 +515,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
merged.streamVolumes = currentState.streamVolumes;
|
merged.streamVolumes = currentState.streamVolumes;
|
||||||
merged.streamMutes = currentState.streamMutes;
|
merged.streamMutes = currentState.streamMutes;
|
||||||
merged.watchingStreams = currentState.watchingStreams;
|
merged.watchingStreams = currentState.watchingStreams;
|
||||||
|
merged.unwatchedCameras = currentState.unwatchedCameras;
|
||||||
return merged;
|
return merged;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user