diff --git a/apps/desktop/src/components/InCallPanel.tsx b/apps/desktop/src/components/InCallPanel.tsx index 607c5e8..8d791d9 100644 --- a/apps/desktop/src/components/InCallPanel.tsx +++ b/apps/desktop/src/components/InCallPanel.tsx @@ -17,6 +17,7 @@ import { CallParticipantTile } from './CallParticipantTile'; import { FocusIcon, GridIcon, LockIcon, MaximizeIcon, SpinnerIcon, UsersIcon } from './icons'; import { ParticipantsPopover, type ParticipantRow } from './ParticipantsPopover'; import { ParticipantVolumeMenu } from './ParticipantVolumeMenu'; +import { ScreenShareContextMenu } from './ScreenShareContextMenu'; import { ScreenShareDialog } from './ScreenShareDialog'; import { ScreenShareViewer } from './ScreenShareViewer'; import { SoundboardPanel } from './SoundboardPanel'; @@ -78,6 +79,7 @@ export function InCallPanel({ conversation }: Props) { micError, clearMicError, retryMic, + dismissedShareUserIds, } = useCall(); const { session } = useAuth(); const myId = session?.user.id ?? null; @@ -88,6 +90,9 @@ export function InCallPanel({ conversation }: Props) { const [volumeMenu, setVolumeMenu] = useState< { userId: string; displayName: string; x: number; y: number } | null >(null); + const [shareMenu, setShareMenu] = useState< + { userId: string; displayName: string; hasAudio: boolean; x: number; y: number } | null + >(null); // Active-speaker auto-focus uses "who most recently started speaking" // rather than "exactly one speaker" — matches Discord more closely and // handles the case where two people talk briefly without the focus @@ -104,13 +109,41 @@ export function InCallPanel({ conversation }: Props) { prevActiveSpeakersRef.current = new Set(activeSpeakers); }, [activeSpeakers]); - const openVolumeMenu = (tile: Tile, e: React.MouseEvent) => { + // Single right-click dispatcher for all tiles. User-tiles open the volume + // menu; screen-tiles open the share-specific menu (volume + mute + stop + // watching). Self-tiles get no menu — no volume to control, and you can + // stop your own share from the control bar. + const openTileContextMenu = (tile: Tile, e: React.MouseEvent) => { if (tile.self) return; - if (tile.kind !== 'user') return; e.preventDefault(); - setVolumeMenu({ + if (tile.kind === 'user') { + setShareMenu(null); + setVolumeMenu({ + userId: tile.userId, + displayName: tile.displayName, + x: e.clientX, + y: e.clientY, + }); + return; + } + // Screen-tile. Check whether the participant has a published screen-share + // audio track so the menu can hide the volume/mute rows when there's + // nothing to control. + const participant = remoteParticipants.find((p) => p.identity === tile.userId); + let hasAudio = false; + if (participant) { + for (const pub of participant.audioTrackPublications.values()) { + if (pub.source === Track.Source.ScreenShareAudio) { + hasAudio = true; + break; + } + } + } + setVolumeMenu(null); + setShareMenu({ userId: tile.userId, - displayName: tile.displayName, + displayName: tile.displayName.replace(/\s·\sBildschirm$/, ''), + hasAudio, x: e.clientX, y: e.clientY, }); @@ -135,7 +168,15 @@ export function InCallPanel({ conversation }: Props) { remoteMute, isScreenSharing, isCameraEnabled, - remoteSharerIds: new Set(remoteScreenShares.map((s) => s.participantId)), + // Sharer ids that survived the user's dismiss-set. If the user did + // "Zuschauen beenden" on someone's share, they drop out of the tile + // grid until that sharer stops + restarts (TrackUnsubscribed clears + // dismissedShareUserIds — see CallContext). + remoteSharerIds: new Set( + remoteScreenShares + .map((s) => s.participantId) + .filter((id) => !dismissedShareUserIds.has(id)), + ), }); // Duration keeps ticking during reconnecting so the user sees the call is @@ -157,11 +198,11 @@ export function InCallPanel({ conversation }: Props) { ? t('app:call.waiting_for_peers', { defaultValue: 'Warte auf andere…' }) : t('app:call.connected'); - // A screen-share tile becomes the auto-focus target when no one explicitly - // picked a tile yet. Focus ids now use Tile.id (kind-prefixed) so we can - // distinguish a user's own avatar tile from their screen tile. - const screenTile = tiles.find((p) => p.kind === 'screen'); - const effectiveFocusedId = focusedId ?? screenTile?.id ?? tiles[0]?.id ?? null; + // Screen shares no longer auto-promote — the user opts in by clicking the + // "Bildschirm anschauen" overlay, which also toggles whether the audio + // plays. Focus falls back to the first tile so focus-mode always has + // something to show when no tile was explicitly picked. + const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null; const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0]; const controls = ( @@ -212,13 +253,15 @@ export function InCallPanel({ conversation }: Props) { })); if (callMode === 'fullscreen') { - // In fullscreen, a "manual focus" = user explicitly picked someone, OR - // someone is sharing a screen, OR the person who most recently started - // speaking (tracked in lastStartedSpeakerId). "Most recent speaker" - // beats "exactly one currently speaking" because two people briefly - // overlapping shouldn't kick us out of auto-focus. + // In fullscreen, a "manual focus" = user explicitly picked someone OR + // the person who most recently started speaking (tracked in + // lastStartedSpeakerId). Screen shares are no longer an auto-focus + // trigger; they stay as equal-size grid tiles until the user clicks + // one. "Most recent speaker" beats "exactly one currently speaking" + // because two people briefly overlapping shouldn't kick us out of + // auto-focus. const autoSpeaker = - focusedId === null && screenTile === undefined && lastStartedSpeakerId !== null + focusedId === null && lastStartedSpeakerId !== null ? tiles.find( (t) => t.kind === 'user' && @@ -226,7 +269,7 @@ export function InCallPanel({ conversation }: Props) { t.userId === lastStartedSpeakerId, ) : undefined; - const hasFocus = focusedId !== null || screenTile !== undefined || autoSpeaker !== undefined; + const hasFocus = focusedId !== null || autoSpeaker !== undefined; const effectiveSpeaker = hasFocus ? speaker ?? autoSpeaker : undefined; return ( <> @@ -253,7 +296,7 @@ export function InCallPanel({ conversation }: Props) { // Toggle: click the already-focused tile to return to grid. setFocusedId(focusedId === id ? null : id); }} - onTileContextMenu={openVolumeMenu} + onTileContextMenu={openTileContextMenu} controls={controls} // Any active popover / menu / banner pins the controls so the user // can interact with them without the chrome fading out under their @@ -262,6 +305,7 @@ export function InCallPanel({ conversation }: Props) { soundboardOpen || participantsOpen || volumeMenu !== null || + shareMenu !== null || micError !== null } /> @@ -274,6 +318,16 @@ export function InCallPanel({ conversation }: Props) { onClose={() => setVolumeMenu(null)} /> )} + {shareMenu && ( + setShareMenu(null)} + /> + )} setSoundboardOpen(false)} @@ -350,7 +404,7 @@ export function InCallPanel({ conversation }: Props) { setFocusedId(id); if (callMode === 'grid') setCallMode('focus'); }} - onTileContextMenu={openVolumeMenu} + onTileContextMenu={openTileContextMenu} compact /> @@ -376,6 +430,17 @@ export function InCallPanel({ conversation }: Props) { /> )} + {shareMenu && ( + setShareMenu(null)} + /> + )} + setSoundboardOpen(false)} @@ -675,6 +740,7 @@ function TileRender({ return (
div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')} > { const id = window.setTimeout(() => setHintGone(true), 3500); return () => window.clearTimeout(id); @@ -953,7 +1023,7 @@ function FullscreenCall({ : {})} />
- {others.length > 0 && ( + {others.length > 0 && !stripHidden && (
{others.map((p) => (
)} + {/* Hide-participant-strip toggle, only meaningful when there's a focus + + extras to hide. Fades alongside the bottom control bar so idle + fullscreen still goes clean. */} + {hasFocus && others.length > 0 && ( + + )} + @@ -108,7 +101,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare ) : (