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(); }