Screenshare stays on the call bar in Firefox/Safari via the in-frame prompt (#43) #244

Merged
jared merged 2 commits from screenshare-prompt into lotus 2026-09-27 13:54:11 -04:00
7 changed files with 123 additions and 36 deletions
+4 -4
View File
@@ -81,7 +81,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.21", "@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
}, },
"node_modules/@lotusguild/element-call-embedded": { "node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.21", "version": "0.25.0-lotus.22",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.21/element-call-embedded-0.25.0-lotus.21.tgz", "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==", "integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true "dev": true
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "4.13.0", "@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.21", "@lotusguild/element-call-embedded": "0.25.0-lotus.22",
"@playwright/test": "1.63.0", "@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
+5 -1
View File
@@ -38,7 +38,7 @@ import {
} from '../hooks/useCallEmbed'; } from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed'; import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
import { toastQueueAtom } from '../state/toast'; import { toastQueueAtom } from '../state/toast';
import { CallEmbed, useCallControlState } from '../plugins/call'; import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
import { hangupCallAndWait } from '../plugins/call/hangup'; import { hangupCallAndWait } from '../plugins/call/hangup';
import { useSelectedRoom } from '../hooks/router/useSelectedRoom'; import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -882,6 +882,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callActive = callEmbed && joined; const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView; const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom; const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate(); const { navigateRoom } = useRoomNavigate();
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control); const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
@@ -1363,6 +1364,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
zIndex: 1, zIndex: 1,
background: 'transparent', background: 'transparent',
cursor: 'grab', cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex', display: 'flex',
alignItems: 'flex-start', alignItems: 'flex-start',
justifyContent: 'flex-end', justifyContent: 'flex-end',
+11 -7
View File
@@ -7,7 +7,7 @@ import {
CallEmbed, CallEmbed,
useCallControlState, useCallControlState,
useCallMicLevel, useCallMicLevel,
useFrameScreenshare, useScreenshareMode,
} from '../../plugins/call'; } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
@@ -197,10 +197,16 @@ export function CallControl({
// Keep a forbidden control visible while its track is still live (so the user // Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely. // can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video; const showCamera = allowCamera || video;
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame. // [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
const frameScreenshare = useFrameScreenshare(callEmbed.control); // where the click can't be delegated, starting asks for the in-frame prompt.
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare); const screenshareMode = useScreenshareMode(callEmbed.control);
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
const [shareConfirm, setShareConfirm] = useState(false); const [shareConfirm, setShareConfirm] = useState(false);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const handleMicrophoneToggle = useCallback( const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(), () => callEmbed.control.toggleMicrophone(),
@@ -255,9 +261,7 @@ export function CallControl({
{!compact && showScreenshare && ( {!compact && showScreenshare && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() => handleScreenshareToggle()}
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
disabled={!callJoined} disabled={!callJoined}
/> />
)} )}
+11 -8
View File
@@ -33,7 +33,7 @@ import {
CallEmbed, CallEmbed,
useCallControlState, useCallControlState,
useCallMicLevel, useCallMicLevel,
useFrameScreenshare, useScreenshareMode,
} from '../../plugins/call'; } from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// can stop it); otherwise hide it entirely. // can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video; const showCamera = allowCamera || video;
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours // [Gitea #43] Where EC shows its own screenshare button in the frame, ours
// is hidden (this engine can't start a share from the host); the // is hidden (the screenshare-audio mute stays); where the click can't be
// screenshare-audio mute stays. // delegated, starting asks for the fork's in-frame "Share your screen?".
const frameScreenshare = useFrameScreenshare(callEmbed.control); const screenshareMode = useScreenshareMode(callEmbed.control);
const handleScreenshareToggle = () => {
if (screenshare) callEmbed.control.toggleScreenshare();
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
else setShareConfirm(true);
};
const showScreenshare = allowScreenshare || screenshare; const showScreenshare = allowScreenshare || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />} {showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && ( {showScreenshare && (
<> <>
{!frameScreenshare && ( {screenshareMode !== 'hidden' && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() => handleScreenshareToggle()}
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
/> />
)} )}
{/* Mute-screenshare-audio sits directly next to the screenshare {/* Mute-screenshare-audio sits directly next to the screenshare
+57 -8
View File
@@ -261,6 +261,15 @@ export class CallControl extends EventEmitter implements CallControlState {
private frameScreenshareListeners = new Set<() => void>(); private frameScreenshareListeners = new Set<() => void>();
// [Gitea #43] The fork draws the "Share your screen?" prompt inside the frame
// on request (io.lotus.prompt_screenshare), so where the click can't be
// delegated our call-bar button asks for that instead of starting the share.
private _screensharePrompt = false;
// [Gitea #43] The fork's in-frame prompt is showing: anything the host lays
// over the frame (the picture-in-picture overlay) must let clicks through.
private _screensharePromptOpen = false;
// [Gitea #43] Whether the room's call policy allows screensharing; the fork // [Gitea #43] Whether the room's call policy allows screensharing; the fork
// hides its in-frame button when it doesn't. // hides its in-frame button when it doesn't.
private frameScreenshareAllowed = true; private frameScreenshareAllowed = true;
@@ -274,7 +283,32 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._frameScreenshare; return this._frameScreenshare;
} }
/** Subscribe to `frameScreenshare` changes. Returns an unsubscribe. */ /**
* Starting a share must go through the fork's in-frame prompt: this engine
* can't delegate the click (Firefox, Safari, WebKitGTK) and the fork has
* the prompt. Stopping never needs it.
*/
public get screenshareNeedsPrompt(): boolean {
return this._screensharePrompt && !canDelegateCapability();
}
/** The fork's in-frame "Share your screen?" prompt is showing. */
public get screensharePromptOpen(): boolean {
return this._screensharePromptOpen;
}
/**
* Ask the fork to show "Share your screen?" inside the call frame; its Share
* button starts the share with the frame's own click.
*/
public promptScreenshare(): void {
this.sendForkAction('io.lotus.prompt_screenshare', {});
}
/**
* Subscribe to `frameScreenshare` / `screenshareNeedsPrompt` /
* `screensharePromptOpen` changes. Returns an unsubscribe.
*/
public onFrameScreenshareChange(cb: () => void): () => void { public onFrameScreenshareChange(cb: () => void): () => void {
this.frameScreenshareListeners.add(cb); this.frameScreenshareListeners.add(cb);
return () => { return () => {
@@ -289,7 +323,8 @@ export class CallControl extends EventEmitter implements CallControlState {
} }
private sendFrameScreenshareAllowed(): void { private sendFrameScreenshareAllowed(): void {
if (!this.joined || !this.forkReady || !this._frameScreenshare) return; if (!this.joined || !this.forkReady) return;
if (!this._frameScreenshare && !this._screensharePrompt) return;
this.sendForkAction('io.lotus.set_frame_screenshare', { this.sendForkAction('io.lotus.set_frame_screenshare', {
visible: this.frameScreenshareAllowed, visible: this.frameScreenshareAllowed,
}); });
@@ -324,17 +359,31 @@ export class CallControl extends EventEmitter implements CallControlState {
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */ /** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) { public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return; if (typeof data !== 'object' || data === null) return;
const { screensharing, layout, frameScreenshare } = data as { const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } =
screensharing?: unknown; data as {
layout?: unknown; screensharing?: unknown;
frameScreenshare?: unknown; layout?: unknown;
}; frameScreenshare?: unknown;
screensharePrompt?: unknown;
screensharePromptOpen?: unknown;
};
const firstReport = !this.forkReady; const firstReport = !this.forkReady;
this.forkReady = true; this.forkReady = true;
const frame = frameScreenshare === true; const frame = frameScreenshare === true;
if (frame !== this._frameScreenshare) { const prompt = screensharePrompt === true;
const promptOpen = screensharePromptOpen === true;
if (promptOpen !== this._screensharePromptOpen) {
this._screensharePromptOpen = promptOpen;
if (frame === this._frameScreenshare && prompt === this._screensharePrompt) {
this.frameScreenshareListeners.forEach((l) => l());
}
}
if (frame !== this._frameScreenshare || prompt !== this._screensharePrompt) {
const promptTurnedOn = prompt && !this._screensharePrompt;
this._frameScreenshare = frame; this._frameScreenshare = frame;
this._screensharePrompt = prompt;
this.frameScreenshareListeners.forEach((l) => l()); this.frameScreenshareListeners.forEach((l) => l());
if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed();
} }
if (firstReport) { if (firstReport) {
this.sendHotkeyCodes(); this.sendHotkeyCodes();
+34 -7
View File
@@ -75,19 +75,46 @@ export const useCallMicLevel = (callEmbed: CallEmbed | undefined): number => {
}; };
/** /**
* [Gitea #43] True when EC's own screenshare button is shown inside the call * [Gitea #43] How the call bar's screenshare button behaves:
* frame (no Capability Delegation in this engine), so the host bar hides its. * - `hidden`: EC's own button is inside the frame (fork lotus.20/21), so ours hides;
* - `prompt`: starting asks the fork for its in-frame "Share your screen?"
* (no Capability Delegation in this engine);
* - `direct`: our own confirm, then a (delegated) set_screenshare.
*/ */
export const useFrameScreenshare = (control: CallControl | undefined): boolean => { export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
const screenshareMode = (control: CallControl): ScreenshareMode => {
if (control.frameScreenshare) return 'hidden';
return control.screenshareNeedsPrompt ? 'prompt' : 'direct';
};
export const useScreenshareMode = (control: CallControl | undefined): ScreenshareMode => {
const [mode, setMode] = useState<ScreenshareMode>(() =>
control ? screenshareMode(control) : 'direct',
);
useEffect(() => { useEffect(() => {
if (!control) { if (!control) {
setFrame(false); setMode('direct');
return undefined; return undefined;
} }
const sync = () => setFrame(control.frameScreenshare); const sync = () => setMode(screenshareMode(control));
sync(); sync();
return control.onFrameScreenshareChange(sync); return control.onFrameScreenshareChange(sync);
}, [control]); }, [control]);
return frame; return mode;
};
/** [Gitea #43] The fork's in-frame "Share your screen?" prompt is showing. */
export const useScreensharePromptOpen = (control: CallControl | undefined): boolean => {
const [open, setOpen] = useState(() => control?.screensharePromptOpen ?? false);
useEffect(() => {
if (!control) {
setOpen(false);
return undefined;
}
const sync = () => setOpen(control.screensharePromptOpen);
sync();
return control.onFrameScreenshareChange(sync);
}, [control]);
return open;
}; };