From 3d37c70e50b239a45d36144b3dfea7e0d2c5d005 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 03:19:15 +0100 Subject: [PATCH] feat: grey badges for local-only actions and sidebar unwatched camera state Use neutral bg-white/20 for unwatched camera badge in grid tile instead of bg-accent-rose/90, matching the local mute badge convention. Show crossed-out camera icon in channel sidebar when a remote user's camera is locally unwatched. Add local mute badge to both grid tile and sidebar. --- .../components/voice/GlobalAudioRenderer.tsx | 4 +- .../components/voice/StreamContextMenu.tsx | 280 +++++++++++++++++ .../web/src/components/voice/StreamTile.tsx | 282 +----------------- .../web/src/components/voice/VoiceChannel.tsx | 14 + .../web/src/components/voice/VoiceUser.tsx | 12 +- .../components/voice/VoiceUserContextMenu.tsx | 27 ++ packages/web/src/stores/voiceStore.ts | 16 + 7 files changed, 359 insertions(+), 276 deletions(-) create mode 100644 packages/web/src/components/voice/StreamContextMenu.tsx diff --git a/packages/web/src/components/voice/GlobalAudioRenderer.tsx b/packages/web/src/components/voice/GlobalAudioRenderer.tsx index 64cd6a0b..b3e071b1 100644 --- a/packages/web/src/components/voice/GlobalAudioRenderer.tsx +++ b/packages/web/src/components/voice/GlobalAudioRenderer.tsx @@ -73,6 +73,7 @@ export function GlobalAudioRenderer() { const outputVolume = useVoiceStore((s) => s.outputVolume); const participantVolumes = useVoiceStore((s) => s.participantVolumes); const streamVolumes = useVoiceStore((s) => s.streamVolumes); + const participantMutes = useVoiceStore((s) => s.participantMutes); const streamMutes = useVoiceStore((s) => s.streamMutes); const watchingStreams = useVoiceStore((s) => s.watchingStreams); const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled); @@ -95,6 +96,7 @@ export function GlobalAudioRenderer() { <> {remoteParticipants.map((p: ParticipantInfo) => { const micVolume = participantVolumes.get(p.userId) ?? 100; + const isMicMuted = participantMutes.get(p.userId) ?? false; const streamVol = streamVolumes.get(p.userId) ?? 100; const isStreamMuted = streamMutes.get(p.userId) ?? false; @@ -107,7 +109,7 @@ export function GlobalAudioRenderer() { globalVolume={outputVolume} perSourceVolume={micVolume} isDeafened={isDeafened} - isMuted={false} + isMuted={isMicMuted} attenuate={false} someoneIsSpeaking={false} attenuationEnabled={false} diff --git a/packages/web/src/components/voice/StreamContextMenu.tsx b/packages/web/src/components/voice/StreamContextMenu.tsx new file mode 100644 index 00000000..937bde84 --- /dev/null +++ b/packages/web/src/components/voice/StreamContextMenu.tsx @@ -0,0 +1,280 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { useVoiceStore } from '../../stores/voiceStore'; +import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; +import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; +import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; + +interface StreamContextMenuProps { + userId: string; + identity: string; + isLocal: boolean; + position: { x: number; y: number }; + onClose: () => void; +} + +export function StreamContextMenu({ userId, identity, isLocal, position, onClose }: StreamContextMenuProps) { + const menuRef = useRef(null); + + const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); + const qualityBtnRef = useRef(null); + + const streamVolumes = useVoiceStore((s) => s.streamVolumes); + const streamMutes = useVoiceStore((s) => s.streamMutes); + const watchingStreams = useVoiceStore((s) => s.watchingStreams); + const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled); + const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength); + const setStreamVolumeAction = useVoiceStore((s) => s.setStreamVolume); + const setStreamMuteAction = useVoiceStore((s) => s.setStreamMute); + const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled); + const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength); + + const isWatching = watchingStreams.has(userId); + const streamVolume = streamVolumes.get(userId) ?? 100; + const isStreamMuted = streamMutes.get(userId) ?? false; + + const handleWatch = useCallback(() => { + useVoiceStore.getState().watchStream(userId); + setStreamSubscription(getActiveRoom(), identity, true); + }, [userId, identity]); + + const handleUnwatch = useCallback(() => { + useVoiceStore.getState().unwatchStream(userId); + setStreamSubscription(getActiveRoom(), identity, false); + }, [userId, identity]); + + const handleStopStreaming = useCallback(async () => { + const room = getActiveRoom(); + if (room) { + await stopScreenShare(room); + } + }, []); + + const handleChangeStream = useCallback(async () => { + const room = getActiveRoom(); + if (room) { + await changeScreenShare(room); + } + }, []); + + // Click-outside dismissal (guards nested popover) + useEffect(() => { + const handler = (e: MouseEvent) => { + if (qualityPopoverOpen) return; + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [onClose, qualityPopoverOpen]); + + // Viewport-aware positioning + useLayoutEffect(() => { + const el = menuRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + let x = position.x; + let y = position.y; + if (rect.right > window.innerWidth) x = window.innerWidth - rect.width - 8; + if (rect.bottom > window.innerHeight) y = window.innerHeight - rect.height - 8; + if (x < 8) x = 8; + if (y < 8) y = 8; + el.style.left = `${x}px`; + el.style.top = `${y}px`; + }, [position]); + + const btnClass = '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'; + const btnStyle: React.CSSProperties = { width: 'calc(100% - 12px)' }; + + return ReactDOM.createPortal( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {isLocal ? ( + <> +
+ + +
+
+
+
+ Stream Quality +
+
+ + {qualityPopoverOpen && ( + setQualityPopoverOpen(false)} + anchorRef={qualityBtnRef} + /> + )} +
+
+ + ) : ( + <> +
+ {isWatching ? ( + + ) : ( + + )} +
+
+
+ +
+
+
+ Stream Volume +
+
+ + + + setStreamVolumeAction(userId, parseInt(e.target.value))} + className="flex-1 accent-accent-primary h-1" + /> + + {streamVolume}% + +
+
+
+
+ +
+ {streamAttenuationEnabled && ( +
+
+ Attenuation Strength +
+
+ setAttenuationStrength(parseInt(e.target.value))} + className="flex-1 accent-accent-primary h-1" + /> + + {streamAttenuationStrength}% + +
+
+ )} + + )} +
, + document.body, + ); +} diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index b3afdd26..79ddfd5b 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -2,8 +2,7 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Avatar } from '../ui/Avatar'; import { useVoiceStore } from '../../stores/voiceStore'; import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; -import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; -import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; +import { StreamContextMenu } from './StreamContextMenu'; import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit'; interface StreamTileProps { @@ -14,11 +13,7 @@ interface StreamTileProps { export function StreamTile({ tile, large }: StreamTileProps) { const videoRef = useRef(null); - const streamVolumes = useVoiceStore((s) => s.streamVolumes); - const streamMutes = useVoiceStore((s) => s.streamMutes); const watchingStreams = useVoiceStore((s) => s.watchingStreams); - const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled); - const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength); const { participant } = tile; const isLocal = participant.isLocal; @@ -26,8 +21,6 @@ export function StreamTile({ tile, large }: StreamTileProps) { const avatarUserId = participant.homeUserId ?? userId; const isWatching = watchingStreams.has(userId); - const streamVolume = streamVolumes.get(userId) ?? 100; - const isStreamMuted = streamMutes.get(userId) ?? false; const liveScreenTrack = tile.screenTrack?.readyState === 'live' ? tile.screenTrack : null; const liveLkScreenTrack = liveScreenTrack ? tile.lkScreenTrack : null; @@ -37,8 +30,6 @@ export function StreamTile({ tile, large }: StreamTileProps) { // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null); - const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false); - const qualityBtnRef = useRef(null); // --- VIDEO --- use LiveKit's track.attach() to register the element // with the adaptive stream observer (enables SFU layer switching by viewport size) @@ -92,42 +83,11 @@ export function StreamTile({ tile, large }: StreamTileProps) { [], ); - useEffect(() => { - if (!contextMenu) return; - const close = () => setContextMenu(null); - window.addEventListener('click', close); - return () => window.removeEventListener('click', close); - }, [contextMenu]); - const handleWatch = useCallback(() => { useVoiceStore.getState().watchStream(userId); setStreamSubscription(getActiveRoom(), participant.identity, true); }, [userId, participant.identity]); - const handleUnwatch = useCallback(() => { - useVoiceStore.getState().unwatchStream(userId); - setStreamSubscription(getActiveRoom(), participant.identity, false); - }, [userId, participant.identity]); - - const handleStopStreaming = useCallback(async () => { - const room = getActiveRoom(); - if (room) { - await stopScreenShare(room); - } - }, []); - - const handleChangeStream = useCallback(async () => { - const room = getActiveRoom(); - if (room) { - await changeScreenShare(room); - } - }, []); - - const setStreamVolumeAction = useVoiceStore((s) => s.setStreamVolume); - const setStreamMuteAction = useVoiceStore((s) => s.setStreamMute); - const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled); - const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength); - const hasVideo = liveScreenTrack !== null; return ( @@ -204,239 +164,13 @@ export function StreamTile({ tile, large }: StreamTileProps) { {/* Context Menu */} {contextMenu && ( -
e.stopPropagation()} - > - {isLocal ? ( - /* Streamer context menu (own stream) */ - <> - - -
-
-
- Stream Quality -
-
- - {qualityPopoverOpen && ( - setQualityPopoverOpen(false)} - anchorRef={qualityBtnRef} - /> - )} -
-
- - ) : ( - /* Viewer context menu (remote stream) */ - <> - {isWatching ? ( - - ) : ( - - )} -
- {/* Mute toggle */} - - {/* Stream Volume slider */} -
-
- Stream Volume -
-
- - - - - setStreamVolumeAction(userId, parseInt(e.target.value)) - } - className="flex-1 accent-accent-primary h-1" - /> - - {streamVolume}% - -
-
-
- {/* Stream Attenuation toggle */} - - {/* Attenuation Strength slider */} - {streamAttenuationEnabled && ( -
-
- Attenuation Strength -
-
- - setAttenuationStrength(parseInt(e.target.value)) - } - className="flex-1 accent-accent-primary h-1" - /> - - {streamAttenuationStrength}% - -
-
- )} - - )} -
+ setContextMenu(null)} + /> )}
); diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index d902418c..f4abc4dc 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -23,6 +23,8 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); + const participantMutes = useVoiceStore((s) => s.participantMutes); + const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const currentUserId = useVoiceStore((s) => { const local = s.participants.find(p => p.isLocal); return local?.userId ?? null; @@ -146,11 +148,23 @@ export function VoiceChannel({ channelId, channelName, onClick, locked }: VoiceC {hasCamera && ( + {userId !== myUser?.id && unwatchedCameras.has(userId) && ( + + )} )} {isScreenSharing && ( LIVE )} + {userId !== myUser?.id && participantMutes.get(userId) && ( + + + + + + + + )}
); diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 75df4b54..cb05e03c 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -20,6 +20,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) { const serverMutedUserIds = useVoiceStore((s) => s.serverMutedUserIds); const serverDeafenedUserIds = useVoiceStore((s) => s.serverDeafenedUserIds); const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); + const participantMutes = useVoiceStore((s) => s.participantMutes); const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null); const [, forceUpdate] = useState(0); @@ -155,7 +156,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
)} {!isLocal && participant.isCameraOn && ( -
+
{unwatchedCameras.has(participant.userId) && ( @@ -164,6 +165,15 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
)} + {!isLocal && participantMutes.get(participant.userId) && ( +
+ + + + + +
+ )} ); })()} diff --git a/packages/web/src/components/voice/VoiceUserContextMenu.tsx b/packages/web/src/components/voice/VoiceUserContextMenu.tsx index 23e98d1b..004212b8 100644 --- a/packages/web/src/components/voice/VoiceUserContextMenu.tsx +++ b/packages/web/src/components/voice/VoiceUserContextMenu.tsx @@ -250,6 +250,10 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume); const perUserVolume = participantVolumes.get(targetUserId) ?? 100; + const participantMutes = useVoiceStore((s) => s.participantMutes); + const setParticipantMute = useVoiceStore((s) => s.setParticipantMute); + const isUserMuted = participantMutes.get(targetUserId) ?? false; + const unwatchedCameras = useVoiceStore((s) => s.unwatchedCameras); const participants = useVoiceStore((s) => s.participants); const targetParticipant = participants.find((p) => p.userId === targetUserId); @@ -339,6 +343,29 @@ export function VoiceUserContextMenu({ targetUserId, channelId, position, onClos
)} +
+ +
+
User Volume diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 89b59994..e0f21974 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -35,6 +35,9 @@ interface VoiceState { participantVolumes: Map; setParticipantVolume: (userId: string, volume: number) => void; getParticipantVolume: (userId: string) => number; + // Per-participant local mute (userId → muted?) + participantMutes: Map; + setParticipantMute: (userId: string, muted: boolean) => void; // Stream widget state streamVolumes: Map; // userId → 0-200 (100 default) streamMutes: Map; // userId → muted? @@ -134,6 +137,15 @@ export const useVoiceStore = create()( }, getParticipantVolume: (userId) => get().participantVolumes.get(userId) ?? 100, + participantMutes: new Map(), + setParticipantMute: (userId, muted) => { + set((state) => { + const newMap = new Map(state.participantMutes); + newMap.set(userId, muted); + return { participantMutes: newMap }; + }); + }, + // Stream widget state streamVolumes: new Map(), streamMutes: new Map(), @@ -381,6 +393,7 @@ export const useVoiceStore = create()( activeDmCall: null, outgoingCall: null, deafenedUserIds: new Set(), + participantMutes: new Map(), streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), @@ -407,6 +420,7 @@ export const useVoiceStore = create()( activeDmCall: null, outgoingCall: null, deafenedUserIds: new Set(), + participantMutes: new Map(), streamVolumes: new Map(), streamMutes: new Map(), watchingStreams: new Set(), @@ -432,6 +446,7 @@ export const useVoiceStore = create()( outputDeviceId: 'default', focusedParticipantId: null, participantVolumes: new Map(), + participantMutes: new Map(), incomingCall: null, outgoingCall: null, activeDmCall: null, @@ -512,6 +527,7 @@ export const useVoiceStore = create()( merged.deafenedUserIds = currentState.deafenedUserIds; merged.voiceUserStates = currentState.voiceUserStates; merged.participantVolumes = currentState.participantVolumes; + merged.participantMutes = currentState.participantMutes; merged.streamVolumes = currentState.streamVolumes; merged.streamMutes = currentState.streamMutes; merged.watchingStreams = currentState.watchingStreams;