Compare commits
1 Commits
fb66c0ed90
...
9742eaea28
| Author | SHA1 | Date | |
|---|---|---|---|
| 9742eaea28 |
+6
-1
@@ -16,9 +16,14 @@
|
|||||||
|
|
||||||
## 📱 Quick Feature Additions
|
## 📱 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.
|
- **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.
|
- **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],
|
[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<{
|
const pipDragRef = React.useRef<{
|
||||||
startX: number;
|
startX: number;
|
||||||
startY: number;
|
startY: number;
|
||||||
@@ -910,19 +925,44 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
padding: '6px',
|
padding: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
|
||||||
style={{
|
{document.fullscreenEnabled && (
|
||||||
background: 'rgba(0,0,0,0.65)',
|
<button
|
||||||
backdropFilter: 'blur(4px)',
|
type="button"
|
||||||
borderRadius: '6px',
|
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
padding: '3px 8px',
|
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
|
||||||
color: '#fff',
|
onClick={(e) => { e.stopPropagation(); handlePipFullscreen(); }}
|
||||||
fontSize: '11px',
|
style={{
|
||||||
fontWeight: 600,
|
background: 'rgba(0,0,0,0.65)',
|
||||||
pointerEvents: 'none',
|
backdropFilter: 'blur(4px)',
|
||||||
}}
|
border: 'none',
|
||||||
>
|
borderRadius: '6px',
|
||||||
↗ Return to call
|
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>
|
||||||
</div>
|
</div>
|
||||||
<PipMuteOverlay callEmbed={callEmbed} />
|
<PipMuteOverlay callEmbed={callEmbed} />
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
{memberVisible && (
|
{memberVisible && (
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<MemberGlance room={room} members={callMembers} speakers={speakers} />
|
<MemberGlance room={room} members={callMembers} speakers={speakers} callEmbed={callEmbed} />
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</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 { 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 { Room } from 'matrix-js-sdk';
|
||||||
import { UserAvatar } from '../../components/user-avatar';
|
import { UserAvatar } from '../../components/user-avatar';
|
||||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||||
@@ -9,67 +20,176 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { StackedAvatar } from '../../components/stacked-avatar';
|
import { StackedAvatar } from '../../components/stacked-avatar';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
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';
|
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 = {
|
type MemberGlanceProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
members: CallMembership[];
|
members: CallMembership[];
|
||||||
speakers: Set<string>;
|
speakers: Set<string>;
|
||||||
|
callEmbed?: CallEmbed;
|
||||||
max?: number;
|
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 mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 visibleMembers = members.slice(0, max);
|
||||||
const remainingCount = max && members.length > max ? members.length - max : 0;
|
const remainingCount = max && members.length > max ? members.length - max : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center">
|
<>
|
||||||
{visibleMembers.map((callMember) => {
|
<Box alignItems="Center">
|
||||||
const { userId } = callMember;
|
{visibleMembers.map((callMember) => {
|
||||||
if (!userId) return null;
|
const { userId } = callMember;
|
||||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
if (!userId) return null;
|
||||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
const avatarUrl = avatarMxc
|
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
|
const avatarUrl = avatarMxc
|
||||||
: undefined;
|
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StackedAvatar
|
<StackedAvatar
|
||||||
key={callMember.memberId}
|
key={callMember.memberId}
|
||||||
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
|
className={speakers.has(callMember.sender) ? css.SpeakerAvatarOutline : undefined}
|
||||||
title={name}
|
title={name}
|
||||||
as="button"
|
as="button"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
size="200"
|
size="200"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
onClick={(evt) =>
|
onClick={(evt) => {
|
||||||
openUserProfile(
|
const rect = evt.currentTarget.getBoundingClientRect();
|
||||||
room.roomId,
|
setMenuState({
|
||||||
undefined,
|
anchor: rect,
|
||||||
userId,
|
profileCords: rect,
|
||||||
getMouseEventCords(evt.nativeEvent),
|
userId,
|
||||||
'Top',
|
name,
|
||||||
)
|
});
|
||||||
}
|
}}
|
||||||
>
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={userId}
|
userId={userId}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
alt={name}
|
alt={name}
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||||
/>
|
/>
|
||||||
</StackedAvatar>
|
</StackedAvatar>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{remainingCount > 0 && (
|
{remainingCount > 0 && (
|
||||||
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
|
<Text size="L400" style={{ paddingLeft: config.space.S100 }}>
|
||||||
+{remainingCount}
|
+{remainingCount}
|
||||||
</Text>
|
</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 ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{screenshare && !!document.fullscreenEnabled && (
|
{!!document.fullscreenEnabled && (
|
||||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -288,6 +288,38 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.settingsButton?.click();
|
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() {
|
public dispose() {
|
||||||
this.controlMutationObserver.disconnect();
|
this.controlMutationObserver.disconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user