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
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
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
observeLocalMicSample$ switchMapped on every allConnections$ emission,
which re-emits far more often than the set of rooms changes. Each one
stopped the clone, closed the AudioContext and emitted null, and the
null reset the muted-speech gate, so the "You're muted" nudge could
never stay true long enough to fire. Map to the room list and only
restart when it actually changes.
Fixes#45
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
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
Browsers hash deviceIds per frame/origin, so ids from the host's own
enumerateDevices() are unknown in here and select() fell back to the
default. io.lotus.audio_output_state now also carries
available: [{id,label}] (frame-valid ids), so the host's call-bar menu
can offer real choices.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
The widget intent presets default sendNotificationType to "notification",
and the URL param could only switch it to ring/notification, so the host
had no way to say "don't notify". In persistent voice channels and space
rooms that notification carries an @room mention: every member got a
highlighted ping (and phone push) whenever a moderator joined first, and
other clients showed it as a missed call.
`sendNotificationType=none` now clears it after the presets are applied.
Unit test added.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
Where the host can't delegate the user's click (Firefox, Safari, WebKitGTK),
lotus.20 showed EC's own screenshare button floating in the frame's corner,
apart from the host's call bar. Now the host keeps its bar button and sends
the new toWidget io.lotus.prompt_screenshare; the fork shows a small
"Share your screen?" card inside the frame, just above the bar, and its
Share button starts the share with the frame's own click. Same two clicks as
the host's confirm on Chromium.
- The card focuses Share; Cancel and Escape close it; it closes if a share
starts some other way or the room's call policy stops allowing sharing,
and never opens where the policy forbids it (set_frame_screenshare).
- Picture-in-picture (~158 px tall): compact card without the description.
- controls_state: frameScreenshare is now always false (the corner button is
gone), screensharePrompt says the host should use the prompt, and
screensharePromptOpen lets the host stop covering the frame meanwhile.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Two things tied EC to the host's origin:
- Widget message check: matrix-widget-api's strictOriginCheck compares
ev.origin with THIS frame's origin, so on call.chat.lotusguild.org every
message from chat.lotusguild.org would be dropped and calls would not
start. restrictToHost() instead requires ev.source === window.parent and
ev.origin === parentUrl's origin. Same-origin deployments keep working
(the host origin is our own origin there), and it is stricter than
before: the sender must also be our parent window.
- Soundboard: the host's blob: clip URL is origin-bound. io.lotus.inject_audio
now accepts the clip's bytes (`audio`, ArrayBuffer, <= 8 MiB) and prefers
them over `url`; hosts that only send `url` are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Firefox, Safari and WebKitGTK have no Capability Delegation, so the host
can't start a share from its call bar: getDisplayMedia needs the click in
this frame. Until now the host clicked EC's hidden footer button through
the DOM, which only works while the frame is same-origin.
With lotusHostControls + lotusFrameScreenshare the fork now shows EC's own
screenshare button (floating bottom-right, footer still hidden), so the
click is native. controls_state reports frameScreenshare so the host hides
its own button. New toWidget io.lotus.set_frame_screenshare { visible }
lets the host hide it while the room's call policy forbids screensharing
(never while a share is live, so it can still be stopped).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The host used to add PTT/deafen key listeners to this frame's window,
which needs same-origin access. Now it sends `io.lotus.set_hotkeys
{ codes }` and the fork reports those keys as `io.lotus.hotkey`
(keydown/keyup with modifiers + whether the target is a text field or a
button), plus window focus changes so a held PTT is released. The
default action is cancelled here with the host's rules (not while typing,
not on a focused button). `controls_state` carries `hotkeys: true`.
Fixes a live bug: EC's own shortcuts (M = toggle mic, Space = PTT) also
fired for the host's keys, so with focus in the call and the default
deafen key M, the first press turned the MIC ON instead of deafening
(even in push-to-talk mode). EC's shortcuts now ignore keys the host owns.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- `lotusHostControls=1`: body.lotus-host-controls hides EC's footer
(position absolute + visibility hidden, still in the DOM so a host on an
engine without Capability Delegation can click its screenshare button).
- With lotusTheme/lotusTransparent, `:root` color-scheme follows the
theme, so the transparent frame gets no opaque backdrop.
Together these replace the CSS the host injected into this frame.
Also fixes unreadable tile name tags in the light theme: the Lotus
near-black canvas override now applies to dark themes only (it sat behind
light-theme dark text).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
toWidget `io.lotus.set_screenshare { on?: boolean }` (omit to toggle)
starts/stops sharing via the view model instead of the host clicking EC's
hidden button. The host sends it with Capability Delegation
(`delegate: "display-capture"`) where supported, so getDisplayMedia keeps
the user's click on engines that require it. `controls_state` now carries
`screenshareAction: true` so the host only uses it on a fork that has it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The host had only a boolean `speaking` for yourself, throttled to 500 ms, so
nothing on the call bar showed that the mic was actually picking you up.
- lotusMicLevel.ts: one shared local mic sampler (a clone of the published
track, ~10 Hz RMS, while a mic is published, muted or not), shared per
call view model so there is one AudioContext.
- MicLevelQuantizer: 0–3 bars (≈ −46/−36/−26 dBFS), rising at once and
falling one bar per sample so it doesn't flicker between words.
- fromWidget io.lotus.mic_level { bars } only when the value changes; 0 while
muted or with no mic. Opt-in with the host state stream (lotusCallState=1).
- "Talking while muted" (#37) now reads the same sampler instead of running
its own; same gate and behaviour.
Verified in a local call with a fake-tone mic: the host receives 0–3 as the
tone rises/falls (about 4 messages/s while it changes), 0 on mute and levels
again on unmute; muted + tone still raises speakingWhileMuted and the host's
"You're muted" notice. Lotus/room suites pass on Node 22 (148 tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The per-participant volume slider lived only in the tile's view model, so it
reset to 100 % on the next call, a reload, or when that person reconnected.
- lotus/lotusVolumeMemory.ts: per-Matrix-user volume in localStorage
(`lotus-per-user-volume`), keyed by user id so it follows them across
devices; 1 (100 %) forgets the entry; at most 50 people, least recently set
dropped first; junk in storage ignored. Local only, never synced.
- VolumeControls take an optional initialVolume and onCommit; remote user
media seeds from and saves to the memory. Screenshare audio stays per-share.
- commitPlaybackVolume now takes the slider's committed value. With the
keyboard the slider commits before its last change arrives, so the
committed (and remembered) volume was one step behind, and returning to
100 % with End never registered.
Verified in a two-person local call: Alice sets Bob to 70 % with the
keyboard → audio element 0.70, stored 0.7; leave + rejoin → slider and audio
0.70; End → entry removed. Unit tests: memory module (LRU, reset, junk),
remembered volume restore + commit-with-value; state/tile/room/lotus suites
all pass on Node 22 (410 tests).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The Lotus host drove these by clicking EC's hidden controls in our DOM and
read screenshare/layout state from it, which only works while the frame is
same-origin and breaks whenever a data-testid changes. New actions:
- toWidget io.lotus.set_layout { layout: "grid" | "spotlight" } → the layout
switch view model's setLayout.
- toWidget io.lotus.open_settings { open?: boolean } → setSettingsOpen
(toggle when `open` is omitted).
- toWidget io.lotus.toggle_reactions → a window event the reactions button
listens for.
- fromWidget io.lotus.controls_state { screensharing, layout } on change; its
arrival tells the host the fork supports the actions above.
Screensharing stays host-driven for now: getDisplayMedia needs the user's
click to reach this frame (Capability Delegation), which a widget message
doesn't carry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
"Failed to connect to Livekit server (Reason: ServerUnreachable)" left a
user with nothing to try; they retried for a long time and gave up.
- Title "Couldn't connect to voice". The message depends on the reason:
unreachable/timeout-style errors explain that voice needs its own live
connection, which VPNs, antivirus web shields and some networks block,
even when chat works, and say what to try; NotAllowed says the call may
be full or access changed.
- The raw reason, status and message move under Technical details.
- LivekitConnectionError and PeerConnectionTimeoutError now get a
Try again button (the existing reconnect path), shown as primary: the
Lotus theme renders `secondary` as dark-on-dark.
Verified in the Lotus client against a local LiveKit with the signal
socket and validate request blocked: the dialog appears after ~20 s;
unblocking and pressing Try again joins with the mic published.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
LiveKit's own ended handler runs before ours and unpublishes the share;
a listener removed during that dispatch never fires. Decide from the
track's readyState on LocalTrackUnpublished as well. Verified end to end:
closing the shared source now toasts in the host.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Watches the local screenshare publication and tells the host, once per
share each: the MediaStreamTrack ended (window closed), it has been muted
(no frames — minimised/occluded window) for 15 s, or 30 min of sharing
with nobody else in the call. Detection only; the host renders the
notices. LiveKit already unpublishes an ended share, so nothing lingers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The spotlight screenshare tile already shows a speaker glyph only when the
share publishes audio. Two gaps: a publication the sharer had muted still
counted as audio, and the host's screenshare-audio mute (io.lotus.set_deafen
→ renderer muted) wasn't reflected — the glyph stayed "on" while nothing
played. audioEnabled$ now tracks publication presence AND !isMuted via
observeParticipantMedia; the glyph shows crossed when either the tile's
own mute or the Lotus mute is active.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The embed hides Element Call's footer, so its own output picker is out of
reach; the host's call bar now sends io.lotus.set_audio_output { deviceId }
and the fork applies it via mediaDevices.audioOutput.select(), answering
with io.lotus.audio_output_state { deviceId } on every change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA