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