From 0ebe24be208ceb142ba9d4714c908563c21bd914 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 24 May 2026 23:16:43 -0400 Subject: [PATCH] feat: screenshare fullscreen button + pip spotlight, fix screenshare view - Remove revert-to-grid logic that was overriding EC's natural screenshare spotlight, causing fullscreen to show user avatars instead of the screen - Add fullscreen button to call controls (visible when screensharing) that requests fullscreen on the call embed container - Add FullscreenButton component with enter/exit SVG icons to Controls.tsx - PIP mode: sync setPipMode to CallControl; auto-enable spotlight when screenshare is active in pip so the screenshare fills the window - Make useCallControlState accept undefined control for safe use in CallEmbedProvider - Add package-lock.json to .gitignore (generated by local npm install) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- src/app/components/CallEmbedProvider.tsx | 19 ++++++++- src/app/features/call/CallControls.tsx | 21 ++++++++++ src/app/features/call/Controls.tsx | 50 ++++++++++++++++++++++++ src/app/plugins/call/CallControl.ts | 12 +++--- src/app/plugins/call/hooks.ts | 15 ++++--- 6 files changed, 106 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 1af58a970..13c74945b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ node_modules devAssets .DS_Store -.idea \ No newline at end of file +.ideapackage-lock.json diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index ff1a79713..ea2af9929 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -35,7 +35,7 @@ import { useCallStart, } from '../hooks/useCallEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; -import { CallEmbed } from '../plugins/call'; +import { CallEmbed, useCallControlState } from '../plugins/call'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { useMatrixClient } from '../hooks/useMatrixClient'; @@ -421,6 +421,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { const pipMode = callActive && !inCallRoom; const { navigateRoom } = useRoomNavigate(); + const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control); + + // Sync pip mode into CallControl so it can adjust behavior accordingly + useEffect(() => { + if (!callEmbed) return; + callEmbed.control.setPipMode(!!pipMode); + }, [pipMode, callEmbed]); + + // When entering pip with screenshare active (or screenshare starts while in pip), + // enable spotlight so the screenshare fills the pip window + useEffect(() => { + if (!pipMode || !callEmbed || !pipScreenshare) return; + if (!callEmbed.control.spotlight) { + callEmbed.control.toggleSpotlight(); + } + }, [pipMode, pipScreenshare, callEmbed]); + const theme = useTheme(); const isDark = theme.kind === ThemeKind.Dark; const [chatBackground] = useSetting(settingsAtom, 'chatBackground'); diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index 18df279b9..53c7d11c1 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -21,6 +21,7 @@ import * as css from './styles.css'; import { ChatButton, ControlDivider, + FullscreenButton, MicrophoneButton, ScreenShareButton, SoundButton, @@ -32,13 +33,16 @@ import { settingsAtom } from '../../state/settings'; import { useResizeObserver } from '../../hooks/useResizeObserver'; import { stopPropagation } from '../../utils/keyboard'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { useCallEmbedRef } from '../../hooks/useCallEmbed'; type CallControlsProps = { callEmbed: CallEmbed; }; export function CallControls({ callEmbed }: CallControlsProps) { const controlRef = useRef(null); + const callEmbedRef = useCallEmbedRef(); const [compact, setCompact] = useState(document.body.clientWidth < 500); + const [isFullscreen, setIsFullscreen] = useState(false); useResizeObserver( useCallback(() => { @@ -49,6 +53,20 @@ export function CallControls({ callEmbed }: CallControlsProps) { useCallback(() => controlRef.current, []), ); + useEffect(() => { + const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement); + document.addEventListener('fullscreenchange', onFullscreenChange); + return () => document.removeEventListener('fullscreenchange', onFullscreenChange); + }, []); + + const handleFullscreen = useCallback(() => { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + callEmbedRef.current?.requestFullscreen(); + } + }, [callEmbedRef]); + const { microphone, video, sound, screenshare, spotlight } = useCallControlState( callEmbed.control, ); @@ -338,6 +356,9 @@ export function CallControls({ callEmbed }: CallControlsProps) { screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true) } /> + {screenshare && ( + + )} {!compact && } diff --git a/src/app/features/call/Controls.tsx b/src/app/features/call/Controls.tsx index bb3fca84e..20b86e19f 100644 --- a/src/app/features/call/Controls.tsx +++ b/src/app/features/call/Controls.tsx @@ -158,6 +158,56 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps) ); } +const FullscreenIcon = () => ( + + + +); + +const ExitFullscreenIcon = () => ( + + + +); + +type FullscreenButtonProps = { + isFullscreen: boolean; + onToggle: () => void; +}; +export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonProps) { + return ( + + {isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'} + + } + > + {(anchorRef) => ( + + {isFullscreen ? ( + + ) : ( + + )} + + )} + + ); +} + export function ChatButton() { const [chat, setChat] = useAtom(callChatAtom); diff --git a/src/app/plugins/call/CallControl.ts b/src/app/plugins/call/CallControl.ts index 7a23959c4..1a405e5df 100644 --- a/src/app/plugins/call/CallControl.ts +++ b/src/app/plugins/call/CallControl.ts @@ -16,6 +16,8 @@ export class CallControl extends EventEmitter implements CallControlState { private controlMutationObserver: MutationObserver; + private _pipMode = false; + private get document(): Document | undefined { return this.iframe.contentDocument ?? this.iframe.contentWindow?.document; } @@ -183,12 +185,6 @@ export class CallControl extends EventEmitter implements CallControlState { ); this.emitStateUpdate(); - // EC auto-switches to spotlight when screenshare starts — revert to grid - if (!prevScreenshare && screenshare) { - setTimeout(() => { - if (this.spotlight) this.gridButton?.click(); - }, 600); - } } public setMicrophone(enabled: boolean) { @@ -247,6 +243,10 @@ export class CallControl extends EventEmitter implements CallControlState { this.spotlightButton?.click(); } + public setPipMode(pip: boolean) { + this._pipMode = pip; + } + public toggleReactions() { this.reactionsButton?.click(); } diff --git a/src/app/plugins/call/hooks.ts b/src/app/plugins/call/hooks.ts index a525b9d74..e57534741 100644 --- a/src/app/plugins/call/hooks.ts +++ b/src/app/plugins/call/hooks.ts @@ -32,13 +32,18 @@ export const useSendClientWidgetApiAction = (api: ClientWidgetApi) => { return sendWidgetAction; }; -export const useCallControlState = (control: CallControl): CallControlState => { - const [state, setState] = useState(control.getState()); +const DEFAULT_CONTROL_STATE = new CallControlState(false, false, false); + +export const useCallControlState = (control: CallControl | undefined): CallControlState => { + const [state, setState] = useState(control?.getState() ?? DEFAULT_CONTROL_STATE); useEffect(() => { - const handleUpdate = () => { - setState(control.getState()); - }; + if (!control) { + setState(DEFAULT_CONTROL_STATE); + return; + } + setState(control.getState()); + const handleUpdate = () => setState(control.getState()); control.on(CallControlEvent.StateUpdate, handleUpdate); return () => { control.off(CallControlEvent.StateUpdate, handleUpdate);