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 (
+
+
+
+ }
+ >
+ {/* 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();
}