Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ef80cef2c | ||
|
|
88c648ba8b | ||
|
|
0aa543cc7d | ||
|
|
cc57292bf4 | ||
|
|
22f9083768 | ||
|
|
d7d75cbba5 | ||
|
|
4be3e92eb6 | ||
|
|
a1dd0e254c | ||
|
|
167ffc2440 | ||
|
|
888ed1a66c | ||
|
|
73bf2133dc | ||
|
|
07e1506dd8 | ||
|
|
58625c86b0 | ||
|
|
e3ac9a96d6 | ||
|
|
34d541b707 | ||
|
|
eb59fd5b3f | ||
|
|
fea233f5e1 | ||
|
|
93905c5bc1 | ||
|
|
3b0050e8bb | ||
|
|
41b45bef25 | ||
|
|
bb675fa50e | ||
|
|
9e4ddb3eb6 | ||
|
|
9d57d104b4 |
@@ -0,0 +1,81 @@
|
|||||||
|
# Lotus fork of Element Call
|
||||||
|
|
||||||
|
This fork is embedded by [Lotus Chat](https://code.lotusguild.org/LotusGuild/cinny)
|
||||||
|
(cinny) as the in-app call frame. Everything it adds is opt-in: without the
|
||||||
|
URL flags and widget actions below it behaves like upstream Element Call.
|
||||||
|
|
||||||
|
The source of truth is `src/lotus/` (the `io.lotus.*` actions are declared in
|
||||||
|
`src/lotus/lotusActions.ts`). Keep this file in step with it: when the host and
|
||||||
|
the fork drift, features fail silently (element-call#43, cinny#260).
|
||||||
|
|
||||||
|
## URL flags
|
||||||
|
|
||||||
|
The host sets these on the widget URL (cinny: `CallEmbed.getWidget`).
|
||||||
|
|
||||||
|
| Flag | Effect |
|
||||||
|
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| `lotusCallState=true` | Stream call state to the host: `io.lotus.call_state` (participants, speaking, mute) and `io.lotus.mic_level`. |
|
||||||
|
| `lotusTheme=true` | Apply the host theme; the frame's root `color-scheme` follows it. |
|
||||||
|
| `lotusTransparent=true` | Transparent canvas so the host's wallpaper shows through. Only meaningful with `lotusTheme` (the fork applies it anyway and warns). |
|
||||||
|
| `lotusHostControls=true` | The host draws the call bar: hide Element Call's footer and enable the host-control actions. |
|
||||||
|
| `lotusFrameScreenshare=true` | With `lotusHostControls`: show the frame's own screenshare button, for engines without Capability Delegation (Firefox, Safari) where the host can't start a share. |
|
||||||
|
| `lotusAudioInject=true` | Accept `io.lotus.inject_audio` (soundboard). |
|
||||||
|
| `lotusDenoiseSource=true` | Run ML noise suppression in-source as a LiveKit audio TrackProcessor. |
|
||||||
|
| `lotusModel=rnnoise\|speex\|dtln\|deepfilternet` | Denoise model (default `rnnoise`; falls back to `rnnoise` once if the chosen model fails). |
|
||||||
|
| `lotusGate=true`, `lotusGateThreshold=<dB>` | Noise gate after denoise (default −45 dB). |
|
||||||
|
| `lotusDenoiseFloor=<0..1>` | Denoise mix floor. |
|
||||||
|
| `lotusDenoiseBase=<url>` | Where the denoise worklets / wasm / models are served (same-origin by default: `./denoise/`). |
|
||||||
|
| `sendNotificationType=none` | Upstream param, extended: `none` sends no call-start notification at all (cinny#295; voice channels shouldn't @room-ping). |
|
||||||
|
|
||||||
|
## Widget actions (`io.lotus.*`)
|
||||||
|
|
||||||
|
"host → call" actions must be allow-listed in `LOTUS_TO_WIDGET_ACTIONS`.
|
||||||
|
The table is generated from `src/lotus/lotusActions.ts`; the last column was
|
||||||
|
checked against cinny's `lotus` branch.
|
||||||
|
|
||||||
|
| Action | Direction | Payload / meaning | Handled by cinny (`lotus`) |
|
||||||
|
| -------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- |
|
||||||
|
| `io.lotus.call_state` | call → host | in-call per-participant speaking / mute state. NOTE: matrix-widget-api `transport.send` is request/response — the host MUST register a handler that replies/acks each one (cinny's `listenAction` does, replying `{}`). If the host has no handler, ClientWidgetApi immediately error-replies "unsupported from-widget action", so the data is silently dropped and each throttled send rejects (caught) — functional miss + log churn, not a hang. | yes |
|
||||||
|
| `io.lotus.focus_participant` | host → call | pin/spotlight (or clear, with userId=null) a participant. | yes |
|
||||||
|
| `io.lotus.inject_audio` | host → call | play an audio clip into the call as a separate published track. | yes |
|
||||||
|
| `io.lotus.set_quality` | host → call | set audio/screenshare encoding quality (bitrate/framerate). | yes |
|
||||||
|
| `io.lotus.decorations` | host → call | per-user avatar-decoration image URLs for in-call tiles. | yes |
|
||||||
|
| `io.lotus.set_deafen` | host → call | deafen remote audio (and optionally mute screenshare audio). | yes |
|
||||||
|
| `io.lotus.request_state` | call → host | sent on (re)registration of a lotus toWidget handler (e.g. decorations) so the host can re-push state that would otherwise only be sent on change (decorations roster, the current focus pin) after an EC-side reconnect/remount. cinny's `resendForkState()` should respond to this the same way it responds to a fresh join. | yes |
|
||||||
|
| `io.lotus.denoise_state` | call → host | real state of the in-source denoise engine — `{ active: boolean, model: string, error?: string }` — sent once the processor attaches (or after the rnnoise fallback also fails), so the host toggle can reflect reality rather than the requested state. | yes |
|
||||||
|
| `io.lotus.call_summary` | call → host | one-shot end-of-call readout for the local participant — `{ durationMs, reconnects, poorMs, verdict }` (#143) — sent on SFU disconnect or in-call teardown, whichever comes first. | yes |
|
||||||
|
| `io.lotus.set_audio_output` | host → call | select the audio output device `{ deviceId }` (#119). | yes |
|
||||||
|
| `io.lotus.audio_output_state` | call → host | the currently selected output `{ deviceId }` (#119). | cinny #417 |
|
||||||
|
| `io.lotus.screenshare_notice` | call → host | local screenshare reminder `{ kind: "ended" \| "no-frames" \| "alone" }` — window closed, no frames for 15 s, or 30 min of sharing with nobody else in the call (#39). Each fires at most once per share. | yes |
|
||||||
|
| `io.lotus.set_layout` | host → call | switch the call layout `{ layout: "grid" \| "spotlight" }` (cinny #43 — replaces the host clicking EC's hidden layout radio). | yes |
|
||||||
|
| `io.lotus.open_settings` | host → call | open EC's settings modal, or close it with `{ open: false }`; omit `open` to toggle (cinny #43). | yes |
|
||||||
|
| `io.lotus.toggle_reactions` | host → call | toggle the reactions / raise-hand menu (cinny #43). | yes |
|
||||||
|
| `io.lotus.set_screenshare` | host → call | start/stop sharing `{ on?: boolean }` (omit to toggle; cinny #43). Starting calls getDisplayMedia, which needs the user's click: the host sends this with Capability Delegation (`delegate: "display-capture"`), and without it the browser rejects the share. | yes |
|
||||||
|
| `io.lotus.set_hotkeys` | host → call | `{ codes: string[] }` — KeyboardEvent.code values (PTT, deafen) to report while focus is inside this frame (cinny #43). `[]` stops. | yes |
|
||||||
|
| `io.lotus.set_frame_screenshare` | host → call | `{ 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. | yes |
|
||||||
|
| `io.lotus.prompt_screenshare` | host → call | `{}` — show the "Share your screen?" prompt inside this frame (cinny #43). For hosts that can't delegate the user's click: the prompt's Share button is clicked here, so getDisplayMedia gets its activation. | yes |
|
||||||
|
| `io.lotus.hotkey` | call → host | a watched key went down/up here, or this window's focus changed; see `LotusHotkeyReport` (cinny #43). | yes |
|
||||||
|
| `io.lotus.controls_state` | call → host | `{ screensharing: boolean, layout: "grid" \| "spotlight" \| null, screenshareAction: true, hotkeys: true, frameScreenshare: false, screensharePrompt: boolean, screensharePromptOpen: 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. | yes |
|
||||||
|
| `io.lotus.mic_level` | call → host | local mic level `{ bars: 0 \| 1 \| 2 \| 3 }` (cinny #146), sent only when it changes (≤ 10 Hz); 0 while muted or with no mic. | yes |
|
||||||
|
| `io.lotus.call_error` | call → host | the call's error page is up (`{ code, recoverable }`) or cleared (`{ code: null }`) (cinny #318), so the host stops showing the call as joined after "Connection lost". | cinny #418 |
|
||||||
|
| `io.lotus.reconnect` | host → call | press the error page's Reconnect / Try again (cinny #318). | cinny #418 |
|
||||||
|
|
||||||
|
Entries naming a cinny PR are handled once that PR merges.
|
||||||
|
|
||||||
|
## Fork-only behaviour (no flag needed)
|
||||||
|
|
||||||
|
Per-person volume memory, screenshare notices (window closed / no frames /
|
||||||
|
sharing alone), mono mic capture, an SFU refusal reason instead of "Something
|
||||||
|
went wrong", "Try again" for failed connects, and support for running on its
|
||||||
|
own origin (cinny#43).
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
1. Merge the change into `lotus`.
|
||||||
|
2. Bump `version` in `embedded/web/package.json` (`0.25.0-lotus.N`), commit
|
||||||
|
`chore(lotus): 0.25.0-lotus.N` on `lotus`, and push a tag `v0.25.0-lotus.N`.
|
||||||
|
3. CI builds the embedded bundle and, for `v*` tags, publishes
|
||||||
|
`@lotusguild/element-call-embedded` to the Gitea npm registry
|
||||||
|
(`https://code.lotusguild.org/api/packages/LotusGuild/npm/`).
|
||||||
|
4. In cinny, pin the exact version in `package.json` (`npm install --save-exact
|
||||||
|
@lotusguild/element-call-embedded@0.25.0-lotus.N`) and open a PR.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@lotusguild/element-call-embedded",
|
"name": "@lotusguild/element-call-embedded",
|
||||||
"version": "0.25.0-lotus.20",
|
"version": "0.25.0-lotus.25",
|
||||||
"files": [
|
"files": [
|
||||||
"README.md",
|
"README.md",
|
||||||
"LICENSE-AGPL-3.0",
|
"LICENSE-AGPL-3.0",
|
||||||
|
|||||||
@@ -305,6 +305,19 @@ describe("UrlParams", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("[lotus] sendNotificationType=none turns the preset notification off", () => {
|
||||||
|
expect(
|
||||||
|
computeUrlParams(
|
||||||
|
"?intent=start_call&widgetId=1234&parentUrl=parent.org&sendNotificationType=none",
|
||||||
|
).sendNotificationType,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
computeUrlParams(
|
||||||
|
"?intent=start_call&widgetId=1234&parentUrl=parent.org",
|
||||||
|
).sendNotificationType,
|
||||||
|
).toBe("notification");
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts join_existing", () => {
|
it("accepts join_existing", () => {
|
||||||
expect(
|
expect(
|
||||||
computeUrlParams(
|
computeUrlParams(
|
||||||
|
|||||||
@@ -523,6 +523,14 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
|
|||||||
...properties,
|
...properties,
|
||||||
...intentPreset,
|
...intentPreset,
|
||||||
...pickBy(configuration, (v?: unknown) => v !== undefined),
|
...pickBy(configuration, (v?: unknown) => v !== undefined),
|
||||||
|
// [lotus] `sendNotificationType=none` turns the intent preset's
|
||||||
|
// notification off. The host passes it for persistent voice channels and
|
||||||
|
// space rooms, where a call start must not @room-ping every member
|
||||||
|
// (LotusGuild/cinny#295). pickBy above drops undefined, so it can't be
|
||||||
|
// expressed as a plain override.
|
||||||
|
...(parser.getParam("sendNotificationType") === "none"
|
||||||
|
? { sendNotificationType: undefined }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
/* [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;
|
|
||||||
}
|
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
/*
|
|
||||||
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(
|
|
||||||
<TooltipProvider>
|
|
||||||
<LotusFrameScreenshare vm={vm} />
|
|
||||||
</TooltipProvider>,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
/*
|
|
||||||
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 (
|
|
||||||
<div className={styles.floating}>
|
|
||||||
<ShareScreenButton
|
|
||||||
size="md"
|
|
||||||
enabled={sharing}
|
|
||||||
onClick={vm.toggleScreenSharing}
|
|
||||||
data-testid="lotus_frame_screenshare"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* [cinny #43] "Share your screen?" card, drawn inside the frame just above the
|
||||||
|
host's call bar (the frame's bottom edge). */
|
||||||
|
.prompt {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(env(safe-area-inset-bottom) + var(--cpd-space-4x));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(360px, calc(100% - 2 * var(--cpd-space-2x)));
|
||||||
|
padding: var(--cpd-space-4x);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
background: var(--cpd-color-bg-canvas-default);
|
||||||
|
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||||
|
border-radius: var(--cpd-space-3x);
|
||||||
|
box-shadow: 0 8px 32px rgb(0 0 0 / 35%);
|
||||||
|
color: var(--cpd-color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--cpd-space-2x);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The call's picture-in-picture window is only ~158px tall: keep the card to
|
||||||
|
the question and the two buttons. */
|
||||||
|
@media (max-height: 240px) {
|
||||||
|
.prompt {
|
||||||
|
bottom: var(--cpd-space-2x);
|
||||||
|
padding: var(--cpd-space-2x) var(--cpd-space-3x);
|
||||||
|
gap: var(--cpd-space-1-5x);
|
||||||
|
}
|
||||||
|
.description {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
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 { afterEach, 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 { LotusScreensharePrompt } from "./LotusScreensharePrompt";
|
||||||
|
import {
|
||||||
|
lotusFrameScreenshareEnabled,
|
||||||
|
lotusFrameScreenshareVisible$,
|
||||||
|
lotusScreensharePrompt$,
|
||||||
|
shouldPromptScreenshare,
|
||||||
|
} 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 renderPrompt = (vm: CallViewModel): void => {
|
||||||
|
render(
|
||||||
|
<TooltipProvider>
|
||||||
|
<LotusScreensharePrompt vm={vm} />
|
||||||
|
</TooltipProvider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const prompt = (): HTMLElement | null =>
|
||||||
|
screen.queryByTestId("lotus_screenshare_prompt");
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
act(() => {
|
||||||
|
lotusScreensharePrompt$.next(false);
|
||||||
|
lotusFrameScreenshareVisible$.next(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LotusScreensharePrompt", () => {
|
||||||
|
it("is enabled by lotusHostControls + lotusFrameScreenshare", () => {
|
||||||
|
expect(lotusFrameScreenshareEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stays hidden until the host asks", () => {
|
||||||
|
renderPrompt(mockVm(vi.fn()));
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
expect(prompt()).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("alertdialog", { name: "Share your screen?" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("focuses Share, and Share starts the share and closes", async () => {
|
||||||
|
const toggle = vi.fn();
|
||||||
|
renderPrompt(mockVm(toggle));
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
const share = screen.getByTestId("lotus_screenshare_prompt_share");
|
||||||
|
expect(share).toHaveFocus();
|
||||||
|
await userEvent.click(share);
|
||||||
|
expect(toggle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Cancel and Escape close without sharing", async () => {
|
||||||
|
const toggle = vi.fn();
|
||||||
|
renderPrompt(mockVm(toggle));
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
expect(toggle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when a share starts some other way", () => {
|
||||||
|
const sharing = new BehaviorSubject(false);
|
||||||
|
renderPrompt(mockVm(vi.fn(), sharing));
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
act(() => sharing.next(true));
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes when the room's policy stops allowing screensharing", () => {
|
||||||
|
renderPrompt(mockVm(vi.fn()));
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
act(() => lotusFrameScreenshareVisible$.next(false));
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing when screensharing is unavailable", () => {
|
||||||
|
renderPrompt(mockVm(null));
|
||||||
|
act(() => lotusScreensharePrompt$.next(true));
|
||||||
|
expect(prompt()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("shouldPromptScreenshare", () => {
|
||||||
|
const ok = { enabled: true, allowed: true, canShare: true, sharing: false };
|
||||||
|
it("prompts only in prompt mode, when allowed, able and not sharing", () => {
|
||||||
|
expect(shouldPromptScreenshare(ok)).toBe(true);
|
||||||
|
expect(shouldPromptScreenshare({ ...ok, enabled: false })).toBe(false);
|
||||||
|
expect(shouldPromptScreenshare({ ...ok, allowed: false })).toBe(false);
|
||||||
|
expect(shouldPromptScreenshare({ ...ok, canShare: false })).toBe(false);
|
||||||
|
expect(shouldPromptScreenshare({ ...ok, sharing: true })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/*
|
||||||
|
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, useCallback, useEffect, useId, useRef } from "react";
|
||||||
|
import { Button, Text } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
|
import { useBehavior } from "../useBehavior";
|
||||||
|
import {
|
||||||
|
lotusFrameScreenshareEnabled,
|
||||||
|
lotusFrameScreenshareVisible$,
|
||||||
|
lotusScreensharePrompt$,
|
||||||
|
} from "./lotusControls";
|
||||||
|
import styles from "./LotusScreensharePrompt.module.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [cinny #43] "Share your screen?" asked inside the call frame, for hosts that
|
||||||
|
* can't hand the user's click to this frame (Firefox, Safari, WebKitGTK). The
|
||||||
|
* host's call-bar button sends `io.lotus.prompt_screenshare`; the Share click
|
||||||
|
* lands here, so getDisplayMedia runs with this frame's own activation. Same
|
||||||
|
* two clicks as the host's own confirm on Chromium.
|
||||||
|
*/
|
||||||
|
export const LotusScreensharePrompt: FC<{ vm: CallViewModel }> = ({ vm }) => {
|
||||||
|
const open = useBehavior(lotusScreensharePrompt$);
|
||||||
|
const sharing = useBehavior(vm.sharingScreen$);
|
||||||
|
const allowed = useBehavior(lotusFrameScreenshareVisible$);
|
||||||
|
const shareRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
|
||||||
|
const close = useCallback(() => lotusScreensharePrompt$.next(false), []);
|
||||||
|
|
||||||
|
// Stale once a share is running (started some other way) or the room's
|
||||||
|
// policy stops allowing it.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && (sharing || !allowed)) close();
|
||||||
|
}, [open, sharing, allowed, close]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) shareRef.current?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return undefined;
|
||||||
|
const onKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
if (e.key !== "Escape") return;
|
||||||
|
e.preventDefault();
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown, true);
|
||||||
|
return (): void => window.removeEventListener("keydown", onKeyDown, true);
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
|
const toggle = vm.toggleScreenSharing;
|
||||||
|
if (!open || !lotusFrameScreenshareEnabled() || !toggle) return null;
|
||||||
|
|
||||||
|
const onShare = (): void => {
|
||||||
|
close();
|
||||||
|
// Synchronously inside this click: getDisplayMedia needs its activation.
|
||||||
|
toggle();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.prompt}
|
||||||
|
role="alertdialog"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
data-testid="lotus_screenshare_prompt"
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
id={titleId}
|
||||||
|
size="md"
|
||||||
|
weight="semibold"
|
||||||
|
className={styles.title}
|
||||||
|
>
|
||||||
|
Share your screen?
|
||||||
|
</Text>
|
||||||
|
<Text id={descriptionId} size="md" className={styles.description}>
|
||||||
|
Your screen will be visible to everyone in this call.
|
||||||
|
</Text>
|
||||||
|
<div className={styles.buttons}>
|
||||||
|
<Button
|
||||||
|
ref={shareRef}
|
||||||
|
size="md"
|
||||||
|
onClick={onShare}
|
||||||
|
data-testid="lotus_screenshare_prompt_share"
|
||||||
|
>
|
||||||
|
Share
|
||||||
|
</Button>
|
||||||
|
<Button size="md" kind="secondary" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -33,6 +33,8 @@ describe("LotusWidgetActions", () => {
|
|||||||
LotusWidgetActions.SetScreenshare,
|
LotusWidgetActions.SetScreenshare,
|
||||||
LotusWidgetActions.SetHotkeys,
|
LotusWidgetActions.SetHotkeys,
|
||||||
LotusWidgetActions.SetFrameScreenshare,
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
|
LotusWidgetActions.PromptScreenshare,
|
||||||
|
LotusWidgetActions.Reconnect,
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
|
||||||
@@ -55,5 +57,6 @@ describe("LotusWidgetActions", () => {
|
|||||||
);
|
);
|
||||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.MicLevel);
|
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.MicLevel);
|
||||||
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.Hotkey);
|
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.Hotkey);
|
||||||
|
expect(LOTUS_TO_WIDGET_ACTIONS).not.toContain(LotusWidgetActions.CallError);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ export enum LotusWidgetActions {
|
|||||||
* policy forbids screensharing (cinny #43). Defaults to visible.
|
* policy forbids screensharing (cinny #43). Defaults to visible.
|
||||||
*/
|
*/
|
||||||
SetFrameScreenshare = "io.lotus.set_frame_screenshare",
|
SetFrameScreenshare = "io.lotus.set_frame_screenshare",
|
||||||
|
/**
|
||||||
|
* toWidget: `{}` — show the "Share your screen?" prompt inside this frame
|
||||||
|
* (cinny #43). For hosts that can't delegate the user's click: the prompt's
|
||||||
|
* Share button is clicked here, so getDisplayMedia gets its activation.
|
||||||
|
*/
|
||||||
|
PromptScreenshare = "io.lotus.prompt_screenshare",
|
||||||
/**
|
/**
|
||||||
* fromWidget: a watched key went down/up here, or this window's focus
|
* fromWidget: a watched key went down/up here, or this window's focus
|
||||||
* changed; see `LotusHotkeyReport` (cinny #43).
|
* changed; see `LotusHotkeyReport` (cinny #43).
|
||||||
@@ -99,7 +105,8 @@ export enum LotusWidgetActions {
|
|||||||
Hotkey = "io.lotus.hotkey",
|
Hotkey = "io.lotus.hotkey",
|
||||||
/**
|
/**
|
||||||
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
|
* fromWidget: `{ screensharing: boolean, layout: "grid" | "spotlight" | null,
|
||||||
* screenshareAction: true, hotkeys: true, frameScreenshare: boolean }`
|
* screenshareAction: true, hotkeys: true, frameScreenshare: false,
|
||||||
|
* screensharePrompt: boolean, screensharePromptOpen: boolean }`
|
||||||
* whenever either changes (cinny #43), so the host stops reading EC's DOM for
|
* 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.
|
* them. Its arrival also tells the host this fork supports the actions above.
|
||||||
*/
|
*/
|
||||||
@@ -109,6 +116,14 @@ export enum LotusWidgetActions {
|
|||||||
* only when it changes (≤ 10 Hz); 0 while muted or with no mic.
|
* only when it changes (≤ 10 Hz); 0 while muted or with no mic.
|
||||||
*/
|
*/
|
||||||
MicLevel = "io.lotus.mic_level",
|
MicLevel = "io.lotus.mic_level",
|
||||||
|
/**
|
||||||
|
* fromWidget: the call's error page is up (`{ code, recoverable }`) or
|
||||||
|
* cleared (`{ code: null }`) (cinny #318), so the host stops showing the call
|
||||||
|
* as joined after "Connection lost".
|
||||||
|
*/
|
||||||
|
CallError = "io.lotus.call_error",
|
||||||
|
/** toWidget: press the error page's Reconnect / Try again (cinny #318). */
|
||||||
|
Reconnect = "io.lotus.reconnect",
|
||||||
}
|
}
|
||||||
|
|
||||||
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
|
/** toWidget Lotus actions that must be allow-listed in `initializeWidget`. */
|
||||||
@@ -125,4 +140,6 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
|
|||||||
LotusWidgetActions.SetScreenshare,
|
LotusWidgetActions.SetScreenshare,
|
||||||
LotusWidgetActions.SetHotkeys,
|
LotusWidgetActions.SetHotkeys,
|
||||||
LotusWidgetActions.SetFrameScreenshare,
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
|
LotusWidgetActions.PromptScreenshare,
|
||||||
|
LotusWidgetActions.Reconnect,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { of } from "rxjs";
|
|||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import {
|
import {
|
||||||
|
canPublishSoundboard,
|
||||||
MAX_INJECT_BYTES,
|
MAX_INJECT_BYTES,
|
||||||
parseInjectSource,
|
parseInjectSource,
|
||||||
startLotusAudioInject,
|
startLotusAudioInject,
|
||||||
@@ -258,3 +259,17 @@ test("parseInjectSource rejects oversized or non-buffer audio", () => {
|
|||||||
expect(parseInjectSource({ audio: "not bytes" })).toBeNull();
|
expect(parseInjectSource({ audio: "not bytes" })).toBeNull();
|
||||||
expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull();
|
expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("canPublishSoundboard: refused by a microphone-only grant (EC #47)", () => {
|
||||||
|
expect(canPublishSoundboard(undefined)).toBe(true);
|
||||||
|
expect(
|
||||||
|
canPublishSoundboard({ canPublish: true, canPublishSources: [] }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canPublishSoundboard({ canPublish: true, canPublishSources: [2] }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
canPublishSoundboard({ canPublish: true, canPublishSources: [2, 0] }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(canPublishSoundboard({ canPublish: false })).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -60,6 +60,22 @@ function acquireSharedAudio(): {
|
|||||||
* No effect unless the host sends the action. Returns a teardown function that
|
* No effect unless the host sends the action. Returns a teardown function that
|
||||||
* also aborts any clip still playing.
|
* also aborts any clip still playing.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Pure: whether these LiveKit permissions allow publishing the soundboard's
|
||||||
|
* Unknown-source track (EC #47). No permissions or an empty source list
|
||||||
|
* means unrestricted; otherwise UNKNOWN (TrackSource 0) must be allowed.
|
||||||
|
*/
|
||||||
|
export function canPublishSoundboard(
|
||||||
|
perm:
|
||||||
|
| { canPublish?: boolean; canPublishSources?: readonly number[] }
|
||||||
|
| undefined,
|
||||||
|
): boolean {
|
||||||
|
if (!perm) return true;
|
||||||
|
if (perm.canPublish === false) return false;
|
||||||
|
const sources = perm.canPublishSources ?? [];
|
||||||
|
return sources.length === 0 || sources.includes(0);
|
||||||
|
}
|
||||||
|
|
||||||
export function startLotusAudioInject(vm: CallViewModel): () => void {
|
export function startLotusAudioInject(vm: CallViewModel): () => void {
|
||||||
const w = widget;
|
const w = widget;
|
||||||
if (!w) return () => undefined;
|
if (!w) return () => undefined;
|
||||||
@@ -116,6 +132,19 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [EC #47] Clips publish as an Unknown-source track. Audio-only rooms get
|
||||||
|
// a token limited to the microphone source, where LiveKit refuses that
|
||||||
|
// publish *after* we replied; say so up front instead of failing silently.
|
||||||
|
if (
|
||||||
|
rooms.some((r) => !canPublishSoundboard(r.localParticipant.permissions))
|
||||||
|
) {
|
||||||
|
w.api.transport.reply(ev.detail, {
|
||||||
|
played: false,
|
||||||
|
reason: "not_permitted",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
w.api.transport.reply(ev.detail, {});
|
w.api.transport.reply(ev.detail, {});
|
||||||
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
|
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
|
||||||
logger.warn("[lotus] inject_audio failed", e),
|
logger.warn("[lotus] inject_audio failed", e),
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/*
|
||||||
|
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 { expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { audioOutputList } from "./lotusAudioOutput";
|
||||||
|
import { type AudioOutputDeviceLabel } from "../state/MediaDevices";
|
||||||
|
|
||||||
|
test("audioOutputList labels every kind of output (cinny #310)", () => {
|
||||||
|
const m = new Map<string, AudioOutputDeviceLabel>([
|
||||||
|
["", { type: "default", name: "Speakers" }],
|
||||||
|
["a1", { type: "name", name: "Headset" }],
|
||||||
|
["a2", { type: "number", number: 3 }],
|
||||||
|
]);
|
||||||
|
expect(audioOutputList(m)).toEqual([
|
||||||
|
{ id: "", label: "Default (Speakers)" },
|
||||||
|
{ id: "a1", label: "Headset" },
|
||||||
|
{ id: "a2", label: "Output 3" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -8,7 +8,12 @@ Please see LICENSE in the repository root for full details.
|
|||||||
import { type IWidgetApiRequest } from "matrix-widget-api";
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
|
|
||||||
import { type MediaDevices } from "../state/MediaDevices";
|
import { combineLatest } from "rxjs";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type AudioOutputDeviceLabel,
|
||||||
|
type MediaDevices,
|
||||||
|
} from "../state/MediaDevices";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
import { lotusSendToHost } from "./lotusWidget";
|
import { lotusSendToHost } from "./lotusWidget";
|
||||||
@@ -19,6 +24,10 @@ import { lotusSendToHost } from "./lotusWidget";
|
|||||||
* selects the output; the fork answers each change (and the initial state)
|
* selects the output; the fork answers each change (and the initial state)
|
||||||
* with `io.lotus.audio_output_state { deviceId, available: [{id,label}] }`.
|
* with `io.lotus.audio_output_state { deviceId, available: [{id,label}] }`.
|
||||||
* No effect unless the host sends the action. Returns a teardown function.
|
* No effect unless the host sends the action. Returns a teardown function.
|
||||||
|
*
|
||||||
|
* [cinny #310] Browsers hash deviceIds per frame/origin, so the host can't
|
||||||
|
* use ids from its own enumerateDevices(); it builds its menu from
|
||||||
|
* `available` here, whose ids are valid in this frame.
|
||||||
*/
|
*/
|
||||||
export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
|
export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
|
||||||
const w = widget;
|
const w = widget;
|
||||||
@@ -33,9 +42,13 @@ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
|
|||||||
};
|
};
|
||||||
w.lazyActions.on(LotusWidgetActions.SetAudioOutput, handler);
|
w.lazyActions.on(LotusWidgetActions.SetAudioOutput, handler);
|
||||||
|
|
||||||
const sub = mediaDevices.audioOutput.selected$.subscribe((selected) => {
|
const sub = combineLatest([
|
||||||
|
mediaDevices.audioOutput.available$,
|
||||||
|
mediaDevices.audioOutput.selected$,
|
||||||
|
]).subscribe(([available, selected]) => {
|
||||||
lotusSendToHost(LotusWidgetActions.AudioOutputState, {
|
lotusSendToHost(LotusWidgetActions.AudioOutputState, {
|
||||||
deviceId: selected?.id ?? null,
|
deviceId: selected?.id ?? null,
|
||||||
|
available: audioOutputList(available),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,3 +57,29 @@ export function startLotusAudioOutput(mediaDevices: MediaDevices): () => void {
|
|||||||
w.lazyActions.off(LotusWidgetActions.SetAudioOutput, handler);
|
w.lazyActions.off(LotusWidgetActions.SetAudioOutput, handler);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The output list as `{id, label}` for the host's menu (#310). */
|
||||||
|
export function audioOutputList(
|
||||||
|
available: Map<string, AudioOutputDeviceLabel>,
|
||||||
|
): { id: string; label: string }[] {
|
||||||
|
return [...available].map(([id, l]) => {
|
||||||
|
let label: string;
|
||||||
|
switch (l.type) {
|
||||||
|
case "name":
|
||||||
|
label = l.name;
|
||||||
|
break;
|
||||||
|
case "number":
|
||||||
|
label = `Output ${l.number}`;
|
||||||
|
break;
|
||||||
|
case "default":
|
||||||
|
label = l.name ? `Default (${l.name})` : "Default";
|
||||||
|
break;
|
||||||
|
case "speaker":
|
||||||
|
label = "Speaker";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label = "Earpiece";
|
||||||
|
}
|
||||||
|
return { id, label };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,23 +26,39 @@ export interface LotusControlsState {
|
|||||||
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
/** This fork handles `io.lotus.set_hotkeys` (always true). */
|
||||||
hotkeys: true;
|
hotkeys: true;
|
||||||
/**
|
/**
|
||||||
* EC's own screenshare button is shown inside the frame (see
|
* EC's own screenshare button is shown inside the frame, so the host should
|
||||||
* `lotusFrameScreenshare`), so the host should hide its own.
|
* hide its own. Always false since the prompt replaced that button; kept so
|
||||||
|
* a host that still reads it doesn't hide its button.
|
||||||
*/
|
*/
|
||||||
frameScreenshare: boolean;
|
frameScreenshare: false;
|
||||||
|
/**
|
||||||
|
* The host should start a share with `io.lotus.prompt_screenshare` (the
|
||||||
|
* in-frame prompt) instead of `set_screenshare`: see
|
||||||
|
* `lotusFrameScreenshareEnabled`.
|
||||||
|
*/
|
||||||
|
screensharePrompt: boolean;
|
||||||
|
/**
|
||||||
|
* The prompt is showing. The host must let clicks through to this frame
|
||||||
|
* meanwhile (e.g. its picture-in-picture overlay).
|
||||||
|
*/
|
||||||
|
screensharePromptOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [cinny #43] `lotusFrameScreenshare=1` (with `lotusHostControls`): the host
|
* [cinny #43] `lotusFrameScreenshare=1` (with `lotusHostControls`): the host
|
||||||
* can't hand the user's click into this frame (no Capability Delegation:
|
* can't hand the user's click into this frame (no Capability Delegation:
|
||||||
* Firefox, Safari, WebKitGTK), and `getDisplayMedia` needs that click here.
|
* Firefox, Safari, WebKitGTK), and `getDisplayMedia` needs that click here.
|
||||||
* So EC's own screenshare button stays visible in the frame, where the click
|
* The host's call-bar button then asks for `io.lotus.prompt_screenshare`, and
|
||||||
* is native, instead of the host clicking it through the DOM.
|
* the "Share your screen?" prompt is drawn inside this frame, where the Share
|
||||||
|
* click is native.
|
||||||
*/
|
*/
|
||||||
export function lotusFrameScreenshareEnabled(): boolean {
|
export function lotusFrameScreenshareEnabled(): boolean {
|
||||||
return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
|
return lotusFlag("lotusHostControls") && lotusFlag("lotusFrameScreenshare");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the in-frame "Share your screen?" prompt is showing. */
|
||||||
|
export const lotusScreensharePrompt$ = new BehaviorSubject(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Host-controlled visibility of the in-frame screenshare button
|
* Host-controlled visibility of the in-frame screenshare button
|
||||||
* (`io.lotus.set_frame_screenshare`): the host hides it while the room's
|
* (`io.lotus.set_frame_screenshare`): the host hides it while the room's
|
||||||
@@ -50,6 +66,21 @@ export function lotusFrameScreenshareEnabled(): boolean {
|
|||||||
*/
|
*/
|
||||||
export const lotusFrameScreenshareVisible$ = new BehaviorSubject(true);
|
export const lotusFrameScreenshareVisible$ = new BehaviorSubject(true);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `io.lotus.prompt_screenshare` should show the prompt: only in
|
||||||
|
* prompt mode, where the room's policy allows sharing, EC can share, and no
|
||||||
|
* share is running (stopping goes through `set_screenshare`, which needs no
|
||||||
|
* click). Exported for tests.
|
||||||
|
*/
|
||||||
|
export function shouldPromptScreenshare(s: {
|
||||||
|
enabled: boolean;
|
||||||
|
allowed: boolean;
|
||||||
|
canShare: boolean;
|
||||||
|
sharing: boolean;
|
||||||
|
}): boolean {
|
||||||
|
return s.enabled && s.allowed && s.canShare && !s.sharing;
|
||||||
|
}
|
||||||
|
|
||||||
/** `{ visible }` payload → the visibility to apply, or undefined. Exported for tests. */
|
/** `{ visible }` payload → the visibility to apply, or undefined. Exported for tests. */
|
||||||
export function parseFrameScreensharePayload(
|
export function parseFrameScreensharePayload(
|
||||||
data: unknown,
|
data: unknown,
|
||||||
@@ -138,30 +169,51 @@ export function startLotusControls(vm: CallViewModel): () => void {
|
|||||||
if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible);
|
if (visible !== undefined) lotusFrameScreenshareVisible$.next(visible);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onPromptScreenshare = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
|
w.api.transport.reply(ev.detail, {});
|
||||||
|
if (
|
||||||
|
shouldPromptScreenshare({
|
||||||
|
enabled: lotusFrameScreenshareEnabled(),
|
||||||
|
allowed: lotusFrameScreenshareVisible$.value,
|
||||||
|
canShare: !!vm.toggleScreenSharing,
|
||||||
|
sharing: vm.sharingScreen$.value,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
lotusScreensharePrompt$.next(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
w.lazyActions.on(LotusWidgetActions.SetScreenshare, onSetScreenshare);
|
||||||
w.lazyActions.on(
|
w.lazyActions.on(
|
||||||
LotusWidgetActions.SetFrameScreenshare,
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
onSetFrameScreenshare,
|
onSetFrameScreenshare,
|
||||||
);
|
);
|
||||||
|
w.lazyActions.on(LotusWidgetActions.PromptScreenshare, onPromptScreenshare);
|
||||||
|
|
||||||
const sub: Subscription = combineLatest([
|
const sub: Subscription = combineLatest([
|
||||||
vm.sharingScreen$,
|
vm.sharingScreen$,
|
||||||
vm.layoutSwitchVm$.pipe(
|
vm.layoutSwitchVm$.pipe(
|
||||||
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
|
switchMap((l) => (l ? l.layout$ : of<LayoutMode | null>(null))),
|
||||||
),
|
),
|
||||||
|
lotusScreensharePrompt$,
|
||||||
])
|
])
|
||||||
.pipe(
|
.pipe(
|
||||||
map(
|
map(
|
||||||
([screensharing, layout]): LotusControlsState => ({
|
([screensharing, layout, promptOpen]): LotusControlsState => ({
|
||||||
screensharing,
|
screensharing,
|
||||||
layout,
|
layout,
|
||||||
screenshareAction: true,
|
screenshareAction: true,
|
||||||
hotkeys: true,
|
hotkeys: true,
|
||||||
frameScreenshare: lotusFrameScreenshareEnabled(),
|
frameScreenshare: false,
|
||||||
|
screensharePrompt: lotusFrameScreenshareEnabled(),
|
||||||
|
screensharePromptOpen: promptOpen,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
distinctUntilChanged(
|
distinctUntilChanged(
|
||||||
(a, b) => a.screensharing === b.screensharing && a.layout === b.layout,
|
(a, b) =>
|
||||||
|
a.screensharing === b.screensharing &&
|
||||||
|
a.layout === b.layout &&
|
||||||
|
a.screensharePromptOpen === b.screensharePromptOpen,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@@ -178,5 +230,10 @@ export function startLotusControls(vm: CallViewModel): () => void {
|
|||||||
LotusWidgetActions.SetFrameScreenshare,
|
LotusWidgetActions.SetFrameScreenshare,
|
||||||
onSetFrameScreenshare,
|
onSetFrameScreenshare,
|
||||||
);
|
);
|
||||||
|
w.lazyActions.off(
|
||||||
|
LotusWidgetActions.PromptScreenshare,
|
||||||
|
onPromptScreenshare,
|
||||||
|
);
|
||||||
|
lotusScreensharePrompt$.next(false);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
|||||||
|
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { MicLevelQuantizer } from "./lotusMicLevel";
|
import { MicLevelQuantizer, sameRooms } from "./lotusMicLevel";
|
||||||
|
|
||||||
describe("MicLevelQuantizer", () => {
|
describe("MicLevelQuantizer", () => {
|
||||||
it("maps RMS to 0–3 bars", () => {
|
it("maps RMS to 0–3 bars", () => {
|
||||||
@@ -35,3 +35,14 @@ describe("MicLevelQuantizer", () => {
|
|||||||
expect(q.push(0)).toBe(0);
|
expect(q.push(0)).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("sameRooms (EC #45)", () => {
|
||||||
|
it("compares rooms by identity and order", () => {
|
||||||
|
const a = {};
|
||||||
|
const b = {};
|
||||||
|
expect(sameRooms([a, b], [a, b])).toBe(true);
|
||||||
|
expect(sameRooms([a], [a, b])).toBe(false);
|
||||||
|
expect(sameRooms([a, b], [b, a])).toBe(false);
|
||||||
|
expect(sameRooms([{}], [{}])).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ const micPublication = (
|
|||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Pure: the same LiveKit rooms, in the same order (by identity). */
|
||||||
|
export const sameRooms = <T>(a: readonly T[], b: readonly T[]): boolean =>
|
||||||
|
a.length === b.length && a.every((r, i) => r === b[i]);
|
||||||
|
|
||||||
const samplers = new WeakMap<
|
const samplers = new WeakMap<
|
||||||
CallViewModel,
|
CallViewModel,
|
||||||
Observable<LocalMicSample | null>
|
Observable<LocalMicSample | null>
|
||||||
@@ -62,10 +66,15 @@ export function observeLocalMicSample$(
|
|||||||
const cached = samplers.get(vm);
|
const cached = samplers.get(vm);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
const sampler$ = vm.allConnections$.pipe(
|
const sampler$ = vm.allConnections$.pipe(
|
||||||
|
map((data) => data.getConnections().map((c) => c.livekitRoom)),
|
||||||
|
// [EC #45] allConnections$ re-emits far more often than the set of
|
||||||
|
// LiveKit rooms changes; restarting the tap each time closed the
|
||||||
|
// AudioContext ~1×/s and emitted null, which reset the muted-speech
|
||||||
|
// gate so the "You're muted" nudge could never fire.
|
||||||
|
distinctUntilChanged(sameRooms),
|
||||||
switchMap(
|
switchMap(
|
||||||
(data) =>
|
(rooms) =>
|
||||||
new Observable<LocalMicSample | null>((subscriber) => {
|
new Observable<LocalMicSample | null>((subscriber) => {
|
||||||
const rooms = data.getConnections().map((c) => c.livekitRoom);
|
|
||||||
let ctx: AudioContext | null = null;
|
let ctx: AudioContext | null = null;
|
||||||
let clone: MediaStreamTrack | null = null;
|
let clone: MediaStreamTrack | null = null;
|
||||||
let timer: ReturnType<typeof setInterval> | undefined;
|
let timer: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type ReactNode,
|
type ReactNode,
|
||||||
type SVGAttributes,
|
type SVGAttributes,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,7 @@ import {
|
|||||||
import { Button } from "@vector-im/compound-web";
|
import { Button } from "@vector-im/compound-web";
|
||||||
import { logger } from "matrix-js-sdk/lib/logger";
|
import { logger } from "matrix-js-sdk/lib/logger";
|
||||||
import { MatrixError } from "matrix-js-sdk";
|
import { MatrixError } from "matrix-js-sdk";
|
||||||
|
import { type IWidgetApiRequest } from "matrix-widget-api";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ConnectionLostError,
|
ConnectionLostError,
|
||||||
@@ -38,6 +40,7 @@ import { FullScreenView } from "../FullScreenView.tsx";
|
|||||||
import { ErrorView } from "../ErrorView.tsx";
|
import { ErrorView } from "../ErrorView.tsx";
|
||||||
import { type WidgetHelpers } from "../widget.ts";
|
import { type WidgetHelpers } from "../widget.ts";
|
||||||
import styles from "../ErrorView.module.css";
|
import styles from "../ErrorView.module.css";
|
||||||
|
import { LotusWidgetActions, lotusSendToHost } from "../lotus/lotusWidget";
|
||||||
|
|
||||||
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
|
||||||
|
|
||||||
@@ -74,6 +77,29 @@ const ErrorPage: FC<ErrorPageProps> = ({
|
|||||||
icon = ErrorSolidIcon;
|
icon = ErrorSolidIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [cinny #318] Tell the host the call dropped (it kept showing the call as
|
||||||
|
// joined), and let its call bar press Reconnect for us.
|
||||||
|
const recoverable =
|
||||||
|
error instanceof ConnectionLostError ||
|
||||||
|
error instanceof LivekitConnectionError ||
|
||||||
|
error instanceof PeerConnectionTimeoutError;
|
||||||
|
useEffect(() => {
|
||||||
|
lotusSendToHost(LotusWidgetActions.CallError, {
|
||||||
|
code: error.code,
|
||||||
|
recoverable,
|
||||||
|
});
|
||||||
|
const w = widget;
|
||||||
|
const onReconnect = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
||||||
|
w?.api.transport.reply(ev.detail, {});
|
||||||
|
if (recoverable) void recoveryActionHandler("reconnect");
|
||||||
|
};
|
||||||
|
w?.lazyActions?.on(LotusWidgetActions.Reconnect, onReconnect);
|
||||||
|
return (): void => {
|
||||||
|
w?.lazyActions?.off(LotusWidgetActions.Reconnect, onReconnect);
|
||||||
|
lotusSendToHost(LotusWidgetActions.CallError, { code: null });
|
||||||
|
};
|
||||||
|
}, [error.code, recoverable, recoveryActionHandler, widget]);
|
||||||
|
|
||||||
const actions: { label: string; onClick: () => void }[] = [];
|
const actions: { label: string; onClick: () => void }[] = [];
|
||||||
if (error instanceof ConnectionLostError) {
|
if (error instanceof ConnectionLostError) {
|
||||||
actions.push({
|
actions.push({
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import { startLotusCallState } from "../lotus/lotusCallState";
|
|||||||
import { startLotusFocus } from "../lotus/lotusFocus";
|
import { startLotusFocus } from "../lotus/lotusFocus";
|
||||||
import { startLotusControls } from "../lotus/lotusControls";
|
import { startLotusControls } from "../lotus/lotusControls";
|
||||||
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
|
import { startLotusHotkeys } from "../lotus/lotusHotkeys";
|
||||||
import { LotusFrameScreenshare } from "../lotus/LotusFrameScreenshare";
|
import { LotusScreensharePrompt } from "../lotus/LotusScreensharePrompt";
|
||||||
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
|
import { startLotusMicLevel } from "../lotus/lotusMicLevel";
|
||||||
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
import { startLotusAudioInject } from "../lotus/lotusAudioInject";
|
||||||
import { startLotusQuality } from "../lotus/lotusQuality";
|
import { startLotusQuality } from "../lotus/lotusQuality";
|
||||||
@@ -693,7 +693,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
|||||||
{earpieceOverlay}
|
{earpieceOverlay}
|
||||||
<ReactionsOverlay vm={vm} />
|
<ReactionsOverlay vm={vm} />
|
||||||
{footer}
|
{footer}
|
||||||
<LotusFrameScreenshare vm={vm} />
|
<LotusScreensharePrompt vm={vm} />
|
||||||
{showModals && (
|
{showModals && (
|
||||||
<>
|
<>
|
||||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||||
|
|||||||
@@ -868,16 +868,19 @@ export function enterRTCSession(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER:
|
// Calculates `maximumNetworkErrorRetryCount`: enough retries to outlast the
|
||||||
// - The /sync loop is unresponsive for > `gracePeriod` ms, or
|
// server's delayed-leave window (see [lotus #46] below).
|
||||||
// - A delayed leave event is emitted (after `leaveDelay` ms period).
|
|
||||||
// Note: Use leaveDelay >> gracePeriod for delegated leave events.
|
|
||||||
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
|
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
|
||||||
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms;
|
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_ms;
|
||||||
const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms;
|
const retryInterval = matrixRtcSessionConfig?.network_error_retry_ms;
|
||||||
|
|
||||||
// Math.min is used to account for the respective worst case: /sync not available or leave event emitted.
|
// [lotus #46] Keep retrying for as long as the homeserver itself would keep
|
||||||
const maxWaitTime = Math.min(gracePeriod, leaveDelay);
|
// our membership: until its delayed leave fires (`leaveDelay`). Capping at
|
||||||
|
// the 10 s sync grace period made a ~20–35 s homeserver stall end everyone's
|
||||||
|
// call ("Connection lost") even with a 60 s server-side leave delay. The
|
||||||
|
// embedded call has no /sync loop of its own, so the grace period doesn't
|
||||||
|
// describe anything it can observe.
|
||||||
|
const maxWaitTime = Math.max(gracePeriod, leaveDelay);
|
||||||
const maximumNetworkErrorRetryCount =
|
const maximumNetworkErrorRetryCount =
|
||||||
Math.ceil(maxWaitTime / retryInterval) + 1;
|
Math.ceil(maxWaitTime / retryInterval) + 1;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user