Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 a951e5b1f2 feat(lotus): detailed call summary for the host's Details card (cinny #253)
CI / Build embedded bundle (pull_request) Successful in 3m1s
CI / Publish to Gitea npm registry (pull_request) Skipped
io.lotus.call_summary gains an optional `details`: the other Matrix users
seen and the peak in the call; how long your connection, each other
person's, and everyone at once (likely the server) was poor/lost; RTT
median/p95, outbound and inbound packet loss and jitter (sampled every 5 s
from LiveKit sender/receiver stats); and your talk, mute and screenshare
time. People already present when you connect are recorded on Connected
(LiveKit sends no ParticipantConnected for them), and joins are idempotent
per device. Older hosts ignore the field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-20 19:45:02 -04:00
Lotus CIandClaude Opus 5.5 438d09cde9 chore(lotus): 0.25.0-lotus.28
CI / Build embedded bundle (push) Successful in 2m15s
CI / Publish to Gitea npm registry (push) Successful in 1m8s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 23:36:30 -04:00
jared 0534fa393c Merge pull request 'feat(lotus): soundboard works in voice channels with a call policy (#47)' (#59) from feat/soundboard-mic-mix into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-19 23:36:00 -04:00
Lotus CIandClaude Opus 5.5 e12b3e5979 style(lotus): format LOTUS.md table
CI / Build embedded bundle (pull_request) Successful in 1m41s
CI / Publish to Gitea npm registry (pull_request) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 23:30:56 -04:00
Lotus CIandClaude Opus 5.5 657271163f feat(lotus): soundboard works in voice channels with a call policy (#47)
CI / Build embedded bundle (pull_request) Failing after 1m1s
CI / Publish to Gitea npm registry (pull_request) Skipped
Clips publish as a separate Unknown-source track, which tokens limited to
listed sources (any room with io.lotus.room_quality) refuse, so #52 could
only answer not_permitted. Where the token allows the microphone, the
clip is now mixed into the published mic for its duration: the track on
the mic's RTCRtpSender (post-denoise) and the clip feed a
MediaStreamAudioDestinationNode, the sender switches to the mix with
replaceTrack, and the original goes back when the clip ends. Muting
mid-clip stops it; a track LiveKit swapped in meanwhile is left alone.
not_permitted remains for tokens without the microphone.

Fixes #47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 23:23:06 -04:00
Lotus CIandClaude Opus 5.5 60398b0a7d chore(lotus): 0.25.0-lotus.27
CI / Build embedded bundle (push) Successful in 2m0s
CI / Publish to Gitea npm registry (push) Successful in 52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:06:39 -04:00
jared 2c8d20ba82 Merge pull request 'feat(lotus): keep your own tile on mobile with the camera off (cinny #265)' (#58) from feat/mobile-self-tile into lotus
CI / Build embedded bundle (push) Successful in 1m52s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-19 14:58:35 -04:00
Lotus CIandClaude Opus 5.5 fbf8e44276 feat(lotus): keep your own tile on mobile with the camera off (cinny #265)
CI / Build embedded bundle (pull_request) Successful in 1m57s
CI / Publish to Gitea npm registry (pull_request) Skipped
Upstream drops the local PiP in the one-on-one mobile layout whenever the
local camera is off. In Lotus voice rooms that is nearly always, so on a
phone you never saw yourself or your own speaking ring. Show it as the
desktop layout does; the tile falls back to your avatar. Tests updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:40:25 -04:00
jared 5693e81bb9 Merge pull request 'feat(lotus): report remote screenshare audio to the host (cinny #277)' (#57) from feat/screenshare-audio-state into lotus
CI / Build embedded bundle (push) Successful in 1m57s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-19 14:28:57 -04:00
Lotus CIandClaude Opus 5.5 5f4844cad5 feat(lotus): report remote screenshare audio to the host (cinny #277)
CI / Build embedded bundle (pull_request) Successful in 1m55s
CI / Publish to Gitea npm registry (pull_request) Skipped
New fromWidget action io.lotus.screenshare_audio_state { present }: each
LivekitRoomAudioRenderer reports whether a remote participant's
ScreenShareAudio track is being received; the combined answer is sent to
the host on change, and cleared when a renderer unmounts. cinny uses it to
show "Mute Screenshare Audio" only when there is something to mute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:21:52 -04:00
Lotus CIandClaude Opus 5.5 848dcaafa9 chore(lotus): 0.25.0-lotus.26
CI / Build embedded bundle (push) Successful in 1m55s
CI / Publish to Gitea npm registry (push) Successful in 1m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:41:55 -04:00
jared 1ad32e56c7 Merge pull request 'feat(lotus): accept decorations from chat.lotusguild.org (matrix #22)' (#56) from feat/decorations-origin into lotus
CI / Build embedded bundle (push) Canceled after 1m46s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-19 12:40:07 -04:00
Lotus CIandClaude Opus 5.5 090edc5c2c feat(lotus): accept decorations from chat.lotusguild.org (matrix #22)
CI / Build embedded bundle (pull_request) Successful in 1m42s
CI / Publish to Gitea npm registry (pull_request) Skipped
Decorations moved off Nextcloud to https://chat.lotusguild.org/decorations.
Add that origin to ALLOWED_DECORATION_ORIGINS (first, so tests use it);
keep drive.lotusguild.org until no host build points there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:37:53 -04:00
jared dbf77cdc60 Merge pull request 'docs(lotus): LOTUS.md lists io.lotus.call_error and io.lotus.reconnect (#50)' (#55) from docs/lotus-call-error into lotus
CI / Build embedded bundle (push) Successful in 2m8s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-19 01:36:12 -04:00
Lotus CIandClaude Opus 5.5 2ef80cef2c docs(lotus): LOTUS.md lists io.lotus.call_error and io.lotus.reconnect (#50)
CI / Build embedded bundle (pull_request) Successful in 2m1s
CI / Publish to Gitea npm registry (pull_request) Skipped
Regenerate the action table from lotusActions.ts: add the two actions
from #50, and note which cinny PR handles each one still in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:01:18 -04:00
Lotus CIandClaude Opus 5.5 88c648ba8b chore(lotus): 0.25.0-lotus.25
CI / Build embedded bundle (push) Successful in 2m35s
CI / Publish to Gitea npm registry (push) Successful in 55s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:27:50 -04:00
jared 0aa543cc7d Merge pull request 'docs(lotus): LOTUS.md: the io.lotus.* widget protocol, URL flags and release flow (#43)' (#53) from docs/lotus-protocol into lotus
CI / Build embedded bundle (push) Successful in 3m6s
CI / Publish to Gitea npm registry (push) Skipped
2026-10-18 07:49:15 -04:00
jared cc57292bf4 Merge pull request 'fix(lotus): soundboard says "not permitted" in audio-only rooms instead of failing silently (#47)' (#52) from lotus-soundboard-not-permitted into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 07:42:09 -04:00
jared 22f9083768 Merge pull request 'fix(lotus): restart the shared mic sampler only when the LiveKit rooms change (#45)' (#51) from lotus-mic-sampler-churn into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 07:38:41 -04:00
jared d7d75cbba5 Merge pull request 'feat(lotus): tell the host when the call drops, and let it press Reconnect (cinny #318)' (#50) from lotus-call-error into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 07:32:17 -04:00
jared 4be3e92eb6 Merge pull request 'fix(lotus): a homeserver stall shorter than the delayed-leave window no longer ends the call (#46)' (#54) from lotus-stall-retry into lotus
CI / Build embedded bundle (push) Canceled after 0s
CI / Publish to Gitea npm registry (push) Canceled after 0s
2026-10-18 04:52:15 -04:00
Lotus CIandClaude Opus 5.5 a1dd0e254c fix(lotus): a homeserver stall shorter than the delayed-leave window no longer ends the call (#46)
CI / Build embedded bundle (pull_request) Successful in 3m33s
CI / Publish to Gitea npm registry (pull_request) Skipped
maximumNetworkErrorRetryCount was sized to min(sync grace period,
leave delay), i.e. 10 s, so the membership manager gave up 20-35 s into
a homeserver stall and showed "Connection lost", even when the server
would keep the membership for 60 s (prod config). Size it to the leave
delay instead: the embedded call has no /sync loop for the grace period
to describe, and the server's delayed leave is what actually ends the
membership.

Fixes #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 02:47:36 -04:00
Lotus CIandClaude Opus 5.5 167ffc2440 docs(lotus): LOTUS.md: the io.lotus.* widget protocol, URL flags and release flow (#43)
CI / Build embedded bundle (pull_request) Successful in 4m5s
CI / Publish to Gitea npm registry (pull_request) Skipped
Everything the host relies on was documented only in src/lotus comments.
Add a table of the 22 io.lotus.* actions (generated from lotusActions.ts,
with direction, payload and whether cinny handles each), the URL flags,
fork-only behaviour and the release/publish steps.

Fixes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:24:22 -04:00
Lotus CIandClaude Opus 5.5 888ed1a66c fix(lotus): soundboard says "not permitted" in audio-only rooms instead of failing silently (#47)
CI / Build embedded bundle (pull_request) Successful in 3m20s
CI / Publish to Gitea npm registry (pull_request) Skipped
Clips publish as an Unknown-source track. In audio-only rooms the guard
signs a microphone-only token, LiveKit refuses the publish, and since
the fork had already replied {} the host never heard. Check the grants
up front and reply { played: false, reason: "not_permitted" }.

Refs #47

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:22:49 -04:00
Lotus CIandClaude Opus 5.5 07e1506dd8 feat(lotus): tell the host when the call drops, and let it press Reconnect (cinny #318)
CI / Build embedded bundle (pull_request) Successful in 3m2s
CI / Publish to Gitea npm registry (pull_request) Skipped
After a homeserver stall the frame shows "Connection lost · Reconnect"
while the host kept showing the call as joined, with controls that did
nothing. While the error page is up, report io.lotus.call_error
{ code, recoverable } (and { code: null } when it clears), and accept
io.lotus.reconnect to run the page's own Reconnect / Try again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:54:48 -04:00
17 changed files with 1069 additions and 38 deletions
+82
View File
@@ -0,0 +1,82 @@
# 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, or mixed into the microphone where the token only allows listed sources (rooms with a call policy, #47). Replies `{ played: false, reason: "muted" \| "not_permitted" }` when it can't. | 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 `{ durationMs, reconnects, poorMs, verdict, details? }` (#143); `details` (cinny #253): participants, peak, per-person poor time, everyone-poor time, RTT/loss/jitter, your talk/mute/screenshare time. | 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_audio_state` | call → host | `{ present }`: whether any remote screenshare audio is being received, sent on change (cinny #277). | cinny #277 |
| `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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lotusguild/element-call-embedded",
"version": "0.25.0-lotus.24",
"version": "0.25.0-lotus.28",
"files": [
"README.md",
"LICENSE-AGPL-3.0",
+16
View File
@@ -19,6 +19,10 @@ import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { useEarpieceAudioConfig } from "../MediaDevicesContext";
import { useReactiveState } from "../useReactiveState";
import * as controls from "../controls";
import {
clearScreenshareAudio,
reportScreenshareAudio,
} from "../lotus/lotusScreenshareAudio";
export interface MatrixAudioRendererProps {
/**
@@ -97,6 +101,18 @@ export function LivekitRoomAudioRenderer({
return true;
});
// [lotus / cinny #277] Tell the host whether remote screenshare audio is
// playing, so its "Mute Screenshare Audio" control only shows when useful.
const hasScreenshareAudio = tracks.some(
(ref) =>
!ref.participant.isLocal &&
ref.publication.source === Track.Source.ScreenShareAudio,
);
useEffect(() => {
reportScreenshareAudio(url, hasScreenshareAudio);
}, [url, hasScreenshareAudio]);
useEffect(() => (): void => clearScreenshareAudio(url), [url]);
// This component is also (in addition to the "only play audio for connected members" logic above)
// responsible for mimicking earpiece audio on iPhones.
// The Safari audio devices enumeration does not expose an earpiece audio device.
+2
View File
@@ -34,6 +34,7 @@ describe("LotusWidgetActions", () => {
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
LotusWidgetActions.Reconnect,
];
expect(new Set(LOTUS_TO_WIDGET_ACTIONS)).toEqual(new Set(expectedToWidget));
@@ -56,5 +57,6 @@ describe("LotusWidgetActions", () => {
);
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.CallError);
});
});
+15
View File
@@ -56,6 +56,12 @@ export enum LotusWidgetActions {
SetAudioOutput = "io.lotus.set_audio_output",
/** fromWidget: the currently selected output `{ deviceId }` (#119). */
AudioOutputState = "io.lotus.audio_output_state",
/**
* fromWidget: `{ present: boolean }`, whether any remote screenshare audio
* is being received, so the host's "Mute Screenshare Audio" control only
* shows when there is something to mute (cinny #277). Sent on change.
*/
ScreenshareAudioState = "io.lotus.screenshare_audio_state",
/**
* fromWidget: local screenshare reminder `{ kind: "ended" | "no-frames" | "alone" }`
* — window closed, no frames for 15 s, or 30 min of sharing with nobody
@@ -116,6 +122,14 @@ export enum LotusWidgetActions {
* only when it changes (≤ 10 Hz); 0 while muted or with no mic.
*/
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`. */
@@ -133,4 +147,5 @@ export const LOTUS_TO_WIDGET_ACTIONS: LotusWidgetActions[] = [
LotusWidgetActions.SetHotkeys,
LotusWidgetActions.SetFrameScreenshare,
LotusWidgetActions.PromptScreenshare,
LotusWidgetActions.Reconnect,
];
+150
View File
@@ -11,7 +11,9 @@ import { of } from "rxjs";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import {
canPublishSoundboard,
MAX_INJECT_BYTES,
soundboardMode,
parseInjectSource,
startLotusAudioInject,
} from "./lotusAudioInject";
@@ -93,6 +95,10 @@ class FakeAudioContext {
connect: vi.fn((n: unknown) => n),
disconnect: vi.fn(),
}));
public createMediaStreamSource = vi.fn(() => ({
connect: vi.fn((n: unknown) => n),
disconnect: vi.fn(),
}));
public createBufferSource = vi.fn(() => ({
buffer: undefined as AudioBuffer | undefined,
connect: vi.fn((n: unknown) => n),
@@ -115,6 +121,12 @@ beforeEach(() => {
// leave a real timer pending.
vi.useFakeTimers();
vi.stubGlobal("AudioContext", FakeAudioContext);
vi.stubGlobal(
"MediaStream",
class {
public constructor(public tracks: unknown[]) {}
},
);
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
@@ -258,3 +270,141 @@ test("parseInjectSource rejects oversized or non-buffer audio", () => {
expect(parseInjectSource({ audio: "not bytes" })).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);
});
test("soundboardMode: own track when unrestricted, the mic under a call policy (EC #47)", () => {
expect(soundboardMode(undefined)).toBe("track");
expect(soundboardMode({ canPublish: true, canPublishSources: [] })).toBe(
"track",
);
expect(soundboardMode({ canPublish: true, canPublishSources: [2, 0] })).toBe(
"track",
);
expect(soundboardMode({ canPublish: true, canPublishSources: [2] })).toBe(
"mic",
);
expect(
soundboardMode({ canPublish: true, canPublishSources: [2, 3, 4] }),
).toBe("mic");
expect(
soundboardMode({ canPublish: true, canPublishSources: [3] }),
).toBeNull();
expect(soundboardMode({ canPublish: false })).toBeNull();
});
/** A room whose token only allows the microphone, with a published mic track. */
function makePolicyRoom(sources: number[]): {
room: unknown;
sender: { track: MediaStreamTrack; replaceTrack: ReturnType<typeof vi.fn> };
original: MediaStreamTrack;
micTrack: EventEmitter;
publishTrack: ReturnType<typeof vi.fn>;
} {
const original = makeTrack();
const sender = {
track: original,
replaceTrack: vi.fn(async (t: MediaStreamTrack) => {
await Promise.resolve();
sender.track = t;
}),
};
const micTrack = Object.assign(new EventEmitter(), { sender });
const publishTrack = vi.fn();
const room = {
localParticipant: {
isMicrophoneEnabled: true,
permissions: { canPublish: true, canPublishSources: sources },
publishTrack,
unpublishTrack: vi.fn(),
getTrackPublication: (): unknown => ({ track: micTrack }),
},
};
return { room, sender, original, micTrack, publishTrack };
}
test("[EC #47] under a mic-only grant the clip is mixed into the microphone, then the mic is restored", async () => {
const { room, sender, original, publishTrack } = makePolicyRoom([2]);
const stop = startLotusAudioInject(mockVm([room]));
send({ url: "https://example.com/clip.mp3" });
await flush();
expect(reply).toHaveBeenCalledWith(expect.anything(), {});
expect(publishTrack).not.toHaveBeenCalled();
expect(sender.replaceTrack).toHaveBeenCalledTimes(1);
const mixed = sender.track;
expect(mixed).not.toBe(original);
const source = contextInstances[0].createBufferSource.mock.results[0].value;
expect(source.start).toHaveBeenCalled();
source.onended();
await flush();
expect(sender.replaceTrack).toHaveBeenLastCalledWith(original);
expect(sender.track).toBe(original);
expect(mixed.stop).toHaveBeenCalled();
stop();
});
test("[EC #47] muting mid-clip stops the clip and restores the microphone", async () => {
const { room, sender, original, micTrack } = makePolicyRoom([2]);
const stop = startLotusAudioInject(mockVm([room]));
send({ url: "https://example.com/clip.mp3" });
await flush();
const source = contextInstances[0].createBufferSource.mock.results[0].value;
micTrack.emit("muted");
await flush();
expect(source.stop).toHaveBeenCalled();
expect(sender.track).toBe(original);
stop();
});
test("[EC #47] a track LiveKit swapped in meanwhile is not overwritten on restore", async () => {
const { room, sender, original } = makePolicyRoom([2]);
const stop = startLotusAudioInject(mockVm([room]));
send({ url: "https://example.com/clip.mp3" });
await flush();
const newer = makeTrack();
sender.track = newer; // e.g. a device switch while the clip plays
const source = contextInstances[0].createBufferSource.mock.results[0].value;
source.onended();
await flush();
expect(sender.track).toBe(newer);
expect(sender.replaceTrack).not.toHaveBeenCalledWith(original);
stop();
});
test("[EC #47] no microphone grant at all is still refused up front", async () => {
const { room, publishTrack, sender } = makePolicyRoom([3]);
const stop = startLotusAudioInject(mockVm([room]));
send({ url: "https://example.com/clip.mp3" });
await flush();
expect(reply).toHaveBeenCalledWith(expect.anything(), {
played: false,
reason: "not_permitted",
});
expect(publishTrack).not.toHaveBeenCalled();
expect(sender.replaceTrack).not.toHaveBeenCalled();
stop();
});
+190 -3
View File
@@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Room as LivekitRoom, Track } from "livekit-client";
import {
type LocalTrack,
type Room as LivekitRoom,
Track,
TrackEvent,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { type IWidgetApiRequest } from "matrix-widget-api";
@@ -52,6 +57,10 @@ function acquireSharedAudio(): {
* that was impossible against the prebuilt EC bundle (LiveKit's
* LocalParticipant lived in EC's module scope).
*
* [EC #47] Exception: rooms with a call policy issue tokens that only allow
* listed sources, which refuse that separate track. There the clip is mixed
* into the published microphone for its duration (`playIntoMicrophone`).
*
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
* `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched
* once EC is served from its own origin, so the host sends the bytes and they
@@ -60,6 +69,41 @@ function acquireSharedAudio(): {
* No effect unless the host sends the action. Returns a teardown function that
* 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);
}
/**
* Pure: how a clip can reach the call under these permissions (EC #47).
* - `"track"`: as its own Unknown-source track (unrestricted rooms).
* - `"mic"`: mixed into the published microphone track. Rooms with a call
* policy grant only listed sources (microphone, maybe screenshare), which
* refuse the separate track, but the mic is allowed.
* - `null`: neither (no publishing at all).
*/
export function soundboardMode(
perm:
| { canPublish?: boolean; canPublishSources?: readonly number[] }
| undefined,
): "track" | "mic" | null {
if (canPublishSoundboard(perm)) return "track";
if (perm?.canPublish === false) return null;
// TrackSource.MICROPHONE = 2
return perm?.canPublishSources?.includes(2) ? "mic" : null;
}
export function startLotusAudioInject(vm: CallViewModel): () => void {
const w = widget;
if (!w) return () => undefined;
@@ -116,8 +160,24 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
return;
}
// [EC #47] Clips publish as an Unknown-source track, which rooms with a
// call policy refuse (their tokens list allowed sources). There the clip
// is mixed into the published microphone instead; only when neither is
// possible say so up front instead of failing silently.
const modes = rooms.map((r) =>
soundboardMode(r.localParticipant.permissions),
);
if (modes.some((m) => m === null)) {
w.api.transport.reply(ev.detail, {
played: false,
reason: "not_permitted",
});
return;
}
const mode = modes.includes("mic") ? "mic" : "track";
w.api.transport.reply(ev.detail, {});
void playInjectedClip(clip, volume, rooms, activeClips).catch((e) =>
void playInjectedClip(clip, volume, rooms, activeClips, mode).catch((e) =>
logger.warn("[lotus] inject_audio failed", e),
);
};
@@ -182,6 +242,7 @@ async function playInjectedClip(
volume: number,
rooms: LivekitRoom[],
activeClips: Set<() => void>,
mode: "track" | "mic" = "track",
): Promise<void> {
if (rooms.length === 0) {
logger.warn("[lotus] inject_audio: no connected rooms");
@@ -266,8 +327,23 @@ async function playInjectedClip(
gain.gain.value = volume;
const source = ctx.createBufferSource();
source.buffer = buffer;
source.connect(gain).connect(dest);
source.connect(gain);
if (mode === "mic") {
playIntoMicrophone(
ctx,
source,
gain,
buffer,
rooms,
activeClips,
placeholder,
() => aborted,
);
return;
}
gain.connect(dest);
const mst = dest.stream.getAudioTracks()[0];
if (!mst) {
source.disconnect();
@@ -346,3 +422,114 @@ async function playInjectedClip(
source.start();
}
/**
* [EC #47] Mix the clip into the published microphone for its duration. For
* every room, the track currently going out on the mic's RTCRtpSender (the
* denoised one, when noise suppression is on) and the clip are mixed in a
* MediaStreamAudioDestinationNode, and the sender switches to the mix with
* `replaceTrack`. The server still sees one ordinary microphone track, which
* rooms with a call policy allow. When the clip ends the original track goes
* back. Muting mid-clip stops the clip (#13: muted means silent), and if
* LiveKit swapped its own track in meanwhile (device change, denoise toggle)
* that one is left alone.
*/
function playIntoMicrophone(
ctx: AudioContext,
source: AudioBufferSourceNode,
gain: GainNode,
buffer: AudioBuffer,
rooms: LivekitRoom[],
activeClips: Set<() => void>,
placeholder: () => void,
isAborted: () => boolean,
): void {
type Mix = {
sender: RTCRtpSender;
original: MediaStreamTrack;
mixed: MediaStreamTrack;
mic: MediaStreamAudioSourceNode;
dest: MediaStreamAudioDestinationNode;
track: LocalTrack;
};
const mixes: Mix[] = [];
let cleanedUp = false;
const cleanup = (): void => {
if (cleanedUp) return;
cleanedUp = true;
activeClips.delete(cleanup);
activeClips.delete(placeholder);
try {
source.stop();
} catch {
/* not started or already stopped */
}
source.disconnect();
gain.disconnect();
for (const m of mixes) {
m.track.off(TrackEvent.Muted, cleanup);
m.mic.disconnect();
// Only undo our own swap: LiveKit may have replaced the track since.
if (m.sender.track === m.mixed)
void m.sender.replaceTrack(m.original).catch(() => undefined);
m.mixed.stop();
}
};
for (const room of rooms) {
const track = room.localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.track;
const sender = track?.sender;
const original = sender?.track;
if (!track || !sender || !original) continue;
const dest = ctx.createMediaStreamDestination();
const mic = ctx.createMediaStreamSource(new MediaStream([original]));
mic.connect(dest);
gain.connect(dest);
const mixed = dest.stream.getAudioTracks()[0];
if (!mixed) {
mic.disconnect();
continue;
}
mixes.push({ sender, original, mixed, mic, dest, track });
track.on(TrackEvent.Muted, cleanup);
}
activeClips.delete(placeholder);
activeClips.add(cleanup);
if (mixes.length === 0) {
logger.warn("[lotus] inject_audio: no published microphone to mix into");
cleanup();
return;
}
if (isAborted()) {
cleanup();
return;
}
// Switch every sender to its mix, then start the clip once they're live so
// its start isn't clipped.
void Promise.all(
mixes.map(async (m) =>
m.sender.replaceTrack(m.mixed).catch((e) => {
logger.warn("[lotus] inject_audio: replaceTrack failed", e);
}),
),
).then(() => {
if (cleanedUp || isAborted()) {
cleanup();
return;
}
source.onended = cleanup;
const durationMs = Number.isFinite(buffer.duration)
? buffer.duration * 1000 + 500
: MAX_CLIP_MS;
const guard = setTimeout(
cleanup,
Math.min(MAX_CLIP_MS, Math.max(0, durationMs)),
);
source.addEventListener("ended", () => clearTimeout(guard));
source.start();
});
}
+94 -1
View File
@@ -8,7 +8,11 @@ Please see LICENSE in the repository root for full details.
import { ConnectionQuality } from "livekit-client";
import { describe, expect, it } from "vitest";
import { CallQualityTracker } from "./lotusCallSummary";
import {
CallDetailsTracker,
CallQualityTracker,
matrixUserOf,
} from "./lotusCallSummary";
const clock = (): { now: () => number; tick: (ms: number) => void } => {
let t = 1_000_000;
@@ -91,3 +95,92 @@ describe("CallQualityTracker", () => {
});
});
});
describe("[cinny #253] CallDetailsTracker", () => {
it("participants, peak, and devices of one person counted once", () => {
const c = clock();
const d = new CallDetailsTracker(c.now);
d.remoteJoined("@bob:x:DEV1");
d.remoteJoined("@bob:x:DEV2");
d.remoteJoined("@carol:x:D");
d.remoteLeft("@carol:x:D");
d.remoteJoined("@dave:x:D");
const r = d.details();
expect(r.participants).toEqual(["@bob:x", "@carol:x", "@dave:x"]);
expect(r.peakParticipants).toBe(3); // you + bob + carol (dave came after carol left)
});
it("joining is idempotent per device (seeding everyone present on connect)", () => {
const d = new CallDetailsTracker();
d.remoteJoined("@bob:x:DEV1");
d.remoteJoined("@bob:x:DEV1"); // seen again by the on-connect seed
d.remoteLeft("@bob:x:DEV1"); // one leave: bob is gone
d.remoteJoined("@carol:x:D");
d.remoteJoined("@dave:x:D"); // peak is you + carol + dave, not + a ghost bob
d.setQuality("@bob:x:DEV1", ConnectionQuality.Poor);
expect(d.details().participants).toEqual(["@bob:x", "@carol:x", "@dave:x"]);
expect(d.details().peakParticipants).toBe(3);
});
it("poor time per person, and everyone-at-once only while all are poor", () => {
const c = clock();
const d = new CallDetailsTracker(c.now);
d.remoteJoined("@bob:x:D");
d.setQuality(undefined, ConnectionQuality.Poor); // you poor
c.tick(10_000);
d.setQuality("@bob:x:D", ConnectionQuality.Lost); // now everyone
c.tick(5_000);
d.setQuality(undefined, ConnectionQuality.Good);
c.tick(3_000);
d.setQuality("@bob:x:D", ConnectionQuality.Excellent);
const q = d.details().quality;
expect(q.youPoorMs).toBe(15_000);
expect(q.othersPoorMs).toEqual({ "@bob:x": 8_000 });
expect(q.everyonePoorMs).toBe(5_000);
});
it("network numbers: RTT median/p95, loss from the latest counters, jitter", () => {
const d = new CallDetailsTracker();
for (const rtt of [0.02, 0.03, 0.04, 0.05, 0.3]) d.addRtt(rtt);
d.addRtt(Number.NaN);
d.setSent("mic", 900, 5);
d.setSent("mic", 995, 5); // cumulative: latest wins
d.setReceived("bob", 490, 10);
d.addJitter(0.004);
d.addJitter(0.006);
const n = d.details().network;
expect(n.rttMedianMs).toBe(40);
expect(n.rttP95Ms).toBe(300);
expect(n.outboundLossPct).toBe(0.5);
expect(n.inboundLossPct).toBe(2);
expect(n.jitterMs).toBe(5);
});
it("no samples: network numbers are absent, not zero", () => {
expect(new CallDetailsTracker().details().network).toEqual({});
});
it("talk, mute and screenshare time", () => {
const c = clock();
const d = new CallDetailsTracker(c.now);
d.setMuted(true);
c.tick(4_000);
d.setMuted(false);
d.setSpeaking(true);
c.tick(2_000);
d.setSpeaking(false);
d.setScreensharing(true);
c.tick(6_000); // still sharing when summarised
expect(d.details().activity).toEqual({
speakingMs: 2_000,
mutedMs: 4_000,
screenshareMs: 6_000,
});
});
it("matrixUserOf reduces a LiveKit identity to the Matrix user", () => {
expect(matrixUserOf("@bob:example.org:ABCDEF")).toBe("@bob:example.org");
expect(matrixUserOf("@bob:example.org")).toBe("@bob:example.org");
expect(matrixUserOf("hashed-federated-id")).toBe("hashed-federated-id");
});
});
+329 -3
View File
@@ -7,9 +7,16 @@ Please see LICENSE in the repository root for full details.
import {
ConnectionQuality,
type LocalAudioTrack,
type LocalTrackPublication,
type Participant,
ParticipantEvent,
type RemoteAudioTrack,
type RemoteParticipant,
type Room as LivekitRoom,
RoomEvent,
Track,
type TrackPublication,
} from "livekit-client";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
@@ -27,6 +34,241 @@ export interface CallSummary {
/** Time the local connection quality was reported poor/lost, ms. */
poorMs: number;
verdict: CallQualityVerdict;
/** [cinny #253] The breakdown behind the host's "Details" card. */
details?: CallSummaryDetails;
}
/** [cinny #253] Everything the host's post-call Details card shows. */
export interface CallSummaryDetails {
/** Other Matrix users seen in the call (`@user:server`), first-seen order. */
participants: string[];
/** Most people in the call at once, you included. */
peakParticipants: number;
quality: {
/** Your connection reported poor/lost, ms. */
youPoorMs: number;
/** Each other person's connection reported poor/lost, ms (only > 0). */
othersPoorMs: Record<string, number>;
/** Everyone, you included, poor at the same time (points at the server). */
everyonePoorMs: number;
};
network: {
rttMedianMs?: number;
rttP95Ms?: number;
/** Your microphone's packets the SFU reported lost, %. */
outboundLossPct?: number;
/** Packets lost on the audio you received, %. */
inboundLossPct?: number;
jitterMs?: number;
};
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
}
/** Time spent in an on/off state, e.g. muted. */
class Stopwatch {
private since: number | undefined;
private total = 0;
public set(on: boolean, now: number): void {
if (on && this.since === undefined) this.since = now;
if (!on && this.since !== undefined) {
this.total += now - this.since;
this.since = undefined;
}
}
public get on(): boolean {
return this.since !== undefined;
}
public ms(now: number): number {
return this.total + (this.since === undefined ? 0 : now - this.since);
}
}
/** `@user:server:DEVICE` (a LiveKit identity) → `@user:server`. */
export function matrixUserOf(identity: string): string {
if (!identity.startsWith("@")) return identity;
const first = identity.indexOf(":");
const second = first === -1 ? -1 : identity.indexOf(":", first + 1);
return second === -1 ? identity : identity.slice(0, second);
}
const percentile = (sorted: number[], p: number): number =>
sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
/**
* [cinny #253] Who was in the call, whose connection was poor and when,
* network numbers, and your own activity. Fed by LiveKit events and periodic
* stats samples; nothing leaves the frame except the final summary, sent to
* the host only.
*/
export class CallDetailsTracker {
// user -> identities (devices) currently in the call
private readonly devices = new Map<string, Set<string>>();
private readonly seen: string[] = [];
private peak = 1;
private readonly poor = new Map<string, Stopwatch>();
private readonly youPoor = new Stopwatch();
private readonly everyonePoor = new Stopwatch();
private readonly rtts: number[] = [];
private readonly jitters: number[] = [];
private readonly sent = new Map<string, { packets: number; lost: number }>();
private readonly received = new Map<
string,
{ packets: number; lost: number }
>();
private readonly speaking = new Stopwatch();
private readonly muted = new Stopwatch();
private readonly sharing = new Stopwatch();
public constructor(private readonly now: () => number = () => Date.now()) {}
/** Idempotent per device: safe to call for everyone already present too. */
public remoteJoined(identity: string): void {
const user = matrixUserOf(identity);
let ids = this.devices.get(user);
if (!ids) this.devices.set(user, (ids = new Set()));
ids.add(identity);
if (!this.seen.includes(user)) this.seen.push(user);
this.peak = Math.max(this.peak, this.devices.size + 1);
this.updateEveryone();
}
public remoteLeft(identity: string): void {
const user = matrixUserOf(identity);
const ids = this.devices.get(user);
ids?.delete(identity);
if (!ids || ids.size === 0) {
this.devices.delete(user);
this.poor.get(user)?.set(false, this.now());
}
this.updateEveryone();
}
/** `identity` undefined = the local participant. */
public setQuality(
identity: string | undefined,
quality: ConnectionQuality,
): void {
if (quality === ConnectionQuality.Unknown) return;
const bad =
quality === ConnectionQuality.Poor || quality === ConnectionQuality.Lost;
if (identity === undefined) this.youPoor.set(bad, this.now());
else {
const user = matrixUserOf(identity);
let sw = this.poor.get(user);
if (!sw) this.poor.set(user, (sw = new Stopwatch()));
sw.set(bad, this.now());
}
this.updateEveryone();
}
private updateEveryone(): void {
const others = [...this.devices.keys()];
const all =
this.youPoor.on &&
others.length > 0 &&
others.every((u) => this.poor.get(u)?.on);
this.everyonePoor.set(all, this.now());
}
public addRtt(seconds: number | undefined): void {
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0)
this.rtts.push(seconds * 1000);
}
public addJitter(seconds: number | undefined): void {
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0)
this.jitters.push(seconds * 1000);
}
/** Cumulative counters for one sent/received track (latest value wins). */
public setSent(key: string, packets?: number, lost?: number): void {
if (typeof packets === "number")
this.sent.set(key, { packets, lost: Math.max(0, lost ?? 0) });
}
public setReceived(key: string, packets?: number, lost?: number): void {
if (typeof packets === "number")
this.received.set(key, { packets, lost: Math.max(0, lost ?? 0) });
}
public setSpeaking(on: boolean): void {
this.speaking.set(on, this.now());
}
public setMuted(on: boolean): void {
this.muted.set(on, this.now());
}
public setScreensharing(on: boolean): void {
this.sharing.set(on, this.now());
}
public details(): CallSummaryDetails {
const now = this.now();
const othersPoorMs: Record<string, number> = {};
for (const [user, sw] of this.poor) {
const ms = sw.ms(now);
if (ms > 0) othersPoorMs[user] = ms;
}
const lossPct = (
m: Map<string, { packets: number; lost: number }>,
): number | undefined => {
let packets = 0;
let lost = 0;
for (const v of m.values()) {
packets += v.packets;
lost += v.lost;
}
return packets + lost > 0
? Math.round((lost / (packets + lost)) * 1000) / 10
: undefined;
};
const rtts = [...this.rtts].sort((a, b) => a - b);
const network: CallSummaryDetails["network"] = {};
if (rtts.length) {
network.rttMedianMs = Math.round(percentile(rtts, 0.5));
network.rttP95Ms = Math.round(percentile(rtts, 0.95));
}
const out = lossPct(this.sent);
if (out !== undefined) network.outboundLossPct = out;
const inn = lossPct(this.received);
if (inn !== undefined) network.inboundLossPct = inn;
if (this.jitters.length)
network.jitterMs = Math.round(
this.jitters.reduce((a, b) => a + b, 0) / this.jitters.length,
);
return {
participants: [...this.seen],
peakParticipants: this.peak,
quality: {
youPoorMs: this.youPoor.ms(now),
othersPoorMs,
everyonePoorMs: this.everyonePoor.ms(now),
},
network,
activity: {
speakingMs: this.speaking.ms(now),
mutedMs: this.muted.ms(now),
screenshareMs: this.sharing.ms(now),
},
};
}
}
/**
@@ -93,13 +335,48 @@ export class CallQualityTracker {
export function startLotusCallSummary(vm: CallViewModel): () => void {
if (!widget) return () => undefined;
const tracker = new CallQualityTracker();
const details = new CallDetailsTracker();
let sent = false;
const send = (): void => {
if (sent) return;
sent = true;
lotusSendToHost(LotusWidgetActions.CallSummary, tracker.summary());
clearInterval(sampler);
lotusSendToHost(LotusWidgetActions.CallSummary, {
...tracker.summary(),
details: details.details(),
});
};
// [cinny #253] Network numbers: sample sender/receiver stats every 5 s.
const sample = async (room: LivekitRoom): Promise<void> => {
const mic = room.localParticipant.getTrackPublication(
Track.Source.Microphone,
)?.track as LocalAudioTrack | undefined;
const s = await mic?.getSenderStats?.().catch(() => undefined);
if (s) {
details.addRtt(s.roundTripTime);
details.setSent(`${room.name}:mic`, s.packetsSent, s.packetsLost);
}
for (const p of room.remoteParticipants.values()) {
for (const pub of p.audioTrackPublications.values()) {
const t = pub.track as RemoteAudioTrack | undefined;
const r = await t?.getReceiverStats?.().catch(() => undefined);
if (r) {
details.addJitter(r.jitter);
details.setReceived(
`${room.name}:${p.identity}:${pub.trackSid}`,
r.packetsReceived,
r.packetsLost,
);
}
}
}
};
const sampler = setInterval(() => {
for (const room of listeners.keys())
void sample(room).catch(() => undefined);
}, 5_000);
const listeners = new Map<LivekitRoom, () => void>();
const attach = (room: LivekitRoom): void => {
const onQuality = (
@@ -107,20 +384,69 @@ export function startLotusCallSummary(vm: CallViewModel): () => void {
participant: Participant,
): void => {
if (participant.isLocal) tracker.setQuality(quality);
details.setQuality(
participant.isLocal ? undefined : participant.identity,
quality,
);
};
// LiveKit doesn't send ParticipantConnected for people already in the
// room when we connect, and the mic isn't published yet at attach time:
// record both once the connection is up.
const onConnected = (): void => {
tracker.start();
for (const p of room.remoteParticipants.values())
details.remoteJoined(p.identity);
details.setMuted(!room.localParticipant.isMicrophoneEnabled);
};
const onConnected = (): void => tracker.start();
const onReconnecting = (): void => tracker.reconnect();
const onDisconnected = (): void => send();
const onJoined = (p: RemoteParticipant): void =>
details.remoteJoined(p.identity);
const onLeft = (p: RemoteParticipant): void =>
details.remoteLeft(p.identity);
const isLocalMic = (pub: TrackPublication, p: Participant): boolean =>
p.isLocal && pub.source === Track.Source.Microphone;
const onMuted = (pub: TrackPublication, p: Participant): void => {
if (isLocalMic(pub, p)) details.setMuted(true);
};
const onUnmuted = (pub: TrackPublication, p: Participant): void => {
if (isLocalMic(pub, p)) details.setMuted(false);
};
const onLocalPublished = (pub: LocalTrackPublication): void => {
if (pub.source === Track.Source.ScreenShare)
details.setScreensharing(true);
if (pub.source === Track.Source.Microphone) details.setMuted(pub.isMuted);
};
const onLocalUnpublished = (pub: LocalTrackPublication): void => {
if (pub.source === Track.Source.ScreenShare)
details.setScreensharing(false);
};
const onSpeaking = (speaking: boolean): void =>
details.setSpeaking(speaking);
room.on(RoomEvent.ConnectionQualityChanged, onQuality);
room.on(RoomEvent.Connected, onConnected);
room.on(RoomEvent.Reconnecting, onReconnecting);
room.on(RoomEvent.Disconnected, onDisconnected);
if (room.state === "connected") tracker.start();
room.on(RoomEvent.ParticipantConnected, onJoined);
room.on(RoomEvent.ParticipantDisconnected, onLeft);
room.on(RoomEvent.TrackMuted, onMuted);
room.on(RoomEvent.TrackUnmuted, onUnmuted);
room.on(RoomEvent.LocalTrackPublished, onLocalPublished);
room.on(RoomEvent.LocalTrackUnpublished, onLocalUnpublished);
room.localParticipant.on(ParticipantEvent.IsSpeakingChanged, onSpeaking);
if (room.state === "connected") onConnected();
listeners.set(room, () => {
room.off(RoomEvent.ConnectionQualityChanged, onQuality);
room.off(RoomEvent.Connected, onConnected);
room.off(RoomEvent.Reconnecting, onReconnecting);
room.off(RoomEvent.Disconnected, onDisconnected);
room.off(RoomEvent.ParticipantConnected, onJoined);
room.off(RoomEvent.ParticipantDisconnected, onLeft);
room.off(RoomEvent.TrackMuted, onMuted);
room.off(RoomEvent.TrackUnmuted, onUnmuted);
room.off(RoomEvent.LocalTrackPublished, onLocalPublished);
room.off(RoomEvent.LocalTrackUnpublished, onLocalUnpublished);
room.localParticipant.off(ParticipantEvent.IsSpeakingChanged, onSpeaking);
});
};
+30
View File
@@ -83,6 +83,36 @@ test("[lotus #28] safeImageUrl only accepts the pinned decoration CDN origin (or
stop();
});
test("[matrix #22] both the new decoration host and the old Nextcloud one are accepted", () => {
const stop = startLotusDecorations();
const { result } = renderHook(() => useLotusDecoration("@alice:example.org"));
pushDecorations({
"@alice:example.org":
"https://chat.lotusguild.org/decorations/joystick.png",
});
expect(result.current).toBe(
"https://chat.lotusguild.org/decorations/joystick.png",
);
pushDecorations({
"@alice:example.org":
"https://drive.lotusguild.org/public.php/dav/files/x/cinny-decorations/joystick.png",
});
expect(result.current).toBe(
"https://drive.lotusguild.org/public.php/dav/files/x/cinny-decorations/joystick.png",
);
// A look-alike subdomain is a different origin.
pushDecorations({
"@alice:example.org":
"https://chat.lotusguild.org.evil.example/joystick.png",
});
expect(result.current).toBeUndefined();
stop();
});
test("[lotus #17] the roster survives a handler remount within the same page session", () => {
const stop1 = startLotusDecorations();
pushDecorations({
+4
View File
@@ -45,7 +45,11 @@ export function useLotusDecoration(userId: string): string | undefined {
// to the actual decoration CDN origin(s) — cinny's `DECORATION_CDN`
// (`avatarDecorations.ts`) — plus `blob:`, which EC itself may use for local
// previews. Exported so a future CDN move is a one-line edit here.
//
// matrix #22: decorations moved off Nextcloud to chat.lotusguild.org/decorations
// (first entry). drive.lotusguild.org stays until no host build points there.
export const ALLOWED_DECORATION_ORIGINS: readonly string[] = [
"https://chat.lotusguild.org",
"https://drive.lotusguild.org",
];
+59
View File
@@ -0,0 +1,59 @@
/*
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 { beforeEach, expect, test, vi } from "vitest";
import { LotusWidgetActions } from "./lotusActions";
import {
clearScreenshareAudio,
reportScreenshareAudio,
resetScreenshareAudioPresence,
} from "./lotusScreenshareAudio";
const send = vi.fn().mockResolvedValue({});
vi.mock("../widget", () => ({
widget: {
api: { transport: { send: (...a: unknown[]): unknown => send(...a) } },
},
}));
const sent = (): unknown[] =>
send.mock.calls
.filter(([action]) => action === LotusWidgetActions.ScreenshareAudioState)
.map(([, data]) => data);
beforeEach(() => {
resetScreenshareAudioPresence();
send.mockClear();
});
test("[cinny #277] reports presence only when the combined answer changes", () => {
reportScreenshareAudio("sfu-a", false);
reportScreenshareAudio("sfu-a", false);
reportScreenshareAudio("sfu-a", true);
reportScreenshareAudio("sfu-a", true);
reportScreenshareAudio("sfu-a", false);
expect(sent()).toEqual([
{ present: false },
{ present: true },
{ present: false },
]);
});
test("[cinny #277] any room with screenshare audio counts as present", () => {
reportScreenshareAudio("sfu-a", true);
reportScreenshareAudio("sfu-b", false);
expect(sent()).toEqual([{ present: true }]);
// A share is still playing in sfu-b after sfu-a's stops.
reportScreenshareAudio("sfu-b", true);
reportScreenshareAudio("sfu-a", false);
expect(sent()).toEqual([{ present: true }]);
// The renderer for sfu-b unmounts (call left): nothing is playing.
clearScreenshareAudio("sfu-b");
expect(sent()).toEqual([{ present: true }, { present: false }]);
});
+39
View File
@@ -8,6 +8,8 @@ Please see LICENSE in the repository root for full details.
import { BehaviorSubject } from "rxjs";
import { globalScope } from "../state/ObservableScope";
import { LotusWidgetActions } from "./lotusActions";
import { lotusSendToHost } from "./lotusWidget";
/**
* Whether the host has muted remote screenshare AUDIO (`io.lotus.set_deafen`
@@ -25,3 +27,40 @@ export const setScreenshareAudioMuted$ = new BehaviorSubject(false);
export const muteScreenshareAudio$ = globalScope.behavior(
setScreenshareAudioMuted$,
);
/**
* [cinny #277] Whether any remote participant's screenshare AUDIO is being
* received, so the host shows its "Mute Screenshare Audio" control only when
* there is something to mute. Each `LivekitRoomAudioRenderer` (one per SFU)
* reports for its own room; the host gets `io.lotus.screenshare_audio_state
* { present }` whenever the combined answer changes.
*/
const presentByRoom = new Map<string, boolean>();
let lastSent: boolean | undefined;
function sendIfChanged(): void {
const present = [...presentByRoom.values()].some(Boolean);
if (present === lastSent) return;
lastSent = present;
lotusSendToHost(LotusWidgetActions.ScreenshareAudioState, { present });
}
export function reportScreenshareAudio(
roomKey: string,
present: boolean,
): void {
presentByRoom.set(roomKey, present);
sendIfChanged();
}
/** The renderer for `roomKey` went away (call left, SFU switched). */
export function clearScreenshareAudio(roomKey: string): void {
presentByRoom.delete(roomKey);
sendIfChanged();
}
/** Test-only: forget all reports. */
export function resetScreenshareAudioPresence(): void {
presentByRoom.clear();
lastSent = undefined;
}
+26
View File
@@ -13,6 +13,7 @@ import {
type ReactNode,
type SVGAttributes,
useCallback,
useEffect,
} from "react";
import { Trans, useTranslation } from "react-i18next";
import {
@@ -24,6 +25,7 @@ import {
import { Button } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { MatrixError } from "matrix-js-sdk";
import { type IWidgetApiRequest } from "matrix-widget-api";
import {
ConnectionLostError,
@@ -38,6 +40,7 @@ import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
import styles from "../ErrorView.module.css";
import { LotusWidgetActions, lotusSendToHost } from "../lotus/lotusWidget";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
@@ -74,6 +77,29 @@ const ErrorPage: FC<ErrorPageProps> = ({
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 }[] = [];
if (error instanceof ConnectionLostError) {
actions.push({
+9 -13
View File
@@ -451,14 +451,16 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
});
});
test("one-on-one mobile layout shows local tile when video is enabled", () => {
// [lotus / cinny #265] The local tile stays on mobile with the camera off
// (upstream hid it), so you can see yourself in a voice call.
test("one-on-one mobile layout shows the local tile whether or not video is enabled", () => {
withTestScheduler(({ behavior, schedule, expectObservable }) => {
// Local participant enables their video, then disables it
const videoInputMarbles = " ny--n";
// While tile is shown, tap the screen twice
// Tap the screen twice
const tapScreenInputMarbles = "--aa-";
// Layout should show local tile, make it small, enlarge it again, then hide it
const expectedLayoutMarbles = "abcba";
// The local tile is shown throughout; taps make it small, then large again
const expectedLayoutMarbles = "b-cb";
withCallViewModel(
{
@@ -477,12 +479,6 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
expectObservable(summarizeLayout$(vm.layout$)).toBe(
expectedLayoutMarbles,
{
a: {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
},
b: {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
@@ -518,7 +514,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
a: {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pip: `${localId}:0`,
pipSize: "lg",
},
});
@@ -566,7 +562,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
a: {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pip: `${localId}:0`,
pipSize: "sm",
},
b: {
@@ -765,7 +761,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
// This is the expected one-on-one layout for a narrow window
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pip: `${localId}:0`,
pipSize: "lg",
},
b: {
+14 -11
View File
@@ -1220,19 +1220,22 @@ export function createCallViewModel$(
}),
);
// [lotus / cinny #265] Upstream drops the local PiP on mobile while your
// camera is off; in Lotus voice rooms that is almost always, so on a phone
// you never saw yourself (or your own speaking ring). Show it as desktop
// does: the tile falls back to your avatar.
const oneOnOneMobileLayoutMedia$: Observable<OneOnOneMobileLayoutMedia | null> =
oneOnOneLayoutMedia$.pipe(
switchMap((media) => {
if (media === null) return of(null);
return media.local.videoEnabled$.pipe(
map((videoEnabled) => ({
type: "one-on-one-mobile" as const,
edgeToEdge: true as const,
spotlight: media.remote,
pip: videoEnabled ? media.local : undefined,
})),
);
}),
map((media) =>
media === null
? null
: {
type: "one-on-one-mobile" as const,
edgeToEdge: true as const,
spotlight: media.remote,
pip: media.local,
},
),
);
const pipLayoutMedia$: Observable<LayoutMedia> = spotlight$.pipe(
@@ -868,16 +868,19 @@ export function enterRTCSession(
};
}
// Calculates `maximumNetworkErrorRetryCount`. The connection is failed if EITHER:
// - The /sync loop is unresponsive for > `gracePeriod` ms, or
// - A delayed leave event is emitted (after `leaveDelay` ms period).
// Note: Use leaveDelay >> gracePeriod for delegated leave events.
// Calculates `maximumNetworkErrorRetryCount`: enough retries to outlast the
// server's delayed-leave window (see [lotus #46] below).
const gracePeriod = Config.get().sync_disconnect_grace_period_ms;
const leaveDelay = matrixRtcSessionConfig?.delayed_leave_event_delay_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.
const maxWaitTime = Math.min(gracePeriod, leaveDelay);
// [lotus #46] Keep retrying for as long as the homeserver itself would keep
// 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 =
Math.ceil(maxWaitTime / retryInterval) + 1;