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
Tracks the local participant's LiveKit ConnectionQuality (time spent
poor/lost) and Reconnecting events for the life of the in-call view and
sends one io.lotus.call_summary { durationMs, reconnects, poorMs, verdict }
to the host on the first SFU disconnect or on teardown. Nothing is stored
or sent anywhere else. CallQualityTracker is pure and unit-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
While the mic is muted LiveKit keeps the capture alive and merely disables the
published MediaStreamTrack, so VAD says nothing. lotusMutedSpeech taps a CLONE
of the published track (post-denoise when the in-source processor is active),
samples RMS at 10 Hz through an AnalyserNode and runs a hysteresis gate (300 ms
of voice on, 800 ms of quiet off, threshold ≈ −36 dBFS). The tap only exists
while a muted mic publication exists; the flag is set on the local entry of
io.lotus.call_state only and is never sent to other participants. Gate is
unit-tested; verified end-to-end: muted with a tone mic → true within 2.5 s,
silence → false, unmute → false.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
mapAuthErrorToUserFriendlyError turned every non-whitelisted error back into
FailToGetOpenIdToken, so lotus.5's refusal reason still surfaced as the
generic page (caught end-to-end with a routed 403 on /sfu/get). Pass it
through like the other user-facing auth errors. Unit-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
When the voice-limit guard (or any JWT service) answers 403 with a reason —
"This voice channel is full.", "You don't have permission to …" — EC wrapped it
in FailToGetOpenIdToken and the user saw the generic error page with
OPEN_ID_ERROR. A 403 MatrixError with a non-empty `error` now throws
SFUTokenRefusedError ("Can't join this call" + the server's sentence), so the
reason is the description. Other failures are unchanged. Unit-tested.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
"Mute Screenshare Audio" (io.lotus.set_deafen screenshareAudioMuted) used
RemoteParticipant.setVolume(0, ScreenShareAudio). EC's own createVolumeControls
writes volume 1 through the same setter the moment a new screenshare media item
resolves, so when the sharer stopped and re-shared (or a late joiner shared)
the audio came back at full volume while the host button still said
"Unmute Screenshare Audio". Reproduced on the local calls stack with two
headless clients: after a re-share the screen_share_audio element read vol=1.
Now the flag is a global behavior (muteScreenshareAudio$) that
LivekitRoomAudioRenderer turns into the `muted` prop of every
Track.Source.ScreenShareAudio element — the exact mechanism deafen already uses
(pub.setEnabled(false): the server stops sending). Verified via the
RemoteTrackPublication behind each <audio>: the re-published track (new sid)
mounts with enabled=false while muted and re-enables on unmute; deafen +
undeafen leaves it muted; teardown resets the flag so the next call starts
clean. Unit tests updated; renderer test asserts only ScreenShareAudio
elements get muted by the new prop.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
audioCaptureDefaults set no channelCount, so Firefox captured a 2-channel
audio interface (Scarlett Solo, one XLR mic on input 1) as stereo and,
with browser audio processing off (the ML denoise tier), published it as
is: peers heard the speaker in the left ear only. Chrome downmixes such
captures itself, which is why it only showed on Firefox. Request
channelCount: 1 for mic capture; screenshare audio is captured separately
and is unaffected. Tested. Bumps to 0.25.0-lotus.3.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- lotusTransparent without lotusTheme warns and applies the theme anyway;
name tags/header/footer get a subtle text-shadow + backdrop blur under
body.lotus-transparent (#21).
- Landscape filmstrip shrink requires (pointer: coarse) so a short
desktop/PiP window isn't reflowed as a phone (#32).
- lotusParam re-reads window.location on every call (#33).
Fixes#21Fixes#32Fixes#33
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- Injection is gated on localParticipant.isMicrophoneEnabled and replies
{ played:false, reason:"muted" } (host UI follow-up in cinny) (#13).
- One lazily created module-level AudioContext/destination for all
clips, ref-counted and closed on last teardown; per clip only a
BufferSource + Gain. The replace-mode race handling is preserved and
three latent dangling-placeholder paths are closed (#14).
Fixes#13Fixes#14
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The same maxBitrate was written into every encoding, so a 1.5 Mbps cap
could mean 4.5 Mbps aggregate. Distribute proportionally to the layers'
existing ratios (floor, so never over). Tested with a 3-layer case.
Fixes#12
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Lotus toWidget actions with no handler used to sit in the LazyEventEmitter
backlog forever (host timed out; stale replay on remount). They now get
an immediate {} reply. Adds RequestState and DenoiseState to the enum
(fromWidget) with a test pinning the toWidget/fromWidget split.
Fixes#18
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- .lotusDecoration 50cqmin -> 62cqmin (cinny's inset ratio); onError
hides a broken image (#4).
- display:none under prefers-reduced-motion, matching the host (#19).
- safeImageUrl only accepts ALLOWED_DECORATION_ORIGINS (the decorations
CDN) plus blob: (#28).
- Roster is no longer wiped on last teardown; the handler sends
io.lotus.request_state on (re)registration so the host can re-push
decorations and the pin (#17 — host half in cinny).
Fixes#4Fixes#19Fixes#28Fixes#17
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- A non-null pin forces layout "spotlight" and remembers the displaced
mode; clearing restores it only if the user hasn't switched since;
gridLayoutMedia$ surfaces the pinned item for narrow mode (#3).
- Pin clears when the user is gone for 5 s or on leave$ (#16).
- Screenshare branch keeps pip$ = auto speaker unless it IS the pinned
user (#29).
- Payload accepts an optional media id (userId:deviceId) and prefers it;
userId-only picks the speaking device (#30).
18 unit tests.
Fixes#3Fixes#16Fixes#29Fixes#30
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- Assets (context, worklets, wasm, DFN core) are prepared as soon as the
flag is seen, so init() under LiveKit's trackChangeLock only wires
already-loaded pieces; resume timeout 3 s -> 500 ms (#7).
- init failure retries once with rnnoise; success/failure is reported to
the host as io.lotus.denoise_state so the UI can reflect reality (#8).
- Mic TrackMuted/TrackUnmuted suspend/resume the processor's context so
no inference runs on silence (#9).
- Every node is explicit mono; the dry path gets a per-model DelayNode so
the floor mix no longer comb-filters (#24, #25).
- DTLN/DFN dynamic imports are typed and their exports asserted at load,
feeding the #8 fallback instead of failing silently (#26).
Unit-tested (13 tests across the two files).
Fixes#7Fixes#8Fixes#9Fixes#24Fixes#25Fixes#26
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The workflow only built and smoke-checked that dist/index.html existed,
so a type error, lint violation or failing test could be tagged straight
to a published npm release. Run `pnpm lint` (tsc + oxlint + knip),
`pnpm format:check` (oxfmt) and `pnpm test:unit --run` before the build,
all hard. Add the same cancel-in-progress concurrency group cinny's CI
uses so rapid pushes to lotus don't queue redundant full builds.
Also fixes the one tsc error the v0.25.0 merge surfaced in
lotusDecorations (lazyActions.off now returns the emitter), so the new
gate is green from its first run.
Fixes#5Fixes#34
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
`pnpm lint:eslint` and `pnpm prettier:check` were never run by fork CI (#5),
and lotus HEAD failed both: a missing return type in lotusDecorations, a
type-only import in lotusWidget, and formatting drift in lotusAudioInject,
lotusFocus, lotusWidget and .gitea/workflows/ci.yml. No behaviour change.
Both gates now pass clean so they can be turned on.
Refs #5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Six fix(lotus) commits reached `lotus` while the upstream merge was in
progress (deafen via global output mute, denoise race/fallback fixes,
strictOriginCheck on the widget transport, call_state standalone skip,
set_quality null-clears-cap, plus four new src/lotus/*.test.ts files).
Merged them on top of the v0.25.0 sync so this branch is a superset of
current `lotus`.
One conflict, src/lotus/lotusDenoiseProcessor.ts: the sync commit had
dropped a meaningless `void` (oxlint no-meaningless-void-operator) on a
line the incoming commit also touched; kept the void-less form so oxlint
stays clean.
Verification after the fold-in (Node 24.11.1 / pnpm 11.21.0): tsc clean;
oxlint clean; oxfmt --check clean; knip exit 0; vitest unit 88 files /
639 passed / 9 skipped; build:embedded OK, staged to embedded/web/dist,
all six io.lotus.* actions present in the bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Merge upstream element-hq/element-call tag v0.25.0 into the Lotus fork
(previous base: v0.20.1; actual merge-base v0.20.1-rc.1). Every Lotus
feature and all six io.lotus.* widget actions are preserved. Version
bumped to 0.25.0-lotus.1.
Conflict files and how each Lotus hunk was re-expressed:
* src/state/CallViewModel/remoteMembers/ConnectionFactory.ts
Upstream moved echoCancellation/noiseSuppression/autoGainControl from
constructor params (fed by URL params) to persisted Settings
(settings.ts) with a developer-settings UI. The cinny host still drives
these per call via URL params (noiseSuppression=false /
autoGainControl=false when the in-source ML denoiser is active, so the
model gets a raw mic) - taking upstream verbatim would silently break
the ML denoise tier. Re-wired as AND semantics in generateRoomOption():
a constraint is enabled only if BOTH the Setting and the URL param
allow it. Params default to true, so with no params this is
byte-for-byte upstream behaviour. Upstream's own echoCancellation /
noiseSuppression URL params (still parsed but dead in v0.25.0) work
again as a side effect. Lotus autoGainControl URL param kept in
UrlParams.ts (auto-merged, unchanged).
* src/state/CallViewModel/remoteMembers/ECConnectionFactory.test.ts
Took upstream (tests now drive via Settings). The lost Lotus coverage
is restored in a NEW colocated file src/lotus/lotusAudioConstraints.test.ts
(3 tests) so the upstream test file stays pristine. Verified the new
test fails against pure-upstream ConnectionFactory and passes with the
re-wiring.
* src/state/CallViewModel/CallViewModel.ts
Three small hunks: kept both the Lotus `userMedia$` interface member
and upstream's new `keyRotationSuppressed$`; dropped the three Lotus
audio constructor args (mechanism removed upstream, see above); kept
both in the returned object. The [lotus #4] overrideSpotlight$ routing,
manualSpotlightUserId$ and setManualSpotlight auto-merged; verified
against upstream's changed ringingMedia$ (now single-or-null instead
of array) - the merge correctly took upstream's outer branch and the
inner screenShares$/spotlightSpeaker$ logic that lotusSpotlight.ts
mirrors is unchanged upstream.
* src/index.css
Kept both: Lotus lotus-transparent / lotus-theme blocks and upstream's
new body[data-background="gradient"]::before full-viewport gradient.
The naive merge swallowed the closing brace of body.lotus-theme -
restored. Added a rule hiding the new gradient pseudo-element under
body.lotus-transparent, since it would otherwise paint over the
transparent body and hide the host wallpaper.
* src/components/CallFooterViewModel.tsx, src/components/CallFooter.stories.tsx
No Lotus content - pure upstream-vs-upstream conflicts caused by the
merge base being v0.20.1-rc.1. Took upstream (layoutMode ->
layoutSwitchVm; setLayoutMode removed). No Lotus code uses
setGridMode/layoutMode.
Non-conflicting but reviewed:
* src/widget.ts auto-merged cleanly. Upstream's removal of .well-known
transport advertisement and the new RTC-transport capability request
did not touch the action registration loop the LOTUS_TO_WIDGET_ACTIONS
spread and widget.lazyActions ride on - nothing to re-wire.
* src/room/InCallView.tsx, src/useAudioContext.tsx, src/useTheme.ts,
src/tile/MediaView.tsx(+.module.css), src/UrlParams.ts(+test),
all *.module.css and .gitea/workflows/ci.yml auto-merged; each diff
against v0.25.0 was checked to equal the original Lotus hunk.
* src/button/Button.module.css: the merge appended an exact duplicate
of upstream's `.rotate`/`@keyframes spin` block (rc.1 merge-base
artefact) - reset to upstream verbatim.
* src/grid/OneOnOnePortraitLayout.module.css was renamed upstream to
OneOnOneMobileLayout.module.css; git followed the rename and the Lotus
safe-area PiP inset fix applies there (the --content-inset-* vars it
uses still exist upstream).
Tooling changes inherited from upstream that affect the fork:
* eslint + prettier were replaced by oxlint + oxfmt (`pnpm lint:oxlint`,
`pnpm format:check`). oxlint flagged 10 issues, all in src/lotus/*:
8x no-meaningless-void-operator (dropped the `void` before void-typed
widget transport.reply / callbacks - no behaviour change), 1x
consistent-type-imports (lotusWidget.ts: `import type`), and 2x
unicorn/no-useless-spread in lotusAudioInject.ts which are FALSE
POSITIVES - `[...activeClips]` is a required defensive copy because
abort() deletes from the Set during iteration; suppressed with an
explanatory eslint-disable-next-line. oxfmt reformatted 7 Lotus
touched files (whitespace only).
* packageManager bumped by upstream to pnpm@11.21.0, which requires
Node >= 22.13 (uses node:sqlite). Node 20 cannot run it; pnpm 10.33
cannot read the new lockfile either (matrix-js-sdk is now a git
dependency on develop, using a version-union pnpm 10 rejects). Fork CI
already uses Node 24 (.node-version), so CI is unaffected.
* matrix-js-sdk is now github:matrix-org/matrix-js-sdk#develop (pinned
by commit in pnpm-lock.yaml).
Lotus behaviour NOT preserved: none found.
Verification (Node 24.11.1, pnpm 11.21.0): pnpm install --frozen-lockfile
OK (lockfile taken from upstream unchanged, no regeneration needed);
tsc clean; oxlint clean; oxfmt --check clean; knip exit 0 (2 config
hints in upstream knip.ts only); vitest unit 84 files / 627 passed /
9 skipped; build:embedded OK, staged to embedded/web/dist (44M), all
six io.lotus.* action strings present in the bundle.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
A null cap only removed the key from the sticky settings and applyToRoom
then skipped that sender, leaving the previously written maxBitrate /
maxFramerate live on the RTCRtpSender — contrary to the host contract
documented in cinny's CallControl. Track which keys this module wrote per
sender and write them back as undefined on clear. Unit-tested.
Fixes#11
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
startLotusCallState gated only on the URL flag, so a standalone (non-widget)
load built the whole per-member pipeline and stringified it at up to 8 Hz
for a host that doesn't exist. Add the same `if (!widget)` guard the sibling
modules use. Also move distinctUntilChanged ahead of throttleTime so an
unchanged value no longer spends a throttle window (first half of #20).
Fixes#31
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
matrix-widget-api's PostmessageTransport accepts toWidget actions from any
origin by default; the fork added actions that inject audio, deafen, retune
the encoder and render images. EC is served same-origin with the cinny
host, so globalThis.origin === parentOrigin and the strict check passes.
Fixes#15
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
apply() guarded on mic.getProcessor(), which is only set after setProcessor()
resolves (after the whole wasm/model load), so mic-published followed by
camera-published constructed two processors — two AudioContexts, two model
loads, double lock hold time. Track the in-flight processor per room, skip
apply() while one is pending, and destroy a pending processor if the module
is torn down before setProcessor resolves. Unit-tested.
Fixes#10
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
On a failed restart() the processor set processedTrack to the LiveKit-owned
input track. LiveKit's internalStopProcessor() stops processedTrack and then
republishes _mediaStreamTrack — the same object — so any later
stopProcessor()/teardown killed the live mic for the rest of the session.
Leave processedTrack undefined so LiveKit falls through to its own track.
Unit-tested.
Fixes#2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Deafen wrote RemoteParticipant.setVolume(0, Microphone), which EC's own
createVolumeControls overwrote with the tile volume every time sink$
re-emitted (every join / re-resolution) — so anyone joining while you were
deafened was audible — and undeafen blanket-wrote 1 to every participant,
clobbering per-tile volume/mute. The ParticipantConnected listeners also
hung off livekitRoomItems$, which is empty while alone, so the first joiner
could be heard briefly.
Deafen now drives setAudioEnabled$ -> muteAllAudio$, the `muted` prop
InCallView already passes to every audio renderer, which also silences
Track.Source.Unknown soundboard audio (the known P6-2 gap). Undeafen
restores the user's own output state and never touches the mute-all
setting. Re-applying the same state is a no-op so resendForkState() is
safe. Screenshare-audio-only mute keeps setVolume(ScreenShareAudio) but
only restores participants it muted itself. Rooms come from
allConnections$ like the sibling modules. Unit-tested.
Fixes#1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA