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