feat(call): full-screen camera broadcast parity
CI / Build & Quality Checks (push) Successful in 10m22s
CI / Trigger Desktop Build (push) Successful in 7s

Four changes to match screenshare full-screen UX for camera feeds:

1. Fullscreen button always visible
   CallControls.tsx: remove `screenshare &&` gate — the ⛶ fullscreen
   button now appears in camera-only calls, not just during screenshare.

2. Per-participant camera focus (CallControl.focusCameraParticipant)
   Finds the target's video tile in the EC iframe DOM via:
     [data-testid="videoTile"] / [data-video-fit]
     closest ancestor of [aria-label="${userId}"]
   Enables spotlight mode if not already active, then clicks the tile
   so EC's internal focus handler runs. Falls back gracefully if the
   tile is not in the DOM (camera off).

3. MemberGlance participant popup
   Clicking a participant avatar in the call status bar now shows a
   small menu: "Focus camera" (calls focusCameraParticipant) and
   "View profile" (existing behaviour). Previously it opened the
   profile immediately with no way to focus the camera.

4. PiP fullscreen button
   A ⛶/⊡ icon button appears in the PiP overlay top-right area,
   letting users go fullscreen directly from PiP mode without
   navigating back to the call room first.

UNTESTED — requires a real multi-participant call to verify tile
clicking behaviour and fullscreen transitions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 20:39:58 -04:00
parent fb66c0ed90
commit 9742eaea28
6 changed files with 260 additions and 63 deletions
+1 -1
View File
@@ -63,7 +63,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
</Box>
{memberVisible && (
<Box shrink="No">
<MemberGlance room={room} members={callMembers} speakers={speakers} />
<MemberGlance room={room} members={callMembers} speakers={speakers} callEmbed={callEmbed} />
</Box>
)}
</Box>
+167 -47
View File
@@ -1,6 +1,17 @@
import { Box, config, Icon, Icons, Text } from 'folds';
import {
Box,
config,
Icon,
Icons,
Menu,
MenuItem,
PopOut,
RectCords,
Text,
} from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React from 'react';
import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
@@ -9,67 +20,176 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { getMouseEventCords } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
import { CallEmbed } from '../../plugins/call/CallEmbed';
import * as css from './styles.css';
type ParticipantMenuProps = {
anchor: RectCords;
name: string;
userId: string;
room: Room;
callEmbed?: CallEmbed;
onClose: () => void;
profileCords: DOMRect;
};
function ParticipantMenu({
anchor,
name,
userId,
room,
callEmbed,
onClose,
profileCords,
}: ParticipantMenuProps) {
const openUserProfile = useOpenUserRoomProfile();
const handleViewProfile = () => {
onClose();
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
};
const handleFocusCamera = () => {
onClose();
callEmbed?.control.focusCameraParticipant(userId);
};
return (
<PopOut
anchor={anchor}
align="Start"
position="Top"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: onClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Menu variant="Surface" style={{ minWidth: 160, padding: config.space.S100 }}>
<Box direction="Column">
<Text
size="L400"
style={{
padding: `${config.space.S100} ${config.space.S200}`,
opacity: 0.6,
}}
truncate
>
{name}
</Text>
{callEmbed && (
<MenuItem
size="300"
variant="Surface"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} />}
onClick={handleFocusCamera}
>
<Text size="B300">Focus camera</Text>
</MenuItem>
)}
<MenuItem
size="300"
variant="Surface"
radii="300"
before={<Icon size="100" src={Icons.User} />}
onClick={handleViewProfile}
>
<Text size="B300">View profile</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
>
{/* PopOut requires a JSX child even if we anchor externally */}
<span />
</PopOut>
);
}
type MemberGlanceProps = {
room: Room;
members: CallMembership[];
speakers: Set<string>;
callEmbed?: CallEmbed;
max?: number;
};
export function MemberGlance({ room, members, speakers, max = 6 }: MemberGlanceProps) {
export function MemberGlance({ room, members, speakers, callEmbed, max = 6 }: MemberGlanceProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const openUserProfile = useOpenUserRoomProfile();
const [menuState, setMenuState] = useState<{
anchor: RectCords;
profileCords: DOMRect;
userId: string;
name: string;
} | null>(null);
const visibleMembers = members.slice(0, max);
const remainingCount = max && members.length > max ? members.length - max : 0;
return (
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
<>
<Box alignItems="Center">
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
return (
<StackedAvatar
key={callMember.memberId}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Top',
)
}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
return (
<StackedAvatar
key={callMember.memberId}
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
title={name}
as="button"
variant="Background"
size="200"
radii="Pill"
onClick={(evt) => {
const rect = evt.currentTarget.getBoundingClientRect();
setMenuState({
anchor: rect,
profileCords: rect,
userId,
name,
});
}}
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{remainingCount > 0 && (
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
+{remainingCount}
</Text>
)}
</Box>
{menuState && (
<ParticipantMenu
anchor={menuState.anchor}
profileCords={menuState.profileCords}
name={menuState.name}
userId={menuState.userId}
room={room}
callEmbed={callEmbed}
onClose={() => setMenuState(null)}
/>
)}
</Box>
</>
);
}
+1 -1
View File
@@ -387,7 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/>
{screenshare && !!document.fullscreenEnabled && (
{!!document.fullscreenEnabled && (
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
)}
</Box>