feat(call): Discord-style screen-share UX

- Watch-gate lifted into CallContext. watchingShareUserIds /
  dismissedShareUserIds / screenShareAudioMutedIds as session-only state,
  cleared on CallState.idle and on TrackUnsubscribed for each sharer.
  Survives layout changes (grid <-> focus <-> fullscreen) without
  resetting which the old local-state viewer dropped on remount.
- ScreenShareAudio tracks tagged via data-track-source="screenshare" at
  attach-time; initial muted follows watching + manual mute mirrors so
  audio never plays before the user clicks "Bildschirm anschauen". Deafen
  still wins at the top of the priority chain.
- New screenShareVolumes store (session-only, keyed by participantId).
  attachTrack pulls the initial volume from this store for screenshare
  audio elements so the context-menu slider takes effect immediately.
- Screen shares are no longer auto-promoted to focus. They render as
  equal-size grid tiles like everyone else; user clicks to focus. The
  "Bildschirm anschauen" overlay replaces auto-play as the opt-in.
- Dismissed sharer-ids filter out of buildTiles, so "Zuschauen beenden"
  really hides the tile until the sharer stops + restarts.
- New ScreenShareContextMenu (portal, Esc / outside-click to close):
  volume slider + audio mute toggle when the share has audio + a
  destructive "Zuschauen beenden" row. Wired via a dispatcher in
  InCallPanel that picks between participant-volume and share-menu
  based on tile.kind.
- Fullscreen cinema gets a "Hide participant strip" toggle (top-right,
  session-only) so focused content reaches the full viewport when the
  bottom thumbnail row would otherwise steal 160px. Fades with the
  auto-hide controls; only surfaces when there's a focus + peers to hide.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 20:42:26 +02:00
