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": {
"@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",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.21",
"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",
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==",
"version": "0.25.0-lotus.22",
"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-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@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",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+5 -1
View File
@@ -38,7 +38,7 @@ import {
} from '../hooks/useCallEmbed';
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
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 { useSelectedRoom } from '../hooks/router/useSelectedRoom';
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
@@ -882,6 +882,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
const callActive = callEmbed && joined;
const callVisible = inCallRoom && callActive && !chatOnlyView;
const pipMode = callActive && !inCallRoom;
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
const { navigateRoom } = useRoomNavigate();
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
@@ -1363,6 +1364,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
zIndex: 1,
background: 'transparent',
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',
alignItems: 'flex-start',
justifyContent: 'flex-end',
+11 -7
View File
@@ -7,7 +7,7 @@ import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useFrameScreenshare,
useScreenshareMode,
} from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
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
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
const frameScreenshare = useFrameScreenshare(callEmbed.control);
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
// where the click can't be delegated, starting asks for the in-frame prompt.
const screenshareMode = useScreenshareMode(callEmbed.control);
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
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(
() => callEmbed.control.toggleMicrophone(),
@@ -255,9 +261,7 @@ export function CallControl({
{!compact && showScreenshare && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
disabled={!callJoined}
/>
)}
+11 -8
View File
@@ -33,7 +33,7 @@ import {
CallEmbed,
useCallControlState,
useCallMicLevel,
useFrameScreenshare,
useScreenshareMode,
} from '../../plugins/call';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
// [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
// screenshare-audio mute stays.
const frameScreenshare = useFrameScreenshare(callEmbed.control);
// is hidden (the screenshare-audio mute stays); where the click can't be
// delegated, starting asks for the fork's in-frame "Share your screen?".
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 showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
{showScreenshare && (
<>
{!frameScreenshare && (
{screenshareMode !== 'hidden' && (
<ScreenShareButton
enabled={screenshare}
onToggle={() =>
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
onToggle={() => handleScreenshareToggle()}
/>
)}
{/* 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>();
// [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
// hides its in-frame button when it doesn't.
private frameScreenshareAllowed = true;
@@ -274,7 +283,32 @@ export class CallControl extends EventEmitter implements CallControlState {
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 {
this.frameScreenshareListeners.add(cb);
return () => {
@@ -289,7 +323,8 @@ export class CallControl extends EventEmitter implements CallControlState {
}
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', {
visible: this.frameScreenshareAllowed,
});
@@ -324,17 +359,31 @@ export class CallControl extends EventEmitter implements CallControlState {
/** [Gitea #43] The fork's `io.lotus.controls_state` report. */
public onControlsState(data: unknown) {
if (typeof data !== 'object' || data === null) return;
const { screensharing, layout, frameScreenshare } = data as {
screensharing?: unknown;
layout?: unknown;
frameScreenshare?: unknown;
};
const { screensharing, layout, frameScreenshare, screensharePrompt, screensharePromptOpen } =
data as {
screensharing?: unknown;
layout?: unknown;
frameScreenshare?: unknown;
screensharePrompt?: unknown;
screensharePromptOpen?: unknown;
};
const firstReport = !this.forkReady;
this.forkReady = 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._screensharePrompt = prompt;
this.frameScreenshareListeners.forEach((l) => l());
if (promptTurnedOn && !firstReport) this.sendFrameScreenshareAllowed();
}
if (firstReport) {
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
* frame (no Capability Delegation in this engine), so the host bar hides its.
* [Gitea #43] How the call bar's screenshare button behaves:
* - `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 => {
const [frame, setFrame] = useState(() => control?.frameScreenshare ?? false);
export type ScreenshareMode = 'hidden' | 'prompt' | 'direct';
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(() => {
if (!control) {
setFrame(false);
setMode('direct');
return undefined;
}
const sync = () => setFrame(control.frameScreenshare);
const sync = () => setMode(screenshareMode(control));
sync();
return control.onFrameScreenshareChange(sync);
}, [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;
};