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:
@@ -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,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user