parent 02ca3e3581
commit 331b1298f8
5 changed files with 621 additions and 56 deletions
+146 -20
View File
@@ -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 && (
<ScreenShareContextMenu
userId={shareMenu.userId}
displayName={shareMenu.displayName}
hasAudio={shareMenu.hasAudio}
x={shareMenu.x}
y={shareMenu.y}
onClose={() => setShareMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => 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 && (
<ScreenShareContextMenu
userId={shareMenu.userId}
displayName={shareMenu.displayName}
hasAudio={shareMenu.hasAudio}
x={shareMenu.x}
y={shareMenu.y}
onClose={() => setShareMenu(null)}
/>
)}
<SoundboardPopover
open={soundboardOpen}
onClose={() => setSoundboardOpen(false)}
@@ -675,6 +740,7 @@ function TileRender({
return (
<div
onClick={onClick}
onContextMenu={onContextMenu}
className={'h-full w-full [&>div]:h-full [&>div]:w-full ' + (onClick ? 'cursor-pointer' : '')}
>
<ScreenShareViewer
@@ -879,6 +945,10 @@ function FullscreenCall({
// fullscreen so tiles aren't partially obscured. Any mousemove (or a
// popover opening via keepControlsVisible) brings them back immediately.
const [controlsVisible, setControlsVisible] = useState(true);
// Session-only toggle to collapse the participant strip while watching a
// focused tile (screen share, speaker). Matches Discord's "Hide non-video
// participants" — gives the focused content the full fullscreen height.
const [stripHidden, setStripHidden] = useState(false);
useEffect(() => {
const id = window.setTimeout(() => setHintGone(true), 3500);
return () => window.clearTimeout(id);
@@ -953,7 +1023,7 @@ function FullscreenCall({
: {})}
/>
</div>
{others.length > 0 && (
{others.length > 0 && !stripHidden && (
<div className="flex h-[160px] shrink-0 gap-2.5 overflow-x-auto px-4 pb-2">
{others.map((p) => (
<div
@@ -1032,6 +1102,38 @@ function FullscreenCall({
</div>
)}
{/* 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 && (
<button
type="button"
onClick={() => setStripHidden((v) => !v)}
aria-pressed={stripHidden}
title={
stripHidden
? 'Teilnehmer einblenden'
: 'Teilnehmer ausblenden'
}
aria-label={
stripHidden
? 'Teilnehmer einblenden'
: 'Teilnehmer ausblenden'
}
className={
'absolute right-5 top-5 z-20 flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 ' +
(controlsVisible
? 'pointer-events-auto opacity-100 '
: 'pointer-events-none opacity-0 ') +
(stripHidden
? 'border-accent bg-accent/20 text-accent-fg'
: 'border-white/15 bg-white/10 text-white hover:bg-white/15')
}
>
<StripToggleIcon hidden={stripHidden} />
</button>
)}
<div
className={
'absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity duration-200 ' +
@@ -1046,6 +1148,30 @@ function FullscreenCall({
);
}
// Users-icon with a diagonal slash when the strip is hidden — mirrors the
// MicOff/HeadphonesOff naming convention used elsewhere.
function StripToggleIcon({ hidden }: { hidden: boolean }) {
return (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
{hidden && <line x1="2" y1="2" x2="22" y2="22" />}
</svg>
);
}
function PttHint() {
const [ptt, setPtt] = useState<PttSettings>(() => getPttSettings());
useEffect(() => subscribePttSettings(setPtt), []);
@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useCall } from '../context/CallContext';
import {
getScreenShareVolume,
setScreenShareVolume,
subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes';
import { HeadphonesIcon, HeadphonesOffIcon, MonitorShareIcon, PhoneOffIcon } from './icons';
interface Props {
/** Participant whose screen share the user right-clicked. */
userId: string;
displayName: string;
/** Whether the share has an audio track published. Controls whether the
* volume / mute rows render — without audio those would be no-ops. */
hasAudio: boolean;
x: number;
y: number;
onClose: () => void;
}
const MENU_W = 260;
const MENU_H_WITH_AUDIO = 200;
const MENU_H_NO_AUDIO = 96;
// Context menu surfaced on right-click of a remote screen share. Mirrors
// Discord's stream menu: volume slider, audio mute toggle (independent of
// volume — matches HTMLMediaElement's `muted` field), and "stop watching"
// which both un-subscribes locally and dismisses the tile from the grid.
export function ScreenShareContextMenu({
userId,
displayName,
hasAudio,
x,
y,
onClose,
}: Props) {
const { t } = useTranslation(['app']);
const {
dismissShare,
screenShareAudioMutedIds,
setScreenShareAudioMuted,
} = useCall();
const [volume, setVolume] = useState<number>(() => getScreenShareVolume(userId));
useEffect(
() =>
subscribeScreenShareVolumes(() => {
setVolume(getScreenShareVolume(userId));
}),
[userId],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target?.closest('[data-share-menu]')) return;
onClose();
};
window.addEventListener('keydown', onKey);
window.addEventListener('mousedown', onDown);
return () => {
window.removeEventListener('keydown', onKey);
window.removeEventListener('mousedown', onDown);
};
}, [onClose]);
const muted = screenShareAudioMutedIds.has(userId);
const height = hasAudio ? MENU_H_WITH_AUDIO : MENU_H_NO_AUDIO;
const left = Math.min(Math.max(8, x), window.innerWidth - MENU_W - 8);
const top = Math.min(Math.max(8, y), window.innerHeight - height - 8);
return createPortal(
<div
data-share-menu
role="menu"
aria-label={t('app:call.share_menu_title', {
defaultValue: 'Bildschirmfreigabe von {{name}}',
name: displayName,
})}
style={{ left, top, width: MENU_W }}
className="fixed z-[80] overflow-hidden rounded-xl border border-line bg-surface-2/95 text-sm shadow-xl backdrop-blur-md"
>
<header className="flex items-center gap-2 border-b border-line px-3 py-2 text-xs text-fg-muted">
<MonitorShareIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" />
<span className="truncate">
{t('app:call.share_menu_owner', {
defaultValue: 'Bildschirmfreigabe · {{name}}',
name: displayName,
})}
</span>
</header>
{hasAudio && (
<>
<div className="flex flex-col gap-1.5 px-3 py-2.5">
<div className="flex items-center justify-between text-xs">
<span className="font-medium text-fg">
{t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
</span>
<span className="tabular-nums text-fg-muted">
{Math.round(volume * 100)}%
</span>
</div>
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => {
const v = Number(e.target.value);
setVolume(v);
setScreenShareVolume(userId, v);
}}
aria-label={t('app:call.share_volume', { defaultValue: 'Lautstärke' })}
className="w-full accent-accent"
/>
</div>
<button
type="button"
onClick={() => setScreenShareAudioMuted(userId, !muted)}
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs text-fg transition hover:bg-surface-3"
>
{muted ? (
<HeadphonesOffIcon className="h-4 w-4 text-rose-500" />
) : (
<HeadphonesIcon className="h-4 w-4 text-fg-muted" />
)}
<span className="flex-1">
{muted
? t('app:call.share_unmute_audio', { defaultValue: 'Audio einschalten' })
: t('app:call.share_mute_audio', { defaultValue: 'Audio stumm' })}
</span>
</button>
</>
)}
<button
type="button"
onClick={() => {
dismissShare(userId);
onClose();
}}
className="flex w-full cursor-pointer items-center gap-2 border-t border-line px-3 py-2 text-left text-xs font-semibold text-rose-600 transition hover:bg-rose-500/10 dark:text-rose-300"
>
<PhoneOffIcon className="h-4 w-4" />
<span>
{t('app:call.stop_watching', { defaultValue: 'Zuschauen beenden' })}
</span>
</button>
</div>,
document.body,
);
}
@@ -2,7 +2,7 @@ import type { RemoteTrack } from 'livekit-client';
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { RemoteScreenShare } from '../context/CallContext';
import { type RemoteScreenShare, useCall } from '../context/CallContext';
import { MonitorShareIcon } from './icons';
interface ScreenShareViewerProps {
@@ -12,12 +12,21 @@ interface ScreenShareViewerProps {
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
// Lifted out of the old InCallPanel so the new CallDock stays lean.
export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShareViewerProps) {
// The watch-state lives in CallContext (not local useState) so it survives
// layout-mode changes (grid → focus → fullscreen) without resetting. Same
// reason the ScreenShareAudio mute follows this state — see attachTrack.
// Right-click handling happens one level up in TileRender — the wrapping
// div catches the event before it reaches the viewer's inner content.
export function ScreenShareViewer({
share,
avatarUrl,
displayName,
}: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [watching, setWatching] = useState(false);
const { watchingShareUserIds, watchShare } = useCall();
const watching = watchingShareUserIds.has(share.participantId);
const [isFullscreen, setIsFullscreen] = useState(false);
const letter = displayName.trim().charAt(0).toUpperCase() || '?';
@@ -68,31 +77,15 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
})}
</span>
{watching && (
<>
<button
type="button"
onClick={toggleFullscreen}
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<FullscreenIcon className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => {
if (document.fullscreenElement === containerRef.current) {
void document.exitFullscreen();
}
setWatching(false);
}}
aria-label={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
title={t('app:call.stop_watching', { defaultValue: 'Nicht mehr anschauen' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<span aria-hidden="true" className="text-[14px] leading-none">×</span>
</button>
</>
<button
type="button"
onClick={toggleFullscreen}
aria-label={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
title={t('app:call.fullscreen', { defaultValue: 'Vollbild' })}
className="inline-flex h-6 w-6 cursor-pointer items-center justify-center rounded-md text-emerald-200 transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/40"
>
<FullscreenIcon className="h-3.5 w-3.5" />
</button>
)}
</div>
@@ -108,7 +101,7 @@ export function ScreenShareViewer({ share, avatarUrl, displayName }: ScreenShare
) : (
<button
type="button"
onClick={() => setWatching(true)}
onClick={() => watchShare(share.participantId)}
aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
style={{ aspectRatio: '16 / 9' }}