From 9742eaea28554e0b1e043c10f8543728107eecb4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Thu, 18 Jun 2026 20:39:58 -0400 Subject: [PATCH] feat(call): full-screen camera broadcast parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- LOTUS_TODO.md | 7 +- src/app/components/CallEmbedProvider.tsx | 66 ++++-- src/app/features/call-status/CallStatus.tsx | 2 +- src/app/features/call-status/MemberGlance.tsx | 214 ++++++++++++++---- src/app/features/call/CallControls.tsx | 2 +- src/app/plugins/call/CallControl.ts | 32 +++ 6 files changed, 260 insertions(+), 63 deletions(-) diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 13c30d224..61b9e6ba5 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -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. --- diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index fe11822ac..78a7218b5 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -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', }} > -
- ↗ Return to call +
+ {document.fullscreenEnabled && ( + + )} +
+ ↗ Return to call +
diff --git a/src/app/features/call-status/CallStatus.tsx b/src/app/features/call-status/CallStatus.tsx index 1ff3b837a..ea2742c21 100644 --- a/src/app/features/call-status/CallStatus.tsx +++ b/src/app/features/call-status/CallStatus.tsx @@ -63,7 +63,7 @@ export function CallStatus({ callEmbed }: CallStatusProps) { {memberVisible && ( - + )} diff --git a/src/app/features/call-status/MemberGlance.tsx b/src/app/features/call-status/MemberGlance.tsx index 567783156..f8afc6a7c 100644 --- a/src/app/features/call-status/MemberGlance.tsx +++ b/src/app/features/call-status/MemberGlance.tsx @@ -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 ( + + + + + {name} + + {callEmbed && ( + } + onClick={handleFocusCamera} + > + Focus camera + + )} + } + onClick={handleViewProfile} + > + View profile + + + + + } + > + {/* PopOut requires a JSX child even if we anchor externally */} + + + ); +} + type MemberGlanceProps = { room: Room; members: CallMembership[]; speakers: Set; + 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 ( - - {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; + <> + + {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 ( - - openUserProfile( - room.roomId, - undefined, - userId, - getMouseEventCords(evt.nativeEvent), - 'Top', - ) - } - > - } - /> - - ); - })} - {remainingCount > 0 && ( - - +{remainingCount} - + return ( + { + const rect = evt.currentTarget.getBoundingClientRect(); + setMenuState({ + anchor: rect, + profileCords: rect, + userId, + name, + }); + }} + > + } + /> + + ); + })} + {remainingCount > 0 && ( + + +{remainingCount} + + )} + + + {menuState && ( + setMenuState(null)} + /> )} - + ); } diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index a9b9dcc66..47eb9cb84 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -387,7 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) } /> - {screenshare && !!document.fullscreenEnabled && ( + {!!document.fullscreenEnabled && ( )} diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 9133f22ee..9ce7bfac4 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -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(`[aria-label="${CSS.escape(userId)}"]`); + // Walk up to the nearest video tile container + const tile = + userEl?.closest('[data-testid="videoTile"]') ?? + userEl?.closest('[data-video-fit]'); + + if (!this.spotlight) { + this.spotlightButton?.click(); + } + + if (tile) { + tile.click(); + } + } + public dispose() { this.controlMutationObserver.disconnect(); }