feat(call): full-screen camera broadcast parity
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:
+6
-1
@@ -16,9 +16,14 @@
|
||||
|
||||
## 📱 Quick Feature Additions
|
||||
|
||||
- [ ] **Full-Screen Camera Broadcasts**
|
||||
- [x] **Full-Screen Camera Broadcasts** ⚠️ UNTESTED — verify in a real call
|
||||
- **Context:** Element Call currently supports full-screening screenshares. We need to parity this functionality for camera broadcasts.
|
||||
- **Goal:** Users should be able to toggle any camera feed to full-screen mode, similar to the existing screenshare full-screen implementation.
|
||||
- **Implemented 2026-06-18:**
|
||||
1. **Fullscreen button always shows** — removed `screenshare &&` gate in `CallControls.tsx`. The fullscreen button is now available in camera-only calls, not just during screenshares.
|
||||
2. **Per-participant camera focus** — `CallControl.focusCameraParticipant(userId)` added. Finds the participant's video tile via `[data-testid="videoTile"]` / `[data-video-fit]` + `[aria-label="${userId}"]`, enables spotlight mode, then clicks the tile to focus them.
|
||||
3. **MemberGlance "Focus camera" action** — clicking a participant avatar in the call status bar now opens a mini popup with "Focus camera" (triggers focusCameraParticipant) and "View profile" options, rather than immediately opening the profile.
|
||||
4. **PiP fullscreen button** — a small fullscreen toggle button (⛶/⊡) is shown in the PiP overlay top-right, allowing users to go fullscreen directly from PiP mode without navigating back to the call room.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -529,6 +529,21 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
[chatBackground, isDark],
|
||||
);
|
||||
|
||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onFsChange);
|
||||
}, []);
|
||||
|
||||
const handlePipFullscreen = useCallback(() => {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
callEmbedRef.current?.requestFullscreen();
|
||||
}
|
||||
}, [callEmbedRef]);
|
||||
|
||||
const pipDragRef = React.useRef<{
|
||||
startX: number;
|
||||
startY: number;
|
||||
@@ -910,19 +925,44 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||
{document.fullscreenEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||
onClick={(e) => { e.stopPropagation(); handlePipFullscreen(); }}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 7px',
|
||||
color: '#fff',
|
||||
fontSize: '13px',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{pipIsFullscreen ? '⊡' : '⛶'}
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '6px',
|
||||
padding: '3px 8px',
|
||||
color: '#fff',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PipMuteOverlay callEmbed={callEmbed} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -288,6 +288,38 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.settingsButton?.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus a specific participant's camera tile in Element Call.
|
||||
*
|
||||
* EC renders video tiles as `[data-testid="videoTile"]`. Each tile wraps a
|
||||
* mute-status indicator with `aria-label` set to the participant's Matrix
|
||||
* user ID. We find the tile containing that user, switch to spotlight mode
|
||||
* if needed, then click the tile so EC's internal focus handler runs.
|
||||
*
|
||||
* Falls back to a plain spotlight toggle if the tile is not found (e.g. the
|
||||
* participant has their camera off and EC didn't render a video tile for
|
||||
* them yet).
|
||||
*/
|
||||
public focusCameraParticipant(userId: string): void {
|
||||
const doc = this.document;
|
||||
if (!doc) return;
|
||||
|
||||
// Find the mute icon / aria-label element that identifies this participant
|
||||
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`);
|
||||
// Walk up to the nearest video tile container
|
||||
const tile =
|
||||
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ??
|
||||
userEl?.closest<HTMLElement>('[data-video-fit]');
|
||||
|
||||
if (!this.spotlight) {
|
||||
this.spotlightButton?.click();
|
||||
}
|
||||
|
||||
if (tile) {
|
||||
tile.click();
|
||||
}
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this.controlMutationObserver.disconnect();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user