Compare commits
1 Commits
fb66c0ed90
...
9742eaea28
| Author | SHA1 | Date | |
|---|---|---|---|
| 9742eaea28 |
+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