diff --git a/src/index.css b/src/index.css
index e9539472..ef59b48e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -91,7 +91,7 @@ body.lotus-transparent[data-background="gradient"]::before {
}
/* [cinny #43] lotusHostControls=1: the host renders its own call bar, so EC's
-footer is hidden but stays in the DOM and in layout-independent position. */
+footer is hidden (kept in the DOM but out of the layout flow). */
body.lotus-host-controls [data-testid="footer-container"] {
position: absolute !important;
visibility: hidden !important;
diff --git a/src/lotus/LotusFrameScreenshare.module.css b/src/lotus/LotusFrameScreenshare.module.css
new file mode 100644
index 00000000..3af74803
--- /dev/null
+++ b/src/lotus/LotusFrameScreenshare.module.css
@@ -0,0 +1,8 @@
+/* [cinny #43] EC's screenshare button, floating in the frame's bottom-right
+corner while the host draws the rest of the call bar. */
+.floating {
+ position: fixed;
+ right: var(--cpd-space-4x);
+ bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
+ z-index: 1;
+}
diff --git a/src/lotus/LotusFrameScreenshare.test.tsx b/src/lotus/LotusFrameScreenshare.test.tsx
new file mode 100644
index 00000000..2a47f3ab
--- /dev/null
+++ b/src/lotus/LotusFrameScreenshare.test.tsx
@@ -0,0 +1,88 @@
+/*
+Copyright 2026 Lotus Guild
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { describe, expect, it, vi } from "vitest";
+import { act, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { TooltipProvider } from "@vector-im/compound-web";
+import { BehaviorSubject } from "rxjs";
+
+import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
+import { LotusFrameScreenshare } from "./LotusFrameScreenshare";
+import {
+ lotusFrameScreenshareEnabled,
+ lotusFrameScreenshareVisible$,
+ parseFrameScreensharePayload,
+} from "./lotusControls";
+
+// lotusFlag memoizes the URL params on first read, so set them once here.
+window.history.pushState(
+ {},
+ "",
+ "/?lotusHostControls=1&lotusFrameScreenshare=1",
+);
+
+const mockVm = (
+ toggle: (() => void) | null,
+ sharing = new BehaviorSubject(false),
+): CallViewModel =>
+ ({
+ sharingScreen$: sharing,
+ toggleScreenSharing: toggle,
+ }) as unknown as CallViewModel;
+
+const renderButton = (vm: CallViewModel): void => {
+ render(
+
+
+ ,
+ );
+};
+
+describe("LotusFrameScreenshare", () => {
+ it("is enabled by lotusHostControls + lotusFrameScreenshare", () => {
+ expect(lotusFrameScreenshareEnabled()).toBe(true);
+ });
+
+ it("toggles the share from a click inside the frame", async () => {
+ const toggle = vi.fn();
+ renderButton(mockVm(toggle));
+ await userEvent.click(screen.getByTestId("lotus_frame_screenshare"));
+ expect(toggle).toHaveBeenCalledTimes(1);
+ });
+
+ it("follows the sharing state", () => {
+ const sharing = new BehaviorSubject(false);
+ renderButton(mockVm(vi.fn(), sharing));
+ const button = screen.getByTestId("lotus_frame_screenshare");
+ expect(button).toHaveAttribute("aria-checked", "false");
+ act(() => sharing.next(true));
+ expect(button).toHaveAttribute("aria-checked", "true");
+ });
+
+ it("hides while the host's policy forbids sharing, unless a share is live", () => {
+ const sharing = new BehaviorSubject(false);
+ act(() => lotusFrameScreenshareVisible$.next(false));
+ renderButton(mockVm(vi.fn(), sharing));
+ expect(screen.queryByTestId("lotus_frame_screenshare")).toBeNull();
+ act(() => sharing.next(true));
+ expect(screen.getByTestId("lotus_frame_screenshare")).toBeInTheDocument();
+ act(() => lotusFrameScreenshareVisible$.next(true));
+ });
+
+ it("parses the set_frame_screenshare payload", () => {
+ expect(parseFrameScreensharePayload({ visible: false })).toBe(false);
+ expect(parseFrameScreensharePayload({ visible: true })).toBe(true);
+ expect(parseFrameScreensharePayload({})).toBeUndefined();
+ expect(parseFrameScreensharePayload(null)).toBeUndefined();
+ });
+
+ it("renders nothing when screensharing is unavailable", () => {
+ renderButton(mockVm(null));
+ expect(screen.queryByTestId("lotus_frame_screenshare")).toBeNull();
+ });
+});
diff --git a/src/lotus/LotusFrameScreenshare.tsx b/src/lotus/LotusFrameScreenshare.tsx
new file mode 100644
index 00000000..91950ab3
--- /dev/null
+++ b/src/lotus/LotusFrameScreenshare.tsx
@@ -0,0 +1,41 @@
+/*
+Copyright 2026 Lotus Guild
+
+SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
+Please see LICENSE in the repository root for full details.
+*/
+
+import { type FC } from "react";
+
+import { ShareScreenButton } from "../button";
+import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
+import { useBehavior } from "../useBehavior";
+import {
+ lotusFrameScreenshareEnabled,
+ lotusFrameScreenshareVisible$,
+} from "./lotusControls";
+import styles from "./LotusFrameScreenshare.module.css";
+
+/**
+ * [cinny #43] EC's screenshare button, shown inside the frame when the host
+ * can't start a share for us (`lotusFrameScreenshare`). The rest of EC's
+ * footer stays hidden behind the host's call bar.
+ */
+export const LotusFrameScreenshare: FC<{ vm: CallViewModel }> = ({ vm }) => {
+ const sharing = useBehavior(vm.sharingScreen$);
+ const visible = useBehavior(lotusFrameScreenshareVisible$);
+ if (!lotusFrameScreenshareEnabled() || !vm.toggleScreenSharing) return null;
+ // Hidden by the host's call policy, but never while a share is live: the
+ // user must still be able to stop it.
+ if (!visible && !sharing) return null;
+ return (
+
+
+
+ );
+};
diff --git a/src/lotus/lotusActions.test.ts b/src/lotus/lotusActions.test.ts
index ed994d3e..2941623c 100644
--- a/src/lotus/lotusActions.test.ts
+++ b/src/lotus/lotusActions.test.ts
@@ -32,6 +32,7 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.ToggleReactions,
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
+ LotusWidgetActions.SetFrameScreenshare,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
diff --git a/src/lotus/lotusActions.ts b/src/lotus/lotusActions.ts
index 7d2feabb..02f4e6d6 100644
--- a/src/lotus/lotusActions.ts
+++ b/src/lotus/lotusActions.ts
@@ -86,6 +86,12 @@ export enum LotusWidgetActions {
* to report while focus is inside this frame (cinny #43). `[]` stops.
*/
SetHotkeys = "io.lotus.set_hotkeys",
+ /**
+ * toWidget: `{ visible: boolean }` — whether the in-frame screenshare button
+ * (`lotusFrameScreenshare`) may show, e.g. false while the room's call
+ * policy forbids screensharing (cinny #43). Defaults to visible.
+ */
+ SetFrameScreenshare = "io.lotus.set_frame_screenshare",
/**
* fromWidget: a watched key went down/up here, or this window's focus
* changed; see `LotusHotkeyReport` (cinny #43).
@@ -93,7 +99,7 @@ export enum LotusWidgetActions {
Hotkey = "io.lotus.hotkey",
/**
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
- * screenshareAction: true, hotkeys: true }`
+ * screenshareAction: true, hotkeys: true, frameScreenshare: boolean }`
* whenever either changes (cinny #43), so the host stops reading EC's DOM for
* them. Its arrival also tells the host this fork supports the actions above.
*/
@@ -118,4 +124,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.ToggleReactions,
LotusWidgetActions.SetScreenshare,
LotusWidgetActions.SetHotkeys,
+ LotusWidgetActions.SetFrameScreenshare,
];
diff --git a/src/lotus/lotusControls.ts b/src/lotus/lotusControls.ts
index 803427ed..fb7728d1 100644
--- a/src/lotus/lotusControls.ts
+++ b/src/lotus/lotusControls.ts
@@ -6,13 +6,13 @@ Please see LICENSE in the repository root for full details.
*/
import { type IWidgetApiRequest } from "matrix-widget-api";
-import { combineLatest, of, type Subscription } from "rxjs";
+import { BehaviorSubject, combineLatest, of, type Subscription } from "rxjs";
import { distinctUntilChanged, map, switchMap } from "rxjs/operators";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
import { widget } from "../widget";
-import { LotusWidgetActions, lotusSendToHost } from "./lotusWidget";
+import { LotusWidgetActions, lotusFlag, lotusSendToHost } from "./lotusWidget";
/** Window event the reactions button listens for (see ReactionToggleButton). */
export const LOTUS_TOGGLE_REACTIONS_EVENT = "lotus:toggle-reactions";
@@ -25,6 +25,38 @@ export interface LotusControlsState {
screenshareAction: true;
/** This fork handles `io.lotus.set_hotkeys` (always true). */
hotkeys: true;
+ /**
+ * EC's own screenshare button is shown inside the frame (see
+ * `lotusFrameScreenshare`), so the host should hide its own.
+ */
+ frameScreenshare: boolean;
+}
+
+/**
+ * [cinny #43] `lotusFrameScreenshare=1` (with `lotusHostControls`): the host
+ * can't hand the user's click into this frame (no Capability Delegation:
+ * Firefox, Safari, WebKitGTK), and `getDisplayMedia` needs that click here.
+ * So EC's own screenshare button stays visible in the frame, where the click
+ * is native, instead of the host clicking it through the DOM.
+ */
+export function lotusFrameScreenshareEnabled(): boolean {
+ return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
+}
+
+/**
+ * Host-controlled visibility of the in-frame screenshare button
+ * (`io.lotus.set_frame_screenshare`): the host hides it while the room's
+ * call policy forbids screensharing, as it does its own call-bar button.
+ */
+export const lotusFrameScreenshareVisible$ = new BehaviorSubject(true);
+
+/** `{ visible }` payload → the visibility to apply, or undefined. Exported for tests. */
+export function parseFrameScreensharePayload(
+ data: unknown,
+): boolean | undefined {
+ if (typeof data !== "object" || data === null) return undefined;
+ const { visible } = data as { visible?: unknown };
+ return typeof visible === "boolean" ? visible : undefined;
}
/** `{ layout }` payload → a layout mode, or undefined if invalid. Exported for tests. */
@@ -100,7 +132,17 @@ export function startLotusControls(vm: CallViewModel): () => void {
w.lazyActions.on(LotusWidgetActions.SetLayout, onSetLayout);
w.lazyActions.on(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.on(LotusWidgetActions.ToggleReactions, onToggleReactions);
+ const onSetFrameScreenshare = (ev: CustomEvent): void => {
+ w.api.transport.reply(ev.detail, {});
+ const visible = parseFrameScreensharePayload(ev.detail.data);
+ if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible);
+ };
+
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
+ w.lazyActions.on(
+ LotusWidgetActions.SetFrameScreenshare,
+ onSetFrameScreenshare,
+ );
const sub: Subscription = combineLatest([
vm.sharingScreen$,
@@ -115,6 +157,7 @@ export function startLotusControls(vm: CallViewModel): () => void {
layout,
screenshareAction: true,
hotkeys: true,
+ frameScreenshare: lotusFrameScreenshareEnabled(),
}),
),
distinctUntilChanged(
@@ -131,5 +174,9 @@ export function startLotusControls(vm: CallViewModel): () => void {
w.lazyActions.off(LotusWidgetActions.OpenSettings, onOpenSettings);
w.lazyActions.off(LotusWidgetActions.ToggleReactions, onToggleReactions);
w.lazyActions.off(LotusWidgetActions.SetScreenshare, onSetScreenshare);
+ w.lazyActions.off(
+ LotusWidgetActions.SetFrameScreenshare,
+ onSetFrameScreenshare,
+ );
};
}
diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx
index e2c2791a..d63af00d 100644
--- a/src/room/InCallView.tsx
+++ b/src/room/InCallView.tsx
@@ -33,6 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState";
import { startLotusFocus } from "../lotus/lotusFocus";
import { startLotusControls } from "../lotus/lotusControls";
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
+import { LotusFrameScreenshare } from "../lotus/LotusFrameScreenshare";
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
import { startLotusQuality } from "../lotus/lotusQuality";
@@ -692,6 +693,7 @@ export const InCallView: FC = ({
{earpieceOverlay}
{footer}
+
{showModals && (
<>
diff --git a/src/useTheme.ts b/src/useTheme.ts
index eee8699a..e0c1c213 100644
--- a/src/useTheme.ts
+++ b/src/useTheme.ts
@@ -83,9 +83,9 @@ export const useTheme = (): void => {
// (The host used to inject `:root { color-scheme }` for this.)
document.documentElement.style.colorScheme = theme;
}
- // [cinny #43] The host draws its own call bar: hide EC's footer, but keep
- // it in the DOM (the host still clicks its screenshare button on engines
- // without Capability Delegation). Replaces the host's injected styles.
+ // [cinny #43] The host draws its own call bar: hide EC's footer. Replaces
+ // the host's injected styles. Where the host can't start a share for us,
+ // LotusFrameScreenshare shows EC's screenshare button on its own.
if (lotusFlag("lotusHostControls"))
document.body.classList.add("lotus-host-controls");
}, [previousTheme, requestedTheme]);