Compare commits

..
118 Commits
Author SHA1 Message Date
fkwp eef86fc06e Merge pull request #4292 from element-hq/spec/background-effects/12-unbuildable-pipeline
Offer no effects for the session where the pipeline cannot be built
2026-11-06 18:27:12 +01:00
Timo K. cd5c367ce9 fix race in processor setup vs camera start 2026-11-06 16:54:42 +01:00
Timo K.andClaude Fable 5.1 190902eec0 Test the session where the pipeline cannot be built
- The effects state's timeline through preparing, settling and attached,
  and that a failed trial build withholds the effects for the session while
  keeping the choice.
- The footer offers nothing once the pipeline fails to build, and passes
  on the wait as the state's single wait field.
- The call leaves the camera out of the join while the pipeline is
  prepared, publishes it once that is known, and turns on a camera turned
  off before the processor was chosen; the lobby opens no camera meanwhile.
  Both view models are driven by one state subject.
- The transformer's trial build answers whether a segmenter can be made.
- End to end, a browser whose pipeline cannot be built sees the effects
  withheld.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-06 16:54:42 +01:00
Timo K.andClaude Opus 5.5 88f748ec21 Offer no effects for the session where the pipeline cannot be built
- A segmenter that can't be built, with the model unreachable or a GPU
  its delegate refuses, took the camera down with the pipeline: the SDK
  fails the track it was attaching to, and the lobby muted camera and
  microphone, on every load while the choice was kept.
- One is now built on trial when an effect is first wanted, before the
  pipeline is attached to anything, on the CPU where the GPU refuses.
  Where it can't be built, the options are disabled with the existing
  notice for the rest of the session. The choice is kept: a browser
  update may fix what failed.
- The view model takes that verdict from the pipeline's state rather
  than
  asking the SDK, so both say the same, and it no longer builds a WebGL
  context of its own to ask.
- Until the trial is over, the lobby opens no camera and the call makes
  no
  camera track, so neither starts without the effect.

Rebased onto main, where the Publisher is gone and the call view model
publishes the camera through the SDK:
- CallViewModel takes videoProcessorPreparing$. While it is true, the
  camera's mute handler waits before making a track, and the join leaves
  the camera out of its requests (initialPublishRequests); the view
  model
  publishes it once it is known whether the pipeline builds. The
  Publisher's test became CallViewModel tests.

Cherry-picked from spec/background-effects/12-unbuildable-pipeline onto
toger5/background-effect/camera-preview, where the camera menu's preview
takes the camera from the footer's caller rather than from a track
synced
on the background-effects state. The footer factories take the
pipeline's
state again, as this commit needs its verdict and not only the settling
flag; the synced camera and the preview's wrapper type are not brought
along.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-06 16:54:42 +01:00
Timo 9639effd28 Merge pull request #4423 from element-hq/toger5/background-effect/camera-preview
Camera Preview in video mute button context menu
2026-11-06 15:01:06 +00:00
Timo K. f2f67019b4 Show the self preview in the video mute button context menu 2026-11-06 15:35:04 +01:00
Timo K. 51fac690f4 Hand the camera track to the footer view model 2026-11-06 15:35:04 +01:00
Johannes Marbach 1ae57e9647 Merge pull request #4420 from element-hq/johannes/mark-release-announcements-viewed
Mark known release announcements as viewed
2026-11-06 13:50:54 +01:00
Timo 4b05ab503e Merge pull request #4416 from element-hq/toger5/fix-flickering-device-id-mismatch
fix camera restart due to device id mismatch
2026-11-06 13:45:04 +01:00
Johannes Marbach ea977cecd9 Mark known release announcements as viewed 2026-11-06 11:31:01 +01:00
Johannes Marbach aadbda39b1 Merge pull request #4405 from element-hq/renovate/npm-minimatch-vulnerability
Update dependency minimatch to v11 [SECURITY]
2026-11-06 11:12:09 +01:00
fkwp 53c1e4c30f Merge pull request #4289 from element-hq/spec/background-effects/9-remove-own
Remove a background of one's own
2026-11-06 09:58:42 +00:00
renovate[bot] 6b56c7e683 Update dependency minimatch to v11 [SECURITY] 2026-11-06 09:35:24 +00:00
fkwpandClaude Opus 5.5 fb4cbc72e2 Remove a background of one's own
- A cross on the tile's top-right corner removes an added background,
  critical-coloured at rest and named by its tooltip, as design draws it.
  The one in force offers none, since taking it away would leave nothing
  chosen; the view model refuses it too.
- The cross sits beside the item rather than in it, which would take the
  press first and choose the tile, and isn't a control of its own, so the
  menu keeps one keyboard model: Delete or Backspace on the tile does the
  same, and the tile says so with aria-keyshortcuts.
- Removed from IndexedDB and its picture released. End to end on
  Chromium and Firefox, a background removed by Delete is still gone
  after a reload.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-06 10:33:22 +01:00
fkwp bac9811829 Merge pull request #4288 from element-hq/spec/background-effects/8-own-backgrounds
Add a background of one's own, kept on this device
2026-11-06 10:33:17 +01:00
renovate[bot]andJohannes Marbach a2cfc2af93 Update ghcr.io/element-hq/element-web:develop Docker digest to 49104e3 (#4415)
* Update ghcr.io/element-hq/element-web:develop Docker digest to e063472

* Fix tests

* Dismiss further toasts

* Close even more toasts

* Yet more toast fixes

* Toast fixes all over

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-11-06 07:24:53 +00:00
Valere Fedronic dcb2b9e8f6 Merge pull request #4418 from element-hq/valere/bug_fix_no_config_uncaught_error
Fix: Uncaught exception in downloadConfig
2026-11-05 16:30:40 +01:00
fkwpandClaude Opus 5.5 373b1e0fc0 Guard what can be added as a background
- Five of one's own at most, so the grid is whole: at the limit the add
  tile goes, and it comes back once one is removed. The store holds the
  limit too.
- An added image is refused if it isn't an image, is animated, or can't
  be decoded; reduced to 1920px on its longest edge; and laid on an opaque
  ground, since a transparent pixel would be a hole in the background.
- Animation is read from the file, as a still and an animated WebP share
  a type. Chromium has no selected track until the decoder's tracks are
  ready; without waiting for them an animated GIF got through.
- A refusal says why, in place of the notice, and is scrolled into view.
  It can be dismissed, is gone once the menu closes, and the next shows
  even if it reads the same. What was in force stays.
- End to end on Chromium and Firefox, from what the device then keeps: an
  animated GIF is refused with its message, a 4000x3000 image is kept at
  1920x1440, and a half transparent one is kept opaque. All three fail
  with the preparation bypassed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 16:20:33 +01:00
fkwpandClaude Opus 5.5 1bc372b587 Add a background of one's own, kept on this device
- An add tile after the pictures opens the file picker, and the image
  chosen joins the backgrounds on offer without being put on; it is in
  force once chosen, like a shipped one. The tile is a command rather
  than a choice, so a menu item beside the radios, and it isn't offered
  where effects can't run.
- Kept in IndexedDB on this device and never sent anywhere, behind a
  small interface, so the unit tests use an in-memory store; the
  IndexedDB store itself is tested against fake-indexeddb, a new dev
  dependency.
- The picker takes the focus, which the menu reads as a click elsewhere,
  so the menu holds itself open until a file is chosen or the picker is
  cancelled.
- A chosen image the device no longer keeps, as after its storage is
  cleared, falls back to no effect once the device has said what it
  keeps.
- Switching is checked between any two kinds of effect. End to end on
  Chromium and Firefox: an added image shows in the preview once chosen;
  once it is in force, no request carries the file or the copy kept,
  which a deliberate upload fails; chosen, it is in force again after a
  reload, gone once storage is cleared, and can be added again.
- Kept as given here; what can be added is checked in the commit after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 16:20:33 +01:00
fkwp 82ba51ea6a Merge pull request #4287 from element-hq/spec/background-effects/7-hold-choice
Hold the chosen effect across calls, and forget one that can't run
2026-11-05 16:20:27 +01:00
fkwp 07aaa326d2 Merge pull request #4286 from element-hq/spec/background-effects/6-no-frame-of-the-room
Check that background effects never publish the room and stay inside the call
2026-11-05 16:20:16 +01:00
Valere e9cffb60dd Fix: Uncaught exception in downloadConfig 2026-11-05 16:08:41 +01:00
fkwpandClaude Opus 5.5 741dab6dbb Hold the chosen effect across calls, and forget one that can't run
- A remembered effect this browser can't run is cleared rather than kept,
  as the menu already showed no effect in force there.
- A camera turned off and on again with an effect chosen gets the same
  pipeline as its track arrives, neither rebuilt nor switched again.
- End to end, on Chromium and Firefox: the effect chosen before a call is
  still chosen, and worn, in the next call after leaving; and a second
  tab still shuts the first down with the existing "Opened in another
  tab" error while an effect runs.
- Switching camera and sharing a screen are checked by hand: they need
  two cameras and a screen, which the fake devices don't give yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 15:30:25 +01:00
Timo K.andClaude Fable 5.1 4f184f504d Expect the join to fetch no model, as it adopts the lobby's pipeline
The e2e check held back the model download at the join and required
one, since the pipeline used to be built again for the call's track.
The join now takes the lobby's running pipeline over, so no download
happens, and the check says so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 15:30:25 +01:00
fkwpandClaude Opus 5.5 ecf2cbc71e Check the background effects stay inside the call everywhere
- The standalone app at five window sizes, from a large desktop to a
  phone-shaped 360x640, on Chromium and Firefox: the camera menu stays
  inside the window, which is the call area there, and the last effect can
  still be scrolled to and chosen.
- As a component in a host page, and as a widget in Element Web: a
  picture chosen reaches the preview, so the pipeline's model and
  WebAssembly load from those builds, and in a small container the last
  effect is reached and chosen.
- Compound portals the menu to the page and takes no container for it, so
  there it has none of the component's scoped styles, as the microphone
  menu has none.
- The colour checks the three share live in playwright/utils.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 14:50:09 +01:00
fkwpandClaude Opus 5.5 fb38a1e6e0 Check that no camera start publishes a frame of the room
- A host in the call records every frame of every video track it receives
  from the moment the track arrives. A guest with a picture chosen joins,
  or turns the camera on in the call from off at joining or from off in
  the call; none of the guest's frames may be closer to the camera image
  than to the picture. A guest with no effect fails it.
- The preview's track stopping destroys the shared pipeline, so the call's
  track builds it again. That build is held back as long as the slower
  route takes, and publishing still waits for it; each check that holds
  the model back also checks that it did.
- The first build survives the preview camera turned off and on while it
  runs, which is what running builds one at a time is for.
- The camera's colour is taken once the preview shows it; taken earlier it
  was black, which let frames of the room pass as the picture. The camera
  checks wait for the call itself, as the lobby has a camera button of
  its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 14:50:09 +01:00
fkwp 06f07fb808 Merge pull request #4285 from element-hq/spec/background-effects/5-slow-and-wait
Say where background effects run slowly, and show the wait for the first
2026-11-05 14:48:35 +01:00
fkwp eee101ffba Merge pull request #4284 from element-hq/spec/background-effects/4-effects-grid
Offer background effects as a grid in the camera menu
2026-11-05 13:09:08 +00:00
fkwpandClaude Opus 5.5 4e779ac4f5 Offer background effects on any platform whose browser can run them
- The desktop rule from #4059 goes: now that the menu says where effects
  run slowly, using them there is the user's choice. A phone on the fast
  route held 99% of its frame rate and was refused; a desktop on the slow
  one held 73% and was allowed.
- Asks @livekit/track-processors directly; the wrapper module goes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 13:48:29 +01:00
fkwpandClaude Opus 5.5 7fb222ce60 Show the wait for the first background effect on its tile
- The first effect chosen in a session shows a spinner on its own tile,
  in place of the tick, until a frame carrying it is drawn. Later changes
  show nothing, having nothing to prepare.
- Ended by the frame, not the promises, which resolve while the segmenter
  is still being built. The transformer reports the first frame that went
  through the segmenter; one passed through untouched doesn't count.
- The pipeline publishes the wait with its state, and both footer view
  models take it as an input.
- The lobby tests' provider mock gains the observable hook, built once: a
  fresh observable per render rebuilt the footer on every render.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 13:48:29 +01:00
fkwpandClaude Opus 5.5 5fdb920ea8 Say where background effects run slowly
- Where the browser has no MediaStreamTrackProcessor, effects draw each
  frame through a canvas: slower, and the first build stalls the page.
  They stay offered and choosable there, with a notice under the grid
  that says so and describes the group.
- Decided in the view model, beside whether effects can run at all, so
  the lobby and the call give the same answer.
- The notice stays within the menu's width however long the sentence,
  which the story checks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 13:48:22 +01:00
Timo K. 813dd4a49e remove unnecassary type 2026-11-05 13:45:59 +01:00
Timo K. 21b54501de rename BackgroundEffectOption to BackgroundEffectGridItem
This makes it more clear that this is a UI struct and not related to the
items in the catalogue
2026-11-05 13:45:59 +01:00
fkwpandClaude Opus 5.5 3d57920556 Offer background effects as a grid in the camera menu
- The camera menu's blur toggle becomes a Background effects section
  under the cameras: no effect, blur and the two shipped backgrounds as
  one radio choice of tiles, three across. It scrolls with the device
  list, so it stays reachable in a short call, and choosing keeps the
  menu open. The lobby gets it too, as both render the same footer.
- The footer's view model gives the options, the notice and the actions;
  the footer only names them.
- Each tile is Compound's MenuItem as a menuitemradio, styled as a tile.
  Compound makes it an item of the dropdown, so the arrow keys walk the
  tiles with the device rows, and takes its tap in the phone drawer. A
  tile that can't be chosen has no select handler, so the keyboard passes
  over it.
- Where the browser can't run effects the tiles are shown, disabled and
  explained by a notice the group is described by. No effect stays
  choosable and shows as in force. The notice is in the scrolling list,
  so a short call doesn't push the menu out of it.
- Section headings are raised, or the positioned tiles scroll over the
  one they sit under; raised within the list only, so the menu's frame
  stays over them. Tiles clip with overflow: clip; with hidden they are
  scroll containers, which focus scrolls into view without their border.
- End to end on Chromium and Firefox: a shipped background chosen in the
  lobby shows in the preview, and None after Blur draws nothing, counted
  in WebGL draws (Blur: about 1400 a second on Chromium, 2300 on
  Firefox).
- The Quick Audio Menu's test of the camera menu keeps its name and now
  finds blur among the effects rather than as a toggle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 13:45:59 +01:00
Timo K. 11e143c8ab fix camera restart due to device id mismatch 2026-11-05 13:21:42 +01:00
fkwp d68e761f7a Merge pull request #4283 from element-hq/spec/background-effects/3-choose-effect
Choose a background effect, not only blur on or off
2026-11-05 10:03:52 +00:00
Timo K. cc994abeb3 review duplicated BackgoundEffects files
One is now called backgroundEffectCatalog
2026-11-05 10:39:57 +01:00
fkwpandClaude Opus 5.5 33f4d5043f Choose a background effect, not only blur on or off
- A catalogue of what can be chosen: no effect, blur, and two shipped
  backgrounds, the app's own gradients flattened onto its canvas colour.
  Their ids are what a saved choice stores, so they are named for the
  pictures.
- One setting for the choice, typed as the ids an effect can have and
  defaulting to blur for anyone who had it on. A stored value we no
  longer ship, or one that isn't even a string, reads as no effect: it
  comes from storage another build or a hand may have written.
- The pipeline follows it, switching in place to a picture as readily as
  to blur, and the camera menu's blur toggle reads and writes it.
- So does Settings' blur checkbox: a picture reads as blur off, and
  checking it replaces the picture with blur. The old on/off setting is
  only read, to carry blur over. First tests for the Settings modal,
  which fail with the checkbox on the old setting.
- The transformer's own init loads a picture chosen before it was built,
  as the base init does and it had not: the first effect always is, and
  the picture was drawn black.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-05 10:39:57 +01:00
fkwp 1e17a6411c Merge pull request #4282 from element-hq/spec/background-effects/2-pipeline
Attach background effects through one switchable pipeline
2026-11-04 19:34:27 +00:00
Timo K.andClaude Opus 5.5 0d60eabef2 Name the upstream cause behind the transformer's update override
@livekit/track-processors skips a frame on
`isDisabled ?? backgroundDisabled`, and `isDisabled` is never
undefined, so a disabled effect that keeps its blur radius is still
segmented and drawn on every frame. Still the case in 0.8.2. The
override stays until that is fixed upstream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:17:22 +01:00
Timo K.andClaude Opus 5.5 22083299fa Turn a camera back on rather than replace it for its processor
The SDK now builds a processor chosen while the camera was off when the
camera turns on, so the call view model no longer unpublishes the
camera and publishes a new one. TrackSupply.replace, the record of
which camera took which processor, and followProcessor's applied
callback go with it.

Answers robintown's review on #4282 ("what's wrong with attaching
processors to already published camera tracks?").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:17:22 +01:00
Timo K.andClaude Opus 5.5 52d5b69487 [SDK] Build a processor set on a camera that is off when it turns on
LiveKit stops a camera's track when it is turned off, and the SDK
refused a processor on a stopped track, so an effect chosen while the
camera was off was dropped.

LiveKit already restarts a track's processor before it hands the new
track to the sender. So the SDK now takes a processor on a camera that
is off and builds it in that restart: the first frame the room sees is
processed, and the track is unmuted rather than replaced. An adapter
let go before it was built leaves the processor alone, since the app
shares one pipeline across camera tracks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:17:22 +01:00
fkwpandClaude Fable 5.1 a0aaa8e09a Ask one question about whether background effects can run
The controls and the pipeline each asked the SDK on their own whether
background processing is supported, so one could offer what the other
refused. One function answers both, and it asks the browser, kept to
desktops until the menu says what the effects cost elsewhere.

Ported from spec/background-effects/2-pipeline (#4282): f78e9c05 and
87638325, the tests on the lobby and call footers as they are.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 20:17:22 +01:00
fkwpandClaude Fable 5.1 146dd3aea1 Start every camera track with the pipeline on
A camera track made without the processor lets the room see the camera
until the processor is built. So the processor travels in the publish
request: the lobby opens the camera with it, the join publishes with it,
the room's capture defaults follow the requests made since the join, and
a camera turned off before the processor was chosen is replaced on turning
it on rather than unmuted, so that the new track starts with it.

Ported from spec/background-effects/2-pipeline (#4282): the publish half
of a91dab8f. The replacement lives in the mute switch's track supply,
which the call view model shares with the lobby.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 20:17:22 +01:00
fkwpandClaude Fable 5.1 3f5f707ca7 Attach background effects through one switchable pipeline
Blur runs through one pipeline, shared by the pre-join preview and the
call: attached the first time an effect is wanted and switched in place
after, never detached. The logic is BackgroundEffects in src/state;
BackgroundEffectsContext only builds it, from the app's view model, so
the standalone app and the component hand the same instance to every
camera. Disabled drops the blur radius, so no effect costs no
segmentation. The SDK gains fromLivekitProcessor beside the adapter,
now toLivekitProcessor.

Ported from spec/background-effects/2-pipeline (#4282): a91dab8f with
its review tidy-ups 445e010c, bbc73d07, 51729bcf, f52d2b79, 813f1e6f
and 584bbaa0. Left out as already solved here: OneStepPipeline (the SDK
runs a processor's steps in turn) and the LiveKit-track sync helpers
(the lobby's camera is an SDK track, synced by followProcessor).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 20:17:22 +01:00
Timo 922a9bbbab Merge pull request #4407 from element-hq/toger5/lobby-local-media
Share tracks: lobby InCallView
2026-11-04 20:17:18 +01:00
Timo K.andClaude Fable 5.1 69c57bd534 Run the lobby on the SDK's local media and keep its tracks through the
join

The lobby captured its own LiveKit tracks and the join captured again:
two permission prompts, a grey tile while the camera restarted, and the
background pipeline built twice. The lobby now opens the devices on the
SDK's `LocalMedia`, follows the mute switches as they change (they start
off until the devices are enumerated), the device choice and the
processor through controls shared with the call view model, and the call
joins with that media, so the tile carries the very track the lobby
previewed.

`useBehavior` subscribes without `useSyncExternalStore`, which left a
view without a re-render for an emission React had been told about,
about one time in three, when its store had switched. The LiveKit
preview hook and the processor sync helpers go, with
`@livekit/components-react`.

Background effects run under the dev server: the node polyfill no longer
rewrites MediaPipe's WASM loader. Taken from fkwp's 2fad4e08 on
spec/background-effects/2-pipeline (#4282).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 18:59:11 +01:00
Timo K.andClaude Fable 5.1 734ecbbecf [SDK] Capture local media ahead of the join and hand it to the participation
A lobby captures the microphone and the camera on the browser's devices
alone, with `createLocalMedia`, so that any backend can take them over:
`LocalMedia` hands out the same `LocalAudioMediaTrack` and
`LocalVideoMediaTrack` a call renders, with mute, device and facing
switches and the host's `VideoProcessor` run on the preview. Given to
`join` as `publish`, the tracks are moved rather than shared: the
publisher takes them over as it starts publishing, their lobby objects
are finished, and the LiveKit backend adopts the device tracks as its
own and the processor with its running output, so the join captures
nothing and initialises nothing again.

Also here, found on the way: a track already on the requested device is
left alone (LiveKit would restart it, always on Firefox, which echoes no
device id in `getConstraints()`), identical captures in flight are
coalesced (React's strict mode mounts twice), and every operation on a
processor takes its turn, since a stopped track tears its processor down
without waiting for the next track's init.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-04 18:42:59 +01:00
Timo 00463c3ede Merge pull request #4410 from element-hq/toger5/abb-back-use-behavior-impl
Fix useBehavoir reading old value
2026-11-04 18:32:32 +01:00
Timo K. 1aa91bf27c Add the old useBehavior implementation as reference 2026-11-04 16:27:52 +01:00
renovate[bot] f930f73c40 Update ghcr.io/element-hq/element-web:develop Docker digest to a798012 (#4406)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-04 02:30:37 +00:00
Johannes Marbach 3914aec53f Merge pull request #4389 from element-hq/johannes/m_livekit
Add support for m.livekit
2026-11-03 17:02:55 +01:00
Johannes Marbach 321340a192 Switch JS SDK to a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01 2026-11-03 16:31:49 +01:00
Timo 818906eef8 Merge pull request #4404 from element-hq/toger5/fix-storybook-docgen-parse
Use a generic instead of a type assertion in useRoomEncryptionSystem
2026-11-03 16:00:58 +01:00
fkwp 60a8fa5464 Merge pull request #4281 from element-hq/spec/background-effects/1-device-menus
Hold both device menus at 296px, framed in Safari too
2026-11-03 15:07:53 +01:00
Timo K. e339098939 Merge branch 'main' into spec/background-effects/1-device-menus 2026-11-03 14:40:13 +01:00
Timo K.andClaude Opus 5.5 1dad38edd2 Use a generic instead of a type assertion in useRoomEncryptionSystem
Storybook's react-docgen plugin parses .ts files with Babel's jsx plugin
enabled, so the angle-bracket assertion `<EncryptionSystem>useMemo(...)`
reads as an opening JSX tag and the Storybook build fails with
"Unexpected token". Passing the type as useMemo's generic is equivalent
and parses either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:38:42 +01:00
Johannes MarbachandTimo 517edc12ac Extend comment
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2026-11-03 11:44:04 +01:00
Timo K. dff4e0f194 use compound release. 2026-11-03 10:02:07 +01:00
renovate[bot] ba27855a43 Update ghcr.io/element-hq/element-web:develop Docker digest to fc21073 (#4402)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-03 03:17:55 +00:00
Valere Fedronic d46840ce81 Merge pull request #4400 from element-hq/localazy-download-only-app-json
Download and upload only app.json from Localazy
2026-11-02 14:42:54 +01:00
ValereandClaude Opus 5.5 c30bb324e3 Download and upload only app.json from Localazy
The Localazy project is about to hold the native iOS and Android call
screens' strings in a file of their own. Without a condition, the download
would copy that file into locales/, and the upload on every push to main
would send a stale copy back, overwriting edits made in Localazy.

Naming app.json in the upload patterns as well means a copy that arrives
another way, such as a local download from an older checkout, is never sent
either.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:10:56 +01:00
Johannes Marbach b4a632b6d5 Merge pull request #4398 from element-hq/renovate/embedded-package-dependencies
Update embedded package dependencies
2026-11-02 08:19:49 +01:00
renovate[bot] 7b6f697c84 Update ghcr.io/element-hq/element-web:develop Docker digest to 84af053 (#4399)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-02 02:17:15 +00:00
renovate[bot] aa9125f0a0 Update ghcr.io/element-hq/element-web:develop Docker digest to dfd40c1 (#4397)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-01 01:23:14 +00:00
renovate[bot] e78b4b2e05 Update embedded package dependencies 2026-11-01 01:00:42 +00:00
renovate[bot] 6cffe649e2 Update ghcr.io/element-hq/element-web:develop Docker digest to 60ff051 (#4396)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-31 02:27:20 +00:00
Timo 844051e24c Merge pull request #4394 from element-hq/toger5/duplicated-js-sdk-bundle
Fix duplicate `matrix-js-sdk` in bundle
2026-10-30 17:16:54 +01:00
Timo a91471b5bc Merge pull request #4374 from element-hq/toger5/publish-component-npm
Add CI to publish EC component to npm
2026-10-30 16:12:53 +01:00
Timo K. 346074ce32 zizmor proposals + review description 2026-10-30 15:38:58 +01:00
Timo K. b9c10ac533 Add readme for component (npm page) 2026-10-30 15:36:12 +01:00
Timo K. 79bd2890a4 Install the component's dependencies with layered.sh 2026-10-30 15:32:31 +01:00
Timo K. 216bd4b911 Move component publishing into its own workflow 2026-10-30 14:53:23 +01:00
Timo K. a6764b4e49 Keep the SDK's matrix-js-sdk imports out of the component bundl 2026-10-30 13:54:26 +01:00
renovate[bot] 35ce907e1f Update ghcr.io/element-hq/element-web:develop Docker digest to 431f742 (#4393)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-30 02:52:45 +00:00
Johannes Marbach 94ee13c639 Add support for m.livekit 2026-10-29 15:40:28 +01:00
Robin 8a987b4d46 Merge pull request #4388 from element-hq/robin/participant-confusion
For each member, aggregate tracks from all matching participants
2026-10-29 15:39:45 +01:00
Robin 0fbd77438a Mark the "all possible transport shapes at once" test as slow 2026-10-29 15:39:22 +01:00
Robin 1dec2b0b1e Don't log that participants are missing when they're not 2026-10-29 15:39:22 +01:00
Robin 19efe87908 For each member, aggregate tracks from all matching participants
Rather than whichever participant has won the race.
2026-10-29 15:39:22 +01:00
Robin f331c75126 Merge pull request #4382 from element-hq/robin/auth-flow-key
Share connections for transports that resolve to same auth flow
2026-10-29 15:39:18 +01:00
Robin 6658dcb677 Remove hard-coded video tile count from end-to-end tests 2026-10-29 15:38:42 +01:00
Robin 6a7f2ca603 Share connections for transports that resolve to same auth flow
By keying the connections by the transport's resolved auth flow. This is important because connecting to the same SFU twice with the same participant ID will cause a 'duplicate participant ID' error that stops one of the connections.
2026-10-29 15:38:42 +01:00
Robin b8c7f4ebfe Merge pull request #4381 from element-hq/robin/always-openid
Always use OpenID auth flow if available
2026-10-29 15:38:38 +01:00
Timo K. 287f711d07 Update publish-embedded-packages.yaml 2026-10-29 15:19:32 +01:00
Timo K. 8ef1dee256 bump compound web 2026-10-29 13:11:44 +01:00
Johannes Marbach 26b09adc64 Merge pull request #4366 from junkerderprovinz/junkerderprovinz/firefox-websocket-prime
Retry the SFU connection once in Firefox when the WebSocket fails
2026-10-29 08:27:45 +01:00
Johannes Marbach b7655417d7 Move comment inline 2026-10-29 08:05:07 +01:00
renovate[bot] 2dd000bab0 Update ghcr.io/element-hq/element-web:develop Docker digest to f4e3c42 (#4385)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 03:19:17 +00:00
junkerderprovinz d6e3fefe82 Retry the join in Firefox instead of priming the WebSocket
The second connection only happens when the first one actually failed
on the WebSocket. connect() rejects after its socket has failed and the
engine is closed, so the retry never overlaps the failing socket.
2026-10-28 23:44:19 +01:00
Timo K. 38ef1add48 use compound web sticky patch 2026-10-28 19:29:08 +01:00
Robin b27c11c381 Always use OpenID auth flow if available
This removes the notion of an auth flow fallback for now. We will no longer try the CS API when authenticating with transports that include both a `url` and `livekit_service_url`.
2026-10-28 19:16:32 +01:00
Timo K. cffdb58521 rop the device list's custom fade under the mete 2026-10-28 16:33:31 +01:00
Timo K. 2abe7ea02f better docs for the sub test trick 2026-10-28 16:27:38 +01:00
Johannes Marbach b7bc218b19 Merge pull request #4367 from RaphaelFakhri/RaphaelFakhri/shared-key-storage-key
Read and write the room shared key under its single-prefixed storage key
2026-10-28 16:21:11 +01:00
Timo K. 17caded88b clean up css calc in ts code 2026-10-28 16:11:49 +01:00
Raphael Fakhri 85424f76b1 Ignore the URL password when the parameters are for another room 2026-10-28 14:33:07 +00:00
Timo K. c713b13994 review: audioMeter instead of iconsAndLabels === "audio" 2026-10-28 15:17:44 +01:00
Robin c233ac6e70 Improve names of transport shape end-to-end tests 2026-10-28 14:51:27 +01:00
Timo K. 2e6c7d1419 add agpl to component package 2026-10-28 13:57:35 +01:00
renovate[bot] 6793a4b46e Update ghcr.io/element-hq/element-web:develop Docker digest to ee8ce55 (#4380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-28 02:32:09 +00:00
Robin 619f5ac3a2 Merge pull request #4375 from element-hq/robin/stats-crash
Fix crash when joining call with connection stats enabled
2026-10-27 18:08:31 +01:00
Robin 5780597b83 Fix crash when joining call with connection stats enabled
As demonstrated by the test. This crashed with "Behavior failed to synchronously emit an initial value."
2026-10-27 16:17:58 +01:00
Timo K. f2976be103 Add CI to publish to npm 2026-10-27 16:05:44 +01:00
Robin 76da59be40 Merge pull request #4301 from element-hq/floating-tile
Make drag & drop of floating tiles less janky
2026-10-27 12:16:12 +01:00
Robin c6ad8b2ac7 Use the right comparators 2026-10-27 11:45:35 +01:00
Robin d33a1eb059 Merge branch 'floating-tile' of github.com:element-hq/element-call into floating-tile 2026-10-27 11:04:12 +01:00
Robin 9fcba5a704 Skip redundant tile wrapper updates 2026-10-27 10:44:33 +01:00
RaphaelFakhri dc1459e45e Prefer the stored shared key when the parameters are for another room
keyForRoom returned the password from the parameters even when they were for a different room, and useRoomSharedKey then saved it for this room. Use the stored key first in that case, so another room's password can't replace it.
2026-10-26 21:13:23 +00:00
Junker der Provinz dfff8be38e Wait for the primed WebSocket to close before connecting 2026-10-26 18:21:19 +01:00
fkwpandClaude Opus 5.5 9e6f2e89a8 Size and scroll the device menus with Compound's menu
- Compound 10.3.0 bounds a menu by the space it has and scrolls what
  doesn't fit, within a boundary the app passes. The menus pass the call
  area, 16px in from its sides, rather than measuring its width.
- The 296px width is the menu's own style. Compound gives that style only
  to the dropdown, so menuIsDrawer, which copied its phone rule, goes.
- Compound draws the menu's frame over its content, so nothing that
  scrolls can hide it in Safari: the list, headings and meter no longer
  keep a border width clear of it, and the checks that they did go.
- The device list is Compound's scroll area, so it fades out at the
  foot with Compound's chevron while there is more below.
- The microphone menu's meter sticks at that foot, with rows scrolling
  under it as before, so the list has no fade above it: Compound's is a
  mask fixed to the list's edge, while a bounce at the top carries the
  meter with the rows. The band under the meter is filled, or rows would
  show there. The edge fades from where the chevron starts, so a bounce
  fades the meter out before it reaches the chevron. A row reached by
  keyboard stays above the meter.
- The list's height is still its share of the call.
- The component harness leaves the host's stylesheets unscoped, Compound's
  among them, as a host does: scoped, they never reached the menu, which is
  portalled out of Element Call's root.
- Its specs check only Element Call's own document styles on the host
  page, and find the device list within Compound's scroll area.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 15:48:52 +01:00
fkwpandClaude Opus 5.5 7a48b09c4c Hold both device menus at 296px, framed in Safari too
- The camera and microphone menus were as wide as their longest device
  name, so a long one widened the menu over more of the call: 340px for a
  long speaker name. Both are now 296px, as design sets them, and a long
  name wraps.
- Narrower only where the call area is, so the menu still fits inside it:
  268px in a 300px call. Taken from the observation of the call area that
  already bounds the list's height.
- Held only for the dropdown. On Android and iOS Compound renders the menu
  as a drawer as wide as the screen; menuIsDrawer follows its rule.
- Once the list is long enough to scroll, Safari paints it above the
  outline the menu is framed with, and the frame vanished down both sides
  of it: 384px of it on the many-devices microphone menu, in WebKit. The
  list now keeps a border width clear of the frame, so the headings and
  the meter inside it no longer need their own.
- Guarded with a long speaker name, which fails at 340px on main's sizing;
  a unit test run as a phone, which fails with the width held there; and
  the many-devices story's inset, which fails on the old stylesheet, since
  the stories run in Chromium, which never showed the Safari bug.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 15:48:07 +01:00
Johannes Marbach 96333dbc3a Merge branch 'main' into floating-tile 2026-10-26 13:46:54 +01:00
Raphael Fakhri bda7b86c39 Read and write the room shared key under its single-prefixed storage key 2026-10-22 12:24:39 +00:00
Junker der ProvinzandMarc ee507ab3ac Prime the SFU WebSocket in Firefox before connecting
Behind a proxy, Firefox fails the first WebSocket it opens to a host, so
the first join fails. The livekit-client in the report turned this into an
insufficient capacity error; current versions report a generic connection
error. Opening a throwaway WebSocket first lets the real connection
through.

Co-authored-by: Marc <10048869+MarcWadai@users.noreply.github.com>
2026-10-22 11:28:11 +02:00
Robin 4441bb5e8c Add snapshot coverage for all call layouts
Snapshotting the layout templates so that they get reviewed as a whole feels a little more straightforward than the existing tests.
2026-09-29 03:20:14 +02:00
Robin f2f49315eb Make drag & drop performant in all layouts
While also giving the logic for drag & drop of a floating tile a common home in the view model layer.
2026-09-28 19:53:30 +02:00
Robin 99256fa01c Make drag & drop of floating tiles less janky
This fixes an issue where upon dragging a floating tile to a different corner of the interface, it would briefly move toward its original position before jerking back in the intended direction.
2026-09-28 19:48:49 +02:00
129 changed files with 8482 additions and 1675 deletions
+5 -3
View File
@@ -26,8 +26,7 @@
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_release_announcement": false
"feature_group_calls": true
},
"default_federate": true,
"default_theme": "light",
@@ -40,7 +39,10 @@
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true
"feature_group_calls": true,
"releaseAnnouncementData": {
"room_list_section": true
}
},
"jitsi": {
"preferred_domain": "meet.element.io"
+5 -3
View File
@@ -26,8 +26,7 @@
"features": {
"feature_element_call_video_rooms": true,
"feature_video_rooms": true,
"feature_group_calls": true,
"feature_release_announcement": false
"feature_group_calls": true
},
"default_federate": true,
"default_theme": "light",
@@ -40,7 +39,10 @@
},
"setting_defaults": {
"breadcrumbs": true,
"feature_group_calls": true
"feature_group_calls": true,
"releaseAnnouncementData": {
"room_list_section": true
}
},
"jitsi": {
"preferred_domain": "meet.element.io"
@@ -109,4 +109,12 @@ describe("scopeStylesToRoot", () => {
".a .b_abc12:hover { border: 0 }",
);
});
it("leaves an excluded stylesheet as it is", async () => {
const css = "body { margin: 0 } .menu { color: red }";
const result = await postcss([
scopeStylesToRoot({ exclude: [/host\.css$/] }),
]).process(css, { from: "/app/host.css" });
expect(result.css).toBe(css);
});
});
+9 -3
View File
@@ -60,12 +60,18 @@ const IS_ROOT = `:where(${ROOT_SELECTOR})`;
* tokens, the component styles, the fonts — is not in this build at all: the
* host supplies it, unscoped, along with its copy of Compound.
*/
export function scopeStylesToRoot(): Plugin {
interface Options {
/** Stylesheets left as they are: a host's own, which it doesn't scope. */
exclude?: RegExp[];
}
export function scopeStylesToRoot({ exclude = [] }: Options = {}): Plugin {
return {
postcssPlugin: "element-call-scope-styles-to-root",
Once(root) {
const isModule =
root.source?.input.file?.endsWith(".module.css") ?? false;
const file = root.source?.input.file ?? "";
if (exclude.some((pattern) => pattern.test(file))) return;
const isModule = file.endsWith(".module.css");
root.walkRules((rule) => {
if (isRelative(rule)) return;
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
+11 -12
View File
@@ -72,9 +72,9 @@ import {
useUrlParams,
} from "../src/UrlParams";
import { MediaDevicesContext } from "../src/MediaDevicesContext";
import { MediaDevices } from "../src/state/MediaDevices";
import { AppViewModel } from "../src/state/AppViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
import { BackgroundEffectsProvider } from "../src/tracks/BackgroundEffectsContext";
import { Config } from "../src/config/Config";
import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
@@ -194,14 +194,12 @@ export const ElementCall: FC<ElementCallProps> = ({
// observers running for the rest of the page's life. Null until then, which
// is one render.
const { controlledAudioDevices, callIntent } = params;
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
const [vm, setVm] = useState<AppViewModel | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMediaDevices(
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
);
setVm(new AppViewModel(scope, { controlledAudioDevices, callIntent }));
return (): void => {
setMediaDevices(null);
setVm(null);
scope.end();
};
}, [controlledAudioDevices, callIntent]);
@@ -222,8 +220,7 @@ export const ElementCall: FC<ElementCallProps> = ({
// is told that Element Call has loaded, as the widget tells its client once
// its own initialisation is over. Once per mount, however often the pieces
// are later swapped out.
const ready =
container !== null && rtcSession !== null && mediaDevices !== null;
const ready = container !== null && rtcSession !== null && vm !== null;
const announcedLoaded = useRef(false);
useEffect(() => {
if (!ready || announcedLoaded.current) return;
@@ -254,8 +251,10 @@ export const ElementCall: FC<ElementCallProps> = ({
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<BackgroundEffectsProvider
effects={vm.backgroundEffects}
>
<CallView
client={client}
rtcSession={rtcSession}
@@ -264,7 +263,7 @@ export const ElementCall: FC<ElementCallProps> = ({
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:79e47d8d5a93848403cb83042a405d8ed233a7727c499fa53b4b39a80f090be4
image: ghcr.io/element-hq/element-web:develop@sha256:e063472b6bdf6d0bfa1d43dfa4ef7effee1efd2c9684daee665d1bedfe746a47
+2
View File
@@ -48,6 +48,8 @@ Test what a user observes: the peer sees the change, it survives a reconnect, it
right after a reload. Reuse `playwright/spa-helpers.ts`,
`playwright/widget/test-helpers.ts`, `playwright/fixtures/`.
To mark new release announcements as viewed, add them in `backend/ew.test*.config.json`.
```sh
pnpm test # unit + storybook
pnpm backend # Synapse + LiveKit, required for e2e
+1 -1
View File
@@ -9,4 +9,4 @@ android_gradle_plugin = { module = "com.android.tools.build:gradle", version.ref
[plugins]
android_library = { id = "com.android.library", version.ref = "android_gradle_plugin" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.36.0" }
maven_publish = { id = "com.vanniktech.maven.publish", version = "0.37.0" }
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.6-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+4 -3
View File
@@ -7,13 +7,13 @@
"features": ["plural_postfix_us", "filter_untranslated"],
"files": [
{
"pattern": "locales/en/*.json",
"pattern": "locales/en/app.json",
"lang": "inherited"
},
{
"group": "existing",
"pattern": "locales/*/*.json",
"excludes": ["locales/en/*.json"],
"pattern": "locales/*/app.json",
"excludes": ["locales/en/app.json"],
"lang": "${autodetectLang}"
}
]
@@ -22,6 +22,7 @@
"download": {
"files": [
{
"conditions": ["equals: ${file}, app.json"],
"output": "locales/${langLsrDash}/${file}"
}
],
+13 -1
View File
@@ -3,7 +3,7 @@
"user_menu": "User menu"
},
"action": {
"blur_background": "Blur background",
"add_background_image": "Add image",
"close": "Close",
"copy_link": "Copy link",
"edit": "Edit",
@@ -23,6 +23,13 @@
"upload_file": "Upload file"
},
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
"background_effects": {
"blur": "Blur",
"none": "None",
"numbered": "Background {{n}}",
"slow": "Background effects run slowly on this platform, which may cause your video to stutter.",
"unavailable": "Background effects are not supported on this platform."
},
"call_ended_view": {
"create_account_button": "Create account",
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
@@ -92,6 +99,10 @@
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
"background_animated": "Animated images cannot be used as a background",
"background_not_an_image": "That file is not a supported image",
"background_not_kept": "That background could not be saved",
"background_undecodable": "That image could not be opened",
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
@@ -212,6 +223,7 @@
"auto_gain_control_label": "Automatic gain control",
"background_blur_header": "Background",
"background_blur_label": "Blur the background of the video",
"background_effects_header": "Background effects",
"bitrate_label": "Bitrate",
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
"camera_header": "Camera quality",
+3 -3
View File
@@ -57,7 +57,6 @@
"@formatjs/intl-durationformat": "^0.10.0",
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.12.0",
"@livekit/components-react": "^2.0.0",
"@livekit/track-processors": "^0.7.1",
"@mediapipe/tasks-vision": "^0.10.18",
"@playwright/test": "^1.60.0",
@@ -88,7 +87,7 @@
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^11.0.0",
"@vector-im/compound-web": "^10.4.0",
"@vector-im/compound-web": "^10.5.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
@@ -98,6 +97,7 @@
"copy-to-clipboard": "^3.3.3",
"eslint-plugin-element-call": "link:eslint",
"eslint-plugin-storybook": "^10.3.6",
"fake-indexeddb": "^6.2.5",
"fetch-mock": "11.1.5",
"i18next": "^26.0.0",
"i18next-browser-languagedetector": "^8.0.0",
@@ -107,7 +107,7 @@
"livekit-client": "^2.18.1",
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01",
"matrix-widget-api": "^1.18.0",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
+952
View File
@@ -0,0 +1,952 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, type Browser, type Page } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers.ts";
import {
averageColour,
cameraColour,
type Colour,
distance,
} from "./utils/colour.ts";
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
// so it rightly offers none of them.
test.skip(
({ browserName }) => browserName === "firefox",
"Background effects need WebGL2, which headless Firefox on CI does not have",
);
test.describe("background effects", () => {
test("pre-join preview shows the chosen effect", async ({ page }) => {
test.slow();
await page.goto("/");
await SpaHelpers.createCall(page, "Effect user", "Background effects");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
// The fake camera shows no person, so the whole frame is background: the
// preview takes on the picture's colours and leaves the camera's.
const picture = await averageColour(tile.locator("img"));
// Long enough for the first effect of a session to be built.
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
});
test.describe("no effect", () => {
test("draws nothing, even after blurring", async ({ page }) => {
test.setTimeout(120_000);
await countDraws(page);
await page.goto("/");
await SpaHelpers.createCall(page, "Idle user", "No effect after blur");
await page.getByRole("button", { name: "Camera", exact: true }).click();
const effects = page.getByRole("group", { name: "Background effects" });
await effects.getByRole("menuitemradio", { name: "Blur" }).click();
// Long enough for the first effect of a session to be built.
await expect
.poll(async () => drawsPerSecond(page), { timeout: 60_000 })
.toBeGreaterThan(0);
const blurred = await drawsPerSecond(page);
await effects.getByRole("menuitemradio", { name: "None" }).click();
// A frame already on its way may still be drawn.
await expect.poll(async () => drawsPerSecond(page)).toBe(0);
test.info().annotations.push({
type: "WebGL draws per second",
description: `blurred ${blurred}, none 0`,
});
});
});
/** Counts every WebGL draw on the page, the segmenter's and the effect's. */
async function countDraws(page: Page): Promise<void> {
await page.addInitScript(() => {
const counter = { draws: 0 };
(window as unknown as { webglDraws: typeof counter }).webglDraws = counter;
for (const name of ["drawArrays", "drawElements"] as const) {
const draw = WebGL2RenderingContext.prototype[name] as (
...args: unknown[]
) => void;
WebGL2RenderingContext.prototype[name] = function (
this: WebGL2RenderingContext,
...args: unknown[]
): void {
counter.draws += 1;
draw.apply(this, args);
} as never;
}
});
}
/** WebGL draws over the next second. */
async function drawsPerSecond(page: Page): Promise<number> {
return page.evaluate(async () => {
const counter = (window as unknown as { webglDraws: { draws: number } })
.webglDraws;
const before = counter.draws;
await new Promise((resolve) => setTimeout(resolve, 1000));
return counter.draws - before;
});
}
test.describe("the camera menu's preview", () => {
test("shows the camera with the effect in force", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Preview user", "Preview in the menu");
const camera = await cameraColour(page.locator("video").first());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const preview = page.getByRole("menu").locator("video");
await expect(preview).toBeVisible();
await expect
.poll(async () => distance(await averageColour(preview), camera), {
timeout: 20_000,
})
.toBeLessThan(30);
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
test("shows the call's own camera once joined", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(
page,
"Call preview user",
"Preview in the call's menu",
true,
);
await expect(page.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const camera = await cameraColour(page.locator("video").first());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const preview = page.getByRole("menu").locator("video");
await expect(preview).toBeVisible();
await expect
.poll(async () => distance(await averageColour(preview), camera), {
timeout: 20_000,
})
.toBeLessThan(30);
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
});
test.describe("background effects section", () => {
test("section stays within the call area at every size", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Sizes user", "Background effect sizes");
for (const size of [
{ width: 1280, height: 800 },
{ width: 800, height: 600 },
{ width: 600, height: 420 },
{ width: 480, height: 360 },
{ width: 360, height: 640 },
]) {
await page.setViewportSize(size);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
const box = (await menu.boundingBox())!;
const where = `at ${size.width}x${size.height}`;
expect(box.x, where).toBeGreaterThanOrEqual(0);
expect(box.y, where).toBeGreaterThanOrEqual(0);
expect(box.x + box.width, where).toBeLessThanOrEqual(size.width);
expect(box.y + box.height, where).toBeLessThanOrEqual(size.height);
const last = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio")
.last();
await last.scrollIntoViewIfNeeded();
await expect(last, where).toBeInViewport({ ratio: 1 });
await last.click();
await expect(last, where).toHaveAttribute("aria-checked", "true");
// Back to no effect for the next size, and close.
await page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "None" })
.click();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
}
});
});
test.describe("a pipeline that fails to build", () => {
test("offers no effects for the session, and keeps the choice", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Failing user", "Pipeline fails");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
await page.route("**/*.tflite*", async (route) => route.abort());
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
await expect(
page.getByText("Background effects are not supported on this platform."),
).toBeVisible({ timeout: 30_000 });
await expect(tile).toBeDisabled();
await page.keyboard.press("Escape");
// The camera and microphone carry on without it.
expect(distance(await cameraColour(preview), camera)).toBeLessThan(30);
await expect(page.getByTestId("incall_mute")).toHaveAttribute(
"aria-checked",
"true",
);
await page.unroute("**/*.tflite*");
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(tile).toHaveAttribute("aria-checked", "true");
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
});
test.describe("the chosen effect", () => {
test("chosen effect survives leaving and rejoining", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Returning user", "Effect remembered");
const { camera, picture } = await chooseAndWear(page, "Background 1");
await page.getByTestId("lobby_joinCall").click();
await page.getByTestId("incall_leave").click();
await expect(page.getByRole("heading")).toContainText(
"your call has ended",
);
await page.goto("/");
await page.getByTestId("home_callName").fill("Effect remembered again");
await page.getByTestId("home_go").click();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 1" });
await expect(tile).toHaveAttribute("aria-checked", "true");
const preview = page.locator("video").first();
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
});
test("second session still shows the existing error", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Two tabs", "Effect in two tabs");
await chooseAndWear(page, "Background 1");
const second = await page.context().newPage();
await second.goto("/");
await expect(
page.getByRole("heading", { name: "Opened in another tab" }),
).toBeVisible();
});
});
/** Chooses an effect in the lobby, and waits until the preview wears it. */
async function chooseAndWear(
page: Page,
name: string,
): Promise<{ camera: Colour; picture: Colour }> {
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
await page.keyboard.press("Escape");
return { camera, picture };
}
test.describe("a background of one's own", () => {
test("adds an image from the device", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Adding user", "Own background");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const red = await redImage(page);
await addImage(page, red);
const mine = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" });
await expect(mine).toBeVisible();
await expect(mine).toHaveAttribute("aria-checked", "false");
await mine.click();
await expect
.poll(async () => distance(await averageColour(preview), RED), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, RED) / 2);
});
test("adding an image makes no upload", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Private user", "Own background kept");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const red = await redImage(page);
const sent: { request: string; body: Buffer | null }[] = [];
page.on("request", (request) =>
sent.push({
request: `${request.method()} ${request.url()}`,
body: request.postDataBuffer(),
}),
);
await addImage(page, red);
await page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" })
.click();
// Kept, chosen and in force: anything that would send it has had its turn.
await expect
.poll(async () => distance(await averageColour(preview), RED), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, RED) / 2);
// The file as given, and the copy kept, which is encoded anew.
const samples = [red, await keptBytes(page)].map((bytes) =>
bytes.subarray(bytes.length - 64),
);
const carriesIt = sent.filter(
({ request, body }) =>
request.includes("/_matrix/media/") ||
samples.some((sample) => body?.includes(sample)),
);
expect(carriesIt.map(({ request }) => request)).toEqual([]);
});
test("added image survives rejoining and is gone after storage is cleared", async ({
page,
}) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Keeping user", "Own background again");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const red = await redImage(page);
await addImage(page, red);
const mine = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" });
await mine.click();
await page.keyboard.press("Escape");
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(mine).toHaveAttribute("aria-checked", "true");
await page.keyboard.press("Escape");
await expect
.poll(async () => distance(await averageColour(preview), RED), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, RED) / 2);
await page.evaluate(
async () =>
new Promise<void>((resolve, reject) => {
const request = indexedDB.deleteDatabase(
"element-call-background-images",
);
request.onsuccess = (): void => resolve();
request.onerror = (): void => reject(request.error);
request.onblocked = (): void => resolve();
}),
);
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(
page.getByRole("group", { name: "Background effects" }),
).toBeVisible();
await expect(mine).toHaveCount(0);
await page.keyboard.press("Escape");
await addImage(page, red);
await expect(mine).toBeVisible();
});
test("a removed image stays removed", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(
page,
"Removing user",
"Own background removed",
);
await addImage(page, await redImage(page));
const mine = page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" });
await expect(mine).toHaveAttribute("aria-keyshortcuts", "Delete");
await mine.focus();
await page.keyboard.press("Delete");
await expect(mine).toHaveCount(0);
await page.reload();
await expect(page.getByTestId("lobby_joinCall")).toBeVisible();
await page.getByRole("button", { name: "Camera", exact: true }).click();
await expect(
page.getByRole("group", { name: "Background effects" }),
).toBeVisible();
await expect(mine).toHaveCount(0);
expect(await keptImages(page)).toEqual([]);
});
});
test.describe("what can be added", () => {
test("refuses an animated image", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Refused user", "Animated refused");
await addImage(page, ANIMATED_GIF, {
name: "moving.gif",
type: "image/gif",
});
await expect(
page.getByText("Animated images cannot be used as a background"),
).toBeVisible();
const section = page.getByRole("group", { name: "Background effects" });
await expect(
section.getByRole("menuitemradio", { name: "Background 3" }),
).toHaveCount(0);
await expect(
section.getByRole("menuitemradio", { name: "None" }),
).toHaveAttribute("aria-checked", "true");
expect(await keptImages(page)).toEqual([]);
});
test("reduces an oversized image", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Large user", "Oversized reduced");
await addImage(page, await pngImage(page, { width: 4000, height: 3000 }));
await expect(
page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" }),
).toBeVisible();
await expect(page.getByRole("alert")).toHaveCount(0);
const [kept] = await keptImages(page);
expect(kept).toMatchObject({ width: 1920, height: 1440 });
});
test("lays every image on an opaque ground", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("/");
await SpaHelpers.createCall(page, "Logo user", "Transparent made opaque");
await addImage(
page,
await pngImage(page, { width: 320, height: 180, transparent: true }),
);
await expect(
page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 3" }),
).toBeVisible();
const [kept] = await keptImages(page);
expect(kept.minAlpha).toBe(255);
});
});
/** Two frames of one pixel, red then blue. */
const ANIMATED_GIF = Buffer.from(
"R0lGODlhAQABAPEAAP8AAAAA/wAAAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACgAAACwAAAAAAQABAAACAkQBACH5BAAKAAAALAAAAAABAAEAAAICTAEAOw==",
"base64",
);
/** A red PNG of the given size; transparent leaves its right half empty. */
async function pngImage(
page: Page,
size: { width: number; height: number; transparent?: boolean },
): Promise<Buffer> {
const dataUrl = await page.evaluate(({ width, height, transparent }) => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d")!;
context.fillStyle = "rgb(255, 0, 0)";
context.fillRect(0, 0, transparent ? width / 2 : width, height);
return canvas.toDataURL("image/png");
}, size);
return Buffer.from(dataUrl.split(",")[1], "base64");
}
/** The images the device keeps, as it keeps them. */
async function keptImages(
page: Page,
): Promise<{ width: number; height: number; minAlpha: number }[]> {
return page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("element-call-background-images");
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
if (!db.objectStoreNames.contains("backgrounds")) return [];
const kept = await new Promise<{ image: Blob }[]>((resolve, reject) => {
const request = db
.transaction("backgrounds")
.objectStore("backgrounds")
.getAll();
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
db.close();
return Promise.all(
kept.map(async ({ image }) => {
const bitmap = await createImageBitmap(image);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext("2d")!;
context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(
0,
0,
bitmap.width,
bitmap.height,
);
let minAlpha = 255;
for (let i = 3; i < data.length; i += 4)
minAlpha = Math.min(minAlpha, data[i]);
return { width: bitmap.width, height: bitmap.height, minAlpha };
}),
);
});
}
/** The bytes of the one image the device keeps. */
async function keptBytes(page: Page): Promise<Buffer> {
const base64 = await page.evaluate(
async () =>
new Promise<string>((resolve, reject) => {
const open = indexedDB.open("element-call-background-images");
open.onerror = (): void => reject(open.error);
open.onsuccess = (): void => {
const all = open.result
.transaction("backgrounds")
.objectStore("backgrounds")
.getAll();
all.onerror = (): void => reject(all.error);
all.onsuccess = (): void => {
const reader = new FileReader();
reader.onload = (): void =>
resolve((reader.result as string).split(",")[1]);
reader.readAsDataURL((all.result[0] as { image: Blob }).image);
};
};
}),
);
return Buffer.from(base64, "base64");
}
const RED: Colour = [255, 0, 0];
/** A solid red PNG, unlike the camera and the shipped pictures. */
async function redImage(page: Page): Promise<Buffer> {
const dataUrl = await page.evaluate(() => {
const canvas = document.createElement("canvas");
canvas.width = 320;
canvas.height = 180;
const context = canvas.getContext("2d")!;
context.fillStyle = "rgb(255, 0, 0)";
context.fillRect(0, 0, canvas.width, canvas.height);
return canvas.toDataURL("image/png");
});
return Buffer.from(dataUrl.split(",")[1], "base64");
}
/** Adds an image from the camera menu's add tile, leaving the menu open. */
async function addImage(
page: Page,
image: Buffer,
{ name, type } = { name: "mine.png", type: "image/png" },
): Promise<void> {
if (!(await page.getByRole("menu").isVisible()))
await page.getByRole("button", { name: "Camera", exact: true }).click();
const chooser = page.waitForEvent("filechooser");
await page.getByRole("menuitem", { name: "Add image" }).click();
await (await chooser).setFiles({ name, mimeType: type, buffer: image });
}
test.describe("joining with a background chosen", () => {
test("publishes no frame of the room", async ({ browser }) => {
// Two first builds of the pipeline, one of them held back.
test.setTimeout(180_000);
const hostContext = await browser.newContext();
const host = await hostContext.newPage();
await recordIncomingFrames(host);
await host.goto("/");
await SpaHelpers.createCall(host, "Host", "Join with a background", true);
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const inviteLink = await SpaHelpers.getCallInviteLink(host);
await host.keyboard.press("Escape");
const guestContext = await browser.newContext();
const guest = await guestContext.newPage();
await guest.goto(inviteLink);
await guest.getByTestId("joincall_displayName").fill("Guest");
await guest.getByTestId("joincall_joincall").click();
const preview = guest.locator("video").first();
await expect(preview).toBeVisible();
const camera = await cameraColour(preview);
await guest.getByRole("button", { name: "Camera", exact: true }).click();
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
// Were the pipeline built again for the call's track, it would take as
// long as on the slower route. Routing bypasses the cache, so it waits.
let heldBack = 0;
await guest.route("**/*.tflite*", async (route) => {
heldBack += 1;
await new Promise((resolve) => setTimeout(resolve, 15_000));
await route.continue();
});
await guest.keyboard.press("Escape");
await guest.getByTestId("lobby_joinCall").click();
const guestTile = host
.getByTestId("videoTile")
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
// Visible can come before the stream is attached.
let trackId: string | undefined;
await expect
.poll(async () => {
trackId = await guestTile
.locator("video")
.evaluate(
(video: HTMLVideoElement) =>
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
);
return trackId;
})
.toBeDefined();
await expectNoFrameOfTheRoom(host, trackId!, 0, camera, picture);
// The join adopts the lobby's pipeline, so the model is never fetched again.
expect(heldBack).toBe(0);
await hostContext.close();
await guestContext.close();
});
});
test.describe("the first build", () => {
test("survives the camera turned off and on while it runs", async ({
page,
}) => {
test.setTimeout(120_000);
const failures: string[] = [];
page.on("console", (m) => {
if (m.text().includes("Failed to attach video processor"))
failures.push(m.text());
});
await page.goto("/");
await SpaHelpers.createCall(page, "Toggling user", "Toggle while building");
const preview = page.locator("video").first();
const camera = await cameraColour(preview);
const held = await holdBackTheModel(page);
await page.getByRole("button", { name: "Camera", exact: true }).click();
const tile = page.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
const picture = await averageColour(tile.locator("img"));
await page.keyboard.press("Escape");
// New preview tracks, each attached while the first is still building.
for (let i = 0; i < 2; i++) {
await page.getByTestId("incall_videomute").click();
await page.getByTestId("incall_videomute").click();
}
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
expect(failures).toEqual([]);
expect(held()).toBeGreaterThan(0);
});
});
test.describe("turning the camera on with a background chosen", () => {
test("publishes no frame of the room when the camera started off", async ({
browser,
}) => {
test.setTimeout(120_000);
const { host, guest, close } = await hostAndGuest(browser);
const camera = await cameraColour(guest.locator("video").first());
await guest.getByTestId("incall_videomute").click();
await guest.getByTestId("lobby_joinCall").click();
const picture = await chooseBackground(guest);
const held = await holdBackTheModel(guest);
await guest.getByTestId("incall_videomute").click();
const trackId = await incomingTrackId(host);
await expectNoFrameOfTheRoom(host, trackId, 0, camera, picture);
expect(held()).toBeGreaterThan(0);
await close();
});
test("publishes no frame of the room when the camera was turned off", async ({
browser,
}) => {
test.setTimeout(120_000);
const { host, guest, close } = await hostAndGuest(browser);
const camera = await cameraColour(guest.locator("video").first());
await guest.getByTestId("lobby_joinCall").click();
const trackId = await incomingTrackId(host);
await expect
.poll(async () => (await framesOf(host, trackId)).length, {
timeout: 20_000,
})
.toBeGreaterThan(0);
await guest.getByTestId("incall_videomute").click();
const picture = await chooseBackground(guest);
const held = await holdBackTheModel(guest);
const before = (await framesOf(host, trackId)).length;
await guest.getByTestId("incall_videomute").click();
await expectNoFrameOfTheRoom(host, trackId, before, camera, picture);
expect(held()).toBeGreaterThan(0);
await close();
});
});
/** A host in a call, recording what it receives, and a guest in its lobby. */
async function hostAndGuest(browser: Browser): Promise<{
host: Page;
guest: Page;
close: () => Promise<void>;
}> {
const hostContext = await browser.newContext();
const host = await hostContext.newPage();
await recordIncomingFrames(host);
await host.goto("/");
await SpaHelpers.createCall(host, "Host", "Camera with a background", true);
await expect(host.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
const inviteLink = await SpaHelpers.getCallInviteLink(host);
await host.keyboard.press("Escape");
const guestContext = await browser.newContext();
const guest = await guestContext.newPage();
await guest.goto(inviteLink);
await guest.getByTestId("joincall_displayName").fill("Guest");
await guest.getByTestId("joincall_joincall").click();
await expect(guest.locator("video").first()).toBeVisible();
return {
host,
guest,
close: async (): Promise<void> => {
await hostContext.close();
await guestContext.close();
},
};
}
/** Chooses the first shipped picture in the call, and answers its colour. */
async function chooseBackground(guest: Page): Promise<Colour> {
// The lobby has a camera button of its own, so wait for the call's.
await expect(guest.getByTestId("incall_leave")).toBeVisible({
timeout: 30_000,
});
await expect(guest.getByTestId("incall_videomute")).toBeEnabled({
timeout: 10_000,
});
await guest.getByRole("button", { name: "Camera", exact: true }).click();
const tile = guest.getByRole("menuitemradio", { name: "Background 1" });
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
const picture = await averageColour(tile.locator("img"));
await guest.keyboard.press("Escape");
return picture;
}
/** Makes the pipeline's first build as slow as on the slower route. */
/** Answers how often it held the model back, which a check should see. */
async function holdBackTheModel(page: Page): Promise<() => number> {
let held = 0;
// Routing bypasses the cache, so it waits.
await page.route("**/*.tflite*", async (route) => {
held += 1;
await new Promise((resolve) => setTimeout(resolve, 5_000));
await route.continue();
});
return () => held;
}
/** The id of the guest's camera track, as the host receives it. */
async function incomingTrackId(host: Page): Promise<string> {
const guestTile = host
.getByTestId("videoTile")
.filter({ has: host.getByTestId("name_tag").getByText("Guest") });
await expect(guestTile.locator("video")).toBeVisible({ timeout: 60_000 });
// Visible can come before the stream is attached.
let trackId: string | undefined;
await expect
.poll(async () => {
trackId = await guestTile
.locator("video")
.evaluate(
(video: HTMLVideoElement) =>
(video.srcObject as MediaStream | null)?.getVideoTracks()[0]?.id,
);
return trackId;
})
.toBeDefined();
return trackId!;
}
/** Waits for the picture to arrive, then finds no frame of the room since. */
async function expectNoFrameOfTheRoom(
host: Page,
trackId: string,
since: number,
camera: Colour,
picture: Colour,
): Promise<void> {
await expect
.poll(
async () => {
const frames = (await framesOf(host, trackId)).slice(since);
// A black frame, as a track starts, is nearer the picture than the
// camera, but shows neither.
return frames.some(
(f) =>
distance(f, picture) <
Math.min(distance(f, camera), distance(f, [0, 0, 0])),
);
},
{ timeout: 60_000 },
)
.toBe(true);
const frames = (await framesOf(host, trackId)).slice(since);
expect(
frames.filter((f) => distance(f, camera) < distance(f, picture)),
).toEqual([]);
}
/**
* Records the mean colour of every frame of every video track the page
* receives, from the moment it arrives: a tile may attach it later than its
* first frame.
*/
async function recordIncomingFrames(page: Page): Promise<void> {
await page.addInitScript(() => {
const frames: Record<string, [number, number, number][]> = {};
(window as unknown as { receivedFrames: typeof frames }).receivedFrames =
frames;
const record = (track: MediaStreamTrack): void => {
if (track.kind !== "video" || frames[track.id]) return;
const seen: [number, number, number][] = (frames[track.id] = []);
const video = document.createElement("video");
video.muted = true;
video.srcObject = new MediaStream([track]);
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 16;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
const step = (): void => {
context.drawImage(video, 0, 0, 16, 16);
const { data } = context.getImageData(0, 0, 16, 16);
const sum = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
seen.push(sum.map((v) => v / (data.length / 4)) as Colour);
video.requestVideoFrameCallback(step);
};
video.requestVideoFrameCallback(step);
void video.play();
};
const Connection = window.RTCPeerConnection;
window.RTCPeerConnection = class extends Connection {
public constructor(...args: ConstructorParameters<typeof Connection>) {
super(...args);
this.addEventListener("track", (e) => record(e.track));
}
};
});
}
async function framesOf(page: Page, trackId: string): Promise<Colour[]> {
return page.evaluate(
(id) =>
(
window as unknown as {
receivedFrames: Record<string, [number, number, number][]>;
}
).receivedFrames[id] ?? [],
trackId,
);
}
@@ -0,0 +1,85 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator, type Page, test } from "@playwright/test";
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
// so it rightly offers none of them.
test.skip(
({ browserName }) => browserName === "firefox",
"Background effects need WebGL2, which headless Firefox on CI does not have",
);
/**
* Background effects with Element Call as a component in a host page: the
* pipeline's model and WebAssembly have to load from the component's bundle,
* and the portalled menu has to be sized against the call, not the window.
* Driven from the lobby, which builds the same menu and pipeline.
*/
// Sign-in, crypto setup and sync happen twice before anything shows.
test.describe.configure({ timeout: 180_000 });
test("puts a picture on the call's preview", async ({ page }) => {
const { username, roomId } = await createUserAndRoom("effects");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
await resizeContainer(pane.getByTestId("call-container"), {
width: 900,
height: 480,
});
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const preview = pane.locator("video").first();
const camera = await cameraColour(preview);
const tile = await openEffect(page, pane, "Background 1");
const picture = await averageColour(tile.locator("img"));
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
await expectWearing(preview, picture, camera);
});
test("keeps every effect reachable in a small container", async ({ page }) => {
const { username, roomId } = await createUserAndRoom("effectsreach");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 400, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const last = await openEffect(page, pane, "Background 2");
const list = page.locator("[role='menu'] div[role='none']").first();
expect((await list.boundingBox())!.height).toBeLessThanOrEqual(
(await container.boundingBox())!.height,
);
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport({ ratio: 1 });
await last.click();
await expect(last).toHaveAttribute("aria-checked", "true");
});
/** Opens a component's camera menu and returns one of its effects. */
async function openEffect(
page: Page,
pane: Locator,
name: string,
): Promise<Locator> {
await pane
.getByRole("button", { name: "Camera", exact: true })
.click({ timeout: 60_000 });
// Portalled out of the pane, so found on the page.
return page
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name });
}
+14 -15
View File
@@ -125,25 +125,24 @@ test("leaves the host's own page unstyled", async ({ page }) => {
timeout: 60_000,
});
// Element Call's stylesheet is written for a page of its own: normalize.css
// gives `html` a line height, Compound gives `body` its font and feature
// settings, and the design tokens live on `:root`. None of that may reach the
// host's document — the harness sets none of these itself, so anything other
// than the browser's defaults here came from us.
// Element Call's page-wide CSS (normalize.css and the tokens in base.css)
// should not reach the host's page. Compound CSS that gets loaded to EW (host)
// on the other hand should impact the host. So probe with styles only Element Call sets:
// - A fresh <sub> keeps the browser's `vertical-align: sub` unless
// normalize.css leaked; it turns that into `baseline`.
// - `--font-scale` is Element Call's own token, so `:root` must not have it.
const host = await page.evaluate(() => {
const html = getComputedStyle(document.documentElement);
const body = getComputedStyle(document.body);
const sub = document.body.appendChild(document.createElement("sub"));
const verticalAlign = getComputedStyle(sub).verticalAlign;
sub.remove();
return {
lineHeight: html.lineHeight,
fontFeatureSettings: body.fontFeatureSettings,
token: html.getPropertyValue("--cpd-color-text-primary"),
verticalAlign,
token: getComputedStyle(document.documentElement).getPropertyValue(
"--font-scale",
),
};
});
expect(host).toEqual({
lineHeight: "normal",
fontFeatureSettings: "normal",
token: "",
});
expect(host).toEqual({ verticalAlign: "sub", token: "" });
// While inside the container, the same rules do apply
const root = panes.first().locator("[data-element-call-root]");
+4 -64
View File
@@ -29,47 +29,10 @@ export interface MyFixtures {
addUser: (username: string, host: string) => Promise<UserBaseFixture>;
}
// Minimal config.json for the local element-web instance
const CONFIG_JSON = {
default_server_config: {
"m.homeserver": {
base_url: "https://synapse.m.localhost",
server_name: "synapse.m.localhost",
},
},
element_call: {
participant_limit: 8,
brand: "Element Call",
},
// The default language is set here for test consistency
setting_defaults: {
language: "en-GB",
feature_group_calls: true,
},
// the location tests want a map style url.
map_style_url:
"https://api.maptiler.com/maps/streets/style.json?key=fU3vlMsMn4Jb6dnEIFsx",
features: {
// We don't want to go through the feature announcement during the e2e test
feature_release_announcement: false,
feature_element_call_video_rooms: true,
feature_video_rooms: true,
feature_group_calls: true,
},
};
export const widgetTest = test.extend<MyFixtures>({
// allow per-test override: `widgetTest.use({ callType: "dm" })`
callType: ["room", { option: true }],
asWidget: async ({ browser, context, callType }, pUse) => {
await context.route(`http://localhost:8081/config.json*`, async (route) => {
await route.fulfill({ json: CONFIG_JSON });
});
asWidget: async ({ browser, callType }, pUse) => {
const brooksDisplayName = `brooks_${Date.now()}`;
const whistlerDisplayName = `whistler_${Date.now()}`;
@@ -109,15 +72,7 @@ export const widgetTest = test.extend<MyFixtures>({
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await expect(
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
).toBeVisible();
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
await TestHelpers.openRoomInvite(ewPage2, "Welcome Room");
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
@@ -125,17 +80,7 @@ export const widgetTest = test.extend<MyFixtures>({
.getByRole("heading", { name: "Welcome Room" }),
).toBeVisible();
} else if (callType === "dm") {
await TestHelpers.closeReleaseAnnouncement(
ewPage1,
"Introducing Sections",
);
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.click();
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
await TestHelpers.chooseFromRoomListNewMenu(ewPage1, "Start chat");
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
await ewPage1.getByRole("button", { name: "Go" }).click();
@@ -157,12 +102,7 @@ export const widgetTest = test.extend<MyFixtures>({
).toBeVisible();
// Accept the DM invite from brooks
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
await TestHelpers.openRoomInvite(ewPage2, brooksDisplayName);
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
+55
View File
@@ -0,0 +1,55 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator } from "@playwright/test";
export type Colour = [number, number, number];
/** The camera's colour, once the preview is showing it rather than nothing. */
export async function cameraColour(preview: Locator): Promise<Colour> {
await expect
.poll(async () => Math.max(...(await averageColour(preview))))
.toBeGreaterThan(40);
return averageColour(preview);
}
/** The mean colour of a video's current frame or an image, drawn small. */
export async function averageColour(element: Locator): Promise<Colour> {
return element.evaluate(
async (source: HTMLVideoElement | HTMLImageElement) => {
if (source instanceof HTMLImageElement) await source.decode();
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 32;
const context = canvas.getContext("2d")!;
context.drawImage(source, 0, 0, 32, 32);
const { data } = context.getImageData(0, 0, 32, 32);
const sum = [0, 0, 0];
for (let i = 0; i < data.length; i += 4)
for (let c = 0; c < 3; c++) sum[c] += data[i + c];
const pixels = data.length / 4;
return sum.map((s) => s / pixels) as Colour;
},
);
}
export function distance(a: Colour, b: Colour): number {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
/** Waits until a preview shows a picture rather than the camera. */
export async function expectWearing(
preview: Locator,
picture: Colour,
camera: Colour,
): Promise<void> {
// Long enough for the first effect of a session to be built.
await expect
.poll(async () => distance(await averageColour(preview), picture), {
timeout: 60_000,
})
.toBeLessThan(distance(camera, picture) / 2);
}
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { TestHelpers } from "./test-helpers.ts";
import { averageColour, cameraColour, expectWearing } from "../utils/colour.ts";
// Background effects need WebGL2, and headless Firefox on a CI runner has none,
// so it rightly offers none of them.
test.skip(
({ browserName }) => browserName === "firefox",
"Background effects need WebGL2, which headless Firefox on CI does not have",
);
widgetTest(
"puts a picture on the preview inside Element Web",
async ({ asWidget }) => {
test.slow();
const { brooks } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
const frame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("lobby_joinCall")).toBeVisible();
const preview = frame.locator("video").first();
const camera = await cameraColour(preview);
await frame.getByRole("button", { name: "Camera", exact: true }).click();
const tile = frame
.getByRole("group", { name: "Background effects" })
.getByRole("menuitemradio", { name: "Background 1" });
const picture = await averageColour(tile.locator("img"));
await tile.click();
await expect(tile).toHaveAttribute("aria-checked", "true");
await expectWearing(preview, picture, camera);
},
);
+72 -36
View File
@@ -153,29 +153,40 @@ export class TestHelpers {
return { page, clientHandle, mxId };
}
// Dismisses any toasts that appear on startup, such as "Failed to load service worker" or "Back up your chats".
// Toast can be stacked, and only the top one can be dismiss, so just look at what is on top and
// dismiss (if part of expected toats)
// Dismisses the toasts a fresh device gets after sign-in.
//
// Toasts stack and only the top one can be dismissed, so look
// at what is on top each time.
public static async dismissStartupToasts(page: Page): Promise<void> {
const expectedToasts = [
const expectedToasts: {
title: string;
button: string;
confirm?: string;
}[] = [
{ title: "Failed to load service worker", button: "OK" },
{ title: "Verify this device", button: "Later" },
{ title: "Back up your chats", button: "Not now" },
{ title: "Turn on key storage", button: "Dismiss" },
{
title: "Turn on key storage",
button: "Dismiss",
confirm: "Yes, dismiss",
},
{ title: "Notifications", button: "Dismiss" },
{ title: "Element does not support this browser", button: "Dismiss" },
];
const toast = page.locator(".mx_Toast_toast");
const toastTitle = toast.locator(".mx_Toast_title h2");
// eslint-disable-next-line no-constant-condition
while (true) {
try {
await toast.waitFor({ state: "visible", timeout: 700 });
const title = await toast.locator(".mx_Toast_title h2").textContent();
const title = await toastTitle.textContent();
if (!title) break;
// Find the matching toast config
const toastConfig = expectedToasts.find((t) =>
title?.includes(t.title),
);
const toastConfig = expectedToasts.find((t) => title.includes(t.title));
if (toastConfig) {
// Bounded so that a renamed button leaves the toast in place and the
@@ -183,6 +194,16 @@ export class TestHelpers {
await toast
.getByRole("button", { name: toastConfig.button })
.click({ timeout: 5000 });
if (toastConfig.confirm) {
const dialog = page.getByRole("dialog");
await dialog
.getByRole("button", { name: toastConfig.confirm })
.click({ timeout: 5000 });
await expect(dialog).toBeHidden({ timeout: 5000 });
}
// The same element shows the next toast a moment after the click, so
// reading it straight away acts on the dismissed one a second time
await expect(toastTitle).not.toHaveText(title, { timeout: 5000 });
} else {
// Unknown toast. We don't want to act on unknown toasts
break;
@@ -194,20 +215,6 @@ export class TestHelpers {
}
}
public static async closeReleaseAnnouncement(
page: Page,
name: string,
): Promise<void> {
try {
await page
.getByRole("dialog", { name })
.getByRole("button", { name: "OK" })
.click({ timeout: 2000 });
} catch {
// Announcement not shown; nothing to do
}
}
public static async expandAllSections(page: Page): Promise<void> {
try {
await page
@@ -223,16 +230,7 @@ export class TestHelpers {
page: Page,
andInvite: string[] = [],
): Promise<void> {
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click({
timeout: 5000,
});
await TestHelpers.chooseFromRoomListNewMenu(page, "New Room");
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByText("You created this room.")).toBeVisible({
@@ -259,6 +257,24 @@ export class TestHelpers {
}
}
/**
* Opens the room list's "New" menu and picks the given item.
*
* Driven by keyboard, as a toast arriving late sits over the button and
* swallows clicks.
*/
public static async chooseFromRoomListNewMenu(
page: Page,
itemName: string,
): Promise<void> {
await TestHelpers.dismissStartupToasts(page);
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New", exact: true })
.press("Enter");
await page.getByRole("menuitem", { name: itemName }).press("Enter");
}
/**
* Accepts a room invite using the room name.
* Locatest the invite in the room list.
@@ -268,9 +284,7 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
await TestHelpers.openRoomInvite(page, roomName);
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
@@ -376,6 +390,28 @@ export class TestHelpers {
return page.locator(`[aria-label^="Open room ${roomName}"]`);
}
/**
* Opens a pending invite from the room list by its room name.
*
* Element Web collapses the Invites section every time it appears, so it is
* expanded here once the invite has arrived.
*/
public static async openRoomInvite(
page: Page,
roomName: string,
): Promise<void> {
const invitesSection = page.getByRole("button", {
name: "Toggle Invites section",
});
await expect(invitesSection).toBeVisible({ timeout: 10000 });
// Toasts sit over the top of the room list, where this header is.
await TestHelpers.dismissStartupToasts(page);
if ((await invitesSection.getAttribute("aria-expanded")) !== "true") {
await invitesSection.click();
}
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page
+70 -133
View File
@@ -8,7 +8,7 @@ overrides:
'@livekit/components-core>rxjs': ^7.8.1
'@livekit/track-processors>@mediapipe/tasks-vision': ^0.10.18
tar: ^7.5.11
minimatch: ^10.2.3
minimatch: ^11.0.0
glob: ^10.5.0
qs: ^6.15.2
js-yaml: ^4.2.0
@@ -40,9 +40,6 @@ importers:
'@livekit/components-core':
specifier: ^0.12.0
version: 0.12.15(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-react':
specifier: ^2.0.0
version: 2.9.24(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
'@livekit/track-processors':
specifier: ^0.7.1
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))
@@ -66,7 +63,7 @@ importers:
version: 10.1.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@rolldown/plugin-babel':
specifier: ^0.2.3
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
version: 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@sentry/react':
specifier: ^8.0.0
version: 8.55.2(react@19.2.8)
@@ -134,11 +131,11 @@ importers:
specifier: ^11.0.0
version: 11.3.1(@types/react@19.2.18)(react@19.2.8)
'@vector-im/compound-web':
specifier: ^10.4.0
version: 10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
specifier: ^10.5.0
version: 10.5.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
version: 6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@vitest/browser-playwright':
specifier: ^4.1.5
version: 4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)
@@ -163,6 +160,9 @@ importers:
eslint-plugin-storybook:
specifier: ^10.3.6
version: 10.6.2(eslint@8.57.1(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
fake-indexeddb:
specifier: ^6.2.5
version: 6.2.5
fetch-mock:
specifier: 11.1.5
version: 11.1.5(node-fetch@2.7.0)
@@ -191,8 +191,8 @@ importers:
specifier: ^1.9.1
version: 1.9.2
matrix-js-sdk:
specifier: github:matrix-org/matrix-js-sdk#3f05dc4d104a3dfc753c5e3549a18c74fab4b076
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076
specifier: github:matrix-org/matrix-js-sdk#a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01
matrix-widget-api:
specifier: ^1.18.0
version: 1.20.0
@@ -397,8 +397,8 @@ packages:
resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
engines: {node: '>=6.9.0'}
'@babel/runtime@8.0.0':
resolution: {integrity: sha512-sL6cvO2IfkSu/iU+zs2S/w01B7A8V7suXSIKEN4hPFFdZoiPGxrj5pAG0lCaqLWiEIrjKzdznIWuaLcxPR53qw==}
'@babel/runtime@8.0.8':
resolution: {integrity: sha512-dEu8nGlRqjsJsvviUUoSMfcRHpwomCiRlXsc2mPfJ79+ffTpDm+JtfUtTMzUrqCApHQnCsVPD3gT1ZT8RgBRCQ==}
'@babel/template@7.29.7':
resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
@@ -408,10 +408,6 @@ packages:
resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
engines: {node: '>=6.9.0'}
'@babel/traverse@7.29.8':
resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==}
engines: {node: '>=6.9.0'}
'@babel/types@7.29.11':
resolution: {integrity: sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==}
engines: {node: '>=6.9.0'}
@@ -1149,6 +1145,9 @@ packages:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
'@isaacs/nonrecursive-glob-match@1.0.3':
resolution: {integrity: sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0':
resolution: {integrity: sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ==}
peerDependencies:
@@ -1184,19 +1183,6 @@ packages:
livekit-client: ^2.20.1
tslib: ^2.6.2
'@livekit/components-react@2.9.24':
resolution: {integrity: sha512-qw5Oy1EfPg1f/xrKYrFielSrmtxY8BZ9mDt6299G5aikotQIztmldBfTNosrVS7VSNit7qeYfOFSyKaML20W6Q==}
engines: {node: '>=18'}
peerDependencies:
'@livekit/krisp-noise-filter': ^0.2.12 || ^0.3.0 || ^0.4.0
livekit-client: ^2.20.1
react: '>=18'
react-dom: '>=18'
tslib: ^2.6.2
peerDependenciesMeta:
'@livekit/krisp-noise-filter':
optional: true
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
@@ -3314,8 +3300,8 @@ packages:
react:
optional: true
'@vector-im/compound-web@10.4.0':
resolution: {integrity: sha512-Tt5hsNK7ulhGdRuy9lzSm5C+T8M/4O4kLDFLkaU+Hi1kVWj6tj/5awW+U+GvtpWRh5llbHN2HRCh79+gQhek+A==}
'@vector-im/compound-web@10.5.0':
resolution: {integrity: sha512-dcxIAKtTKBLKz2Y83mNkXyYx3shtSs80KvdGKN5ryFvo6OnVOhHPEHiQNbaPQ0EDjc6xStjY9+oz2Fh4c+kniQ==}
peerDependencies:
'@fontsource/inconsolata': ^5
'@fontsource/inter': ^5
@@ -3421,11 +3407,6 @@ packages:
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.18.0:
resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==}
engines: {node: '>=0.4.0'}
hasBin: true
acorn@8.19.0:
resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==}
engines: {node: '>=0.4.0'}
@@ -3552,8 +3533,8 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
brace-expansion@5.0.9:
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
brace-expansion@5.0.12:
resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
engines: {node: 20 || >=22}
braces@3.0.3:
@@ -3697,10 +3678,6 @@ packages:
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -4077,6 +4054,10 @@ packages:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fake-indexeddb@6.2.5:
resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==}
engines: {node: '>=18'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -4536,9 +4517,6 @@ packages:
jose@6.2.13:
resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==}
jose@6.2.9:
resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==}
js-tokens@10.0.0:
resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==}
@@ -4694,9 +4672,6 @@ packages:
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -4759,9 +4734,9 @@ packages:
matrix-events-sdk@0.0.1:
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
resolution: {gitHosted: true, integrity: sha512-Ly8eS7fNNKDo9KmPvTkE49KvD8iMUmrVWJMNERNuxc8UksMLMG36NLz3fs8HytdwrB7AKPfyVo392VZsxILDIg==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076}
version: 44.0.0
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
resolution: {gitHosted: true, integrity: sha512-ZAhn/zB44MgrXqUzi9ffSFcmZY3+2nx7bEgfEJR7chYDFyT/XmlmfCJx7Ar4rOTAYZJRVnMxrEwiEHGDapUi0Q==, tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01}
version: 45.0.0
engines: {node: '>=22.0.0'}
matrix-widget-api@1.20.0:
@@ -4796,9 +4771,9 @@ packages:
minimalistic-crypto-utils@1.0.1:
resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==}
minimatch@10.2.6:
resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
engines: {node: 18 || 20 || >=22}
minimatch@11.0.4:
resolution: {integrity: sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==}
engines: {node: 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -5988,12 +5963,6 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
usehooks-ts@3.1.1:
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
@@ -6357,10 +6326,10 @@ snapshots:
'@babel/helper-compilation-targets': 7.29.7
'@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)
'@babel/helpers': 7.29.7
'@babel/parser': 7.29.8
'@babel/parser': 7.29.11
'@babel/template': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@babel/traverse': 7.29.10(supports-color@7.2.0)
'@babel/types': 7.29.11
'@jridgewell/remapping': 2.3.5
convert-source-map: 2.0.0
debug: 4.4.3(supports-color@7.2.0)
@@ -6372,8 +6341,8 @@ snapshots:
'@babel/generator@7.29.8':
dependencies:
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/types': 7.29.11
'@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31
jsesc: 3.1.0
@@ -6390,8 +6359,8 @@ snapshots:
'@babel/helper-module-imports@7.29.7(supports-color@7.2.0)':
dependencies:
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/types': 7.29.8
'@babel/traverse': 7.29.10(supports-color@7.2.0)
'@babel/types': 7.29.11
transitivePeerDependencies:
- supports-color
@@ -6400,7 +6369,7 @@ snapshots:
'@babel/core': 7.29.7(supports-color@7.2.0)
'@babel/helper-module-imports': 7.29.7(supports-color@7.2.0)
'@babel/helper-validator-identifier': 7.29.7
'@babel/traverse': 7.29.8(supports-color@7.2.0)
'@babel/traverse': 7.29.10(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -6417,7 +6386,7 @@ snapshots:
'@babel/helpers@7.29.7':
dependencies:
'@babel/template': 7.29.7
'@babel/types': 7.29.8
'@babel/types': 7.29.11
'@babel/parser@7.29.11':
dependencies:
@@ -6433,13 +6402,13 @@ snapshots:
'@babel/runtime@7.29.7': {}
'@babel/runtime@8.0.0': {}
'@babel/runtime@8.0.8': {}
'@babel/template@7.29.7':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/parser': 7.29.8
'@babel/types': 7.29.8
'@babel/parser': 7.29.11
'@babel/types': 7.29.11
'@babel/traverse@7.29.10(supports-color@7.2.0)':
dependencies:
@@ -6448,19 +6417,7 @@ snapshots:
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.11
'@babel/template': 7.29.7
'@babel/types': 7.29.8
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
'@babel/traverse@7.29.8(supports-color@7.2.0)':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/generator': 7.29.8
'@babel/helper-globals': 7.29.7
'@babel/parser': 7.29.8
'@babel/template': 7.29.7
'@babel/types': 7.29.8
'@babel/types': 7.29.11
debug: 4.4.3(supports-color@7.2.0)
transitivePeerDependencies:
- supports-color
@@ -6948,7 +6905,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.3.1
minimatch: 10.2.6
minimatch: 11.0.4
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -7027,7 +6984,7 @@ snapshots:
dependencies:
'@humanwhocodes/object-schema': 2.0.3
debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6
minimatch: 11.0.4
transitivePeerDependencies:
- supports-color
@@ -7163,6 +7120,8 @@ snapshots:
wrap-ansi: 8.1.0
wrap-ansi-cjs: wrap-ansi@7.0.0
'@isaacs/nonrecursive-glob-match@1.0.3': {}
'@joshwooding/vite-plugin-react-docgen-typescript@0.7.0(typescript@5.9.3)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
glob: 10.5.0
@@ -7203,18 +7162,6 @@ snapshots:
rxjs: 7.8.2
tslib: 2.8.1
'@livekit/components-react@2.9.24(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.12.15(livekit-client@2.23.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
clsx: 2.1.1
events: 3.3.0
jose: 6.2.9
livekit-client: 2.23.0(@types/dom-mediacapture-record@1.0.22)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
usehooks-ts: 3.1.1(react@19.2.8)
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.52.1':
@@ -8167,13 +8114,13 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.2.15':
optional: true
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
'@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.29.7(supports-color@7.2.0)
picomatch: 4.0.4
rolldown: 1.2.15
optionalDependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 8.0.8
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
'@rolldown/pluginutils@1.0.1': {}
@@ -8834,7 +8781,7 @@ snapshots:
'@typescript-eslint/types': 8.69.0
'@typescript-eslint/visitor-keys': 8.69.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6
minimatch: 11.0.4
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -8849,7 +8796,7 @@ snapshots:
'@typescript-eslint/types': 8.72.0
'@typescript-eslint/visitor-keys': 8.72.0
debug: 4.4.3(supports-color@7.2.0)
minimatch: 10.2.6
minimatch: 11.0.4
semver: 7.8.5
tinyglobby: 0.2.17
ts-api-utils: 2.5.0(typescript@5.9.3)
@@ -8903,7 +8850,7 @@ snapshots:
'@types/react': 19.2.18
react: 19.2.8
'@vector-im/compound-web@10.4.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
'@vector-im/compound-web@10.5.0(@fontsource/inconsolata@5.3.0)(@fontsource/inter@5.3.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(@vector-im/compound-design-tokens@11.3.1(@types/react@19.2.18)(react@19.2.8))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)':
dependencies:
'@floating-ui/react': 0.27.20(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)
@@ -8925,12 +8872,12 @@ snapshots:
- '@types/react-dom'
- react-dom
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
'@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0)
optionalDependencies:
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.0)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7(supports-color@7.2.0))(@babel/runtime@8.0.8)(rolldown@1.2.15)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
babel-plugin-react-compiler: 1.0.0
'@vitest/browser-playwright@4.1.11(playwright@1.62.1)(vite@8.2.4(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))(vitest@4.1.11)':
@@ -9055,14 +9002,12 @@ snapshots:
'@webcontainer/env@1.1.1': {}
acorn-jsx@5.3.2(acorn@8.18.0):
acorn-jsx@5.3.2(acorn@8.19.0):
dependencies:
acorn: 8.18.0
acorn: 8.19.0
acorn@8.16.0: {}
acorn@8.18.0: {}
acorn@8.19.0: {}
agent-base@6.0.2(supports-color@7.2.0):
@@ -9176,7 +9121,7 @@ snapshots:
boolbase@1.0.0: {}
brace-expansion@5.0.9:
brace-expansion@5.0.12:
dependencies:
balanced-match: 4.0.4
@@ -9354,8 +9299,6 @@ snapshots:
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -9723,7 +9666,7 @@ snapshots:
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
minimatch: 10.2.6
minimatch: 11.0.4
natural-compare: 1.4.0
optionator: 0.9.4
strip-ansi: 6.0.1
@@ -9733,8 +9676,8 @@ snapshots:
espree@9.6.1:
dependencies:
acorn: 8.18.0
acorn-jsx: 5.3.2(acorn@8.18.0)
acorn: 8.19.0
acorn-jsx: 5.3.2(acorn@8.19.0)
eslint-visitor-keys: 3.4.3
esprima@4.0.1: {}
@@ -9781,6 +9724,8 @@ snapshots:
expect-type@1.4.0: {}
fake-indexeddb@6.2.5: {}
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -9841,7 +9786,7 @@ snapshots:
filelist@1.0.6:
dependencies:
minimatch: 10.2.6
minimatch: 11.0.4
fill-range@7.1.1:
dependencies:
@@ -9941,7 +9886,7 @@ snapshots:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
minimatch: 10.2.6
minimatch: 11.0.4
minipass: 7.1.3
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
@@ -10066,7 +10011,7 @@ snapshots:
jiti: 2.7.0
jsonc-parser: 3.3.1
magic-string: 1.2.3
minimatch: 10.2.6
minimatch: 11.0.4
ora: 9.4.1
react: 19.2.8
react-i18next: 17.0.13(i18next@26.4.1(typescript@5.9.3))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(typescript@5.9.3)
@@ -10247,8 +10192,6 @@ snapshots:
jose@6.2.13: {}
jose@6.2.9: {}
js-tokens@10.0.0: {}
js-tokens@4.0.0: {}
@@ -10409,8 +10352,6 @@ snapshots:
lodash-es@4.18.1: {}
lodash.debounce@4.0.8: {}
lodash.merge@4.6.2: {}
log-symbols@7.0.1:
@@ -10468,9 +10409,9 @@ snapshots:
matrix-events-sdk@0.0.1: {}
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3f05dc4d104a3dfc753c5e3549a18c74fab4b076:
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/a9ef09fbf2f1644c8663d0aeaf6a921cd16e7b01:
dependencies:
'@babel/runtime': 8.0.0
'@babel/runtime': 8.0.8
'@matrix-org/matrix-sdk-crypto-wasm': 18.9.0
another-json: 0.2.0
bs58: 6.0.0
@@ -10513,9 +10454,10 @@ snapshots:
minimalistic-crypto-utils@1.0.1: {}
minimatch@10.2.6:
minimatch@11.0.4:
dependencies:
brace-expansion: 5.0.9
'@isaacs/nonrecursive-glob-match': 1.0.3
brace-expansion: 5.0.12
minimist@1.2.8: {}
@@ -11701,7 +11643,7 @@ snapshots:
terser@5.46.1:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.18.0
acorn: 8.19.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -11869,11 +11811,6 @@ snapshots:
dependencies:
react: 19.2.8
usehooks-ts@3.1.1(react@19.2.8):
dependencies:
lodash.debounce: 4.0.8
react: 19.2.8
util-deprecate@1.0.2: {}
util@0.12.5:
+1 -1
View File
@@ -27,7 +27,7 @@ overrides:
# Security fixes:
# - https://github.com/advisories/GHSA-7r86-cg39-jmmj
# - https://github.com/advisories/GHSA-23c5-xmqv-rm74
"minimatch": "^10.2.3"
"minimatch": "^11.0.0"
# Security fix: https://github.com/element-hq/element-call/security/dependabot/109
"glob": "^10.5.0"
# Security fixes:
+3 -2
View File
@@ -44,8 +44,9 @@ const TARGETS = [
{
config: "vite-component.config.ts",
what: "the component build",
sources: ["src", "component"],
excluded: ["component/dev"],
// `sdk` too: the build resolves `@element-hq/matrixrtc-sdk` to its source
sources: ["src", "component", "sdk"],
excluded: ["component/dev", "sdk/dev"],
mustBeExternal: [
"react",
"react-dom",
+10 -5
View File
@@ -126,10 +126,15 @@ probe that decides whether the service that issued the token can take over the
delayed leave, which picks the delayed leave timings the join uses. The join
waits for the publisher to hold its token, so that the LiveKit room exists
before a subscriber, who lacks the permission to create it, asks for a token
of its own. Until `join()` the tracks exist but their upstream is paused: a host
can show a preview without anyone hearing it. The upstream flows exactly while the
membership is `joined`: a sync outage pauses it, and `leave()` pauses it for good
and sends the leave.
of its own. The tracks are created once the membership is `joined`, with their
upstream paused until then. A lobby that wants them earlier captures them with
`createLocalMedia`, on the browser's devices alone so that any backend fits, and
hands the media to `join()` as `publish`: the tracks are moved, not shared — they
leave the lobby's `tracks$`, their objects are finished, and the backend
publishes the very same device tracks and the processor already running on them,
so nothing is captured or initialised again at the join. The upstream flows
exactly while the membership is `joined`: a sync outage pauses it, and `leave()`
pauses it for good and sends the leave.
## Vocabulary
@@ -538,7 +543,7 @@ backend is handed the whole session's view and gives back media:
type MemberMediaFields = Pick<RTCMember, "tracks$" | "encryptionError$">;
export interface MediaBackend {
readonly transportType: string; // "livekit"
readonly transportTypes: readonly string[]; // ["m.livekit", "livekit"]
/** Authenticate with the transport the client found; the probe for delegation runs here. */
prepareLocalTransport(
transport: Transport,
+7 -2
View File
@@ -61,8 +61,13 @@ export {
createLivekitBackend,
type LivekitBackendOptions,
} from "./src/media-backend/livekit/LivekitBackend";
// For a host that creates LiveKit tracks itself, such as a preview before joining
export { convertToLivekitProcessor } from "./src/media-backend/livekit/videoProcessor";
// Tracks for a lobby, captured ahead of the join and handed to it
export { createLocalMedia } from "./src/LocalMedia";
// For a host whose processor is a livekit-client one, such as @livekit/track-processors
export {
fromLivekitProcessor,
toLivekitProcessor,
} from "./src/media-backend/livekit/videoProcessor";
// For a developer panel to check a transport before using it
export {
getSFUConfig as authenticateWithTransport,
+237
View File
@@ -0,0 +1,237 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeAll, describe, expect, it, vi } from "vitest";
import { createLocalMedia, type GetUserMedia } from "./LocalMedia";
import { type LocalVideoMediaTrack, type VideoProcessor } from "./media-api";
describe("createLocalMedia", () => {
it("opens the microphone and the camera in one request", async () => {
const getUserMedia = fakeGetUserMedia();
const media = createLocalMedia(getUserMedia);
const tracks = await media.capture([
{ source: "microphone", deviceId: "mic" },
{
source: "camera",
capture: { resolution: { width: 640, height: 480 } },
},
]);
expect(getUserMedia).toHaveBeenCalledExactlyOnceWith({
audio: { deviceId: "mic" },
video: { width: 640, height: 480, frameRate: undefined },
});
expect(tracks.map((t) => t.source)).toEqual(["microphone", "camera"]);
expect(media.tracks$.value).toEqual(tracks);
expect(media.requests.map((r) => r.source)).toEqual([
"microphone",
"camera",
]);
});
it("joins an identical capture still in flight rather than opening the devices again", async () => {
const getUserMedia = fakeGetUserMedia();
const media = createLocalMedia(getUserMedia);
const requests = [{ source: "camera" as const }];
const [first, second] = await Promise.all([
media.capture(requests),
media.capture(requests),
]);
expect(getUserMedia).toHaveBeenCalledOnce();
expect(second).toEqual(first);
expect(media.tracks$.value).toEqual(first);
});
it("shows the processor's output, initialised once", async () => {
const processor = fakeProcessor();
const media = createLocalMedia(fakeGetUserMedia());
const [camera] = await media.capture([{ source: "camera", processor }]);
const element = document.createElement("video");
camera.attach(element);
expect(processor.init).toHaveBeenCalledOnce();
expect(tracksOn(element)).toEqual([processor.output]);
});
it("hands the tracks over as they are: no second capture, no second init", async () => {
const getUserMedia = fakeGetUserMedia();
const processor = fakeProcessor();
const media = createLocalMedia(getUserMedia);
const [microphone, camera] = await media.capture([
{ source: "microphone" },
{ source: "camera", processor },
]);
const taken = media.takeTracks();
expect(taken.get("microphone")?.track).toBe(trackBehind(microphone));
expect(taken.get("camera")).toEqual({
track: trackBehind(camera),
constraints: {},
muted: false,
processor: { processor, processedTrack: processor.output },
});
expect(media.tracks$.value).toEqual([]);
expect(getUserMedia).toHaveBeenCalledOnce();
expect(processor.init).toHaveBeenCalledOnce();
expect(processor.destroy).not.toHaveBeenCalled();
media.stop();
expect(taken.get("camera")!.track.stop).not.toHaveBeenCalled();
});
it("finishes a handed-over track", async () => {
const media = createLocalMedia(fakeGetUserMedia());
const [camera] = await media.capture([{ source: "camera" }]);
const element = document.createElement("video");
camera.attach(element);
media.takeTracks();
expect(element.srcObject).toBeNull();
await expect(camera.setEnabled(false)).rejects.toThrow("handed over");
expect(() => camera.attach(element)).toThrow("handed over");
});
it("mutes by disabling the track and hands the mute over", async () => {
const media = createLocalMedia(fakeGetUserMedia());
const [microphone] = await media.capture([{ source: "microphone" }]);
await expect(microphone.setEnabled(false)).resolves.toBe(false);
expect(microphone.muted$.value).toBe(true);
expect(trackBehind(microphone).enabled).toBe(false);
expect(media.takeTracks().get("microphone")?.muted).toBe(true);
});
it("switches the device by opening it anew", async () => {
const getUserMedia = fakeGetUserMedia();
const processor = fakeProcessor();
const media = createLocalMedia(getUserMedia);
const [camera] = await media.capture([{ source: "camera", processor }]);
const first = trackBehind(camera);
const element = document.createElement("video");
camera.attach(element);
await camera.setDevice("other");
expect(getUserMedia).toHaveBeenLastCalledWith({
video: { deviceId: "other" },
});
expect(first.stop).toHaveBeenCalled();
expect(processor.restart).toHaveBeenCalledOnce();
const [restartedOn, input] = vi.mocked(processor.restart).mock.calls[0];
expect(restartedOn).not.toBe(first);
expect(input).toBe(vi.mocked(processor.init).mock.calls[0][1]);
expect(tracksOn(element)).toEqual([processor.output]);
expect(media.takeTracks().get("camera")?.constraints).toEqual({
deviceId: "other",
});
});
it("reports the facing mode and switches it", async () => {
const getUserMedia = fakeGetUserMedia({ facingMode: "user" });
const media = createLocalMedia(getUserMedia);
const [camera] = (await media.capture([{ source: "camera" }])) as [
LocalVideoMediaTrack,
];
expect(camera.facingMode$.value).toBe("user");
await camera.switchFacingMode();
expect(getUserMedia).toHaveBeenLastCalledWith({
video: { facingMode: "environment" },
});
});
it("releases one device and stops the rest", async () => {
const media = createLocalMedia(fakeGetUserMedia());
const [microphone, camera] = await media.capture([
{ source: "microphone" },
{ source: "camera" },
]);
media.release("camera");
expect(trackBehind(camera).stop).toHaveBeenCalled();
expect(media.tracks$.value).toEqual([microphone]);
media.stop();
expect(trackBehind(microphone).stop).toHaveBeenCalled();
expect(media.tracks$.value).toEqual([]);
});
it("refuses a screen share", async () => {
const media = createLocalMedia(fakeGetUserMedia());
await expect(media.capture([{ source: "screenShare" }])).rejects.toThrow(
"screen share",
);
});
});
class FakeMediaStream {
public constructor(private readonly tracks: MediaStreamTrack[]) {}
public getTracks(): MediaStreamTrack[] {
return this.tracks;
}
}
beforeAll(() => {
globalThis.MediaStream = FakeMediaStream as unknown as typeof MediaStream;
});
let nextId = 0;
const tracksById = new Map<string, MediaStreamTrack>();
function fakeTrack(
kind: "audio" | "video",
settings: MediaTrackSettings = {},
): MediaStreamTrack {
const track = {
kind,
id: `${kind}-${nextId++}`,
enabled: true,
stop: vi.fn(),
getSettings: () => settings,
} as unknown as MediaStreamTrack;
tracksById.set(track.id, track);
return track;
}
function fakeGetUserMedia(videoSettings?: MediaTrackSettings): GetUserMedia {
return vi.fn(async (constraints: MediaStreamConstraints) => {
const tracks: MediaStreamTrack[] = [];
if (constraints.audio) tracks.push(fakeTrack("audio"));
if (constraints.video) tracks.push(fakeTrack("video", videoSettings));
return Promise.resolve(
new FakeMediaStream(tracks) as unknown as MediaStream,
);
});
}
function fakeProcessor(): VideoProcessor & { output: MediaStreamTrack } {
const output = fakeTrack("video");
return {
name: "blur",
output,
init: vi.fn(async () => Promise.resolve(output)),
restart: vi.fn(async () => Promise.resolve(output)),
destroy: vi.fn(async () => Promise.resolve()),
};
}
/** The device's track, which a media track was created on and keeps the id of. */
function trackBehind(mediaTrack: { id: string }): MediaStreamTrack {
return tracksById.get(mediaTrack.id)!;
}
function tracksOn(element: HTMLMediaElement): MediaStreamTrack[] {
return (element.srcObject as unknown as FakeMediaStream).getTracks();
}
+335
View File
@@ -0,0 +1,335 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject, type Observable, of } from "rxjs";
import { MatrixRTCError } from "./errors";
import {
type HandedOverTrack,
type LocalAudioMediaTrack,
type LocalMedia,
type LocalVideoMediaTrack,
type MediaSource,
type MediaStreamStats,
type PublishRequest,
type VideoProcessor,
} from "./media-api";
import { constant } from "./reactive/Behavior";
import { inTurn } from "./utils/processorTurns";
type Source = "microphone" | "camera";
type Request = Extract<PublishRequest, { source: Source }>;
export type GetUserMedia = (
constraints: MediaStreamConstraints,
) => Promise<MediaStream>;
/**
* Local media on the browser's devices alone, for a lobby ahead of a join:
* no backend is involved, so whichever backend joins can take the tracks
* over. Activity and level are not measured; stats are none.
*/
export function createLocalMedia(
getUserMedia: GetUserMedia = async (constraints) =>
navigator.mediaDevices.getUserMedia(constraints),
): LocalMedia {
const capturedTracks = new Map<Source, CapturedTrack>();
const requests = new Map<Source, Request>();
const tracks$ = new BehaviorSubject<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>([]);
const publishCapturedTracks = (): void =>
tracks$.next(
[...capturedTracks.values()].map(({ mediaTrack }) => mediaTrack),
);
// A second identical capture while the first is open joins it: a host that
// mounts twice (React's strict mode) must not open the devices twice and
// replace the tracks it is already showing
const pending = new Map<
string,
Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>
>();
const capture = async (
wanted: PublishRequest[],
): Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]> => {
const key = JSON.stringify(
wanted.map((request) => ({ ...request, processor: undefined })),
);
const running = pending.get(key);
if (running) return running;
const started = captureNow(wanted).finally(() => pending.delete(key));
pending.set(key, started);
return started;
};
const captureNow = async (
wanted: PublishRequest[],
): Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]> => {
const bySource = new Map<Source, Request>();
for (const request of wanted) {
if (request.source === "screenShare")
throw new MatrixRTCError(
"A screen share cannot be captured ahead of a join",
);
bySource.set(request.source, request);
}
const constraints: MediaStreamConstraints = {};
for (const request of bySource.values())
constraints[request.source === "microphone" ? "audio" : "video"] =
trackConstraints(request);
const stream = await getUserMedia(constraints);
const captured: CapturedTrack[] = [];
for (const track of stream.getTracks()) {
const source: Source = track.kind === "audio" ? "microphone" : "camera";
const request = bySource.get(source)!;
capturedTracks.get(source)?.stop();
const captured_ = new CapturedTrack(
source,
track,
trackConstraints(request),
getUserMedia,
);
capturedTracks.set(source, captured_);
requests.set(source, request);
if (request.source === "camera" && request.processor)
await captured_.setProcessor(request.processor);
captured.push(captured_);
}
publishCapturedTracks();
return captured.map(({ mediaTrack }) => mediaTrack);
};
const release = (source: MediaSource): void => {
if (source !== "microphone" && source !== "camera") return;
capturedTracks.get(source)?.stop();
capturedTracks.delete(source);
publishCapturedTracks();
};
const stop = (): void => {
for (const track of capturedTracks.values()) track.stop();
capturedTracks.clear();
publishCapturedTracks();
};
const takeTracks = (): Map<Source, HandedOverTrack> => {
const taken = new Map<Source, HandedOverTrack>();
for (const [source, track] of capturedTracks)
taken.set(source, track.handOver());
capturedTracks.clear();
publishCapturedTracks();
return taken;
};
return {
get requests(): PublishRequest[] {
return [...requests.values()];
},
tracks$,
capture,
release,
stop,
takeTracks,
};
}
/** The constraints a request means to the browser. */
function trackConstraints(request: Request): MediaTrackConstraints {
const device = request.deviceId && { deviceId: request.deviceId };
if (request.source === "microphone") return { ...device, ...request.capture };
const resolution = request.capture?.resolution;
return {
...device,
...(resolution && {
width: resolution.width,
height: resolution.height,
frameRate: resolution.frameRate,
}),
};
}
/**
* Internal Class that is the owner of the tracks.
* Will get stored in `capturedTracks` of `createLocalMedia`.
* To the outside this class will return the `LocalMedia` interface.
*/
class CapturedTrack {
public readonly mediaTrack: LocalAudioMediaTrack | LocalVideoMediaTrack;
private readonly muted$ = new BehaviorSubject(false);
private readonly facingMode$: BehaviorSubject<
"user" | "environment" | undefined
>;
private readonly elements = new Set<HTMLMediaElement>();
private processor: HandedOverTrack["processor"];
/** What the processor reads frames from; a processor needs a playing element. */
private processorInput?: HTMLVideoElement;
private finished = false;
public constructor(
public readonly source: Source,
private track: MediaStreamTrack,
private constraints: MediaTrackConstraints,
private readonly getUserMedia: GetUserMedia,
) {
this.facingMode$ = new BehaviorSubject(facingModeOf(track));
this.mediaTrack = this.asMediaTrack();
}
public handOver(): HandedOverTrack {
const { track, processor } = this;
// The device actually in use, so that a backend asked for it again
// recognises it rather than restarting the track
const { deviceId } = track.getSettings();
const constraints = { ...this.constraints, ...(deviceId && { deviceId }) };
this.finish();
return { track, constraints, muted: this.muted$.value, processor };
}
public stop(): void {
const { processor } = this;
if (processor)
void inTurn(processor.processor, async () =>
processor.processor.destroy(),
);
this.processor?.processedTrack.stop();
this.track.stop();
this.finish();
}
public async setProcessor(
processor: VideoProcessor | undefined,
): Promise<void> {
this.live();
if (processor === this.processor?.processor) return;
const previous = this.processor?.processor;
if (previous) await inTurn(previous, async () => previous.destroy());
this.processor = processor && {
processor,
processedTrack: await inTurn(processor, async () =>
processor.init(this.track, this.input()),
),
};
this.show();
}
private input(): HTMLVideoElement {
this.processorInput ??= document.createElement("video");
this.processorInput.muted = true;
this.processorInput.playsInline = true;
play(this.processorInput, this.track);
return this.processorInput;
}
private finish(): void {
this.finished = true;
for (const element of this.elements) element.srcObject = null;
this.elements.clear();
}
private live(): void {
if (this.finished)
throw new MatrixRTCError(
`The ${this.source} was handed over to a participation`,
);
}
private shown(): MediaStreamTrack {
return this.processor?.processedTrack ?? this.track;
}
private show(): void {
for (const element of this.elements) play(element, this.shown());
}
private async reacquire(constraints: MediaTrackConstraints): Promise<void> {
this.live();
this.constraints = { ...this.constraints, ...constraints };
const stream = await this.getUserMedia({
[this.track.kind]: this.constraints,
});
this.track.stop();
this.track = stream.getTracks()[0];
this.track.enabled = !this.muted$.value;
const { processor } = this;
if (processor)
processor.processedTrack = await inTurn(processor.processor, async () =>
processor.processor.restart(this.track, this.input()),
);
this.facingMode$.next(facingModeOf(this.track));
this.show();
}
private asMediaTrack(): LocalAudioMediaTrack | LocalVideoMediaTrack {
const base = {
source: this.source,
id: this.track.id,
muted$: this.muted$,
// Nothing leaves the device before the track is published, so there is
// nothing unencrypted to report
encrypted: true,
stats$: (): Observable<MediaStreamStats> => of(undefined),
attach: (element: HTMLMediaElement): void => {
this.live();
this.elements.add(element);
play(element, this.shown());
},
detach: (element: HTMLMediaElement): void => {
if (this.elements.delete(element)) element.srcObject = null;
},
setEnabled: async (enabled: boolean): Promise<boolean> => {
this.live();
this.track.enabled = enabled;
this.muted$.next(!enabled);
return Promise.resolve(enabled);
},
setDevice: async (deviceId: string): Promise<void> => {
if (this.track.getSettings().deviceId === deviceId) return;
await this.reacquire({ deviceId });
},
};
if (this.source === "microphone")
return {
...base,
kind: "audio",
isActive$: constant(false),
audioLevel$: constant(0),
setAudioContext: () => {},
setVolume: () => {},
};
return {
...base,
kind: "video",
facingMode$: this.facingMode$,
switchFacingMode: async () => {
const facingMode = this.facingMode$.value;
if (facingMode === undefined) return;
await this.reacquire({
facingMode: facingMode === "user" ? "environment" : "user",
});
return this.track.getSettings().deviceId;
},
setProcessor: async (processor) => this.setProcessor(processor),
};
}
}
function play(element: HTMLMediaElement, track: MediaStreamTrack): void {
element.srcObject = new MediaStream([track]);
// Autoplay is not a given for a stream set after mount; a test DOM returns
// nothing from play
const playing = element.play() as Promise<void> | undefined;
playing?.catch(() => undefined);
}
function facingModeOf(
track: MediaStreamTrack,
): "user" | "environment" | undefined {
const { facingMode } = track.getSettings();
return facingMode === "user" || facingMode === "environment"
? facingMode
: undefined;
}
+1 -1
View File
@@ -164,7 +164,7 @@ function setup(
context: MediaBackendContext,
): MediaBackend => {
const backend: MediaBackend = {
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
prepareLocalTransport: async () => fake.prepared.promise,
delegateDelayedLeave: fake.delegateDelayedLeave,
local: {
+3 -2
View File
@@ -136,7 +136,8 @@ export function createRTCParticipation(
// fallback: another member's SFU may let us publish (MSC4195). LiveKit only,
// so it stays out of the backend interface
const transportFromBackends = async (): Promise<Transport | null> =>
backend.transportType === "livekit"
backend.transportTypes.includes("m.livekit") ||
backend.transportTypes.includes("livekit")
? findRemotePublishLivekitTransport({
client,
// A snapshot: if nobody with a usable SFU is here yet, the user has
@@ -152,7 +153,7 @@ export function createRTCParticipation(
const preparedTransport$ = from(
discoverLocalTransport(
client,
backend.transportType,
backend.transportTypes,
options,
transportFromBackends,
logger,
+10 -6
View File
@@ -20,6 +20,7 @@ import { type Observable } from "rxjs";
import { type Behavior } from "./reactive/Behavior";
import {
type LocalAudioMediaTrack,
type LocalMedia,
type LocalMemberMedia,
type LocalVideoMediaTrack,
type MediaSource,
@@ -84,12 +85,15 @@ export interface RTCSlot {
export interface RTCParticipationOptions {
/**
* Published at the join; `publish` on the participation adds to it from then
* on. Kept as an option, rather than only the method, because the LiveKit
* backend reads the initial requests to build the room's default capture
* options before the first connection exists.
* What the local member publishes at the join: requests the SDK captures
* itself, or the media a lobby captured ahead of the join, which the
* participation takes over (its tracks leave `media.tracks$`) and publishes
* in place of capturing. `publish` on the participation adds to either from
* then on. Kept as an option, rather than only the method, because the
* LiveKit backend reads it to build the room's default capture options
* before the first connection exists.
*/
publish: PublishRequest[];
publish: PublishRequest[] | LocalMedia;
/**
* LEGACY
*
@@ -230,7 +234,7 @@ export interface DataMessage {
* backend-specific developer panel and for connection diagnostics.
*/
export interface TransportMetadata {
/** `"livekit"` today. */
/** `"m.livekit"` or `"livekit"` today. */
type: string;
/** Stable key, unique per transport in the slot: the raw transport, serialised with sorted keys. */
id: string;
+5 -5
View File
@@ -48,7 +48,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
c,
"livekit",
["m.livekit", "livekit"],
{ transport: hostTransport },
fromBackends(),
logger,
@@ -61,7 +61,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
["m.livekit", "livekit"],
{ fallbackTransport: hostTransport },
fromBackends(),
logger,
@@ -73,7 +73,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
client([{ type: "not_livekit" }]),
"livekit",
["m.livekit", "livekit"],
{ fallbackTransport: hostTransport },
fromBackends(),
logger,
@@ -87,7 +87,7 @@ describe("discoverLocalTransport", () => {
await expect(
discoverLocalTransport(
client([homeserverTransport]),
"livekit",
["m.livekit", "livekit"],
{},
backends,
logger,
@@ -99,7 +99,7 @@ describe("discoverLocalTransport", () => {
it("fails naming the homeserver where there is no transport at all", async () => {
const promise = discoverLocalTransport(
client([]),
"livekit",
["m.livekit", "livekit"],
{},
fromBackends(),
logger,
+5 -4
View File
@@ -15,14 +15,15 @@ import { TransportAutoDiscovery } from "./TransportAutoDiscovery";
/**
* The transport the local member advertises: the one the host names, else the
* homeserver's first of the type the backend serves, else the host's fallback,
* else the first the backends can provide, which are asked only then.
* homeserver's first of the most preferred type the backend serves, else the
* host's fallback, else the first the backends can provide, which are asked
* only then.
*
* @throws {MatrixRTCTransportMissingError} where none of these exists.
*/
export async function discoverLocalTransport(
client: Pick<MatrixClient, "getDomain" | "_unstable_getRTCTransports">,
transportType: string,
transportTypes: readonly string[],
{
transport: override,
fallbackTransport,
@@ -34,7 +35,7 @@ export async function discoverLocalTransport(
override ??
(await new TransportAutoDiscovery({
client,
transportType,
transportTypes,
fallbackTransport,
logger,
}).discoverPreferredTransport()) ??
@@ -61,7 +61,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -75,6 +75,29 @@ describe("TransportAutoDiscovery", () => {
},
);
it("prefers a transport of an earlier type over one listed before it", async () => {
const mLivekit: Transport = {
type: "m.livekit",
url: "wss://sfu.example.org",
};
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([
backendTransport,
mLivekit,
]);
const discovery = new TransportAutoDiscovery({
client,
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
mLivekit,
);
});
it("Retries limit_exceeded backend transport", async () => {
const client = makeClient();
client._unstable_getRTCTransports
@@ -92,7 +115,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -116,7 +139,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: configTransport,
logger: rootLogger,
});
@@ -133,7 +156,7 @@ describe("TransportAutoDiscovery", () => {
const discovery = new TransportAutoDiscovery({
client,
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
fallbackTransport: undefined,
logger: rootLogger,
});
+15 -16
View File
@@ -17,27 +17,27 @@ type TransportDiscoveryClient = Pick<
export interface TransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
/** The transport type the media backend serves. */
transportType: string;
/** A transport to use when the homeserver advertises none of the type. */
/** The transport types we want to discover. Those are the ones, the local media backend supports. most preferred first. */
transportTypes: readonly string[];
/** A transport to use when the homeserver advertises none of the types. */
fallbackTransport?: Transport;
logger: Logger;
}
export class TransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly transportType: string;
private readonly transportTypes: readonly string[];
private readonly fallbackTransport: Transport | undefined;
private readonly logger: Logger;
public constructor({
client,
transportType,
transportTypes,
fallbackTransport,
logger,
}: TransportAutoDiscoveryProps) {
this.client = client;
this.transportType = transportType;
this.transportTypes = transportTypes;
this.fallbackTransport = fallbackTransport;
this.logger = logger.getChild("[TransportAutoDiscovery]");
}
@@ -64,7 +64,7 @@ export class TransportAutoDiscovery {
}
/**
* Fetches the first transport of the backend's type from the homeserver.
* Fetches the first transport of the backend's most preferred type from the homeserver.
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
@@ -76,17 +76,16 @@ export class TransportAutoDiscovery {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(
(transport) => transport.type === this.transportType,
);
if (first) {
return first;
} else {
this.logger.info(
`No ${this.transportType} transport found in getRTCTransports end point`,
transportList,
for (const type of this.transportTypes) {
const first = transportList.find(
(transport) => transport.type === type,
);
if (first) return first;
}
this.logger.info(
`No ${this.transportTypes.join(" or ")} transport found in getRTCTransports end point`,
transportList,
);
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
}
+42
View File
@@ -185,3 +185,45 @@ export interface MemberMedia {
export interface LocalMemberMedia extends MemberMedia {
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[] | null>;
}
/**
* Tracks captured ahead of a join, for a lobby: the same local tracks a
* participation hands out, on nothing but the browser's devices, so they fit
* whichever backend joins. Handed to `join` as `publish`, they are moved once
* the participation publishes them: they leave `tracks$`, their objects are
* finished (controls reject, elements detached), and the same devices come
* back under `localMember$.tracks$` as new objects. `stop` releases only what
* was not moved; `capture` works again afterwards.
*/
export interface LocalMedia {
/** What was captured with, one per source; a join reads its defaults from here. */
readonly requests: PublishRequest[];
tracks$: Behavior<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>;
/**
* Opens the devices, in one permission prompt where the browser asks for
* one. Rejects where a device cannot be opened, having opened none of them.
* A source captured already is replaced.
*/
capture(
requests: PublishRequest[],
): Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]>;
/** Closes the device behind a source; nothing where it was not captured. */
release(source: MediaSource): void;
/** Closes every device still held. */
stop(): void;
/**
* For a media backend, as it starts publishing: moves every track out,
* finishing its object here. Not for a host; the participation calls it.
*/
takeTracks(): Map<"microphone" | "camera", HandedOverTrack>;
}
/** One track as a backend receives it: the device's own track and what runs on it. */
export interface HandedOverTrack {
track: MediaStreamTrack;
/** What it was opened with, for a backend that restarts it. */
constraints: MediaTrackConstraints;
muted: boolean;
/** Where a processor runs: it and its output, to adopt rather than initialise again. */
processor?: { processor: VideoProcessor; processedTrack: MediaStreamTrack };
}
+8 -4
View File
@@ -20,6 +20,7 @@ import { type Observable } from "rxjs";
import { type ResolvedTransport, type RTCMember } from "../api";
import {
type LocalAudioMediaTrack,
type LocalMedia,
type LocalMemberMedia,
type LocalVideoMediaTrack,
type MediaSource,
@@ -56,8 +57,8 @@ export interface MediaBackendContext {
/** The slot the session is in, `application#id`; a token is issued for it. */
slotId: string;
ownMembershipIdentity: CallMembershipIdentityParts;
/** What the local member publishes at the join. */
publish: PublishRequest[];
/** What the local member publishes at the join: requests, or a lobby's media. */
publish: PublishRequest[] | LocalMedia;
encryptionSystem: EncryptionSystem;
/**
* Every media key the session knows: the current ones replayed on
@@ -113,8 +114,11 @@ export interface DataPacket {
}
export interface MediaBackend {
/** The membership transport type this backend serves: "livekit". */
readonly transportType: string;
/**
* The membership transport types this backend serves, ordered decreasingly
* by preference.
*/
readonly transportTypes: readonly string[];
/**
* Makes `transport` usable as the one the local member publishes on. The
@@ -16,6 +16,7 @@ import {
vi,
} from "vitest";
import {
ConnectionError,
type LocalParticipant,
type RemoteParticipant,
type RemoteTrack,
@@ -41,7 +42,7 @@ import {
} from "./Connection.ts";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type ClientGetTokenParts, type ClientOpenIDParts } from "./auth";
import { MatrixRTCError } from "../../errors";
import { LivekitConnectionError, MatrixRTCError } from "../../errors";
import { testJWTToken } from "../../utils/test-fixtures";
import { ownMemberMock } from "../../utils/test";
import { mockRemoteParticipant } from "./test";
@@ -57,6 +58,10 @@ let fakeLocalParticipant: MockedObject<LocalParticipant>;
const ROOM_ID = "!roomID:example.org";
const SLOT_ID = "m.call#ROOM";
const FIREFOX_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:147.0) Gecko/20100101 Firefox/147.0";
const CHROME_USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36";
const transport: UnstableLivekitTransport = {
type: "livekit",
@@ -147,6 +152,13 @@ function setupRemoteConnection(): Connection {
return new Connection(opts, logger);
}
function mockUserAgent(userAgent: string): void {
const spy = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue(userAgent);
onTestFinished(() => spy.mockRestore());
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
@@ -375,6 +387,89 @@ describe("Start connection states", () => {
expect(connection.livekitAlias).toBe("!example_room_id");
});
it("retries a join that failed on the WebSocket in Firefox", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
const capturedStates: ConnectionState[] = [];
const s = connection.state$.subscribe((value) => {
capturedStates.push(value);
});
onTestFinished(() => s.unsubscribe());
const firstJoin = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
const started = connection.start();
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
await started;
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
expect(capturedStates.at(-1)).toEqual({
state: "authenticated",
livekitState: LivekitConnectionState.Connected,
});
expect(capturedStates.some((st) => st.state === "error")).toBe(false);
});
it("retries a join in Firefox only once", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
fakeLivekitRoom.connect.mockRejectedValue(
ConnectionError.websocket("WS closed unexpectedly"),
);
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(2);
});
it("does not retry a join that failed for another reason in Firefox", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
fakeLivekitRoom.connect.mockRejectedValueOnce(
ConnectionError.serverUnreachable("server was not reachable"),
);
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
});
it("does not retry a join that failed on the WebSocket in other browsers", async () => {
setupTest();
mockUserAgent(CHROME_USER_AGENT);
const connection = setupRemoteConnection();
fakeLivekitRoom.connect.mockRejectedValueOnce(
ConnectionError.websocket("WS closed unexpectedly"),
);
await expect(connection.start()).rejects.toThrow(LivekitConnectionError);
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
});
it("does not retry a join in Firefox once stopped", async () => {
setupTest();
mockUserAgent(FIREFOX_USER_AGENT);
const connection = setupRemoteConnection();
const firstJoin = Promise.withResolvers<void>();
fakeLivekitRoom.connect.mockReturnValueOnce(firstJoin.promise);
fakeLivekitRoom.disconnect.mockResolvedValue(undefined);
const started = connection.start();
await vi.waitFor(() => expect(fakeLivekitRoom.connect).toHaveBeenCalled());
const stopping = connection.stop();
firstJoin.reject(ConnectionError.websocket("WS closed unexpectedly"));
await stopping;
await expect(started).resolves.toBeUndefined();
expect(fakeLivekitRoom.connect).toHaveBeenCalledTimes(1);
});
it("stopping while connecting does not report an error", async () => {
setupTest();
const connection = setupRemoteConnection();
+19 -1
View File
@@ -14,6 +14,7 @@ import {
ConnectionError,
ConnectionErrorReason,
type ConnectionState as LivekitConnectionState,
getBrowser,
type Participant,
type RemoteParticipant,
type RemoteTrackPublication,
@@ -322,7 +323,7 @@ export class Connection {
try {
this.logger.info(`livekitRoom.connect ${url}`);
await this.livekitRoom.connect(url, jwt);
await this.connectLivekitRoom(url, jwt);
this.logger.info(`livekitRoom.connect SUCCESS ${url}`);
} catch (e) {
this.logger.info(`livekitRoom.connect FAILED ${url}`, e);
@@ -382,6 +383,23 @@ export class Connection {
}
}
private async connectLivekitRoom(url: string, jwt: string): Promise<void> {
try {
await this.livekitRoom.connect(url, jwt);
} catch (e) {
// Behind a proxy, Firefox fails the first WebSocket to a host after trying
// it over HTTP/2, so a join that fails on the WebSocket gets a second try.
// https://bugzilla.mozilla.org/show_bug.cgi?id=2015411
const failedOnFirefoxWebSocket =
getBrowser()?.name === "Firefox" &&
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.WebSocket;
if (this.stopped || !failedOnFirefoxWebSocket) throw e;
this.logger.info(`livekitRoom.connect RETRY ${url}`, e);
await this.livekitRoom.connect(url, jwt);
}
}
protected async getSFUConfig(): Promise<SFUConfig> {
return await getSFUConfig({
client: this.client,
@@ -92,7 +92,7 @@ export function createLivekitBackend(
roomId,
slotId,
ownMembershipIdentity,
publish,
publish: requestsOrMedia,
encryptionSystem,
mediaKeys$,
timings,
@@ -101,6 +101,9 @@ export function createLivekitBackend(
{ client, mediaQuality }: LivekitBackendOptions,
): MediaBackend {
const logger = parentLogger.getChild("[LivekitBackend]");
const [publish, media] = Array.isArray(requestsOrMedia)
? [requestsOrMedia, undefined]
: [requestsOrMedia.requests, requestsOrMedia];
const keyProvider = createKeyProvider(encryptionSystem, mediaKeys$, logger);
const transports = createTransportRegistry(scope);
const localTransport$ = new BehaviorSubject<TransportLocator | null>(null);
@@ -137,6 +140,7 @@ export function createLivekitBackend(
connectionMap$,
localTransport$,
publish,
media,
audioOutputDeviceId$,
encryptionSystem,
logger,
@@ -279,7 +283,7 @@ export function createLivekitBackend(
});
return {
transportType: "livekit",
transportTypes: ["m.livekit", "livekit"],
prepareLocalTransport,
delegateDelayedLeave: delegateLeave,
local: localMedia_,
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { EventEmitter } from "events";
import {
type LocalParticipant,
LocalVideoTrack,
ParticipantEvent,
RemoteAudioTrack,
type RemoteParticipant,
@@ -17,10 +18,22 @@ import {
TrackEvent,
type TrackPublication,
} from "livekit-client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
afterEach,
beforeEach,
describe,
expect,
it,
onTestFinished,
vi,
} from "vitest";
import { ObservableScope } from "../../reactive/ObservableScope";
import { type AudioMediaTrack } from "../../media-api";
import {
type AudioMediaTrack,
type LocalVideoMediaTrack,
type VideoProcessor,
} from "../../media-api";
import {
createLivekitMediaTrack,
createLocalLivekitMediaTrack,
@@ -205,8 +218,100 @@ describe("createLivekitMediaTrack", () => {
await expect(track.setEnabled(false)).resolves.toBe(false);
expect(setEnabled).toHaveBeenCalledWith("microphone", false);
await track.setDevice("mic-2");
expect(switchActiveDevice).toHaveBeenCalledWith("audioinput", "mic-2");
expect(switchActiveDevice).toHaveBeenCalledWith(
"audioinput",
"mic-2",
false,
);
});
// LiveKit's media stream track is the processed one while a processor runs,
// and that one names no device; the camera behind it does
it.each([
{ deviceId: "cam-1", switches: false },
{ deviceId: "cam-2", switches: true },
])(
"leaves a processed camera alone unless its device changes: $deviceId",
async ({ deviceId, switches }) => {
vi.stubGlobal("MediaStreamTrack", class {});
onTestFinished(() => {
vi.unstubAllGlobals();
});
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
source: Track.Source.Camera,
_mediaStreamTrack: {
readyState: "live",
label: "",
getSettings: () => ({ deviceId: "cam-1" }),
},
// What LiveKit shows as the track, with no device of its own
processor: {
processedTrack: { label: "", getSettings: () => ({}) },
},
}) as LocalVideoTrack;
const switchActiveDevice = vi.fn().mockResolvedValue(true);
const { participant, publication, room } = fakes({
track: camera as unknown as FakeTrack,
});
const track = createLocalLivekitMediaTrack(
scope,
Object.assign(participant, {
isLocal: true,
}) as unknown as LocalParticipant,
publication as unknown as TrackPublication,
Object.assign(room, { switchActiveDevice }),
vi.fn(),
);
await track.setDevice(deviceId);
expect(switchActiveDevice.mock.calls).toEqual(
switches ? [["videoinput", deviceId, false]] : [],
);
},
);
// LiveKit stops a camera that is turned off; the processor is built when it
// is turned on again
it.each([
{ state: "is off", isMuted: true, takes: true },
{ state: "has stopped for good", isMuted: false, takes: false },
])(
"gives a processor to a camera that $state: $takes",
async ({ isMuted, takes }) => {
// jsdom has none; LiveKit's facing mode checks against it
vi.stubGlobal("MediaStreamTrack", class {});
onTestFinished(() => {
vi.unstubAllGlobals();
});
const camera = Object.assign(Object.create(LocalVideoTrack.prototype), {
isMuted,
_mediaStreamTrack: {
readyState: "ended",
label: "",
getSettings: () => ({}),
},
getProcessor: () => undefined,
setProcessor: vi.fn().mockResolvedValue(undefined),
}) as LocalVideoTrack;
const { participant, publication, room } = fakes({
track: camera as unknown as FakeTrack,
});
const track = createLocalLivekitMediaTrack(
scope,
Object.assign(participant, {
isLocal: true,
}) as unknown as LocalParticipant,
publication as unknown as TrackPublication,
room,
vi.fn(),
) as LocalVideoMediaTrack;
await track.setProcessor({ name: "blur" } as VideoProcessor);
expect(vi.mocked(camera.setProcessor).mock.calls.length).toBe(
takes ? 1 : 0,
);
},
);
});
type FakeTrack = Track & {
@@ -50,7 +50,7 @@ import {
type VideoMediaTrack,
} from "../../media-api";
import { MatrixRTCError } from "../../errors";
import { convertToLivekitProcessor } from "./videoProcessor";
import { toLivekitProcessor, type LivekitProcessor } from "./videoProcessor";
export const mediaSources: Record<Track.Source, MediaSource> = {
[Track.Source.Microphone]: "microphone",
@@ -197,9 +197,21 @@ export function createLocalLivekitMediaTrack(
setDevice: async (deviceId: string): Promise<void> => {
if (base.source !== "microphone" && base.source !== "camera")
throw new MatrixRTCError(`A ${base.source} track has no device`);
// LiveKit decides whether to restart by the constraints it keeps, which
// a browser that does not echo the device id back (Firefox) leaves
// without one; a track already on the device is left alone here. Asked
// of the camera track itself: with a processor on, LiveKit's media
// stream track is the processed one, which names no device
const current = publication.track;
if (
current instanceof LocalTrack &&
(await current.getDeviceId(false)) === deviceId
)
return;
await room.switchActiveDevice(
base.kind === "audio" ? "audioinput" : "videoinput",
deviceId,
false,
);
},
};
@@ -228,11 +240,17 @@ export function createLocalLivekitMediaTrack(
},
setProcessor: async (processor) => {
const track = videoTrack();
// A processor cannot be built on a track that has already ended
if (!track || track.mediaStreamTrack.readyState === "ended") return;
if (processor)
await track.setProcessor(convertToLivekitProcessor(processor));
else if (track.getProcessor()) await track.stopProcessor();
// A camera that is off has a stopped track and takes the processor on
// turning on again; one stopped for good takes none
if (
!track ||
(track.mediaStreamTrack.readyState === "ended" && !track.isMuted)
)
return;
const current = track.getProcessor() as LivekitProcessor | undefined;
if (processor === current?.source) return;
if (processor) await track.setProcessor(toLivekitProcessor(processor));
else if (current) await track.stopProcessor();
},
};
}
@@ -27,6 +27,7 @@ import {
import {
type LocalAudioMediaTrack,
type LocalMedia,
type LocalVideoMediaTrack,
type MediaSource,
type PublishRequest,
@@ -57,6 +58,8 @@ interface Props {
/** The transport we publish on; null until the session has found it. */
localTransport$: Behavior<TransportLocator | null>;
publish: PublishRequest[];
/** Tracks the host captured ahead of the join; the publisher takes them over. */
media?: LocalMedia;
/** Read by the connection factory for the rooms it creates from now on. */
audioOutputDeviceId$: BehaviorSubject<string | undefined>;
encryptionSystem: EncryptionSystem;
@@ -79,6 +82,7 @@ export function createLivekitLocalMedia({
connectionMap$,
localTransport$,
publish: initialRequests,
media,
audioOutputDeviceId$,
encryptionSystem,
logger: parentLogger,
@@ -119,6 +123,7 @@ export function createLivekitLocalMedia({
connection.livekitRoom,
desired,
logger.getChild(`[Publisher ${JSON.stringify(connection.transport)}]`),
media,
);
publisher$.next(publisher);
return Promise.resolve(async (): Promise<void> => {
+173 -2
View File
@@ -11,16 +11,32 @@ import {
LocalParticipant,
type LocalTrack,
type LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
type Room as LivekitRoom,
Track,
type TrackPublishOptions,
} from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { type PublishRequest } from "../../media-api";
import {
type HandedOverTrack,
type LocalMedia,
type PublishRequest,
type VideoProcessor,
} from "../../media-api";
import { flushPromises } from "../../utils/test";
import { type DesiredMedia, Publisher } from "./Publisher";
import { type LivekitProcessor } from "./videoProcessor";
describe("Publisher", () => {
describe("setAudioOutputDevice", () => {
@@ -104,6 +120,24 @@ describe("Publisher", () => {
expect(participant.createTracks).not.toHaveBeenCalled();
});
it("creates both with the options of requests made since the join", async () => {
const processor: VideoProcessor = {
name: "effect",
init: vi.fn(),
restart: vi.fn(),
destroy: vi.fn(),
};
want(microphone, { source: "camera", deviceId: "front", processor });
publisher.start();
await flushPromises();
expect(room.options.videoCaptureDefaults).toMatchObject({
deviceId: "front",
processor: expect.objectContaining({ source: processor }),
});
});
it("asks for the camera and the microphone in one call", async () => {
const enableBoth = vi.spyOn(participant, "enableCameraAndMicrophone");
want(microphone, camera);
@@ -327,6 +361,143 @@ function fakeRoom(
return { room, switchActiveDevice };
}
// A lobby's tracks come as the device's own MediaStreamTracks; LiveKit's
// track classes are real here so that what is published is what LiveKit will
// restart and stop as its own
describe("adopting a lobby's tracks", () => {
// LiveKit wraps every track in a MediaStream, which jsdom does not have
beforeAll(() => {
globalThis.MediaStream = class {
public constructor(private readonly tracks: MediaStreamTrack[]) {}
public getTracks(): MediaStreamTrack[] {
return this.tracks;
}
} as unknown as typeof MediaStream;
});
function setup(
source: "microphone" | "camera",
handed: HandedOverTrack,
): { participant: LocalParticipant; publisher: Publisher } {
const participant = fakeLocalParticipant(async () => Promise.resolve());
const room = {
options: {},
setE2EEEnabled: () => undefined,
localParticipant: participant,
} as unknown as LivekitRoom;
const desired: DesiredMedia = new Map([
[source, { request: { source }, enabled: true }],
]);
const media = {
takeTracks: () => new Map([[source, handed]]),
} as unknown as LocalMedia;
const publisher = new Publisher(room, desired, logger, media);
return { participant, publisher };
}
it("publishes the lobby's track as its own rather than capturing", async () => {
const handed = handedOver("video");
const { participant, publisher } = setup("camera", handed);
publisher.start();
await flushPromises();
expect(participant.createTracks).not.toHaveBeenCalled();
const [published, options] = vi.mocked(participant.publishTrack).mock
.calls[0] as [LocalVideoTrack, TrackPublishOptions];
expect(published).toBeInstanceOf(LocalVideoTrack);
expect(published.mediaStreamTrack).toBe(handed.track);
expect(published.isUserProvided).toBe(false);
expect(options.source).toBe(Track.Source.Camera);
});
it("adopts the processor's output without initialising it again", async () => {
const setProcessor = vi
.spyOn(LocalVideoTrack.prototype, "setProcessor")
.mockResolvedValue(undefined);
const processor = {
name: "blur",
init: vi.fn(),
restart: vi.fn(),
destroy: vi.fn(),
} as unknown as VideoProcessor;
const processedTrack = fakeMediaStreamTrack("video");
const handed = handedOver("video", {
processor: { processor, processedTrack },
});
const { publisher } = setup("camera", handed);
publisher.start();
await flushPromises();
const adapter = setProcessor.mock.calls[0][0] as LivekitProcessor;
await adapter.init({ kind: Track.Kind.Video, track: handed.track });
expect(adapter.processedTrack).toBe(processedTrack);
expect(adapter.source).toBe(processor);
expect(processor.init).not.toHaveBeenCalled();
setProcessor.mockRestore();
});
// A development build mounts, unmounts and mounts the call again: the first
// publisher must not have taken the tracks the second one needs
it("leaves the lobby's tracks alone until it starts", async () => {
const participant = fakeLocalParticipant(async () => Promise.resolve());
const room = {
options: {},
setE2EEEnabled: () => undefined,
localParticipant: participant,
} as unknown as LivekitRoom;
const takeTracks = vi.fn(() => new Map());
const media = { takeTracks } as unknown as LocalMedia;
const publisher = new Publisher(room, new Map(), logger, media);
await publisher.destroy();
expect(takeTracks).not.toHaveBeenCalled();
});
it("joins muted where the lobby muted", async () => {
const { participant, publisher } = setup(
"microphone",
handedOver("audio", { muted: true }),
);
publisher.start();
await flushPromises();
const [published] = vi.mocked(participant.publishTrack).mock.calls[0];
expect((published as LocalTrack).isMuted).toBe(true);
});
});
function fakeMediaStreamTrack(kind: "audio" | "video"): MediaStreamTrack {
return {
kind,
id: `${kind}-track`,
label: "",
enabled: true,
readyState: "live",
getSettings: () => ({}),
getConstraints: () => ({}),
getCapabilities: () => ({}),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
stop: vi.fn(),
} as unknown as MediaStreamTrack;
}
function handedOver(
kind: "audio" | "video",
extra: Partial<HandedOverTrack> = {},
): HandedOverTrack {
return {
track: fakeMediaStreamTrack(kind),
constraints: { deviceId: "lobby" },
muted: false,
...extra,
};
}
function fakeLocalTrack(source: Track.Source): LocalTrack {
const state = { isMuted: false, isUpstreamPaused: false };
const track = {
+99 -6
View File
@@ -6,15 +6,23 @@ Please see LICENSE in the repository root for full details.
*/
import {
LocalAudioTrack,
type LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
type Room as LivekitRoom,
type Track,
} from "livekit-client";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { type MediaSource, type PublishRequest } from "../../media-api";
import {
type HandedOverTrack,
type LocalMedia,
type MediaSource,
type PublishRequest,
} from "../../media-api";
import { livekitSources, mediaSources } from "./LivekitMediaTrack";
import { toLivekitProcessor } from "./videoProcessor";
import {
audioCaptureOptions,
screenShareCaptureOptions,
@@ -31,6 +39,9 @@ export interface DesiredPublication {
export type DesiredMedia = Map<MediaSource, DesiredPublication>;
/** A lobby's tracks, moved out of its media, until each is published. */
export type HandedOverTracks = ReturnType<LocalMedia["takeTracks"]>;
/**
* Publishes the local media on one LiveKit room, following the host's
* requests.
@@ -46,10 +57,19 @@ export class Publisher {
public started = false;
private readonly room: LivekitRoom;
/** A lobby's tracks, taken over at `start`; each leaves the map as it is published. */
private captured: HandedOverTracks = new Map();
/**
* @param media - What a lobby opened ahead of the join, published in place
* of capturing. Taken over only at `start`, so that a publisher torn down
* before it publishes leaves the tracks to the next one.
*/
public constructor(
room: LivekitRoom,
private readonly desired: DesiredMedia,
private readonly logger: Logger,
private readonly media?: LocalMedia,
) {
this.room = room;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
@@ -79,33 +99,106 @@ export class Publisher {
public start(): void {
if (this.started) return;
this.started = true;
this.captured = this.media?.takeTracks() ?? new Map();
// A track muted in the lobby joins muted
for (const [source, { muted }] of this.captured) {
const wanted = this.desired.get(source);
if (wanted && muted) wanted.enabled = false;
}
const requests = [...this.desired.values()]
.filter(({ enabled }) => enabled)
.filter(
({ request, enabled }) => enabled || this.hasCaptured(request.source),
)
.map(({ request }) => request);
const sources = new Set(requests.map((request) => request.source));
const toCapture = new Set(
requests
.map((request) => request.source)
.filter((source) => !this.hasCaptured(source)),
);
// Microphone and camera in one call so that the browser asks for
// permission once; the room defaults carry their options. LiveKit
// resolves these once the track is published, which may block on the
// connection; LocalTrackPublished is what tells us a track exists.
if (sources.has("microphone") && sources.has("camera"))
const captureBoth = toCapture.has("microphone") && toCapture.has("camera");
if (captureBoth) {
this.takeCaptureDefaults(requests);
void this.room.localParticipant.enableCameraAndMicrophone();
}
for (const request of requests)
if (
request.source === "screenShare" ||
!(sources.has("microphone") && sources.has("camera"))
this.hasCaptured(request.source) ||
!captureBoth
)
this.publish(request).catch((e) => {
this.logger.error(`Failed to publish the ${request.source}`, e);
});
}
/**
* Makes the requests the room's capture defaults. The room took its defaults
* from the requests at the join, but one made since, such as a camera turned
* on before the start, has options of its own: a video processor dropped
* here would let the room see the camera before it is applied.
*/
private takeCaptureDefaults(requests: PublishRequest[]): void {
const options = this.room.options;
for (const request of requests)
if (request.source === "microphone")
options.audioCaptureDefaults = {
...options.audioCaptureDefaults,
...audioCaptureOptions(request),
};
else if (request.source === "camera")
options.videoCaptureDefaults = {
...options.videoCaptureDefaults,
...videoCaptureOptions(request),
};
}
private hasCaptured(source: MediaSource): boolean {
return (
(source === "microphone" || source === "camera") &&
this.captured.has(source)
);
}
private takeCaptured(request: PublishRequest): HandedOverTrack | undefined {
if (request.source === "screenShare") return undefined;
const track = this.captured.get(request.source);
this.captured.delete(request.source);
return track;
}
/** A lobby's track as if this participant had captured it: LiveKit restarts and stops it as its own. */
private async adopt(
{ track, constraints, muted, processor }: HandedOverTrack,
request: PublishRequest,
): Promise<LocalTrackPublication> {
const local =
track.kind === "audio"
? new LocalAudioTrack(track, constraints, false)
: new LocalVideoTrack(track, constraints, false);
if (muted) await local.mute();
if (processor && local instanceof LocalVideoTrack)
await local.setProcessor(
toLivekitProcessor(processor.processor, processor.processedTrack),
);
return this.room.localParticipant.publishTrack(local, {
source: livekitSources[request.source],
...(request.source === "camera" && videoPublishOptions(request.capture)),
});
}
/** Publishes one request and resolves with its publication once LiveKit has it. */
public async publish(
request: PublishRequest,
): Promise<LocalTrackPublication | undefined> {
const participant = this.room.localParticipant;
let publication: LocalTrackPublication | undefined;
if (request.source === "microphone")
const captured = this.takeCaptured(request);
if (captured) publication = await this.adopt(captured, request);
else if (request.source === "microphone")
publication = await participant.setMicrophoneEnabled(
true,
audioCaptureOptions(request),
@@ -13,6 +13,7 @@ import {
} from "matrix-js-sdk/lib/matrixrtc";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { serverNameOfTransport } from "../../utils/serverName";
import { isCsApiUnavailable } from "./auth";
import * as csApi from "./auth/csApi";
import { decodeSFUJWTPayload } from "./auth/types";
@@ -59,7 +60,7 @@ export async function findRemotePublishLivekitTransport({
if (!isUnstableLivekitTransport(transport) || !("url" in transport))
continue;
const { url } = transport;
const serverName = membership.getTransportServerName(transport);
const serverName = serverNameOfTransport(transport, membership.userId);
const key = JSON.stringify([url, serverName]);
if (tried.has(key)) continue;
tried.add(key);
@@ -16,7 +16,7 @@ import {
type PublishRequest,
type VideoCaptureSettings,
} from "../../media-api";
import { convertToLivekitProcessor } from "./videoProcessor";
import { toLivekitProcessor } from "./videoProcessor";
export type PublishRequestFor<S extends PublishRequest["source"]> = Extract<
PublishRequest,
@@ -40,7 +40,7 @@ export function videoCaptureOptions({
if (!deviceId && !processor && !capture?.resolution) return undefined;
return {
...(deviceId && { deviceId }),
...(processor && { processor: convertToLivekitProcessor(processor) }),
...(processor && { processor: toLivekitProcessor(processor) }),
...(capture?.resolution && { resolution: capture.resolution }),
};
}
@@ -5,13 +5,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { Track, type VideoProcessorOptions } from "livekit-client";
import { describe, expect, it, vi } from "vitest";
import {
LocalVideoTrack,
Track,
type TrackProcessor,
type VideoProcessorOptions,
} from "livekit-client";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { type VideoProcessor } from "../../media-api";
import { convertToLivekitProcessor } from "./videoProcessor";
import { fromLivekitProcessor, toLivekitProcessor } from "./videoProcessor";
describe("convertToLivekitProcessor", () => {
describe("toLivekitProcessor", () => {
const captured = { id: "captured" } as MediaStreamTrack;
const processed = { id: "processed" } as MediaStreamTrack;
const restarted = { id: "restarted" } as MediaStreamTrack;
@@ -33,7 +38,7 @@ describe("convertToLivekitProcessor", () => {
it("holds the processed track where LiveKit reads it", async () => {
const processor = fakeProcessor();
const adapter = convertToLivekitProcessor(processor);
const adapter = toLivekitProcessor(processor);
expect(adapter.name).toBe("blur");
expect(adapter.processedTrack).toBeUndefined();
@@ -47,4 +52,202 @@ describe("convertToLivekitProcessor", () => {
await adapter.destroy();
expect(processor.destroy).toHaveBeenCalled();
});
// A stopped track tears its processor down without waiting, while the track
// replacing it initialises the same one
it("runs a destroy and the next init on one processor in turn, across adapters", async () => {
const processor = fakeProcessor();
let finishDestroy = (): void => {};
vi.mocked(processor.destroy).mockImplementation(
async () =>
new Promise<void>((resolve) => {
finishDestroy = resolve;
}),
);
const previous = toLivekitProcessor(processor);
const next = toLivekitProcessor(processor);
await previous.init(options);
vi.mocked(processor.init).mockClear();
const destroying = previous.destroy();
const initialising = next.init(options);
await Promise.resolve();
expect(processor.init).not.toHaveBeenCalled();
finishDestroy();
await Promise.all([destroying, initialising]);
expect(processor.init).toHaveBeenCalledOnce();
expect(next.processedTrack).toBe(processed);
});
});
describe("a processor set on a camera that is off", () => {
const ended = { id: "ended", readyState: "ended" } as MediaStreamTrack;
function fakeProcessor(): VideoProcessor {
return {
name: "blur",
init: vi.fn().mockResolvedValue({ id: "processed" }),
restart: vi.fn().mockResolvedValue({ id: "restarted" }),
destroy: vi.fn().mockResolvedValue(undefined),
};
}
it("builds on the restart that turns the camera on, not before", async () => {
const processor = fakeProcessor();
const adapter = toLivekitProcessor(processor);
await adapter.init({ kind: Track.Kind.Video, track: ended });
expect(processor.init).not.toHaveBeenCalled();
expect(adapter.processedTrack).toBeUndefined();
const live = { id: "live", readyState: "live" } as MediaStreamTrack;
await adapter.restart({ kind: Track.Kind.Video, track: live });
expect(processor.init).toHaveBeenCalledWith(live, undefined);
expect(processor.restart).not.toHaveBeenCalled();
expect(adapter.processedTrack).toEqual({ id: "processed" });
});
// The processor may be the app's one pipeline, running on another track
it("leaves the processor alone when let go before it was built", async () => {
const processor = fakeProcessor();
const adapter = toLivekitProcessor(processor);
await adapter.init({ kind: Track.Kind.Video, track: ended });
await adapter.destroy();
expect(processor.destroy).not.toHaveBeenCalled();
});
// LiveKit's own camera track, so that the order of its restart is what is
// tested: a raw frame handed to the sender would reach the room
describe("on LiveKit's camera track", () => {
// jsdom has no MediaStream; LiveKit wraps every track in one
beforeAll(() => {
const original = globalThis.MediaStream;
globalThis.MediaStream = class {
public constructor(private readonly tracks: MediaStreamTrack[] = []) {}
public getTracks(): MediaStreamTrack[] {
return this.tracks;
}
public getVideoTracks(): MediaStreamTrack[] {
return this.tracks.filter(({ kind }) => kind === "video");
}
public getAudioTracks(): MediaStreamTrack[] {
return this.tracks.filter(({ kind }) => kind === "audio");
}
public addTrack(track: MediaStreamTrack): void {
this.tracks.push(track);
}
public removeTrack(track: MediaStreamTrack): void {
this.tracks.splice(this.tracks.indexOf(track), 1);
}
} as unknown as typeof MediaStream;
// LiveKit plays its processor element and catches a refusal
const play = vi
.spyOn(HTMLMediaElement.prototype, "play")
.mockResolvedValue(undefined);
return (): void => {
globalThis.MediaStream = original;
play.mockRestore();
};
});
function deviceTrack(id: string): MediaStreamTrack {
const track = {
kind: "video",
id,
label: "",
enabled: true,
readyState: "live",
getSettings: () => ({}),
getConstraints: () => ({}),
getCapabilities: () => ({}),
applyConstraints: vi.fn().mockResolvedValue(undefined),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
stop: vi.fn(() => {
Object.assign(track, { readyState: "ended" });
}),
};
return track as unknown as MediaStreamTrack;
}
it("hands the sender only processed frames when the camera turns on", async () => {
const reacquired = deviceTrack("reacquired");
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: vi.fn().mockResolvedValue({
getTracks: () => [reacquired],
}),
},
});
const processedTrack = { id: "processed" } as MediaStreamTrack;
const processor = {
...fakeProcessor(),
init: vi.fn().mockResolvedValue(processedTrack),
};
const camera = new LocalVideoTrack(deviceTrack("first"), {}, false);
camera.source = Track.Source.Camera;
const replaceTrack = vi.fn().mockResolvedValue(undefined);
Object.assign(camera, {
sender: {
replaceTrack,
getParameters: () => ({ encodings: [] }),
setParameters: vi.fn().mockResolvedValue(undefined),
},
});
await camera.mute();
expect(camera.mediaStreamTrack.readyState).toBe("ended");
// What the camera sent before it was turned off is not in question
replaceTrack.mockClear();
await camera.setProcessor(toLivekitProcessor(processor));
await camera.unmute();
expect(processor.init).toHaveBeenCalledWith(
reacquired,
expect.anything(),
);
expect(replaceTrack.mock.calls).toEqual([[processedTrack]]);
// LiveKit plays attached elements again on a timer
await new Promise((resolve) => setTimeout(resolve, 0));
});
});
});
describe("fromLivekitProcessor", () => {
const captured = { id: "captured" } as MediaStreamTrack;
const processed = { id: "processed" } as MediaStreamTrack;
function fakeLivekitProcessor(
processedTrack: MediaStreamTrack | undefined,
): TrackProcessor<Track.Kind.Video> {
return {
name: "background-blur",
processedTrack,
init: vi.fn().mockResolvedValue(undefined),
restart: vi.fn().mockResolvedValue(undefined),
destroy: vi.fn().mockResolvedValue(undefined),
};
}
it("resolves with the track the LiveKit processor produced", async () => {
const livekitProcessor = fakeLivekitProcessor(processed);
const processor = fromLivekitProcessor(livekitProcessor);
expect(processor.name).toBe("background-blur");
await expect(processor.init(captured)).resolves.toBe(processed);
expect(livekitProcessor.init).toHaveBeenCalledWith({
kind: Track.Kind.Video,
track: captured,
});
await expect(processor.restart(captured)).resolves.toBe(processed);
await processor.destroy();
expect(livekitProcessor.destroy).toHaveBeenCalled();
});
it("rejects where the LiveKit processor produced no track", async () => {
await expect(
fromLivekitProcessor(fakeLivekitProcessor(undefined)).init(captured),
).rejects.toThrow("produced no track");
});
});
+71 -17
View File
@@ -5,30 +5,84 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Track, type TrackProcessor } from "livekit-client";
import { Track, type TrackProcessor } from "livekit-client";
import { type VideoProcessor } from "../../media-api";
import { inTurn } from "../../utils/processorTurns";
export interface LivekitProcessor extends TrackProcessor<Track.Kind.Video> {
/** The processor this adapts, so that setting the same one again is a no-op. */
source: VideoProcessor;
}
/**
* A `VideoProcessor` as livekit-client wants it. LiveKit reads the processed
* track off the processor after `init` and `restart` rather than taking it as
* their result, so the adapter keeps it there.
* A `VideoProcessor` as a livekit-client processor. LiveKit reads the
* processed track off the processor after `init` and `restart` rather than
* taking it as their result, so the adapter keeps it there. A processor a
* lobby already runs on the track is adopted with its output, `running`, so
* that the join does not initialise it again. Every step takes its turn on
* the processor, which a stopped track tears down without waiting for the
* next one's init.
*
* Set on a camera that is off, whose track LiveKit has stopped, the build
* waits for the restart that turns it back on. LiveKit restarts a processor
* before it hands the new track to the sender, so the camera's first frame
* out is already processed.
*/
export function convertToLivekitProcessor(
export function toLivekitProcessor(
processor: VideoProcessor,
): TrackProcessor<Track.Kind.Video> {
const adapter: TrackProcessor<Track.Kind.Video> = {
running?: MediaStreamTrack,
): LivekitProcessor {
let built = false;
const adapter: LivekitProcessor = {
name: processor.name,
init: async (opts) => {
adapter.processedTrack = await processor.init(opts.track, opts.element);
},
restart: async (opts) => {
adapter.processedTrack = await processor.restart(
opts.track,
opts.element,
);
},
destroy: async () => processor.destroy(),
source: processor,
init: async (opts) =>
inTurn(processor, async () => {
if (running === undefined && opts.track.readyState === "ended") return;
adapter.processedTrack =
running ?? (await processor.init(opts.track, opts.element));
running = undefined;
built = true;
}),
restart: async (opts) =>
inTurn(processor, async () => {
adapter.processedTrack = built
? await processor.restart(opts.track, opts.element)
: await processor.init(opts.track, opts.element);
built = true;
}),
destroy: async () =>
inTurn(processor, async () => {
if (built) await processor.destroy();
}),
};
return adapter;
}
/**
* A livekit-client processor, such as one from `@livekit/track-processors`,
* as a `VideoProcessor`: `init` and `restart` resolve with the track it
* leaves on itself.
*/
export function fromLivekitProcessor(
processor: TrackProcessor<Track.Kind.Video>,
): VideoProcessor {
const processedTrack = (): MediaStreamTrack => {
if (processor.processedTrack === undefined)
throw new Error(`Processor ${processor.name} produced no track`);
return processor.processedTrack;
};
return {
name: processor.name,
init: async (track, element) => {
await processor.init({ kind: Track.Kind.Video, track, element });
return processedTrack();
},
restart: async (track, element) => {
await processor.restart({ kind: Track.Kind.Video, track, element });
return processedTrack();
},
destroy: async () => processor.destroy(),
};
}
+26
View File
@@ -0,0 +1,26 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type VideoProcessor } from "../media-api";
const turns = new WeakMap<VideoProcessor, Promise<unknown>>();
/**
* One operation at a time on a processor. A host shares one across the
* tracks it captures and publishes, and a second init while the first still
* loads its model, or a destroy while an init runs, leaves it broken: the
* backend's own locking is per track, and a track that is stopped tears its
* processor down without waiting.
*/
export async function inTurn<T>(
processor: VideoProcessor,
run: () => Promise<T>,
): Promise<T> {
const turn = (turns.get(processor) ?? Promise.resolve()).then(run, run);
turns.set(processor, turn);
return turn;
}
+3 -3
View File
@@ -35,7 +35,7 @@ import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { type WidgetHelpers } from "./widget";
import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./tracks/TrackProcessorContext";
import { BackgroundEffectsProvider } from "./tracks/BackgroundEffectsContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import {
@@ -148,7 +148,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
loaded && clientReady ? (
<ClientProvider client={widgetClient}>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<BackgroundEffectsProvider effects={vm.backgroundEffects}>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
>
@@ -159,7 +159,7 @@ export const App: FC<Props> = ({ vm, widget }) => {
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
@@ -0,0 +1,131 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.grid {
display: grid;
/* A zero floor, so a long label wraps inside its tile rather than widening
the grid past the menu. */
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: var(--cpd-space-3x);
padding: var(--cpd-space-2x) var(--cpd-space-4x) 0;
}
/* Within the grid, to outweigh the menu item styles the tiles are built on. */
.grid .tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--cpd-space-1x);
min-inline-size: 0;
padding: var(--cpd-space-3x) var(--cpd-space-2x);
/* Not hidden: that makes the tile a scroll container, which focus scrolls
into view without its border, and so without the focus ring. */
overflow: clip;
border: var(--cpd-border-width-2) solid
var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-2x);
background: none;
color: var(--cpd-color-icon-secondary);
font: inherit;
cursor: pointer;
}
/* As the menu items highlight on hover. */
.grid .tile[data-highlighted] {
background: var(--cpd-color-bg-action-secondary-hovered);
}
.grid .tile[aria-checked="true"] {
border-color: var(--cpd-color-border-accent);
}
.grid .tile[data-disabled] {
cursor: default;
opacity: 0.55;
}
.tile[data-disabled] .picture {
filter: grayscale(1);
}
.swatch {
display: flex;
align-items: center;
justify-content: center;
}
.picture {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* Positioned, or the picture, positioned itself, would cover it. */
.mark {
position: relative;
display: inline-flex;
color: var(--cpd-color-icon-accent-primary);
}
/* On a picture the mark needs a ground of its own to be seen. */
.picture ~ .mark {
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
box-shadow: 0 0 0 var(--cpd-border-width-2) var(--cpd-color-bg-canvas-default);
}
.label {
max-inline-size: 100%;
font: var(--cpd-font-body-sm-medium);
color: var(--cpd-color-text-primary);
text-align: center;
overflow-wrap: anywhere;
}
.removable {
position: relative;
display: flex;
min-inline-size: 0;
}
.removable > .tile {
flex: 1;
}
/* Clear of the target for choosing the tile. */
.remove {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
translate: 20% -20%;
display: none;
align-items: center;
justify-content: center;
inline-size: 28px;
block-size: 28px;
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-icon-critical-primary);
box-shadow: 0 1.2px 2.4px 0 rgb(0 0 0 / 15%);
cursor: pointer;
}
.remove:hover {
background: var(--cpd-color-bg-critical-subtle-hovered);
box-shadow: 0 0 0 var(--cpd-border-width-1)
var(--cpd-color-border-critical-subtle);
}
.removable:hover .remove,
.removable:focus-within .remove {
display: flex;
}
+224
View File
@@ -0,0 +1,224 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type KeyboardEvent, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { InlineSpinner, MenuItem, Tooltip } from "@vector-im/compound-web";
import {
BlockIcon,
BlurIcon,
CheckCircleSolidIcon,
CloseIcon,
PlusIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./BackgroundEffectGrid.module.css";
/**
* One choice in the camera menu's Background effects section, as the view
* model offers it. The grid names it: pictures are numbered in order.
*/
export interface BackgroundEffectGridItem {
/** Its option id, as `backgroundEffect` gives the one in force. */
id: string;
kind: "none" | "blur" | "image";
/** For kind "image". */
imageUrl?: string;
/** Whether it is the user's to remove. */
removable?: boolean;
}
interface Props {
effects: BackgroundEffectGridItem[];
selected: string | undefined;
/** Undefined where effects can't run: all but no effect are disabled. */
onSelect: ((id: string) => void) | undefined;
/** Shown on the selected tile until its effect is on screen. */
settling?: boolean;
/** Id of the text saying what the user should know before choosing. */
describedBy?: string;
/** Offers to add a background of one's own. Omit to leave that tile out. */
onAdd?: () => void;
addLabel?: string;
/** Removes a removable one, never the one in force. Omit to offer none. */
onRemove?: (id: string) => void;
removeLabel?: string;
}
/**
* The background effects as a grid of tiles: one radio choice, walked by the
* menu's arrow keys like the device rows. Sits in a menu section, which names
* it.
*/
export const BackgroundEffectGrid: FC<Props> = ({
effects,
selected,
onSelect,
settling = false,
describedBy,
onAdd,
addLabel,
onRemove,
removeLabel,
}) => {
const { t } = useTranslation();
const choose = (id: string): void => {
if (id !== selected) onSelect?.(id);
};
let pictures = 0;
const tiles = effects.map((effect) => {
const label =
effect.kind === "none"
? t("background_effects.none")
: effect.kind === "blur"
? t("background_effects.blur")
: t("background_effects.numbered", { n: ++pictures });
const checked = effect.id === selected;
const disabled = onSelect === undefined && effect.kind !== "none";
const remove =
effect.removable && !checked && onRemove !== undefined
? (): void => onRemove(effect.id)
: undefined;
const removeProps =
remove === undefined
? {}
: {
"aria-keyshortcuts": "Delete",
onKeyDown: (e: KeyboardEvent): void => {
if (e.key !== "Delete" && e.key !== "Backspace") return;
e.preventDefault();
remove();
},
};
const content = (
<TileContent
effect={effect}
label={label}
checked={checked}
settling={checked && settling}
/>
);
const tile = (
<MenuItem
key={effect.id}
role="menuitemradio"
aria-checked={checked}
aria-busy={checked && settling}
disabled={disabled}
data-disabled={disabled ? "" : undefined}
label={null}
hideChevron
className={styles.tile}
// Without a handler it isn't one of the menu's items, so the keyboard
// passes over it.
onSelect={
disabled
? null
: (e): void => {
// Kept open, so the user sees what they chose take effect.
e.preventDefault();
choose(effect.id);
}
}
{...removeProps}
>
{content}
</MenuItem>
);
if (remove === undefined) return tile;
// Beside the item, not in it: inside, the item takes the press first and
// the tile is chosen instead.
return (
<div key={effect.id} className={styles.removable}>
{tile}
<Tooltip label={removeLabel ?? ""}>
{/* Not a control of its own, so the menu keeps one keyboard model:
the keyboard reaches the same action through Delete. */}
<span aria-hidden className={styles.remove} onClick={remove}>
<CloseIcon width={20} height={20} />
</span>
</Tooltip>
</div>
);
});
// A command rather than a choice, so an item beside the radios.
const addContent = (
<>
<span aria-hidden className={styles.swatch}>
<PlusIcon width={24} height={24} />
</span>
<VisuallyHidden>{addLabel}</VisuallyHidden>
</>
);
const addTile =
onAdd === undefined ? null : (
<MenuItem
label={null}
hideChevron
className={styles.tile}
onSelect={(e) => {
e.preventDefault();
onAdd();
}}
>
{addContent}
</MenuItem>
);
return (
// A group of its own, as the section's can't carry the description.
<div role="group" aria-describedby={describedBy} className={styles.grid}>
{tiles}
{addTile}
</div>
);
};
function TileContent({
effect,
label,
checked,
settling,
}: {
effect: BackgroundEffectGridItem;
/** The tile's accessible name; drawn only where the tile has no picture. */
label: string;
checked: boolean;
settling: boolean;
}): ReactElement {
return (
<>
<span aria-hidden className={styles.swatch}>
{effect.kind === "image" && (
<img className={styles.picture} src={effect.imageUrl} alt="" />
)}
{settling ? (
<span className={styles.mark}>
<InlineSpinner size={20} />
</span>
) : checked ? (
<CheckCircleSolidIcon
className={styles.mark}
width={20}
height={20}
/>
) : effect.kind === "none" ? (
<BlockIcon width={20} height={20} />
) : effect.kind === "blur" ? (
<BlurIcon width={20} height={20} />
) : null}
</span>
{effect.kind === "image" ? (
<VisuallyHidden>{label}</VisuallyHidden>
) : (
<span className={styles.label}>{label}</span>
)}
</>
);
}
+19 -2
View File
@@ -22,6 +22,7 @@ import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
import { constant } from "../state/Behavior";
import { type LayoutMode } from "../state/LayoutSwitchViewModel";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
// consts for tests
const reactionIdentifier = "@user:example.com:DEVICE";
@@ -123,8 +124,24 @@ export const Default: Story = {
toggleAudio: fn(),
toggleVideo: fn(),
toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
selectBackgroundEffect: fn(),
backgroundEffect: "blur",
backgroundEffects: [
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image" as const,
imageUrl: background.imagePath,
removable: false,
})),
],
backgroundEffectNotice: undefined,
backgroundEffectSettling: false,
backgroundImageRefusal: undefined,
addBackgroundImage: fn(),
removeBackgroundEffect: fn(),
cameraTrack: null,
hangup: fn(),
buttonSize: "lg",
showFooter: true,
+68 -6
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, type JSX, type Ref, useMemo } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -31,6 +32,14 @@ import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
import { LayoutSwitch } from "../room/LayoutSwitch";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { type UnusableReason } from "../tracks/backgroundImages";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
export interface BackgroundImageRefusal {
reason: UnusableReason | "not-kept";
}
export interface AudioOutputSwitcher {
targetOutput: string;
@@ -56,20 +65,37 @@ export interface FooterActions {
toggleAudio: (() => void) | undefined;
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Undefined where background effects can't be chosen. */
selectBackgroundEffect: ((id: string) => void) | undefined;
/** Undefined where no background can be added. */
addBackgroundImage: ((file: File) => void) | undefined;
/** Removes an added background by its option id; never the one in force. */
removeBackgroundEffect: ((id: string) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
/** Also controls if the hangup button is visible */
hangup: (() => void) | undefined;
}
// we do not use any ? optional properties so that the vm type is including all fields.
export interface FooterState {
audioEnabled: boolean;
audioBusy: boolean;
videoEnabled: boolean;
videoBusy: boolean;
videoBlurEnabled: boolean;
/** The background effect in force, as its option id. */
backgroundEffect: string;
/** Every effect on offer, in the order they are shown. */
backgroundEffects: BackgroundEffectGridItem[];
/** What the user is told about effects here, if anything. */
backgroundEffectNotice: "unavailable" | "slow" | undefined;
/** Whether the first effect chosen is still being prepared. */
backgroundEffectSettling: boolean;
/** Why the last file offered couldn't be kept, if it couldn't. */
backgroundImageRefusal: BackgroundImageRefusal | undefined;
/** The camera to preview, with the effect in force; null while it is off. */
cameraTrack: LocalVideoMediaTrack | null;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -117,12 +143,14 @@ export interface FooterProps {
children?: JSX.Element | JSX.Element[] | false;
vm: ViewModel<FooterSnapshot>;
}
export const CallFooter: FC<FooterProps> = ({
className,
ref,
children,
vm,
}) => {
const { t } = useTranslation();
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
@@ -151,8 +179,29 @@ export const CallFooter: FC<FooterProps> = ({
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const backgroundEffect = useBehavior(vm.backgroundEffect$);
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const backgroundEffectNotice = useBehavior(vm.backgroundEffectNotice$);
const backgroundEffectSettling = useBehavior(vm.backgroundEffectSettling$);
const addBackgroundImage = useBehavior(vm.addBackgroundImage$);
const removeBackgroundEffect = useBehavior(vm.removeBackgroundEffect$);
const cameraTrack = useBehavior(vm.cameraTrack$);
const refusal = useBehavior(vm.backgroundImageRefusal$);
const refusalMessage = useMemo(() => {
switch (refusal?.reason) {
case undefined:
return undefined;
case "not-an-image":
return { text: t("error.background_not_an_image") };
case "animated":
return { text: t("error.background_animated") };
case "undecodable":
return { text: t("error.background_undecodable") };
case "not-kept":
return { text: t("error.background_not_kept") };
}
}, [refusal, t]);
const backgroundEffects = useBehavior(vm.backgroundEffects$);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
@@ -214,8 +263,21 @@ export const CallFooter: FC<FooterProps> = ({
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
backgroundEffects={backgroundEffects}
selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
backgroundEffectSettling={backgroundEffectSettling}
onAddBackgroundImage={addBackgroundImage}
onRemoveBackgroundEffect={removeBackgroundEffect}
selfPreview={<SelfPreview track={cameraTrack} />}
backgroundImageRefusal={refusalMessage}
backgroundEffectNotice={
backgroundEffectNotice === "unavailable"
? t("background_effects.unavailable")
: backgroundEffectNotice === "slow"
? t("background_effects.slow")
: undefined
}
/>,
);
} else {
+323 -12
View File
@@ -5,17 +5,37 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { testScope, mockMuteStates, mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import {
flushPromises,
testScope,
mockMuteStates,
mockMediaDevices,
mockVideoTrack,
} from "../utils/test";
import { type Behavior, constant } from "../state/Behavior";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
import {
createCallFooterViewModel,
createLobbyFooterViewModel,
} from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
import { backgroundEffect as backgroundEffectSetting } from "../settings/settings";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import type * as BackgroundImages from "../tracks/backgroundImages";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -24,12 +44,30 @@ vi.mock("../Platform", () => ({
},
}));
// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
// The SDK's own check needs WebGL and canvas APIs jsdom does not have.
const sdkSupportMock = vi.hoisted(() => vi.fn(() => false));
const modernRouteMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
supportsModernBackgroundProcessors: (): boolean => modernRouteMock(),
}));
const store = vi.hoisted(() => ({
add: vi.fn<(file: Blob) => Promise<string>>(),
remove: vi.fn<(id: string) => Promise<void>>(),
}));
vi.mock("../tracks/backgroundImages", async (original) => {
const { BehaviorSubject } = await import("rxjs");
return {
...(await original<typeof BackgroundImages>()),
addedBackgrounds: {
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
add: store.add,
remove: store.remove,
},
};
});
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
@@ -43,6 +81,7 @@ vi.mock("livekit-client", () => ({
function buildMinimalCallViewModel(layout: Layout): CallViewModel {
return {
layout$: constant(layout),
camera$: constant(undefined),
edgeToEdge$: constant(false),
showHeader$: constant(false),
hangup: (): void => {},
@@ -65,10 +104,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
const gridLayout: Layout = {
type: "grid",
grid: [],
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: (_: number) => {},
};
@@ -78,6 +113,16 @@ const pipLayout: Layout = {
spotlight: {} as SpotlightTileViewModel,
};
const noPipeline = constant<BackgroundEffectsState>({
supported: false,
processor: undefined,
});
/** The provider's verdict, which the SDK's check stands for here. */
function pipelineAsTheSdkSays(): Behavior<BackgroundEffectsState> {
return constant({ supported: sdkSupportMock(), processor: undefined });
}
const twoMicsAndOneCamMediaDevices = mockMediaDevices({
audioInput: {
available$: constant(
@@ -109,6 +154,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -151,6 +197,7 @@ describe("createCallFooterViewModel", () => {
select: vi.fn(),
},
}),
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -169,6 +216,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(layout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -191,6 +239,7 @@ describe("createCallFooterViewModel", () => {
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
@@ -222,4 +271,266 @@ describe("createCallFooterViewModel", () => {
]);
});
});
describe("background effects", () => {
afterEach(() => backgroundEffectSetting.setValue("none"));
// The lobby, because in a call the whole device switcher is already
// withheld on a phone, whatever the verdict says.
function lobbyOn(
platform: string,
): ReturnType<typeof createLobbyFooterViewModel> {
platformMock.mockReturnValue(platform);
return createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
}
it("offers them on a phone whose browser can run them", () => {
sdkSupportMock.mockReturnValue(true);
expect(lobbyOn("ios").selectBackgroundEffect$.value).toBeDefined();
});
it("offers them on a desktop whose browser can run them", () => {
sdkSupportMock.mockReturnValue(true);
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeDefined();
});
it("offers nothing where the browser itself cannot run them", () => {
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").selectBackgroundEffect$.value).toBeUndefined();
expect(lobbyOn("desktop").addBackgroundImage$.value).toBeUndefined();
});
it("puts no effect in force where the browser cannot run them", () => {
backgroundEffectSetting.setValue("blur");
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("none");
sdkSupportMock.mockReturnValue(true);
expect(lobbyOn("desktop").backgroundEffect$.value).toBe("blur");
});
it("stores the effect chosen", () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyOn("desktop");
vm.selectBackgroundEffect$.value?.("image:arc");
expect(vm.backgroundEffect$.value).toBe("image:arc");
// What isn't an effect on offer is stored as none.
vm.selectBackgroundEffect$.value?.("image:gone");
expect(backgroundEffectSetting.getValue()).toBe("none");
});
it("says they run slowly where only the slower route exists", () => {
sdkSupportMock.mockReturnValue(true);
modernRouteMock.mockReturnValue(false);
const slow = lobbyOn("desktop");
expect(slow.backgroundEffectNotice$.value).toBe("slow");
expect(slow.selectBackgroundEffect$.value).toBeDefined();
modernRouteMock.mockReturnValue(true);
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBeUndefined();
});
it("offers every effect in order, the added ones removable", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
added$.next([{ id: "mine", url: "blob:mine" }]);
expect(lobbyOn("desktop").backgroundEffects$.value).toEqual([
{ id: "none", kind: "none", removable: false },
{ id: "blur", kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image",
imageUrl: background.imagePath,
removable: false,
})),
{
id: "added:mine",
kind: "image",
imageUrl: "blob:mine",
removable: true,
},
]);
added$.next([]);
});
it("says they are unavailable where they cannot be chosen", () => {
sdkSupportMock.mockReturnValue(false);
expect(lobbyOn("desktop").backgroundEffectNotice$.value).toBe(
"unavailable",
);
});
it("passes on the wait for the first effect", () => {
platformMock.mockReturnValue("desktop");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "settling",
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffectSettling$.value).toBe(true);
pipeline$.next({ ...pipeline$.value, wait: undefined });
expect(vm.backgroundEffectSettling$.value).toBe(false);
});
it("offers nothing once the pipeline fails to build, keeping the choice", () => {
platformMock.mockReturnValue("desktop");
backgroundEffectSetting.setValue("blur");
const pipeline$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipeline$,
/* camera$ */ constant(null),
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.backgroundEffect$.value).toBe("blur");
pipeline$.next({ supported: false, processor: undefined });
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.addBackgroundImage$.value).toBeUndefined();
expect(vm.backgroundEffect$.value).toBe("none");
expect(backgroundEffectSetting.getValue()).toBe("blur");
});
it("adding is unavailable at the limit", () => {
sdkSupportMock.mockReturnValue(true);
const added$ = addedBackgrounds.added$ as BehaviorSubject<
AddedBackground[] | undefined
>;
const vm = lobbyOn("desktop");
expect(vm.addBackgroundImage$.value).toBeDefined();
added$.next(
Array.from({ length: maxAddedBackgrounds }, (_, i) => ({
id: `${i}`,
url: `blob:${i}`,
})),
);
expect(vm.addBackgroundImage$.value).toBeUndefined();
added$.next(added$.value!.slice(1));
expect(vm.addBackgroundImage$.value).toBeDefined();
added$.next([]);
});
it("says why each refused file was refused", async () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyOn("desktop");
store.add.mockRejectedValue(new UnusableImage("animated"));
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
const first = vm.backgroundImageRefusal$.value;
expect(first).toEqual({ reason: "animated" });
// The same again is a refusal of its own, so it is shown again.
vm.addBackgroundImage$.value!(new File(["x"], "a.gif"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "animated" });
expect(vm.backgroundImageRefusal$.value).not.toBe(first);
store.add.mockRejectedValue(new Error("quota"));
vm.addBackgroundImage$.value!(new File(["x"], "b.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toEqual({ reason: "not-kept" });
store.add.mockResolvedValue("kept");
vm.addBackgroundImage$.value!(new File(["x"], "c.png"));
await flushPromises();
expect(vm.backgroundImageRefusal$.value).toBeUndefined();
});
it("never removes the background in force", () => {
sdkSupportMock.mockReturnValue(true);
store.remove.mockResolvedValue();
const vm = lobbyOn("desktop");
backgroundEffectSetting.setValue("added:worn");
vm.removeBackgroundEffect$.value!("added:worn");
expect(store.remove).not.toHaveBeenCalled();
vm.removeBackgroundEffect$.value!("added:spare");
expect(store.remove).toHaveBeenCalledWith("spare");
});
it("previews the lobby's camera while there is one", () => {
const track = mockVideoTrack({});
const camera$ = new BehaviorSubject<LocalVideoMediaTrack | null>(track);
const vm = createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
camera$,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
expect(vm.cameraTrack$.value).toBe(track);
// The lobby releases the camera when it is turned off
camera$.next(null);
expect(vm.cameraTrack$.value).toBeNull();
});
it("previews the call's camera only while it is not muted", () => {
const muted$ = new BehaviorSubject(false);
const track = mockVideoTrack({ muted$ });
const vm = createCallFooterViewModel(
testScope(),
{ ...buildMinimalCallViewModel(gridLayout), camera$: constant(track) },
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
noPipeline,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.cameraTrack$.value).toBe(track);
muted$.next(true);
expect(vm.cameraTrack$.value).toBeNull();
muted$.next(false);
expect(vm.cameraTrack$.value).toBe(track);
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
const lobby = lobbyOn("desktop");
const inCall = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
pipelineAsTheSdkSays(),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
const offeredInLobby =
lobby.selectBackgroundEffect$.value !== undefined;
expect(inCall.selectBackgroundEffect$.value !== undefined).toBe(
offeredInLobby,
);
expect(offeredInLobby).toBe(supported);
}
});
});
});
+181 -18
View File
@@ -5,15 +5,38 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, type Observable, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
map,
type Observable,
of,
switchMap,
} from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import { type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { supportsModernBackgroundProcessors } from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import {
parseEffect,
serializeEffect,
shippedBackgrounds,
} from "../tracks/backgroundEffectCatalog";
import {
type AddedBackground,
addedBackgrounds,
maxAddedBackgrounds,
UnusableImage,
} from "../tracks/backgroundImages";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
backgroundBlur as backgroundBlurSettings,
backgroundEffect as backgroundEffectSetting,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
@@ -22,7 +45,8 @@ import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
import { type BackgroundImageRefusal, type FooterSnapshot } from "./CallFooter";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
/**
* Shared helper: maps MuteStates into the audio/video enabled + toggle behaviors
@@ -56,13 +80,16 @@ function buildMuteBehaviors(
/**
* Shared helper: maps MediaDevices into the audio/video device-list behaviors
* needed by FooterSnapshot (options, selection, callbacks, blur toggle).
* needed by FooterSnapshot (options, selection, callbacks, background effect).
*/
function buildDeviceBehaviors(
scope: ObservableScope,
mediaDevices: MediaDevices,
/** return empty arrays for audioOptions and videoOptions*/
disableSwitcher$: Behavior<boolean>,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
/** The camera as the pipeline has it, for the menu's preview. */
camera$: Observable<LocalVideoMediaTrack | null | undefined>,
): Pick<
ViewModel<FooterSnapshot>,
| "audioOptions$"
@@ -74,8 +101,15 @@ function buildDeviceBehaviors(
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
| "backgroundEffect$"
| "selectBackgroundEffect$"
| "backgroundEffects$"
| "backgroundEffectNotice$"
| "backgroundEffectSettling$"
| "addBackgroundImage$"
| "backgroundImageRefusal$"
| "removeBackgroundEffect$"
| "cameraTrack$"
> {
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
@@ -92,6 +126,19 @@ function buildDeviceBehaviors(
),
);
// Also false once the pipeline has failed to build this session.
const supported$ = backgroundEffectsState$.pipe(
map(({ supported }) => supported === true),
distinctUntilChanged(),
);
const slow = !supportsModernBackgroundProcessors();
const offered$ = combineLatest([disableSwitcher$, supported$]).pipe(
map(([switcherDisabled, supported]) => !switcherDisabled && supported),
);
// A new object for each refusal, so the same one twice is shown twice.
const refusal$ = new BehaviorSubject<BackgroundImageRefusal | undefined>(
undefined,
);
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
selectedAudio$: scope.behavior(
@@ -116,18 +163,93 @@ function buildDeviceBehaviors(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
selectVideoButtonOption$: constant(mediaDevices.videoInput.select),
toggleBlur$: scope.behavior(
combineLatest([backgroundBlurSettings.value$, disableSwitcher$]).pipe(
map(([current, switcherDisabled]) => {
return !switcherDisabled && supportsBackgroundProcessors()
? (): void => {
backgroundBlurSettings.setValue(!current);
backgroundEffect$: scope.behavior(
combineLatest([backgroundEffectSetting.value$, supported$]).pipe(
map(([raw, supported]) =>
supported ? serializeEffect(parseEffect(raw)) : "none",
),
),
),
selectBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (id: string): void =>
backgroundEffectSetting.setValue(
serializeEffect(parseEffect(id)),
)
: undefined,
),
),
),
backgroundEffects$: scope.behavior(
addedBackgrounds.added$.pipe(
map((added) => backgroundEffectChoices(added ?? [])),
),
),
backgroundEffectNotice$: scope.behavior(
offered$.pipe(
map((offered) =>
!offered ? "unavailable" : slow ? "slow" : undefined,
),
),
),
backgroundEffectSettling$: scope.behavior(
backgroundEffectsState$.pipe(
map(({ wait }) => wait !== undefined),
distinctUntilChanged(),
),
false,
),
// Kept and offered, not put on: that waits for the user to choose it.
addBackgroundImage$: scope.behavior(
combineLatest([offered$, addedBackgrounds.added$]).pipe(
map(([offered, added]) =>
offered && (added?.length ?? 0) < maxAddedBackgrounds
? (file: File): void => {
refusal$.next(undefined);
addedBackgrounds.add(file).catch((e) => {
logger.warn("Could not keep that background", e);
refusal$.next({
reason: e instanceof UnusableImage ? e.reason : "not-kept",
});
});
}
: undefined;
}),
: undefined,
),
),
),
backgroundImageRefusal$: refusal$,
// None while muted: the call mutes its track rather than releasing it.
cameraTrack$: scope.behavior(
camera$.pipe(
switchMap((camera) =>
camera
? camera.muted$.pipe(map((muted) => (muted ? null : camera)))
: of(null),
),
),
null,
),
// Never the one in force: taking it away would leave nothing chosen.
removeBackgroundEffect$: scope.behavior(
offered$.pipe(
map((offered) =>
offered
? (optionId: string): void => {
const effect = parseEffect(optionId);
if (effect.kind !== "added") return;
if (backgroundEffectSetting.getValue() === optionId) return;
addedBackgrounds
.remove(effect.id)
.catch((e) =>
logger.warn("Could not remove that background", e),
);
}
: undefined,
),
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
};
}
@@ -138,6 +260,8 @@ function buildDeviceBehaviors(
* @param callModel - The root CallViewModel; provides layout, grid mode, reactions, etc.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
* @param options - `showControls`: whether the call controls should be shown.
@@ -148,6 +272,7 @@ export function createCallFooterViewModel(
callModel: CallViewModel,
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
reactionIdentifier: string | undefined,
options: { showControls: boolean; header: HeaderStyle },
): ViewModel<FooterSnapshot> {
@@ -162,7 +287,13 @@ export function createCallFooterViewModel(
);
return {
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, disableDeviceSwitcher$),
...buildDeviceBehaviors(
scope,
mediaDevices,
disableDeviceSwitcher$,
backgroundEffectsState$,
callModel.camera$,
),
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
@@ -224,6 +355,9 @@ export function createCallFooterViewModel(
* @param scope - ObservableScope that bounds the lifetime of derived behaviors.
* @param muteStates - Audio and video mute state + toggles.
* @param mediaDevices - Available and selected input devices.
* @param backgroundEffectsState$ - The background pipeline's state: whether
* effects run here, and the wait for the first.
* @param camera$ - The lobby's camera, for the camera menu's preview.
* @param openSettings - Callback to open the settings modal, or undefined.
* @param hangup - Callback to leave/cancel, or undefined (hides the button).
* @param showLogo - Whether to show the Element Call logo.
@@ -232,6 +366,8 @@ export function createLobbyFooterViewModel(
scope: ObservableScope,
muteStates: MuteStates,
mediaDevices: MediaDevices,
backgroundEffectsState$: Observable<BackgroundEffectsState>,
camera$: Behavior<LocalVideoMediaTrack | null>,
openSettings: (() => void) | undefined,
hangup: (() => void) | undefined,
showLogo: boolean,
@@ -275,6 +411,33 @@ export function createLobbyFooterViewModel(
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
...buildDeviceBehaviors(scope, mediaDevices, constant(false)),
...buildDeviceBehaviors(
scope,
mediaDevices,
constant(false),
backgroundEffectsState$,
camera$,
),
};
}
function backgroundEffectChoices(
added: AddedBackground[],
): BackgroundEffectGridItem[] {
return [
{ id: serializeEffect({ kind: "none" }), kind: "none", removable: false },
{ id: serializeEffect({ kind: "blur" }), kind: "blur", removable: false },
...shippedBackgrounds.map((background) => ({
id: serializeEffect({ kind: "shipped", id: background.id }),
kind: "image" as const,
imageUrl: background.imagePath,
removable: false,
})),
...added.map((background) => ({
id: serializeEffect({ kind: "added", id: background.id }),
kind: "image" as const,
imageUrl: background.url,
removable: true,
})),
];
}
@@ -36,20 +36,17 @@ Please see LICENSE in the repository root for full details.
}
}
/* The width design sets: a long device name wraps. */
.menu {
display: flex;
flex-direction: column;
inline-size: 296px;
}
/* Only the device lists scroll, bounded by the measured call area: the menu is
portalled out of the root, so CSS can't size it against the call. */
/* Its share of the measured call area: the menu is portalled out of the root,
so CSS can't size it against the call. */
.deviceList {
overflow-y: auto;
min-block-size: 0;
max-block-size: var(--device-list-max-height);
/* Keeps a row reached by keyboard clear of the sticky heading and meter. */
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
var(--device-list-scroll-padding-end, 0);
/* The raised headings stay raised within the list, under the menu's frame. */
isolation: isolate;
}
/* Compound's heading rule heads each section; the design adds space below it. */
@@ -62,23 +59,10 @@ Please see LICENSE in the repository root for full details.
margin-block-start: var(--cpd-space-7x);
}
/* Sticky within its own section. Opaque and inset a border width so it doesn't
paint over the menu's outline. */
.sectionHeading {
position: sticky;
inset-block-start: 0;
background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1);
margin-block-start: var(--cpd-border-width-1);
}
/* Sticky at the foot of the microphone section; opaque and inset as above. */
.stickyMeter {
position: sticky;
inset-block-end: 0;
background: var(--cpd-color-bg-canvas-default);
margin-inline: var(--cpd-border-width-1);
margin-block-end: var(--cpd-border-width-1);
/* Raised, or the positioned effect tiles scroll over the heading they sit
under. Compound's sticky heading is the section's aria-hidden first child. */
.deviceList [role="group"] > [aria-hidden="true"]:first-child {
z-index: 1;
}
/* Radix focuses whatever the pointer is over, so the browser's ring would
@@ -92,3 +76,56 @@ Please see LICENSE in the repository root for full details.
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2));
}
.notice {
display: flex;
align-items: flex-start;
gap: var(--cpd-space-3x);
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
padding: var(--cpd-space-3x);
border-radius: var(--cpd-space-2x);
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-regular);
}
.notice > svg {
flex-shrink: 0;
color: var(--cpd-color-icon-secondary);
}
.refusal {
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
}
.selfPreview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
aspect-ratio: 16 / 9;
margin-block-end: var(--cpd-space-2x);
overflow: clip;
border-start-start-radius: var(--cpd-space-3x);
border-start-end-radius: var(--cpd-space-3x);
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-icon-secondary);
}
.selfPreview > video {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
/* Rounded itself too: a video can be drawn on a layer of its own, which the
frame's rounded clip doesn't always reach. */
border-radius: inherit;
}
/* Up to the menu's frame, over the padding at its top. */
.selfPreviewPinned,
.deviceListLeadsWithPreview {
margin-block-start: calc(-1 * var(--cpd-space-5x));
}
@@ -11,6 +11,9 @@ import { useEffect, useState, type FC, type JSX, type ReactNode } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MediaMuteAndSwitchButton } from "./MediaMuteAndSwitchButton";
import styles from "./MediaMuteAndSwitchButton.module.css";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { SelfPreview } from "./SelfPreview";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import meterStyles from "./MicrophoneLevelMeter.module.css";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { RootElementProvider } from "../RootElementContext";
@@ -58,15 +61,64 @@ const WithAMicrophone: FC<{ children: ReactNode }> = ({ children }) => {
return <>{children}</>;
};
/** A camera for the preview: a figure against a room, drawn to a stream. */
const FakeCamera: FC = () => {
const [stream, setStream] = useState<MediaStream>();
useEffect(() => {
const canvas = document.createElement("canvas");
canvas.width = 640;
canvas.height = 360;
const context = canvas.getContext("2d")!;
const draw = (): void => {
context.fillStyle = "#6b7f8e";
context.fillRect(0, 0, 640, 360);
context.fillStyle = "#c9a27e";
context.beginPath();
context.arc(320, 150, 62, 0, 2 * Math.PI);
context.fill();
context.fillStyle = "#34495e";
context.beginPath();
context.ellipse(320, 380, 150, 140, 0, Math.PI, 2 * Math.PI);
context.fill();
};
draw();
// Redrawn, so the stream keeps delivering frames.
const timer = setInterval(draw, 200);
const captured = canvas.captureStream(10);
setStream(captured);
return (): void => {
clearInterval(timer);
captured.getTracks().forEach((track) => track.stop());
};
}, []);
return (
<video
data-testid="preview-camera"
autoPlay
muted
playsInline
ref={(video): void => {
if (video && stream && video.srcObject !== stream)
video.srcObject = stream;
}}
/>
);
};
/** Supplies a call-sized root. Without one the list is bounded by the whole Storybook frame. */
const WithACallArea: FC<{ children: ReactNode }> = ({ children }) => {
const WithACallArea: FC<{ children: ReactNode; blockSize?: number }> = ({
children,
// A call's size: anything smaller makes a short device list scroll, and a
// narrower one narrows the menu.
blockSize = 720,
}) => {
const [callArea, setCallArea] = useState<HTMLElement | null>(null);
return (
<div
ref={setCallArea}
style={{
// A call's size: anything smaller makes a short device list scroll.
blockSize: 720,
blockSize,
inlineSize: 1024,
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
@@ -97,6 +149,10 @@ const meta = {
export default meta;
type Story = StoryObj<typeof meta>;
// By place: Compound's section heading is aria-hidden, as its group carries
// the name.
const SECTION_HEADING = "[role='group'] > [aria-hidden]";
export const Default: Story = {
args: {
iconsAndLabels: "audio",
@@ -127,8 +183,6 @@ export const AudioMute: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
videoBlurEnabled: true,
videoBlurToggleClick: fn(),
selectedOption: "2",
},
play: async ({ args, canvasElement }) => {
@@ -162,11 +216,21 @@ export const VideoMute: Story = {
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedOption: "1",
selfPreview: <SelfPreview track={null} />,
},
};
const effects: BackgroundEffectGridItem[] = [
{ id: "none", kind: "none" },
{ id: "blur", kind: "blur" },
...shippedBackgrounds.map((background) => ({
id: `image:${background.id}`,
kind: "image" as const,
imageUrl: background.imagePath,
})),
];
export const VideoUnmute: Story = {
args: {
iconsAndLabels: "video",
@@ -175,9 +239,486 @@ export const VideoUnmute: Story = {
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
videoBlurEnabled: true,
videoBlurToggleClick: fn(),
selectedOption: "2",
backgroundEffects: effects,
selectedBackgroundEffect: "none",
onSelectBackgroundEffect: fn(),
onAddBackgroundImage: fn(),
// As the footer always gives the camera menu one.
selfPreview: <FakeCamera />,
},
};
/** One choice among no effect, blur and the shipped images, under the cameras. */
export const BackgroundEffects: Story = {
args: VideoUnmute.args,
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const section = await menu.findByRole("group", {
name: "Background effects",
});
const tiles = within(section).getAllByRole("menuitemradio");
await expect(tiles.map((tile) => tile.textContent)).toEqual([
"None",
"Blur",
"Background 1",
"Background 2",
]);
await expect(
within(section).getByRole("menuitemradio", { checked: true }),
).toHaveTextContent("None");
const cameras = menu.getByRole("group", { name: "Camera" });
await expect(
cameras.compareDocumentPosition(section) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
const [first, second, third, fourth] = tiles.map((tile) =>
tile.getBoundingClientRect(),
);
await expect(second.top).toBe(first.top);
await expect(third.top).toBe(first.top);
await expect(fourth.top).toBeGreaterThan(first.bottom);
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
for (const tile of [first, second, third])
await expect(tile.left >= frame.left && tile.right <= frame.right).toBe(
true,
);
const add = within(section).getByRole("menuitem", { name: "Add image" });
await expect(
tiles[3].compareDocumentPosition(add) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
await expect(menu.getByRole("menu")).toBeVisible();
},
};
/** Where effects can't run: shown, disabled and explained, except no effect. */
export const BackgroundEffectsUnavailable: Story = {
args: {
...VideoUnmute.args,
onSelectBackgroundEffect: undefined,
backgroundEffectNotice:
"Background effects are not supported on this platform.",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const section = await menu.findByRole("group", {
name: "Background effects",
});
// On the tiles' own group: the section's can't carry a description.
await expect(
within(section).getByRole("group"),
).toHaveAccessibleDescription(
"Background effects are not supported on this platform.",
);
for (const tile of within(section).getAllByRole("menuitemradio"))
if (tile.textContent === "None") await expect(tile).toBeEnabled();
else await expect(tile).toBeDisabled();
},
};
/** Where only the slower route exists: offered and choosable, with the cost said. */
export const BackgroundEffectsSlowInThisBrowser: Story = {
args: {
...VideoUnmute.args,
backgroundEffectNotice:
"Background effects run slowly on this platform, which may cause your video to stutter.",
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const section = await menu.findByRole("group", {
name: "Background effects",
});
// On the tiles' own group: the section's can't carry a description.
await expect(
within(section).getByRole("group"),
).toHaveAccessibleDescription(
"Background effects run slowly on this platform, which may cause your video to stutter.",
);
for (const tile of within(section).getAllByRole("menuitemradio"))
await expect(tile).not.toHaveAttribute("aria-disabled");
await userEvent.click(
within(section).getByRole("menuitemradio", { name: "Background 1" }),
);
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith(
"image:arc",
);
const notice = document.body.querySelector(`.${styles.notice}`)!;
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(frame.width).toBeLessThanOrEqual(296);
await expect(notice.getBoundingClientRect().right).toBeLessThanOrEqual(
frame.right,
);
},
};
/** The first effect of a session shows its wait on its own tile. */
export const BackgroundEffectsSettling: Story = {
args: {
...VideoUnmute.args,
selectedBackgroundEffect: "blur",
backgroundEffectSettling: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const chosen = await menu.findByRole("menuitemradio", {
name: "Blur",
checked: true,
});
await expect(chosen).toHaveAttribute("aria-busy", "true");
const marks = chosen.querySelectorAll("svg");
await expect(marks).toHaveLength(1);
await expect(getComputedStyle(marks[0]).animationName).not.toBe("none");
const section = menu.getByRole("group", { name: "Background effects" });
for (const other of within(section).getAllByRole("menuitemradio", {
checked: false,
}))
await expect(other).toHaveAttribute("aria-busy", "false");
await expect(menu.queryByTestId("preview-camera")).toBeNull();
const frames = document.body.querySelectorAll(`.${styles.selfPreview} svg`);
await expect(frames).toHaveLength(1);
},
};
/** A file that can't be used: said where it was chosen, and nothing changes. */
export const BackgroundImageRefused: Story = {
args: {
...VideoUnmute.args,
selectedBackgroundEffect: "blur",
backgroundEffectNotice:
"Background effects run slowly on this platform, which may cause your video to stutter.",
backgroundImageRefusal: { text: "That file is not a supported image" },
},
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={300}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const message = await menu.findByText("That file is not a supported image");
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
const scrollport = list.getBoundingClientRect();
const shown = message.getBoundingClientRect();
await expect(shown.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
await expect(shown.top).toBeGreaterThanOrEqual(scrollport.top - 1);
await expect(menu.queryByText(/run slowly/)).toBeNull();
await expect(
menu.getByRole("menuitemradio", { name: "Blur" }),
).toHaveAttribute("aria-checked", "true");
},
};
/** A flat picture, for backgrounds of one's own. */
function swatch(colour: string): string {
return `data:image/svg+xml,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="9"><rect width="16" height="9" fill="${colour}"/></svg>`,
)}`;
}
/** One's own backgrounds are removable, never the one in force. */
export const AddedBackgroundsCanBeRemoved: Story = {
args: {
...VideoUnmute.args,
backgroundEffects: [
...effects,
{
id: "added:one",
kind: "image",
imageUrl: swatch("#c2410c"),
removable: true,
},
{
id: "added:two",
kind: "image",
imageUrl: swatch("#1d4ed8"),
removable: true,
},
],
selectedBackgroundEffect: "added:one",
onRemoveBackgroundEffect: fn(),
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const body = within(document.body);
const inForce = await body.findByRole("menuitemradio", {
name: "Background 3",
});
await expect(inForce).not.toHaveAttribute("aria-keyshortcuts");
const other = body.getByRole("menuitemradio", { name: "Background 4" });
await expect(other).toHaveAttribute("aria-keyshortcuts", "Delete");
other.focus();
await userEvent.keyboard("{Delete}");
await expect(args.onRemoveBackgroundEffect).toHaveBeenCalledWith(
"added:two",
);
const wrapper = other.parentElement!;
await userEvent.hover(wrapper);
const cross = wrapper.querySelector<HTMLElement>(
"[aria-hidden]:last-child",
)!;
const probe = document.createElement("span");
probe.style.color = "var(--cpd-color-icon-critical-primary)";
cross.append(probe);
const critical = getComputedStyle(probe).color;
probe.remove();
await expect(getComputedStyle(cross).color).toBe(critical);
const tile = other.getBoundingClientRect();
const box = cross.getBoundingClientRect();
await expect(box.right).toBeGreaterThan(tile.right);
await expect(box.top).toBeLessThan(tile.top);
await userEvent.hover(cross);
await waitFor(async () =>
expect(
[...document.body.querySelectorAll("div")].some(
(d) => d.textContent === "Remove" && d.offsetParent !== null,
),
).toBe(true),
);
},
};
/** Removing by pointer while the selection really changes. */
export const RemovingWithLiveSelection: Story = {
args: AddedBackgroundsCanBeRemoved.args,
render: function WithLiveSelection(args): JSX.Element {
const [selected, setSelected] = useState("none");
const [offered, setOffered] = useState(args.backgroundEffects ?? []);
return (
<MediaMuteAndSwitchButton
{...args}
backgroundEffects={offered}
selectedBackgroundEffect={selected}
onSelectBackgroundEffect={setSelected}
onRemoveBackgroundEffect={(id): void =>
setOffered((current) => current.filter((o) => o.id !== id))
}
/>
);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const body = within(document.body);
const tile = await body.findByRole("menuitemradio", {
name: "Background 4",
});
await userEvent.hover(tile);
const cross = tile.parentElement!.querySelector<HTMLElement>(
"[aria-hidden]:last-child",
)!;
await expect(cross).toBeVisible();
await userEvent.click(cross);
await waitFor(async () =>
expect(
body.queryByRole("menuitemradio", { name: "Background 4" }),
).toBeNull(),
);
await expect(
body.getByRole("menuitemradio", { name: "None" }),
).toHaveAttribute("aria-checked", "true");
},
};
/** Where the call leaves room, the camera holds the top while the options scroll. */
export const BackgroundEffectsWithPreview: Story = {
args: {
...VideoUnmute.args,
// Enough that the list fills its share, so the preview's cost counts.
options: Array.from({ length: 8 }, (_, i) => ({
label: { type: "name" as const, name: `Camera ${i + 1}` },
id: `${i + 1}`,
})),
},
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={640}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const picture = await within(document.body).findByTestId("preview-camera");
const preview = picture.parentElement!;
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
const box = preview.getBoundingClientRect();
await expect(list.contains(preview)).toBe(false);
await expect(box.top - frame.top).toBeLessThanOrEqual(2);
await expect(frame.width - box.width).toBeLessThanOrEqual(2);
await expect(box.width / box.height).toBeCloseTo(16 / 9, 1);
await expect(box.bottom).toBeLessThanOrEqual(
list.getBoundingClientRect().top,
);
const listShare = Math.round(640 * 0.6);
// The list runs on through the menu's padding at its foot, which isn't
// part of its share.
const listEnd =
list.getBoundingClientRect().bottom -
Number.parseFloat(getComputedStyle(list).paddingBlockEnd);
await expect(listEnd - box.top).toBeLessThanOrEqual(listShare + 1);
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
list.scrollTop = list.scrollHeight;
await expect(preview.getBoundingClientRect().top).toBe(box.top);
},
};
/** With the camera off the preview says so, and effects can still be chosen. */
export const BackgroundEffectsPreviewWithTheCameraOff: Story = {
args: {
...VideoUnmute.args,
enabled: false,
selfPreview: <SelfPreview track={null} />,
},
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
await menu.findByRole("group", { name: "Background effects" });
const preview = document.body.querySelector(`.${styles.selfPreview}`)!;
await expect(preview.querySelector("video")).toBeNull();
await expect(preview.querySelector("svg")).not.toBeNull();
await userEvent.click(menu.getByRole("menuitemradio", { name: "Blur" }));
await expect(args.onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
},
};
/** In a call too short to hold it, the camera leads the options and scrolls away. */
export const BackgroundEffectsPreviewScrollsWithTheList: Story = {
args: VideoUnmute.args,
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={300}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const picture = await within(document.body).findByTestId("preview-camera");
const preview = picture.parentElement!;
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.contains(preview)).toBe(true);
const cameras = within(document.body).getByRole("group", {
name: "Camera",
});
await expect(
preview.compareDocumentPosition(cameras) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
list.scrollTop = list.scrollHeight;
await expect(preview.getBoundingClientRect().bottom).toBeLessThan(
list.getBoundingClientRect().top,
);
},
};
/** In a short call the effects scroll into view with the list. */
export const BackgroundEffectsScrollWhenTheyDoNotFit: Story = {
args: {
...VideoUnmute.args,
// It sits with the effects, so it must fit the same share of the call.
backgroundEffectNotice:
"Background effects run slowly on this platform, which may cause your video to stutter.",
},
decorators: [
(Story): JSX.Element => (
<WithACallArea blockSize={300}>
<Story />
</WithACallArea>
),
],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const menu = within(document.body);
const last = await menu.findByRole("menuitemradio", {
name: "Background 2",
});
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight);
await expect(last.getBoundingClientRect().top).toBeGreaterThan(
list.getBoundingClientRect().bottom,
);
// Scrolled under the section's heading, the tiles pass beneath it. By
// position: Compound's heading is the section's hidden first child.
const heading = menu.getByRole("group", { name: "Background effects" })
.firstElementChild as HTMLElement;
list.scrollTop = heading.offsetTop + 30;
await waitFor(async () => {
const box = heading.getBoundingClientRect();
const drawn = document.elementFromPoint(
box.left + box.width / 2,
box.bottom - 2,
);
await expect(heading.contains(drawn)).toBe(true);
});
list.scrollTop = 0;
for (let i = 0; i < 10 && document.activeElement !== last; i++)
await userEvent.keyboard("{ArrowDown}");
await expect(document.activeElement).toBe(last);
const scrollport = list.getBoundingClientRect();
const reached = last.getBoundingClientRect();
await expect(reached.bottom).toBeLessThanOrEqual(scrollport.bottom + 1);
await expect(reached.top).toBeGreaterThanOrEqual(scrollport.top - 1);
const callArea = canvasElement
.querySelector<HTMLElement>("[style*='block-size: 300px']")!
.getBoundingClientRect();
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(frame.top).toBeGreaterThanOrEqual(callArea.top);
await expect(frame.bottom).toBeLessThanOrEqual(callArea.bottom);
},
};
@@ -219,9 +760,8 @@ export const SpeakerAndMicrophoneSections: Story = {
await expect(
document.body.querySelectorAll("[role='separator']"),
).toHaveLength(0);
const headings = document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
);
const headings =
document.body.querySelectorAll<HTMLElement>(SECTION_HEADING);
await expect(headings).toHaveLength(2);
const frame = menu.getBoundingClientRect();
for (const heading of headings) {
@@ -256,6 +796,37 @@ export const SpeakerAndMicrophoneSections: Story = {
},
};
/** A long name wraps rather than widening the menu, which design sets. */
export const LongDeviceNameWraps: Story = {
args: {
...SpeakerAndMicrophoneSections.args,
outputOptions: [
{
label: {
type: "name",
name: "Logitech BRIO 4K Ultra HD Pro Business Webcam Speakers (046d:085e)",
},
id: "long",
},
{ label: { type: "name", name: "Headset" }, id: "spk2" },
],
selectedOutputOption: "long",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const body = within(document.body);
const long = await body.findByRole("menuitemradio", { name: /Logitech/ });
const short = await body.findByRole("menuitemradio", { name: "Headset" });
const menu = document.body.querySelector("[role='menu']")!;
await expect(Math.round(menu.getBoundingClientRect().width)).toBe(296);
await expect(long.getBoundingClientRect().height).toBeGreaterThan(
short.getBoundingClientRect().height,
);
},
};
export const OutputCannotBeChosen: Story = {
args: {
...Default.args,
@@ -402,14 +973,14 @@ export const ManyDevices: Story = {
await userEvent.click(canvas.getByRole("button", { name: "Microphone" }));
const menu = within(document.body);
// The sticky wrapper rather than the meter: without a fake microphone (as on
// The meter's box rather than its level: without a fake microphone (as on
// WebKit) the meter shows a message instead of a level.
const list = document.body.querySelector<HTMLElement>(
`.${styles.deviceList}`,
)!;
const sticky = await waitFor(() => {
const element = document.body.querySelector<HTMLElement>(
`.${styles.stickyMeter}`,
`.${meterStyles.meter}`,
);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
@@ -436,10 +1007,6 @@ export const ManyDevices: Story = {
.getBoundingClientRect();
await expect(frame.top).toBeGreaterThanOrEqual(0);
await expect(frame.bottom).toBeLessThanOrEqual(window.innerHeight + 1);
// The meter is the menu's one opaque part, so it must stay inside the frame.
await expect(pinned.left).toBeGreaterThan(frame.left);
await expect(pinned.right).toBeLessThan(frame.right);
},
};
@@ -536,7 +1103,7 @@ export const KeyboardReachesEveryDevice: Story = {
const meter = await waitFor(() => {
const element = document.body.querySelector<HTMLElement>(
`.${styles.stickyMeter}`,
`.${meterStyles.meter}`,
);
if (element === null) throw new Error("the meter has not rendered yet");
return element;
@@ -545,9 +1112,7 @@ export const KeyboardReachesEveryDevice: Story = {
// Drawn over the list: the sticky headings and the meter.
const overlays = [
meter,
...document.body.querySelectorAll<HTMLElement>(
`.${styles.sectionHeading}`,
),
...document.body.querySelectorAll<HTMLElement>(SECTION_HEADING),
];
const items = within(document.body).getAllByRole("menuitemradio");
@@ -591,10 +1156,7 @@ export const HeadingsStayWhileScrolling: Story = {
`.${styles.deviceList}`,
)!;
const group = await menu.findByRole("group", { name: "Microphone" });
// By class: the heading is aria-hidden, and its group carries the name.
const heading = group.querySelector<HTMLElement>(
`.${styles.sectionHeading}`,
)!;
const heading = group.querySelector<HTMLElement>(":scope > [aria-hidden]")!;
// Far enough that the heading is only on screen if it is stuck there.
list.scrollTop +=
@@ -607,13 +1169,6 @@ export const HeadingsStayWhileScrolling: Story = {
await expect(heading.getBoundingClientRect().bottom).toBeGreaterThan(
scrollport.top,
);
// Clear of the menu's frame.
const frame = document.body
.querySelector("[role='menu']")!
.getBoundingClientRect();
await expect(heading.getBoundingClientRect().left).toBeGreaterThan(
frame.left,
);
},
};
@@ -628,31 +1183,23 @@ function overlapping(element: Element, overlays: Element[]): number {
}, 0);
}
/** The blur toggle gets the keyboard ring too: it is the menu's child, not the list's. */
export const FocusRingCoversTheBlurToggle: Story = {
args: {
...VideoUnmute.args,
iconsAndLabels: "video",
videoBlurEnabled: false,
videoBlurToggleClick: fn(),
},
/** The effect tiles get the keyboard ring the device rows get. */
export const FocusRingCoversTheEffectTiles: Story = {
args: VideoUnmute.args,
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "Camera" }));
const toggle = await within(document.body).findByRole("menuitemcheckbox", {
name: /Blur background/,
const tile = await within(document.body).findByRole("menuitemradio", {
name: "Blur",
});
// Arrow down past the cameras to the toggle.
for (let i = 0; i < 6 && document.activeElement !== toggle; i++)
for (let i = 0; i < 6 && document.activeElement !== tile; i++)
await userEvent.keyboard("{ArrowDown}");
await expect(document.activeElement).toBe(toggle);
// The same ring the device rows get.
await expect(outlineWidth(toggle)).toBeGreaterThan(0);
await expect(document.activeElement).toBe(tile);
await expect(outlineWidth(tile)).toBeGreaterThan(0);
// And the pointer takes it away, with the toggle still focused.
await userEvent.hover(toggle);
await expect(document.activeElement).toBe(toggle);
await expect(outlineWidth(toggle)).toBe(0);
await userEvent.hover(tile);
await expect(document.activeElement).toBe(tile);
await expect(outlineWidth(tile)).toBe(0);
},
};
+201 -17
View File
@@ -5,10 +5,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test, vi } from "vitest";
import { afterEach, describe, expect, onTestFinished, test, vi } from "vitest";
import { axe } from "vitest-axe";
import {
act,
fireEvent,
render,
screen,
within,
@@ -22,10 +23,18 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type BackgroundEffectGridItem } from "./BackgroundEffectGrid";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices } from "../state/MediaDevices";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
const effects: BackgroundEffectGridItem[] = [
{ id: "none", kind: "none" },
{ id: "blur", kind: "blur" },
{ id: "image:arc", kind: "image", imageUrl: "" },
{ id: "image:glow", kind: "image", imageUrl: "" },
];
interface RenderOptions {
requestDeviceNames: () => void;
}
@@ -305,30 +314,202 @@ describe("MediaMuteAndSwitchButton", () => {
expect(mic2ItemAfter.querySelector(".rotate")).toBeNull();
});
test("renders menu with toggle control and calls toggle callback", async () => {
test("background effects are one keyboard-operable labelled choice", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const onVideoBlurToggle = vi.fn();
const onSelectBackgroundEffect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
videoBlurToggleClick={onVideoBlurToggle}
onSelect={onSelect}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
onSelect={vi.fn()}
backgroundEffects={effects}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={onSelectBackgroundEffect}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
const toggle = screen.getByRole("menuitemcheckbox", {
name: "Blur background",
const section = screen.getByRole("group", { name: "Background effects" });
within(section).getByRole("menuitemradio", { name: "None", checked: true });
within(section).getByRole("menuitemradio", {
name: "Background 1",
checked: false,
});
expect(toggle).toBeInTheDocument();
expect(toggle).toHaveAttribute("aria-checked", "false");
await user.click(toggle);
const blur = within(section).getByRole("menuitemradio", { name: "Blur" });
for (let i = 0; i < 6 && document.activeElement !== blur; i++)
await user.keyboard("{ArrowDown}");
expect(document.activeElement).toBe(blur);
await user.keyboard("{Enter}");
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("blur");
expect(screen.getByRole("menu")).toBeInTheDocument();
});
expect(onVideoBlurToggle).toHaveBeenCalled();
test("does not choose the effect already in force again", async () => {
const user = userEvent.setup();
const onSelectBackgroundEffect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={effects}
selectedBackgroundEffect="blur"
onSelectBackgroundEffect={onSelectBackgroundEffect}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
await user.click(screen.getByRole("menuitemradio", { name: "Blur" }));
expect(onSelectBackgroundEffect).not.toHaveBeenCalled();
});
test("added images are chosen like shipped ones", async () => {
const user = userEvent.setup();
const onSelectBackgroundEffect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={[
...effects,
{
id: "added:mine",
kind: "image",
imageUrl: "",
},
]}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={onSelectBackgroundEffect}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
const section = screen.getByRole("group", { name: "Background effects" });
await user.click(
within(section).getByRole("menuitemradio", { name: "Background 3" }),
);
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("added:mine");
});
test("adds the file chosen from the add tile, and stays open", async () => {
const user = userEvent.setup();
const onAddBackgroundImage = vi.fn();
const pick = vi
.spyOn(HTMLInputElement.prototype, "click")
.mockImplementation(() => {});
try {
const { getByRole, container } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={effects}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={vi.fn()}
onAddBackgroundImage={onAddBackgroundImage}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
await user.click(screen.getByRole("menuitem", { name: "Add image" }));
expect(pick).toHaveBeenCalled();
// The picker takes the focus, which would otherwise close the menu.
await user.keyboard("{Escape}");
expect(screen.getByRole("menu")).toBeInTheDocument();
const file = new File(["x"], "mine.png", { type: "image/png" });
// What the picker hands back; the open menu blocks pointer events.
fireEvent.change(
container.querySelector<HTMLInputElement>("input[type=file]")!,
{ target: { files: [file] } },
);
expect(onAddBackgroundImage).toHaveBeenCalledWith(file);
screen.getByRole("menuitemradio", { name: "None", checked: true });
} finally {
pick.mockRestore();
}
});
test("a refusal can be dismissed and does not outlive the menu", async () => {
const user = userEvent.setup();
// jsdom lays nothing out, so has nothing to scroll.
const scroll = Element.prototype.scrollIntoView;
Element.prototype.scrollIntoView = vi.fn();
onTestFinished(() => {
Element.prototype.scrollIntoView = scroll;
});
const menu = (refusal?: { text: string }): JSX.Element =>
withProviders(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={effects}
selectedBackgroundEffect="blur"
onSelectBackgroundEffect={vi.fn()}
onAddBackgroundImage={vi.fn()}
backgroundImageRefusal={refusal}
/>,
);
const { rerender, getByRole } = render(menu());
await user.click(getByRole("button", { name: "Camera" }));
rerender(menu({ text: "That file is not a supported image" }));
expect(
screen.getByText("That file is not a supported image"),
).toBeVisible();
screen.getByRole("menuitemradio", { name: "Blur", checked: true });
await user.click(screen.getByRole("button", { name: "Close" }));
expect(screen.queryByText("That file is not a supported image")).toBeNull();
// The next refusal is shown, even if it says the same.
rerender(menu({ text: "That file is not a supported image" }));
expect(
screen.getByText("That file is not a supported image"),
).toBeVisible();
await user.keyboard("{Escape}");
await user.click(getByRole("button", { name: "Camera" }));
expect(screen.queryByText("That file is not a supported image")).toBeNull();
});
test("offers the background effects in a phone's drawer", async () => {
const userAgent = vi
.spyOn(navigator, "userAgent", "get")
.mockReturnValue("Mozilla/5.0 (Linux; Android 14)");
try {
const user = userEvent.setup();
const onSelectBackgroundEffect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
iconsAndLabels="video"
enabled={true}
options={[{ label: { type: "name", name: "Camera 1" }, id: "cam1" }]}
selectedOption="cam1"
backgroundEffects={effects}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={onSelectBackgroundEffect}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
const section = screen.getByRole("group", { name: "Background effects" });
// A click alone: the drawer's drag handling reads layout jsdom lacks.
fireEvent.click(
within(section).getByRole("menuitemradio", { name: "Background 2" }),
);
expect(onSelectBackgroundEffect).toHaveBeenCalledWith("image:glow");
} finally {
userAgent.mockRestore();
}
});
test("marks the selected menu item as checked", async () => {
@@ -552,7 +733,9 @@ describe("MediaMuteAndSwitchButton", () => {
]}
selectedOption="cam1"
onSelect={vi.fn()}
videoBlurToggleClick={vi.fn()}
backgroundEffects={effects}
selectedBackgroundEffect="none"
onSelectBackgroundEffect={vi.fn()}
/>,
);
@@ -560,9 +743,10 @@ describe("MediaMuteAndSwitchButton", () => {
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument();
within(screen.getByRole("group", { name: "Background effects" })).getByRole(
"menuitemradio",
{ name: "Blur" },
);
});
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
+220 -119
View File
@@ -7,23 +7,29 @@ Please see LICENSE in the repository root for full details.
import {
useCallback,
useId,
useRef,
useState,
type CSSProperties,
type FC,
useEffect,
type ReactElement,
type ReactNode,
} from "react";
import {
Alert,
Button,
InlineSpinner,
Menu,
MenuItem,
MenuTitle,
MenuScrollArea,
MenuScrollSection,
RadioInput,
ToggleMenuItem,
} from "@vector-im/compound-web";
import {
ChevronUpIcon,
ChevronDownIcon,
InfoIcon,
SpinnerIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
@@ -40,6 +46,10 @@ import { useMediaDevices } from "../MediaDevicesContext";
import { useRootElement } from "../RootElementContext";
import { observeElementSize$ } from "../utils/elementSize";
import { LiveMicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import {
BackgroundEffectGrid,
type BackgroundEffectGridItem,
} from "./BackgroundEffectGrid";
export interface MenuOptions {
label: DeviceLabel | AudioOutputDeviceLabel;
@@ -64,8 +74,23 @@ export interface MediaMuteAndSwitchButtonProps {
selectedOutputOption?: string;
/** Picks an output device. Undefined disables the speaker section. */
onSelectOutput?: (id: string) => void;
videoBlurToggleClick?: () => void;
videoBlurEnabled?: boolean;
/** Camera menu only. None or an empty list leaves the section out. */
backgroundEffects?: BackgroundEffectGridItem[];
selectedBackgroundEffect?: string;
/** Undefined where effects can't run: all but no effect are disabled. */
onSelectBackgroundEffect?: (id: string) => void;
/** What the user should know before choosing an effect, if anything. */
backgroundEffectNotice?: string;
/** Whether the first effect chosen is still being prepared. */
backgroundEffectSettling?: boolean;
/** Called with the file chosen from the add tile. Omit to leave it out. */
onAddBackgroundImage?: (file: File) => void;
/** Removes a removable effect, never the one in force. */
onRemoveBackgroundEffect?: (id: string) => void;
/** Why the last file offered couldn't be used; a new object each time. */
backgroundImageRefusal?: { text: string };
/** The user's own camera, shown at the top of the camera menu. */
selfPreview?: ReactNode;
/**
* For any toggle and option this method will be called.
* So toggles need to be implemented by listening here and setting the right toggle item to `enabled`
@@ -73,8 +98,6 @@ export interface MediaMuteAndSwitchButtonProps {
onSelect?: (id: string) => void;
}
const BLUR_ID = "blur";
/** Id of the placeholder "Default" row, shown when the platform lists no outputs. */
const DEFAULT_OUTPUT_ID = "default";
@@ -84,6 +107,16 @@ const LIST_SHARE_OF_CALL = 0.6;
/** Smallest device list height in px, so a short call still shows more than one device. */
const MIN_LIST_HEIGHT = 160;
/** The menu's width as its stylesheet sets it, to place the preview before
the menu is drawn. */
const MENU_WIDTH = 296;
/** Space kept between the menu and the call area's sides. */
const MENU_MARGIN = 16;
/** Below the preview, as its stylesheet draws it. */
const PREVIEW_GAP = 12;
export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
enabled,
busy,
@@ -94,8 +127,15 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
outputOptions,
selectedOutputOption,
onSelectOutput,
videoBlurEnabled,
videoBlurToggleClick,
backgroundEffects,
selectedBackgroundEffect,
onSelectBackgroundEffect,
backgroundEffectNotice,
backgroundEffectSettling,
onAddBackgroundImage,
onRemoveBackgroundEffect,
backgroundImageRefusal,
selfPreview,
onSelect,
}) => {
// Requested but not yet selected. Keyed by kind too, since Chrome uses
@@ -105,14 +145,46 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
id: string;
} | null>(null);
const [menuOpen, setMenuOpen] = useState(false);
const onOpenChange = useCallback((open: boolean): void => {
setMenuOpen(open);
// Drop a request that never arrived.
if (!open) setPlannedSelection(null);
// The file picker takes the focus, which the menu reads as a click
// elsewhere; it is held open until the picker is done.
const choosingFile = useRef(false);
const fileInput = useRef<HTMLInputElement | null>(null);
// A refusal is about the attempt that caused it: shown until dismissed or
// until the menu closes, and again for the next one.
const [seenRefusal, setSeenRefusal] = useState<object>();
const refusal =
backgroundImageRefusal !== seenRefusal ? backgroundImageRefusal : undefined;
const onOpenChange = useCallback(
(open: boolean): void => {
if (!open && choosingFile.current) return;
setMenuOpen(open);
// Drop a request that never arrived.
if (!open) setPlannedSelection(null);
if (!open) setSeenRefusal(backgroundImageRefusal);
},
[backgroundImageRefusal],
);
// Scrolled to as it appears, as the list may be scrolled away from it.
const scrollIntoView = useCallback((element: HTMLElement | null): void => {
element?.scrollIntoView({ block: "nearest" });
}, []);
const watchFileInput = useCallback(
(input: HTMLInputElement | null): (() => void) | undefined => {
fileInput.current = input;
if (input === null) return;
// Dismissing the picker fires cancel, which React doesn't type.
const done = (): void => {
choosingFile.current = false;
};
input.addEventListener("cancel", done);
return (): void => input.removeEventListener("cancel", done);
},
[],
);
const isBusy = busy ?? false;
const { t } = useTranslation();
const devices = useMediaDevices();
const noticeId = useId();
/**
* Records on the menu whether the keyboard or the pointer moved focus, for
@@ -142,33 +214,65 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
// Measured on the call area: CSS can't size the portalled menu against it.
const rootElement = useRootElement();
const [listMaxHeight, setListMaxHeight] = useState<number>();
const [listShare, setListShare] = useState<number>();
const [previewBlock, setPreviewBlock] = useState(0);
useEffect(() => {
if (!menuOpen) return;
// Followed, since a host can resize the call while the menu is open.
const subscription = observeElementSize$(rootElement)
.pipe(
map(({ height }) =>
Math.max(MIN_LIST_HEIGHT, Math.round(height * LIST_SHARE_OF_CALL)),
map(({ width, height }) => ({
height: Math.round(height * LIST_SHARE_OF_CALL),
width: Math.min(MENU_WIDTH, Math.round(width - 2 * MENU_MARGIN)),
})),
distinctUntilChanged(
(a, b) => a.height === b.height && a.width === b.width,
),
distinctUntilChanged(),
)
.subscribe(setListMaxHeight);
.subscribe(({ height, width }) => {
setListShare(height);
setPreviewBlock(Math.round((width * 9) / 16) + PREVIEW_GAP);
});
return (): void => subscription.unsubscribe();
}, [menuOpen, rootElement]);
// Kept clear at the list's foot, so a row reached by keyboard isn't under
// the meter.
const [meterHeight, meter] = useMeasuredHeight();
// Likewise at its head, for the sticky headings.
const [headingHeight, heading] = useMeasuredHeight();
// Paid for out of the list's share, so a preview makes the menu no taller.
const hasPreview =
iconsAndLabels === "video" &&
selfPreview !== undefined &&
listShare !== undefined;
const previewPinned =
hasPreview && listShare - previewBlock >= MIN_LIST_HEIGHT;
const listMaxHeight =
listShare === undefined
? undefined
: Math.max(
MIN_LIST_HEIGHT,
listShare - (previewPinned ? previewBlock : 0),
);
const preview = hasPreview && (
<div
aria-hidden
className={classNames(styles.selfPreview, {
[styles.selfPreviewPinned]: previewPinned,
})}
>
{backgroundEffectSettling ? <InlineSpinner size={32} /> : selfPreview}
</div>
);
useEffect(() => {
if (menuOpen) devices.requestDeviceNames(); // No-op after the first call
}, [menuOpen, devices]);
const MuteButton = iconsAndLabels === "audio" ? MicButton : VideoButton;
const audioMeter = iconsAndLabels === "audio" && (
<LiveMicrophoneLevelMeter
deviceId={selectedOption}
// Only while open, so the microphone isn't held all call.
active={menuOpen}
/>
);
const MuteButton = audioMeter ? MicButton : VideoButton;
const button = (
<MuteButton
enabled={enabled ?? false}
@@ -179,22 +283,12 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
e.stopPropagation();
}}
disabled={isBusy || onMuteClick === undefined}
data-testid={
iconsAndLabels === "audio" ? "incall_mute" : "incall_videomute"
}
data-testid={audioMeter ? "incall_mute" : "incall_videomute"}
/>
);
const toggles =
iconsAndLabels === "video" && videoBlurToggleClick !== undefined
? [
{
label: t("action.blur_background"),
enabled: videoBlurEnabled ?? false,
id: BLUR_ID,
},
]
: [];
const showEffects =
iconsAndLabels === "video" && (backgroundEffects?.length ?? 0) > 0;
let optionsButtonLabel: string;
let menuTitle: string;
@@ -318,6 +412,21 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
>
{/* The mute button lives inside */}
{button}
{onAddBackgroundImage !== undefined && (
<input
ref={watchFileInput}
type="file"
accept="image/*"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
// Cleared, so the same file can be chosen twice in a row.
e.target.value = "";
choosingFile.current = false;
if (file) onAddBackgroundImage(file);
}}
/>
)}
<Menu
className={styles.menu}
// Named for screen readers only: each section has its own heading.
@@ -326,6 +435,8 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
open={menuOpen}
onOpenChange={onOpenChange}
side="top"
collisionBoundary={rootElement}
collisionPadding={MENU_MARGIN}
trigger={
<Button
iconOnly
@@ -340,101 +451,91 @@ export const MediaMuteAndSwitchButton: FC<MediaMuteAndSwitchButtonProps> = ({
/>
}
>
<div
{previewPinned && preview}
<MenuScrollArea
ref={trackFocusSource}
// Keeps the items the menu's own children for assistive tech.
role="none"
className={styles.deviceList}
className={classNames(styles.deviceList, {
[styles.deviceListLeadsWithPreview]: hasPreview && !previewPinned,
})}
data-testid="device-list"
style={
{
"--device-list-max-height":
listMaxHeight === undefined ? undefined : `${listMaxHeight}px`,
"--device-list-scroll-padding-end":
meterHeight === undefined ? undefined : `${meterHeight}px`,
"--device-list-scroll-padding-start":
headingHeight === undefined ? undefined : `${headingHeight}px`,
} as CSSProperties
}
>
{iconsAndLabels === "audio" && speakerOptions && (
<>
{/* A menu may only contain items, separators and groups, so each
heading is a hidden part of a named group. */}
<div role="group" aria-label={t("settings.devices.speaker")}>
<div aria-hidden className={styles.sectionHeading}>
<MenuTitle title={t("settings.devices.speaker")} />
</div>
{deviceItems(
"output",
speakerOptions,
selectedSpeaker,
onSelectOutput,
(n) => t("settings.devices.speaker_numbered", { n }),
)}
</div>
</>
)}
<div role="group" aria-label={optionsButtonLabel}>
<div ref={heading} aria-hidden className={styles.sectionHeading}>
<MenuTitle title={optionsButtonLabel} />
</div>
{/* Apart from the heading, so the sticky meter can't ride over it. */}
<div role="none">
{hasPreview && !previewPinned && preview}
{audioMeter && speakerOptions && (
<MenuScrollSection title={t("settings.devices.speaker")}>
{deviceItems(
"input",
options,
selectedOption,
onSelect,
numberedLabel,
"output",
speakerOptions,
selectedSpeaker,
onSelectOutput,
(n) => t("settings.devices.speaker_numbered", { n }),
)}
{iconsAndLabels === "audio" && (
<LiveMicrophoneLevelMeter
ref={meter}
deviceId={selectedOption}
// Only while open, so the microphone isn't held all call.
active={menuOpen}
className={styles.stickyMeter}
/>
</MenuScrollSection>
)}
<MenuScrollSection
title={optionsButtonLabel}
stickyBottomItem={audioMeter}
>
{deviceItems(
"input",
options,
selectedOption,
onSelect,
numberedLabel,
)}
</MenuScrollSection>
{showEffects && (
<MenuScrollSection title={t("settings.background_effects_header")}>
<BackgroundEffectGrid
effects={backgroundEffects ?? []}
selected={selectedBackgroundEffect}
onSelect={onSelectBackgroundEffect}
settling={backgroundEffectSettling}
describedBy={
backgroundEffectNotice === undefined ? undefined : noticeId
}
onAdd={
onAddBackgroundImage === undefined
? undefined
: (): void => {
choosingFile.current = true;
fileInput.current?.click();
}
}
addLabel={t("action.add_background_image")}
onRemove={onRemoveBackgroundEffect}
removeLabel={t("action.remove")}
/>
{/* In the list, so the menu grows no taller for it. */}
{refusal !== undefined && (
<div
ref={scrollIntoView}
role="none"
className={styles.refusal}
>
<Alert
type="critical"
title={refusal.text}
onClose={(): void => setSeenRefusal(refusal)}
/>
</div>
)}
</div>
</div>
</div>
{toggles.length > 0 && <hr />}
{toggles.map((toggle) => (
<ToggleMenuItem
label={toggle.label}
onSelect={(e) => {
videoBlurToggleClick?.();
e.preventDefault();
}}
checked={toggle.enabled}
key={toggle.id}
/>
))}
{refusal === undefined &&
backgroundEffectNotice !== undefined && (
<div id={noticeId} role="none" className={styles.notice}>
<InfoIcon width={20} height={20} />
<span>{backgroundEffectNotice}</span>
</div>
)}
</MenuScrollSection>
)}
</MenuScrollArea>
</Menu>
</div>
);
};
/** Follows an element's height. */
function useMeasuredHeight(): [
number | undefined,
(element: HTMLElement | null) => (() => void) | undefined,
] {
const [height, setHeight] = useState<number>();
const ref = useCallback(
(element: HTMLElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map((size) => size.height),
distinctUntilChanged(),
)
.subscribe(setHeight);
return (): void => subscription.unsubscribe();
},
[],
);
return [height, ref];
}
@@ -16,10 +16,10 @@ Please see LICENSE in the repository root for full details.
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x);
/* Centres the icon on the radio column: less the row's inset border and half
the icon's extra width. Checked in the MeterAlignsWithTheDeviceRows story. */
/* Centres the icon on the radio column: less half the icon's extra width.
Checked in the MeterAlignsWithTheDeviceRows story. */
padding-inline-start: calc(
var(--cpd-space-4x) - var(--cpd-border-width-1) -
var(--cpd-space-4x) -
(var(--meter-icon-size) - var(--device-control-size)) / 2
);
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
+3 -19
View File
@@ -5,17 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
useCallback,
useLayoutEffect,
useRef,
useState,
type FC,
type Ref,
} from "react";
import { useCallback, useLayoutEffect, useRef, useState, type FC } from "react";
import { Text } from "@vector-im/compound-web";
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { distinctUntilChanged, map } from "rxjs";
@@ -30,8 +22,6 @@ export interface LiveMicrophoneLevelMeterProps {
deviceId: string | undefined;
/** Whether to hold a capture at all. */
active: boolean;
className?: string;
ref?: Ref<HTMLDivElement>;
}
/**
@@ -41,24 +31,18 @@ export interface LiveMicrophoneLevelMeterProps {
export const LiveMicrophoneLevelMeter: FC<LiveMicrophoneLevelMeterProps> = ({
deviceId,
active,
className,
ref,
}) => {
const state = useMicrophoneLevel(deviceId, active);
return <MicrophoneLevelMeter state={state} className={className} ref={ref} />;
return <MicrophoneLevelMeter state={state} />;
};
export interface MicrophoneLevelMeterProps {
state: MicrophoneState;
className?: string;
ref?: Ref<HTMLDivElement>;
}
/** The input level of a microphone, or why there is none. */
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
state,
className,
ref,
}) => {
const { t } = useTranslation();
// Bars keep one size, so their count follows the width. Starts full so the
@@ -104,7 +88,7 @@ export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
}, [state, barCount, t]);
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<div className={styles.meter}>
<MicOnIcon className={styles.icon} aria-hidden />
{state.type !== "level" ? (
<Text size="sm" className={styles.message}>
+10
View File
@@ -0,0 +1,10 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.mirrored {
transform: scaleX(-1);
}
+47
View File
@@ -0,0 +1,47 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { act, render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { BehaviorSubject } from "rxjs";
import { SelfPreview } from "./SelfPreview";
import styles from "./SelfPreview.module.css";
import { mockVideoTrack } from "../utils/test";
describe("SelfPreview", () => {
it("detaches only its own element from a camera shown twice", () => {
const track = mockVideoTrack({});
const first = render(<SelfPreview track={track} />);
const second = render(<SelfPreview track={track} />);
const [a, b] = [first, second].map((r) =>
r.container.querySelector("video"),
);
expect(track.attach).toHaveBeenCalledWith(a);
expect(track.attach).toHaveBeenCalledWith(b);
first.unmount();
expect(track.detach).toHaveBeenCalledExactlyOnceWith(a);
});
it("mirrors the camera while it faces the user, and only then", () => {
const facingMode$ = new BehaviorSubject<"user" | "environment">("user");
const { container } = render(
<SelfPreview track={mockVideoTrack({ facingMode$ })} />,
);
const video = (): HTMLVideoElement => container.querySelector("video")!;
expect(video().classList.contains(styles.mirrored)).toBe(true);
act(() => facingMode$.next("environment"));
expect(video().classList.contains(styles.mirrored)).toBe(false);
});
it("says the camera is off while there is none", () => {
const { container } = render(<SelfPreview track={null} />);
expect(container.querySelector("video")).toBeNull();
expect(container.querySelector("svg")).not.toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useRef } from "react";
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./SelfPreview.module.css";
import { useBehavior } from "../useBehavior";
const noFacingMode = constant(undefined);
interface Props {
/** The camera as the pipeline has it, or none while the camera is off. */
track: LocalVideoMediaTrack | null;
}
/**
* The user's own camera with the effect in force: the pipeline's own track,
* shown a second time rather than captured again.
*/
export const SelfPreview: FC<Props> = ({ track }) => {
const videoRef = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const element = videoRef.current;
if (!track || !element) return;
track.attach(element);
// This element only: the pre-join preview may be showing the same camera
return (): void => {
track.detach(element);
};
}, [track]);
const facingMode = useBehavior(track?.facingMode$ ?? noFacingMode);
if (!track) return <VideoCallOffIcon width={32} height={32} />;
return (
<video
ref={videoRef}
muted
playsInline
className={facingMode === "user" ? styles.mirrored : undefined}
/>
);
};
@@ -9,7 +9,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_0_"
aria-labelledby="_r_1_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -39,7 +39,7 @@ exports[`MediaMuteAndSwitchButton > renders 1`] = `
data-kind="tertiary"
data-size="lg"
data-state="closed"
id="radix-_r_5_"
id="radix-_r_6_"
role="button"
tabindex="0"
type="button"
+69 -2
View File
@@ -5,11 +5,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test } from "vitest";
import { afterEach, describe, expect, test, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { E2eeType } from "@element-hq/matrixrtc-sdk";
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
import {
getKeyForRoom,
saveKeyForRoom,
useRoomEncryptionSystem,
} from "./sharedKeyManagement";
vi.mock("../ClientContext", () => {
const room = { hasEncryptionStateEvent: (): boolean => false };
const client = { getRoom: (): typeof room => room };
return { useClient: (): { client: typeof client } => ({ client }) };
});
const roomId = "!room:example.org";
const otherRoomId = "!other:example.org";
describe("getKeyForRoom", () => {
afterEach(() => {
@@ -33,4 +46,58 @@ describe("getKeyForRoom", () => {
test("has no key to offer for a room it has never seen", () => {
expect(getKeyForRoom(roomId)).toBeNull();
});
test("prefers the stored key when the parameters are for another room", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
expect(getKeyForRoom(roomId)).toBe("stored");
});
test("ignores the password in the parameters for another room", () => {
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
expect(getKeyForRoom(roomId)).toBeNull();
});
});
describe("useRoomEncryptionSystem", () => {
afterEach(() => {
window.location.hash = "#";
localStorage.clear();
});
test("picks up a key saved for the room after it rendered", () => {
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
expect(result.current).toEqual({ kind: E2eeType.NONE });
act(() => saveKeyForRoom(roomId, "stored"));
expect(result.current).toEqual({
kind: E2eeType.SHARED_KEY,
secret: "stored",
});
});
test("stores a key from the parameters under the room's key only", () => {
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
renderHook(() => useRoomEncryptionSystem(roomId));
expect(Object.keys(localStorage)).toEqual([`room-shared-key-${roomId}`]);
expect(getKeyForRoom(roomId)).toBe("from-the-link");
});
test("keeps the stored key when the parameters are for another room", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(otherRoomId)}&password=other-room`;
const { result } = renderHook(() => useRoomEncryptionSystem(roomId));
expect(result.current).toEqual({
kind: E2eeType.SHARED_KEY,
secret: "stored",
});
expect(localStorage.getItem(`room-shared-key-${roomId}`)).toBe("stored");
});
});
+12 -11
View File
@@ -59,22 +59,27 @@ const useRoomSharedKey = (
/**
* The shared key for a room, preferring one supplied in the parameters Element
* Call was started with over whatever is in local storage.
* Call was started with over whatever is in local storage. If the parameters
* are for another room, only the stored key is used, because the password in
* the parameters belongs to that other room.
*/
function keyForRoom(
roomId: string,
paramsRoomId: string | null,
password: string | null,
): string | null {
if (roomId !== paramsRoomId)
const storedKey = localStorage.getItem(
getRoomSharedKeyLocalStorageKey(roomId),
);
if (roomId !== paramsRoomId) {
logger.warn(
"requested key for a roomId which is not the current call room id (from the URL)",
roomId,
paramsRoomId,
);
return (
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
);
return storedKey;
}
return password ?? storedKey;
}
export function getKeyForRoom(roomId: string): string | null {
@@ -89,16 +94,12 @@ export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
// TODO: this passes an already-prefixed key where a room ID is expected, so
// the local storage key ends up prefixed twice and never matches what
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
// refactor; the reactive read is effectively dead until it is fixed.
getRoomSharedKeyLocalStorageKey(roomId),
roomId,
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
const room = client?.getRoom(roomId);
const e2eeSystem = <EncryptionSystem>useMemo(() => {
const e2eeSystem = useMemo<EncryptionSystem>(() => {
if (!room) return { kind: E2eeType.NONE };
if (storedPassword)
return {
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+100 -150
View File
@@ -5,28 +5,25 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { act, render, screen } from "@testing-library/react";
import { BehaviorSubject } from "rxjs";
import { describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { arrangeTiles, type Bounds } from "./CallLayout";
import { type CallLayout } from "./CallLayout";
import { makeGridLayout } from "./GridLayout";
import { makeSpotlightLandscapeLayout } from "./SpotlightLandscapeLayout";
import { makeSpotlightPortraitLayout } from "./SpotlightPortraitLayout";
import { type DragCallback, type LayoutProps } from "./Grid";
import { type LayoutProps } from "./Grid";
import { constant } from "../state/Behavior";
import {
type Alignment,
type GridLayout,
type SpotlightLandscapeLayout,
type SpotlightPortraitLayout,
} from "../state/layout-types";
import { type Alignment } from "../state/layout-types";
import { type UserMediaViewModel } from "../state/media/UserMediaViewModel";
import {
GridTileViewModel,
SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel";
import { makeOneOnOneMobileLayout } from "./OneOnOneMobileLayout";
import { makeSpotlightExpandedLayout } from "./SpotlightExpandedLayout";
import { makeOneOnOneDesktopLayout } from "./OneOnOneDesktopLayout";
// Grid supplies these hooks to the layouts it renders; on their own the
// layouts have no grid to report to
@@ -36,152 +33,105 @@ vi.mock("./Grid", async (importOriginal) => ({
useVisibleTiles: (): void => {},
}));
describe("grid layout", () => {
it("sizes the tiles from the minimum bounds", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 800, height: 600 });
const { scrolling: Scrolling } = makeGridLayout({ minBounds$ });
render(<Scrolling model={gridModel()} Slot={Slot} />);
const layer = screen.getByTestId("slot-0").parentElement!;
expect(layer.style.width).toBe("800px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(800, 600, 3).tileWidth)}px`,
);
const minBounds$ = constant({ width: 800, height: 800 });
act(() => minBounds$.next({ width: 400, height: 300 }));
expect(layer.style.width).toBe("400px");
expect(layer.style.getPropertyValue("--width")).toBe(
`${Math.floor(arrangeTiles(400, 300, 3).tileWidth)}px`,
);
});
it("moves the spotlight only when a drag reaches another corner", () => {
const model = gridModel();
const { fixed: Fixed } = makeGridLayout({ minBounds$: bounds });
render(<Fixed model={model} Slot={Slot} />);
const alignments: Alignment[] = [];
model.spotlightAlignment$.subscribe((a) => alignments.push(a));
const drag = drags.get("spotlight")!;
// Still in the bottom right corner
act(() => drag({ x: 0, y: 0, xRatio: 0.9, yRatio: 0.9 }));
// Over to the bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.1, yRatio: 0.9 }));
// And a little further, still bottom left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.8 }));
// Up to the top left
act(() => drag({ x: 0, y: 0, xRatio: 0.2, yRatio: 0.1 }));
expect(alignments).toEqual([
{ inline: "end", block: "end" },
{ inline: "start", block: "end" },
{ inline: "start", block: "start" },
]);
const slot = screen.getByTestId("slot-spotlight");
expect(slot.getAttribute("data-inline-alignment")).toBe("start");
expect(slot.getAttribute("data-block-alignment")).toBe("start");
});
});
describe("spotlight landscape layout", () => {
it("puts the spotlight in the fixed layer and the grid in the scrolling one", () => {
const { fixed: Fixed, scrolling: Scrolling } = makeSpotlightLandscapeLayout(
{ minBounds$: bounds },
);
const model = landscapeModel();
const { unmount } = render(<Fixed model={model} Slot={Slot} />);
expect(screen.getByTestId("slot-spotlight")).toBeInTheDocument();
unmount();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
});
});
describe("spotlight portrait layout", () => {
it("sizes the grid tiles from the minimum width", () => {
const minBounds$ = new BehaviorSubject<Bounds>({ width: 300, height: 700 });
const { scrolling: Scrolling } = makeSpotlightPortraitLayout({
minBounds$,
});
const model = portraitModel();
render(<Scrolling model={model} Slot={Slot} />);
expect(slotIds()).toEqual(model.grid.map((tile) => tile.id));
const layer = screen.getByTestId(`slot-${model.grid[0].id}`).parentElement!
.parentElement!;
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(300, 300, 3).tileWidth)}px`,
);
act(() => minBounds$.next({ width: 500, height: 700 }));
expect(layer.style.getPropertyValue("--grid-tile-width")).toBe(
`${Math.floor(arrangeTiles(500, 500, 3).tileWidth)}px`,
);
});
});
const bounds = constant<Bounds>({ width: 800, height: 600 });
// The layouts only pass tile view models through to their slots
const media = {} as UserMediaViewModel;
const spotlight = new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
const spotlight = {
vm: new SpotlightTileViewModel(
constant([]),
constant(false),
constant("solid"),
),
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
};
const grid = Array.from(
{ length: 3 },
() => new GridTileViewModel(constant({} as UserMediaViewModel)),
);
const tiles = (): GridTileViewModel[] =>
Array.from({ length: 3 }, () => new GridTileViewModel(constant(media)));
const pip = {
alignment$: constant<Alignment>({ inline: "end", block: "end" }),
onDrag: () => {},
vm: { id: "pip" } as GridTileViewModel,
size$: constant("sm" as const),
};
function gridModel(): GridLayout {
return {
type: "grid",
spotlight,
grid: tiles(),
spotlightAlignment$: new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
}),
setVisibleTiles: () => {},
};
}
function landscapeModel(): SpotlightLandscapeLayout {
return {
type: "spotlight-landscape",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
function portraitModel(): SpotlightPortraitLayout {
return {
type: "spotlight-portrait",
spotlight,
grid: tiles(),
setVisibleTiles: () => {},
};
}
// Slots record their drag callback so a test can drag them, and show the id
// of the tile they hold
const drags = new Map<string, DragCallback | undefined>();
const Slot: LayoutProps<unknown, TileViewModel, HTMLDivElement>["Slot"] = ({
id,
model,
onDrag,
children,
...props
}) => {
drags.set(id, onDrag);
return (
<div data-testid={`slot-${id}`} {...props}>
{model instanceof GridTileViewModel ? model.id : "spotlight"}
</div>
);
};
}) => (
<div data-testid={`slot-${id}`} {...props}>
{children}
</div>
);
function slotIds(): string[] {
return screen
.getAllByTestId(/^slot-/)
.map((slot) => slot.textContent ?? "")
.filter((id) => id !== "spotlight");
function snapshotLayout<Model>(layout: CallLayout<Model>, model: Model): void {
const {
foreground,
scrolling: Scrolling,
fixed: Fixed,
} = layout({ minBounds$ });
const scrollingLayer = <Scrolling model={model} Slot={Slot} />;
const fixedLayer = <Fixed model={model} Slot={Slot} />;
const { container } = render(
foreground === "fixed" ? (
<>
{scrollingLayer}
{fixedLayer}
</>
) : (
<>
{fixedLayer}
{scrollingLayer}
</>
),
);
expect(container).toMatchSnapshot();
}
test("grid layout", () =>
snapshotLayout(makeGridLayout, {
type: "grid",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight landscape layout", () =>
snapshotLayout(makeSpotlightLandscapeLayout, {
type: "spotlight-landscape",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight portrait layout", () =>
snapshotLayout(makeSpotlightPortraitLayout, {
type: "spotlight-portrait",
spotlight,
grid,
setVisibleTiles: () => {},
}));
test("spotlight expanded layout", () =>
snapshotLayout(makeSpotlightExpandedLayout, {
type: "spotlight-expanded",
spotlight: spotlight.vm,
pip,
}));
test("one-on-one desktop layout", () =>
snapshotLayout(makeOneOnOneDesktopLayout, {
type: "one-on-one-desktop",
spotlight: { id: "spotlight" } as GridTileViewModel,
pip,
}));
test("one-on-one mobile layout", () =>
snapshotLayout(makeOneOnOneMobileLayout, {
type: "one-on-one-mobile",
spotlight: spotlight.vm,
pip,
}));
+12 -29
View File
@@ -40,6 +40,7 @@ import { useMergedRefs } from "../useMergedRefs";
import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
import { type DragCallback } from "../state/layout-types";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
@@ -83,7 +84,10 @@ interface DragState {
cursorY: number;
}
interface SlotProps<Model> extends Omit<ComponentProps<"div">, "onDrag"> {
export interface SlotProps<Model> extends Omit<
ComponentProps<"div">,
"onDrag"
> {
id: string;
model: Model;
onDrag?: DragCallback;
@@ -181,27 +185,6 @@ export interface TileProps<Model, R extends HTMLElement> {
model: Model;
}
interface Drag {
/**
* The X coordinate of the dragged tile in grid space.
*/
x: number;
/**
* The Y coordinate of the dragged tile in grid space.
*/
y: number;
/**
* The X coordinate of the dragged tile, as a scalar of the grid width.
*/
xRatio: number;
/**
* The Y coordinate of the dragged tile, as a scalar of the grid height.
*/
yRatio: number;
}
export type DragCallback = (drag: Drag) => void;
interface LayoutMemoProps<
LayoutModel,
TileModel,
@@ -464,13 +447,13 @@ export function Grid<
)
.catch(logger.error);
if (endOfGesture)
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
});
callback({
x: tileX,
y: tileY,
xRatio: tileX / (gridBounds.width - tile.width),
yRatio: tileY / (gridBounds.height - tile.height),
endOfGesture,
});
};
// Callback for useDrag. We could call useDrag here, but the default
+30 -25
View File
@@ -8,15 +8,21 @@ Please see LICENSE in the repository root for full details.
import {
type CSSProperties,
type ReactNode,
useCallback,
useMemo,
type FC,
type ComponentType,
} from "react";
import { type GridLayout as GridLayoutModel } from "../state/layout-types.ts";
import styles from "./GridLayout.module.css";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import { type DragCallback, useUpdateLayout, useVisibleTiles } from "./Grid";
import { type SlotProps, useUpdateLayout, useVisibleTiles } from "./Grid";
import { useBehavior } from "../useBehavior";
import {
type SpotlightTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type FloatingTile } from "../state/FloatingTile.ts";
interface GridCSSProperties extends CSSProperties {
"--gap": string;
@@ -24,6 +30,27 @@ interface GridCSSProperties extends CSSProperties {
"--height": string;
}
interface SpotlightSlotProps {
model: FloatingTile & { vm: SpotlightTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const SpotlightSlot: FC<SpotlightSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.slot}
id="spotlight"
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "grid" layout, in which all participants are shown
* together in a scrolling grid.
@@ -37,32 +64,10 @@ export const makeGridLayout: CallLayout<GridLayoutModel> = ({
// lives
fixed: function GridLayoutFixed({ ref, model, Slot }): ReactNode {
useUpdateLayout();
const alignment = useBehavior(model.spotlightAlignment$);
const onDragSpotlight: DragCallback = useCallback(
({ xRatio, yRatio }) => {
const block = yRatio < 0.5 ? "start" : "end";
const inline = xRatio < 0.5 ? "start" : "end";
// A drag reports the same alignment on every move; only a change is
// worth a re-render
const current = model.spotlightAlignment$.value;
if (current.block !== block || current.inline !== inline)
model.spotlightAlignment$.next({ block, inline });
},
[model.spotlightAlignment$],
);
return (
<div ref={ref} className={styles.fixed}>
{model.spotlight && (
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
onDrag={onDragSpotlight}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
<SpotlightSlot model={model.spotlight} Slot={Slot} />
)}
</div>
);
+6 -15
View File
@@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback, useMemo } from "react";
import { type ReactNode, useMemo } from "react";
import classNames from "classnames";
import { type OneOnOneDesktopLayout as OneOnOneDesktopLayoutModel } from "../state/layout-types.ts";
import { type CallLayout, arrangeTiles } from "./CallLayout";
import styles from "./OneOnOneDesktopLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
/**
@@ -37,21 +37,12 @@ export const makeOneOnOneDesktopLayout: CallLayout<
}): ReactNode {
useUpdateLayout();
const { width, height } = useBehavior(minBounds$);
const pipAlignment = useBehavior(model.pipAlignment$);
const pipAlignment = useBehavior(model.pip.alignment$);
const { tileWidth, tileHeight } = useMemo(
() => arrangeTiles(width, height, 1),
[width, height],
);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -62,9 +53,9 @@ export const makeOneOnOneDesktopLayout: CallLayout<
>
<Slot
className={classNames(styles.slot, styles.local)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
id={model.pip.vm.id}
model={model.pip.vm}
onDrag={model.pip.onDrag}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
+32 -24
View File
@@ -6,14 +6,43 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ComponentType, type FC, type ReactNode } from "react";
import classNames from "classnames";
import { type OneOnOneMobileLayout as OneOnOneMobileLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import styles from "./OneOnOneMobileLayout.module.css";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel; size$: Behavior<"sm" | "lg"> };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const size = useBehavior(model.size$);
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={classNames(styles.pip)}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-size={size}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "one-on-one" layout for mobile platforms, in which
@@ -44,30 +73,9 @@ export const makeOneOnOneMobileLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipSize = useBehavior(model.pipSize$);
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragLocalTile: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={classNames(styles.pip)}
id={model.pip.id}
model={model.pip}
onDrag={onDragLocalTile}
data-size={pipSize}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+29 -24
View File
@@ -5,13 +5,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type ReactNode, useCallback } from "react";
import { type ReactNode, type ComponentType, type FC } from "react";
import { type SpotlightExpandedLayout as SpotlightExpandedLayoutModel } from "../state/layout-types.ts";
import { type CallLayout } from "./CallLayout";
import { type DragCallback, useUpdateLayout } from "./Grid";
import { type SlotProps, useUpdateLayout } from "./Grid";
import styles from "./SpotlightExpandedLayout.module.css";
import { useBehavior } from "../useBehavior";
import { type FloatingTile } from "../state/FloatingTile.ts";
import {
type GridTileViewModel,
type TileViewModel,
} from "../state/TileViewModel.ts";
interface PipSlotProps {
model: FloatingTile & { vm: GridTileViewModel };
Slot: ComponentType<SlotProps<TileViewModel>>;
}
const PipSlot: FC<PipSlotProps> = ({ model, Slot }) => {
useUpdateLayout();
const alignment = useBehavior(model.alignment$);
return (
<Slot
className={styles.pip}
id={model.vm.id}
model={model.vm}
onDrag={model.onDrag}
data-block-alignment={alignment.block}
data-inline-alignment={alignment.inline}
/>
);
};
/**
* An implementation of the "expanded spotlight" layout, in which the spotlight
@@ -28,7 +54,6 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
return (
<div ref={ref} className={styles.layer}>
<Slot
@@ -46,29 +71,9 @@ export const makeSpotlightExpandedLayout: CallLayout<
Slot,
}): ReactNode {
useUpdateLayout();
const pipAlignment = useBehavior(model.pipAlignment$);
const onDragPip: DragCallback = useCallback(
({ xRatio, yRatio }) =>
model.pipAlignment$.next({
block: yRatio < 0.5 ? "start" : "end",
inline: xRatio < 0.5 ? "start" : "end",
}),
[model.pipAlignment$],
);
return (
<div ref={ref} className={styles.layer}>
{model.pip && (
<Slot
className={styles.pip}
id={model.pip.id}
model={model.pip}
onDrag={onDragPip}
data-block-alignment={pipAlignment.block}
data-inline-alignment={pipAlignment.inline}
/>
)}
{model.pip && <PipSlot model={model.pip} Slot={Slot} />}
</div>
);
},
+2 -2
View File
@@ -40,7 +40,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
<div className={styles.grid} />
@@ -59,7 +59,7 @@ export const makeSpotlightLandscapeLayout: CallLayout<
return (
<div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} />
<SpotlightSlot media$={model.spotlight.vm.media$} />
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
+2 -2
View File
@@ -43,7 +43,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
<Slot
className={styles.slot}
id="spotlight"
model={model.spotlight}
model={model.spotlight.vm}
/>
</div>
</div>
@@ -65,7 +65,7 @@ export const makeSpotlightPortraitLayout: CallLayout<
width,
model.grid.length,
);
const withIndicators = useBehavior(model.spotlight.media$).length > 1;
const withIndicators = useBehavior(model.spotlight.vm.media$).length > 1;
return (
<div
@@ -0,0 +1,186 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`grid layout 1`] = `
<div>
<div
class="_scrolling_e8555d"
style="width: 800px; --gap: 20px; --width: 370px; --height: 277px;"
>
<div
class="_slot_e8555d"
data-testid="slot-0"
/>
<div
class="_slot_e8555d"
data-testid="slot-1"
/>
<div
class="_slot_e8555d"
data-testid="slot-2"
/>
</div>
<div
class="_fixed_e8555d"
>
<div
class="_slot_e8555d"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-spotlight"
/>
</div>
</div>
`;
exports[`one-on-one desktop layout 1`] = `
<div>
<div
class="_layer_185815"
>
<div
class="_container_185815"
data-testid="slot-spotlight"
style="width: 760px; height: 570px;"
>
<div
class="_slot_185815 _local_185815"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
<div />
</div>
`;
exports[`one-on-one mobile layout 1`] = `
<div>
<div
class="_layer_c81c30"
>
<div
class="_spotlight_c81c30"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_c81c30"
>
<div
class="_pip_c81c30"
data-block-alignment="end"
data-inline-alignment="end"
data-size="sm"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight expanded layout 1`] = `
<div>
<div
class="_layer_e70191"
>
<div
class="_spotlight_e70191"
data-testid="slot-spotlight"
/>
</div>
<div
class="_layer_e70191"
>
<div
class="_pip_e70191"
data-block-alignment="end"
data-inline-alignment="end"
data-testid="slot-pip"
/>
</div>
</div>
`;
exports[`spotlight landscape layout 1`] = `
<div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-spotlight"
/>
</div>
<div
class="_grid_29809c"
/>
</div>
<div
class="_layer_29809c"
>
<div
class="_spotlight_29809c"
/>
<div
class="_grid_29809c"
>
<div
class="_slot_29809c"
data-testid="slot-0"
/>
<div
class="_slot_29809c"
data-testid="slot-1"
/>
<div
class="_slot_29809c"
data-testid="slot-2"
/>
</div>
</div>
</div>
`;
exports[`spotlight portrait layout 1`] = `
<div>
<div
class="_layer_065bbc"
style="--grid-gap: 20px; --grid-tile-width: 370px; --grid-tile-height: 277px;"
>
<div
class="_spotlight_065bbc"
/>
<div
class="_grid_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-0"
/>
<div
class="_slot_065bbc"
data-testid="slot-1"
/>
<div
class="_slot_065bbc"
data-testid="slot-2"
/>
</div>
</div>
<div
class="_layer_065bbc"
>
<div
class="_spotlight_065bbc"
>
<div
class="_slot_065bbc"
data-testid="slot-spotlight"
/>
</div>
</div>
</div>
`;
+9 -3
View File
@@ -50,6 +50,7 @@ import {
mockMediaDevices,
mockRtcMembership,
MockRTCSession,
testScope,
} from "../utils/test";
import { CallView } from "./CallView";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
@@ -61,7 +62,10 @@ import {
} from "../HostBridge";
import { type JoinCallData } from "../widget";
import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
import {
BackgroundEffectsProvider,
createAppBackgroundEffects,
} from "../tracks/BackgroundEffectsContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { constant } from "../state/Behavior";
@@ -193,7 +197,9 @@ function createCallView(
<HostBridgeProvider value={hostBridge}>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
<BackgroundEffectsProvider
effects={createAppBackgroundEffects(testScope())}
>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
@@ -201,7 +207,7 @@ function createCallView(
) : (
callView
)}
</ProcessorProvider>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</TooltipProvider>
</HostBridgeProvider>
+5
View File
@@ -72,6 +72,7 @@ import { useRootElement } from "../RootElementContext.ts";
import { useHostBridge } from "../HostBridge.ts";
import { useMuteStates } from "../state/useMuteStates.ts";
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
import { useLocalMedia } from "../useLocalMedia.ts";
/**
* If there already are this many participants in the call, we automatically mute
@@ -273,6 +274,8 @@ const LoadedCallView: FC<LoadedProps> = ({
const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);
// Opened by the lobby, published by the call: one capture for both
const media = useLocalMedia();
const enterRTCSessionOrError = useCallback(
async (rtcSession: MatrixRTCSession): Promise<void> => {
@@ -500,6 +503,7 @@ const LoadedCallView: FC<LoadedProps> = ({
<LobbyView
client={client}
matrixInfo={matrixInfo}
media={media}
muteStates={muteStates}
onEnter={() => setJoined(true)}
confineToRoom={confineToRoom}
@@ -529,6 +533,7 @@ const LoadedCallView: FC<LoadedProps> = ({
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
onLeft={onLeft}
media={media}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
//otelGroupCallMembership={otelGroupCallMembership}
+27 -5
View File
@@ -28,6 +28,7 @@ import {
type MockRTCSession,
mockRTCSlot,
mockRTCMember,
testScope,
} from "../utils/test";
import { E2eeType } from "../e2ee/e2eeType";
import {
@@ -48,7 +49,10 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { type MediaDevices as ECMediaDevices } from "../state/MediaDevices";
import { AppBar } from "../AppBar";
import { type MatrixInfo } from "./VideoPreview";
import { ProcessorProvider } from "../tracks/TrackProcessorContext";
import {
BackgroundEffectsProvider,
createAppBackgroundEffects,
} from "../tracks/BackgroundEffectsContext";
import { initializeWidget } from "../widget";
import { RootElementProvider } from "../RootElementContext";
@@ -227,6 +231,18 @@ describe("InCallView", () => {
});
});
// Media that opened nothing: the join captures as it always did
function fakeLocalMedia(): MatrixRTCSdk.LocalMedia {
return {
requests: [],
tracks$: constant([]),
capture: vi.fn(async () => Promise.resolve([])),
release: vi.fn(),
stop: vi.fn(),
takeTracks: () => new Map(),
};
}
describe("ActiveCall", () => {
it("creates the view models and renders the call", async () => {
const mediaDevices = mockMediaDevices({});
@@ -234,7 +250,9 @@ describe("ActiveCall", () => {
const { findByTestId } = render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<BackgroundEffectsProvider
effects={createAppBackgroundEffects(testScope())}
>
<TooltipProvider>
<ActiveCall
client={matrixRoom.client}
@@ -244,10 +262,11 @@ describe("ActiveCall", () => {
matrixInfo={matrixInfo}
onShareClick={null}
e2eeSystem={{ kind: E2eeType.NONE }}
media={fakeLocalMedia()}
onLeft={(): void => {}}
/>
</TooltipProvider>
</ProcessorProvider>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
@@ -282,7 +301,9 @@ describe("ActiveCall", () => {
<BrowserRouter>
<RootElementProvider value={root}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<BackgroundEffectsProvider
effects={createAppBackgroundEffects(testScope())}
>
<TooltipProvider>
<ActiveCall
client={matrixRoom.client}
@@ -292,10 +313,11 @@ describe("ActiveCall", () => {
matrixInfo={matrixInfo}
onShareClick={null}
e2eeSystem={{ kind: E2eeType.NONE }}
media={fakeLocalMedia()}
onLeft={(): void => {}}
/>
</TooltipProvider>
</ProcessorProvider>
</BackgroundEffectsProvider>
</MediaDevicesContext>
</RootElementProvider>
</BrowserRouter>,
+20 -9
View File
@@ -50,6 +50,7 @@ import { Grid, type TileProps } from "../grid/Grid";
import { SpotlightTile } from "../tile/SpotlightTile";
import {
type EncryptionSystem,
type LocalMedia,
type MatrixRTCMode,
} from "@element-hq/matrixrtc-sdk";
import { E2eeType } from "../e2ee/e2eeType";
@@ -74,7 +75,6 @@ import {
type RTCSlotOptions,
constant,
} from "@element-hq/matrixrtc-sdk";
import { map } from "rxjs";
import {
advancedCamera,
advancedScreenShare,
@@ -109,7 +109,7 @@ import { useBehavior } from "../useBehavior.ts";
import { useValueBehavior } from "../useValueBehavior.ts";
import { Toast } from "../Toast.tsx";
import overlayStyles from "../Overlay.module.css";
import { useTrackProcessorState$ } from "../tracks/TrackProcessorContext.tsx";
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
import { type Layout } from "../state/layout-types.ts";
import { ObservableScope } from "../state/ObservableScope.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
@@ -133,6 +133,8 @@ export interface ActiveCallProps extends Omit<
"vm" | "footerVm" | "developerSettingsVm"
> {
e2eeSystem: EncryptionSystem;
/** The tracks the lobby opened, for the join to publish. */
media: LocalMedia;
// TODO refactor those reasons into an enum
onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
@@ -150,7 +152,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorState$();
const backgroundEffectsState$ = useBackgroundEffectsState$();
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
@@ -170,7 +172,16 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
);
const rtcParticipation = slot.join({
...rtcParticipationOptions(urlParams),
publish: initialPublishRequests(props.muteStates, mediaDevices, capture),
// Without a lobby nothing was captured and the join captures itself
publish:
props.media.requests.length > 0
? props.media
: initialPublishRequests(
props.muteStates,
mediaDevices,
capture,
backgroundEffectsState$.value,
),
});
const vm = createCallViewModel$(
@@ -183,9 +194,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
capture,
videoProcessor$: scope.behavior(
trackProcessorState$.pipe(map((state) => state.processor)),
),
backgroundEffectsState$,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
@@ -214,8 +223,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
urlParams,
hostBridge,
mediaDevices,
trackProcessorState$,
backgroundEffectsState$,
props.client,
props.media,
rootElement,
]);
@@ -228,6 +238,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm,
props.muteStates,
mediaDevices,
backgroundEffectsState$,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
{ showControls: urlParams.showControls, header: urlParams.header },
);
@@ -245,7 +256,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
backgroundEffectsState$,
props.client,
vm,
]);
+28 -10
View File
@@ -10,23 +10,41 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient, type RoomSummary } from "matrix-js-sdk";
import {
type Behavior,
constant,
type LocalMedia,
} from "@element-hq/matrixrtc-sdk";
import { KnockLobbyView } from "./KnockLobbyView";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../tracks/TrackProcessorContext";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { mockMediaDevices } from "../utils/test";
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
// One instance each: the lobby keys effects on them
const stable = vi.hoisted(() => ({
processorState$: undefined as unknown,
media: undefined as unknown,
}));
vi.mock("../tracks/TrackProcessorContext", () => ({
useTrackProcessor: (): ProcessorState => ({
supported: false,
processor: undefined,
}),
useTrackProcessorSync: (): void => {},
vi.mock("../tracks/BackgroundEffectsContext", () => ({
useBackgroundEffectsState$: (): Behavior<BackgroundEffectsState> =>
(stable.processorState$ ??= constant({
supported: false,
processor: undefined,
})) as Behavior<BackgroundEffectsState>,
}));
// Media that opens no device: the view is what is under test
vi.mock("../useLocalMedia", () => ({
useLocalMedia: (): LocalMedia =>
(stable.media ??= {
requests: [],
tracks$: constant([]),
capture: vi.fn(async () => Promise.resolve([])),
release: vi.fn(),
stop: vi.fn(),
takeTracks: () => new Map(),
}) as LocalMedia,
}));
vi.mock("react-use-measure", () => ({
+3
View File
@@ -13,6 +13,7 @@ import { CheckIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
import { useMuteStates } from "../state/useMuteStates";
import { useLocalMedia } from "../useLocalMedia";
interface Props {
client: MatrixClient;
@@ -49,6 +50,7 @@ export const KnockLobbyView: FC<Props> = ({
}): ReactNode => {
const { t } = useTranslation();
const muteStates = useMuteStates();
const media = useLocalMedia();
if (muteStates === null) return null;
@@ -85,6 +87,7 @@ export const KnockLobbyView: FC<Props> = ({
confineToRoom={confineToRoom}
hideHeader={hideHeader}
participantCount={null}
media={media}
muteStates={muteStates}
onShareClick={null}
/>
+26 -10
View File
@@ -12,6 +12,11 @@ import { of } from "rxjs";
import { LeaveToHomeProvider } from "../LeaveToHomeContext";
import { TooltipProvider } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import {
type Behavior,
constant,
type LocalMedia,
} from "@element-hq/matrixrtc-sdk";
import { axe } from "vitest-axe";
import {
ArrowLeftIcon,
@@ -28,7 +33,7 @@ import {
} from "../utils/test";
import { type MediaDevices } from "../state/MediaDevices";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type ProcessorState } from "../tracks/TrackProcessorContext";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import lobbyStyles from "./LobbyView.module.css";
import headerStyles from "../Header.module.css";
@@ -37,17 +42,27 @@ import { AppBar } from "../AppBar";
// Somewhere to go home to, so that the lobby offers the way back
const leaveToHome = vi.fn();
vi.mock("@livekit/components-react", () => ({
usePreviewTracks: (): unknown[] => [],
// One instance: the view keys an effect on it
const processorState$ = vi.hoisted(() => ({ current: undefined as unknown }));
vi.mock("../tracks/BackgroundEffectsContext", () => ({
useBackgroundEffectsState$: (): Behavior<BackgroundEffectsState> =>
(processorState$.current ??= constant({
supported: false,
processor: undefined,
})) as Behavior<BackgroundEffectsState>,
}));
vi.mock("../tracks/TrackProcessorContext", () => ({
useTrackProcessor: (): ProcessorState => ({
supported: false,
processor: undefined,
}),
useTrackProcessorSync: (): void => {},
}));
// Media that opens no device: the view is what is under test
function fakeLocalMedia(): LocalMedia {
return {
requests: [],
tracks$: constant([]),
capture: vi.fn(async () => Promise.resolve([])),
release: vi.fn(),
stop: vi.fn(),
takeTracks: () => new Map(),
};
}
vi.mock("react-use-measure", () => ({
default: (): [() => void, object] => [(): void => {}, {}],
@@ -95,6 +110,7 @@ function renderLobbyView(
<LobbyView
client={mockClient}
matrixInfo={matrixInfo}
media={fakeLocalMedia()}
muteStates={muteStates}
onEnter={() => {}}
confineToRoom={false}
+47 -72
View File
@@ -5,26 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
useCallback,
useMemo,
useState,
type JSX,
useEffect,
} from "react";
import { type FC, useCallback, useState, type JSX, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { type MatrixClient } from "matrix-js-sdk";
import { Button } from "@vector-im/compound-web";
import classNames from "classnames";
import { logger } from "matrix-js-sdk/lib/logger";
import { usePreviewTracks } from "@livekit/components-react";
import {
type CreateLocalTracksOptions,
type LocalVideoTrack,
Track,
} from "livekit-client";
import { convertToLivekitProcessor } from "@element-hq/matrixrtc-sdk";
constant,
type LocalMedia,
type LocalVideoMediaTrack,
} from "@element-hq/matrixrtc-sdk";
import inCallStyles from "./InCallView.module.css";
import styles from "./LobbyView.module.css";
@@ -39,22 +30,26 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
import { useLeaveToHome } from "../LeaveToHomeContext";
import { useMediaDevices } from "../MediaDevicesContext";
import { ObservableScope } from "../state/ObservableScope";
import { useInitial } from "../useInitial";
import {
useTrackProcessor,
useTrackProcessorSync,
} from "../tracks/TrackProcessorContext";
import { getValue } from "../utils/observable";
import { useBackgroundEffectsState$ } from "../tracks/BackgroundEffectsContext";
import { useBehavior } from "../useBehavior";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { createLobbyFooterViewModel } from "../components/CallFooterViewModel";
import { type ViewModel } from "../state/ViewModel";
import { useAppBarPrimaryButtonIconKind } from "../AppBar";
import {
createLobbyMediaViewModel,
type LobbyMediaViewModel,
} from "../state/LobbyMediaViewModel";
import { captureSettings } from "./InCallView";
const noVideoTrack = constant<LocalVideoMediaTrack | null>(null);
interface Props {
client: MatrixClient;
matrixInfo: MatrixInfo;
/** The media to open the devices on; the join takes it over. */
media: LocalMedia;
muteStates: MuteStates;
onEnter: () => void;
enterLabel?: JSX.Element | string;
@@ -68,6 +63,7 @@ interface Props {
export const LobbyView: FC<Props> = ({
client,
matrixInfo,
media,
muteStates,
onEnter,
enterLabel,
@@ -87,7 +83,6 @@ export const LobbyView: FC<Props> = ({
const { t } = useTranslation();
useAppBarPrimaryButtonIconKind("back");
const audioEnabled = useBehavior(muteStates.audio.enabled$);
const videoEnabled = useBehavior(muteStates.video.enabled$);
const toggleAudio = useBehavior(muteStates.audio.toggle$);
const toggleVideo = useBehavior(muteStates.video.toggle$);
@@ -126,55 +121,26 @@ export const LobbyView: FC<Props> = ({
const devices = useMediaDevices();
const videoInputId = useBehavior(devices.videoInput.selected$)?.id;
const backgroundEffectsState$ = useBackgroundEffectsState$();
// Capture the audio options as they were when we first mounted, because
// we're not doing anything with the audio anyway so we don't need to
// re-open the devices when they change (see below).
const initialAudioOptions = useInitial(
() =>
audioEnabled && {
deviceId: getValue(devices.audioInput.selected$)?.id,
},
);
const { processor } = useTrackProcessor();
const initialProcessor = useInitial(() => processor);
const localTrackOptions = useMemo<CreateLocalTracksOptions>(
() => ({
// The only reason we request audio here is to get the audio permission
// request over with at the same time. But changing the audio settings
// shouldn't cause this hook to recreate the track, which is why we
// reference the initial values here.
// We also pass in a clone because livekit mutates the object passed in,
// which would cause the devices to be re-opened on the next render.
audio: Object.assign({}, initialAudioOptions),
video: videoEnabled && {
deviceId: videoInputId,
processor:
initialProcessor && convertToLivekitProcessor(initialProcessor),
},
}),
[initialAudioOptions, videoEnabled, videoInputId, initialProcessor],
);
const onError = useCallback(
(error: Error) => {
logger.error("Error while creating preview Tracks:", error);
muteStates.audio.setEnabled$.value?.(false);
muteStates.video.setEnabled$.value?.(false);
},
[muteStates],
);
const tracks = usePreviewTracks(localTrackOptions, onError);
const videoTrack = useMemo(
() =>
(tracks?.find((t) => t.kind === Track.Kind.Video) ??
null) as LocalVideoTrack | null,
[tracks],
);
const [mediaVm, setMediaVm] = useState<LobbyMediaViewModel | null>(null);
useEffect(() => {
const mediaScope = new ObservableScope();
setMediaVm(
createLobbyMediaViewModel(
mediaScope,
media,
muteStates,
devices,
captureSettings(),
backgroundEffectsState$,
),
);
return (): void => {
mediaScope.end();
};
}, [media, muteStates, devices, backgroundEffectsState$]);
const videoTrack = useBehavior(mediaVm?.videoTrack$ ?? noVideoTrack);
useEffect(() => {
if (videoTrack && videoInputId === undefined) {
@@ -185,18 +151,19 @@ export const LobbyView: FC<Props> = ({
}
}, [devices, videoInputId, videoTrack]);
useTrackProcessorSync(videoTrack);
const [footerVm, setFooterVm] = useState<ViewModel<FooterSnapshot> | null>(
null,
);
useEffect(() => {
if (mediaVm === null) return;
const footerScope = new ObservableScope();
setFooterVm(
createLobbyFooterViewModel(
footerScope,
muteStates,
devices,
backgroundEffectsState$,
mediaVm.videoTrack$,
openSettings,
hangup,
// Logo and header are connected: only show the logo in SPA with header.
@@ -206,7 +173,15 @@ export const LobbyView: FC<Props> = ({
return (): void => {
footerScope.end();
};
}, [devices, hangup, hideHeader, muteStates, openSettings]);
}, [
devices,
backgroundEffectsState$,
hangup,
hideHeader,
mediaVm,
muteStates,
openSettings,
]);
// TODO: Unify this component with InCallView, so we can get slick joining
// animations and don't have to feel bad about reusing its CSS
+11 -15
View File
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import { useEffect, useMemo, useRef, type FC, type ReactNode } from "react";
import useMeasure from "react-use-measure";
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
import { constant, type LocalVideoMediaTrack } from "@element-hq/matrixrtc-sdk";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
@@ -15,6 +15,9 @@ import { TileAvatar } from "../tile/TileAvatar";
import styles from "./VideoPreview.module.css";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import videoPlaceholder from "../graphics/video-placeholder.gif";
import { useBehavior } from "../useBehavior";
const noFacingMode = constant(undefined);
export type MatrixInfo = {
userId: string;
@@ -30,7 +33,7 @@ export type MatrixInfo = {
interface Props {
matrixInfo: MatrixInfo;
videoEnabled: boolean;
videoTrack: LocalVideoTrack | null;
videoTrack: LocalVideoMediaTrack | null;
children: ReactNode;
}
@@ -46,14 +49,12 @@ export const VideoPreview: FC<Props> = ({
const videoEl = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
// Effect to connect the videoTrack with the video element.
if (videoEl.current) {
videoTrack?.attach(videoEl.current);
}
return (): void => {
videoTrack?.detach();
};
const element = videoEl.current;
if (!element || !videoTrack) return;
videoTrack.attach(element);
return (): void => videoTrack.detach(element);
}, [videoTrack]);
const facingMode = useBehavior(videoTrack?.facingMode$ ?? noFacingMode);
const cameraIsStarting = useMemo(
() => videoEnabled && !videoTrack,
@@ -63,12 +64,7 @@ export const VideoPreview: FC<Props> = ({
return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
className={
videoTrack &&
facingModeFromLocalTrack(videoTrack).facingMode === "user"
? styles.mirror
: undefined
}
className={facingMode === "user" ? styles.mirror : undefined}
ref={videoEl}
muted
playsInline
+93
View File
@@ -0,0 +1,93 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type MatrixClient } from "matrix-js-sdk";
import { TooltipProvider } from "@vector-im/compound-web";
import { SettingsModal } from "./SettingsModal";
import { backgroundEffect } from "./settings";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { type BackgroundEffectsState } from "../state/BackgroundEffects";
import { mockMediaDevices } from "../utils/test";
import { constant } from "../state/Behavior";
import { type MediaDevices } from "../state/MediaDevices";
vi.mock("../tracks/BackgroundEffectsContext", () => ({
useBackgroundEffects: (): BackgroundEffectsState => ({
supported: true,
processor: undefined,
}),
}));
// Behaviors, as the device menus read them.
const noDevices = {
available$: constant(new Map()),
selected$: constant(undefined),
select: vi.fn(),
};
function renderVideoTab(): void {
render(
<TooltipProvider>
<MediaDevicesContext
value={mockMediaDevices({
requestDeviceNames: vi.fn(),
audioInput: noDevices,
audioOutput: noDevices,
videoInput: noDevices,
} as unknown as Partial<MediaDevices>)}
>
<SettingsModal
open
onDismiss={vi.fn()}
tab="video"
onTabChange={vi.fn()}
client={
{
getUserId: () => "@user:example.org",
getUser: () => null,
} as unknown as MatrixClient
}
/>
</MediaDevicesContext>
</TooltipProvider>,
);
}
const blurControl = (): HTMLElement =>
screen.getByRole("checkbox", { name: "Blur the background of the video" });
describe("SettingsModal", () => {
afterEach(() => backgroundEffect.setValue("none"));
it("blur control turns blur on and off", async () => {
const user = userEvent.setup();
renderVideoTab();
expect(blurControl()).not.toBeChecked();
await user.click(blurControl());
expect(backgroundEffect.getValue()).toBe("blur");
expect(blurControl()).toBeChecked();
await user.click(blurControl());
expect(backgroundEffect.getValue()).toBe("none");
expect(blurControl()).not.toBeChecked();
});
it("blur control reflects and replaces an image background", async () => {
const user = userEvent.setup();
backgroundEffect.setValue("image:arc");
renderVideoTab();
expect(blurControl()).not.toBeChecked();
await user.click(blurControl());
expect(backgroundEffect.getValue()).toBe("blur");
});
});
+16 -6
View File
@@ -21,13 +21,17 @@ import { useHostBridge } from "../HostBridge";
import {
useSetting,
soundEffectVolume as soundEffectVolumeSetting,
backgroundBlur as backgroundBlurSetting,
backgroundEffect as backgroundEffectSetting,
developerMode,
} from "./settings";
import { PreferencesSettingsTab } from "./PreferencesSettingsTab";
import { Slider } from "../Slider";
import { DeviceSelection } from "./DeviceSelection";
import { useTrackProcessor } from "../tracks/TrackProcessorContext";
import { useBackgroundEffects } from "../tracks/BackgroundEffectsContext";
import {
parseEffect,
serializeEffect,
} from "../tracks/backgroundEffectCatalog";
import {
DeveloperSettingsTab,
type DeveloperSettingsSnapshot,
@@ -78,9 +82,11 @@ export const SettingsModal: FC<Props> = ({
// Generate a `Checkbox` input to turn blur on or off.
const BlurCheckbox: React.FC = (): ReactNode => {
const { supported } = useTrackProcessor();
const { supported } = useBackgroundEffects();
const [blurActive, setBlurActive] = useSetting(backgroundBlurSetting);
// The camera menu's setting, of which this control sees only blur.
const [effect, setEffect] = useSetting(backgroundEffectSetting);
const blurActive = parseEffect(effect).kind === "blur";
return (
<>
@@ -94,8 +100,12 @@ export const SettingsModal: FC<Props> = ({
supported ? "" : t("settings.blur_not_supported_by_browser")
}
type="checkbox"
checked={!!blurActive}
onChange={(b): void => setBlurActive(b.target.checked)}
checked={blurActive}
onChange={(b): void =>
setEffect(
serializeEffect({ kind: b.target.checked ? "blur" : "none" }),
)
}
disabled={!supported}
/>
</FieldRow>
+9 -1
View File
@@ -13,6 +13,7 @@ import { type Behavior } from "../state/Behavior";
import { useBehavior } from "../useBehavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { type UnstableLivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
import { type EffectId } from "../tracks/backgroundEffectCatalog";
export class Setting<T> {
public constructor(
@@ -116,7 +117,14 @@ export const videoInput = new Setting<string | undefined>(
undefined,
);
export const backgroundBlur = new Setting<boolean>("background-blur", false);
// Only read, to carry blur over for anyone who had it on.
const backgroundBlur = new Setting<boolean>("background-blur", false);
/** The chosen background effect, in the form `parseEffect` reads. */
export const backgroundEffect = new Setting<EffectId>(
"background-effect",
backgroundBlur.getValue() ? "blur" : "none",
);
export const showHandRaisedTimer = new Setting<boolean>(
"hand-raised-show-timer",
+2
View File
@@ -7,6 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
import { type ObservableScope } from "./ObservableScope";
import { createAppBackgroundEffects } from "../tracks/BackgroundEffectsContext";
/**
* The top-level state holder for the application.
@@ -16,6 +17,7 @@ export class AppViewModel {
this.scope,
this.audioOutputOptions,
);
public readonly backgroundEffects = createAppBackgroundEffects(this.scope);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
+262
View File
@@ -0,0 +1,262 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BehaviorSubject, distinctUntilChanged, map, of } from "rxjs";
import { type BackgroundProcessorWrapper } from "@livekit/track-processors";
import {
type BackgroundEffects,
createBackgroundEffects,
type BackgroundEffectsOptions,
type BackgroundEffectsState,
} from "./BackgroundEffects";
import { type AddedBackground } from "../tracks/backgroundImages";
import { shippedBackgrounds } from "../tracks/backgroundEffectCatalog";
import { flushPromises, testScope, withTestScheduler } from "../utils/test";
/** A pipeline that records what it is switched to. */
function fakePipeline(): {
pipeline: BackgroundProcessorWrapper;
switches: unknown[];
/** Holds the next switch until it resolves, as a picture loading does. */
holdNext: () => () => void;
} {
const switches: unknown[] = [];
let slow: Promise<void> | undefined;
const pipeline = {
switchTo: vi.fn(async (options: unknown) => {
switches.push(options);
const held = slow;
slow = undefined;
await held;
}),
} as unknown as BackgroundProcessorWrapper;
return {
pipeline,
switches,
holdNext: () => {
let release!: () => void;
slow = new Promise((resolve) => (release = resolve));
return release;
},
};
}
/** One letter per state: idle, preparing, waiting for a frame, attached. */
function letter(state: BackgroundEffectsState): string {
if (state.supported === false) return "x";
if (state.wait === "preparing") return "p";
if (state.processor === undefined) return "i";
return state.wait === "settling" ? "w" : "a";
}
describe("the pipeline's state", () => {
function testState({
effect,
answer = "-y",
firstFrame = "",
expected,
}: {
effect: string;
/** When the trial build answers, from being asked. */
answer?: string;
firstFrame?: string;
expected: string;
}): void {
withTestScheduler(({ behavior, cold, schedule, expectObservable }) => {
const transformer = {
onFirstFrame: undefined as (() => void) | undefined,
};
const effects = createBackgroundEffects(testScope(), {
supported: true,
effect$: behavior(effect, { n: "none", b: "blur" }),
setEffect: vi.fn(),
added$: new BehaviorSubject<AddedBackground[] | undefined>([]),
canSegment: () => cold(answer, { y: true, n: false }),
pipeline: fakePipeline().pipeline,
transformer,
});
schedule(firstFrame, { f: () => transformer.onFirstFrame?.() });
expectObservable(
effects.state$.pipe(map(letter), distinctUntilChanged()),
).toBe(expected);
});
}
it("defaults to no effect", () => testState({ effect: "n", expected: "i" }));
it("attaches only once it is known the pipeline builds", () =>
testState({ effect: "nb", answer: "--y", expected: "ip-w" }));
it("offers none where the pipeline fails to build", () =>
testState({ effect: "nb", answer: "-n", expected: "ipx" }));
it("waits from the first effect until a frame carries it", () =>
testState({
effect: " nb---n-b",
firstFrame: "---f",
expected: " ipwa",
}));
it("attaches on first use and stays attached", () =>
testState({ effect: "nb--n", firstFrame: "---f", expected: "ipwa" }));
});
describe("background effects", () => {
let effect$: BehaviorSubject<string>;
let added$: BehaviorSubject<AddedBackground[] | undefined>;
let setEffect: (raw: string) => void;
let fake: ReturnType<typeof fakePipeline>;
function build(
options: Partial<BackgroundEffectsOptions> = {},
): BackgroundEffects {
return createBackgroundEffects(testScope(), {
supported: true,
effect$,
setEffect,
added$,
canSegment: () => of(true),
pipeline: fake.pipeline,
transformer: { onFirstFrame: undefined },
...options,
});
}
const choose = async (raw: string): Promise<void> => {
effect$.next(raw);
await flushPromises();
};
beforeEach(() => {
effect$ = new BehaviorSubject("none");
added$ = new BehaviorSubject<AddedBackground[] | undefined>(undefined);
setEffect = vi.fn((raw: string) => effect$.next(raw));
fake = fakePipeline();
});
it("offers none for the session where the pipeline fails to build", async () => {
const effects = build({ canSegment: () => of(false) });
await choose("blur");
expect(effects.state$.value.supported).toBe(false);
expect(effects.state$.value.processor).toBeUndefined();
expect(effects.state$.value.wait).toBeUndefined();
// A browser update may fix it, so the next session asks again.
expect(effect$.value).toBe("blur");
});
it("puts a shipped background on as that picture", async () => {
build();
await choose(`image:${shippedBackgrounds[0].id}`);
expect(fake.switches).toEqual([
{
mode: "virtual-background",
imagePath: shippedBackgrounds[0].imagePath,
},
]);
});
it("switches in place rather than reattaching", async () => {
const effects = build();
await choose("blur");
const pipeline = effects.state$.value.processor;
await choose("none");
await choose("blur");
expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual([
{ mode: "background-blur", blurRadius: 15 },
{ mode: "disabled" },
{ mode: "background-blur", blurRadius: 15 },
]);
});
it("keeps switching after a switch fails", async () => {
vi.mocked(fake.pipeline.switchTo).mockRejectedValueOnce(new Error("lost"));
build();
await choose("blur");
await choose("none");
expect(fake.switches).toEqual([{ mode: "disabled" }]);
});
it("switches one at a time, skipping those overtaken", async () => {
const finished = fake.holdNext();
build();
await choose("blur");
await choose("none");
await choose("blur");
await choose("none");
expect(fake.switches).toHaveLength(1);
finished();
await flushPromises();
expect(fake.switches).toEqual([
{ mode: "background-blur", blurRadius: 15 },
{ mode: "disabled" },
]);
});
it("clears a remembered effect when it cannot be honoured", () => {
effect$.next(`image:${shippedBackgrounds[0].id}`);
const effects = build({ supported: false });
expect(effect$.value).toBe("none");
expect(effects.state$.value.processor).toBeUndefined();
});
it("puts an added background on as its picture", async () => {
added$.next([{ id: "mine", url: "blob:mine" }]);
build();
await choose("added:mine");
expect(fake.switches).toEqual([
{ mode: "virtual-background", imagePath: "blob:mine" },
]);
});
it("switches between any two effects in place", async () => {
added$.next([{ id: "mine", url: "blob:mine" }]);
const shipped = shippedBackgrounds[0];
const options: Record<string, unknown> = {
none: { mode: "disabled" },
blur: { mode: "background-blur", blurRadius: 15 },
[`image:${shipped.id}`]: {
mode: "virtual-background",
imagePath: shipped.imagePath,
},
"added:mine": { mode: "virtual-background", imagePath: "blob:mine" },
};
// Each kind to each other kind, and back.
const order = [
"blur",
`image:${shipped.id}`,
"added:mine",
"blur",
"added:mine",
`image:${shipped.id}`,
"none",
"added:mine",
"none",
"blur",
];
const effects = build();
await choose(order[0]);
const pipeline = effects.state$.value.processor;
for (const raw of order.slice(1)) await choose(raw);
expect(effects.state$.value.processor).toBe(pipeline);
expect(fake.switches).toEqual(order.map((raw) => options[raw]));
});
it("forgets an added background the device no longer keeps", () => {
effect$.next("added:gone");
build();
// Not before the device has said what it keeps.
expect(effect$.value).toBe("added:gone");
added$.next([]);
expect(effect$.value).toBe("none");
});
});
+212
View File
@@ -0,0 +1,212 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
combineLatest,
distinctUntilChanged,
first,
from,
map,
Observable,
type ObservableInput,
of,
scan,
startWith,
switchMap,
} from "rxjs";
import {
type BackgroundProcessorWrapper,
type SwitchBackgroundProcessorOptions,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import {
fromLivekitProcessor,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { type Behavior } from "./Behavior";
import { type ObservableScope } from "./ObservableScope";
import {
type BackgroundEffect,
type EffectId,
imagePathFor,
parseEffect,
} from "../tracks/backgroundEffectCatalog";
import { type AddedBackground } from "../tracks/backgroundImages";
const blurRadius = 15;
export interface BackgroundEffectsState {
supported: boolean | undefined;
processor: undefined | VideoProcessor;
/**
* The effect is not ready:
* preparing, asking whether the pipeline can be built.
* settling, once it is attached until a frame carrying the effect
*/
wait?: "preparing" | "settling";
}
export interface BackgroundEffectsOptions {
/** Whether this browser can run a pipeline at all. */
supported: boolean;
/** The effect chosen, as the setting stores it. */
effect$: Behavior<string>;
/** Stores a choice, to forget one that can't be honoured. */
setEffect: (id: EffectId) => void;
/** The backgrounds the device keeps, undefined until it has said. */
added$: Behavior<AddedBackground[] | undefined>;
/** Builds a segmenter on trial, and answers whether it could. */
canSegment: () => ObservableInput<boolean>;
/** The background processor pipeline to be switched between effects. */
pipeline: BackgroundProcessorWrapper;
/** Tells, once, that a frame carrying an effect has been drawn. */
transformer: { onFirstFrame: (() => void) | undefined };
}
/** The background effect pipeline, as the camera tracks and the menus see it. */
export interface BackgroundEffects {
readonly state$: Behavior<BackgroundEffectsState>;
}
/** Switches the pipeline as the choice changes, for as long as the scope lasts. */
export function createBackgroundEffects(
scope: ObservableScope,
{
supported,
effect$,
setEffect,
added$,
canSegment,
pipeline,
transformer,
}: BackgroundEffectsOptions,
): BackgroundEffects {
// Wrapped once, so the call and the preview see one processor throughout.
const processor = fromLivekitProcessor(pipeline);
const choice$ = effect$.pipe(map(parseEffect));
const wanted$ = scope.behavior(
choice$.pipe(
map((effect) => effect.kind !== "none"),
distinctUntilChanged(),
),
);
combineLatest([choice$, added$])
.pipe(scope.bind())
.subscribe(([effect, added]) => {
if (effect.kind === "none") return;
if (
!supported ||
(effect.kind === "added" &&
added !== undefined &&
!added.some(({ id }) => id === effect.id))
)
setEffect("none");
});
// Asked once an effect is first wanted. A browser update may fix what
// fails, so the choice is kept for the next session rather than cleared.
const buildable$ = scope.behavior<boolean | undefined>(
supported
? wanted$.pipe(
first(Boolean),
switchMap(() => from(canSegment())),
startWith(undefined),
)
: of(undefined),
);
const drewAFrame$ = scope.behavior(
new Observable<boolean>((subscriber) => {
subscriber.next(false);
transformer.onFirstFrame = (): void => subscriber.next(true);
return (): void => {
transformer.onFirstFrame = undefined;
};
}),
);
// In one step, so that nothing sees the pipeline neither preparing nor
// attached.
const state$ = scope.behavior(
combineLatest([wanted$, buildable$, drewAFrame$]).pipe(
scan<[boolean, boolean | undefined, boolean], BackgroundEffectsState>(
(previous, [wanted, buildable, drewAFrame]) => {
// Attached the first time an effect is wanted and never detached
// after, so someone who never turns one on pays for none of it.
const enable =
previous.processor !== undefined || (wanted && buildable === true);
const preparing = supported && wanted && buildable === undefined;
return {
supported: supported && buildable !== false,
processor: enable ? processor : undefined,
wait: preparing
? "preparing"
: enable && !drewAFrame
? "settling"
: undefined,
};
},
{ supported, processor: undefined },
),
),
);
const switchOptions$ = scope.behavior<
SwitchBackgroundProcessorOptions | undefined
>(
combineLatest([state$, choice$, added$]).pipe(
map(([{ processor }, effect, added]) =>
processor === undefined
? undefined
: switchOptionsFor(
effect,
effect.kind === "added"
? added?.find(({ id }) => id === effect.id)?.url
: undefined,
),
),
distinctUntilChanged(deepCompare),
),
);
// In turn: a picture's switch ends once it loads, so an earlier one could land last.
scope.reconcile(switchOptions$, async (options) => {
if (options === undefined) return;
try {
await pipeline.switchTo(options);
} catch (e) {
logger.warn("Failed to switch background effect", e);
}
});
return { state$ };
}
/** `addedUrl` is the picture of the added background chosen, if still kept. */
function switchOptionsFor(
effect: BackgroundEffect,
addedUrl: string | undefined,
): SwitchBackgroundProcessorOptions {
const withImage = (
imagePath: string | undefined,
): SwitchBackgroundProcessorOptions =>
imagePath
? { mode: "virtual-background", imagePath }
: { mode: "disabled" };
switch (effect.kind) {
case "blur":
return { mode: "background-blur", blurRadius };
case "shipped":
return withImage(imagePathFor(effect.id));
case "added":
return withImage(addedUrl);
default:
return { mode: "disabled" };
}
}
+155 -11
View File
@@ -28,6 +28,8 @@ import { type Layout } from "../layout-types.ts";
import {
mockMatrixRoomMember,
mockMediaDevices,
mockMuteStates,
flushPromises,
mockRemoteParticipant,
withTestScheduler,
mockRtcMembership,
@@ -62,7 +64,17 @@ import {
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
import { initializeWidget } from "../../widget.ts";
import { computeUrlParams } from "../../UrlParams.ts";
import { callViewModelOptionsFromParams } from "./CallViewModel.ts";
import {
callViewModelOptionsFromParams,
initialPublishRequests,
} from "./CallViewModel.ts";
import { type Handler, MuteState } from "../MuteStates.ts";
import {
type LocalVideoMediaTrack,
type RTCParticipation,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { type BackgroundEffectsState } from "../BackgroundEffects";
initializeWidget();
@@ -155,7 +167,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "grid":
return combineLatest(
[
l.spotlight?.media$ ?? constant(undefined),
l.spotlight?.vm.media$ ?? constant(undefined),
...l.grid.map((vm) => vm.media$),
],
(spotlight, ...grid) => ({
@@ -167,7 +179,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
case "spotlight-landscape":
case "spotlight-portrait":
return combineLatest(
[l.spotlight.media$, ...l.grid.map((vm) => vm.media$)],
[l.spotlight.vm.media$, ...l.grid.map((vm) => vm.media$)],
(spotlight, ...grid) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -176,7 +188,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "spotlight-expanded":
return combineLatest(
[l.spotlight.media$, l.pip?.media$ ?? constant(undefined)],
[l.spotlight.media$, l.pip?.vm.media$ ?? constant(undefined)],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.map((vm) => vm.id),
@@ -185,7 +197,7 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
);
case "one-on-one-desktop":
return combineLatest(
[l.spotlight.media$, l.pip.media$],
[l.spotlight.media$, l.pip.vm.media$],
(spotlight, pip) => ({
type: l.type,
spotlight: spotlight.id,
@@ -196,8 +208,8 @@ function summarizeLayout$(l$: Observable<Layout>): Observable<LayoutSummary> {
return combineLatest(
[
l.spotlight.media$,
l.pip?.media$ ?? constant(undefined),
l.pipSize$,
l.pip?.vm.media$ ?? constant(undefined),
l.pip?.size$ ?? constant(undefined),
],
(spotlight, pip, pipSize) => ({
type: l.type,
@@ -506,7 +518,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
type: "one-on-one-mobile",
@@ -544,7 +556,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
});
// It wouldn't be clear whether Alice or Bob is the remote video tile,
@@ -592,7 +604,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "sm",
pipSize: undefined,
},
b: {
type: "spotlight-landscape",
@@ -791,7 +803,7 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
type: "one-on-one-mobile",
spotlight: [`${aliceId}:0`],
pip: undefined,
pipSize: "lg",
pipSize: undefined,
},
b: {
// In a larger window, expect the one-on-one desktop layout
@@ -1664,3 +1676,135 @@ describe("callViewModelOptionsFromParams", () => {
expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true);
});
});
describe("a camera with a video processor", () => {
const withCallViewModel = withCallViewModelInMode(MatrixRTCMode.Matrix_2_0);
const processor = { name: "effect" } as VideoProcessor;
/** The handler the view model gives the camera's mute switch. */
const cameraHandler = (setHandler: {
mock: { calls: [Handler][] };
}): Handler => setHandler.mock.calls[1][0];
it("starts each camera track with the processor", () => {
const muteStates = mockMuteStates();
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
expect(
initialPublishRequests(
muteStates,
mockMediaDevices({}),
{},
{ supported: true, processor },
).find((request) => request.source === "camera"),
).toMatchObject({ processor });
});
it("leaves the camera out of the join while the processor is prepared", () => {
const muteStates = mockMuteStates();
vi.spyOn(muteStates.video.enabled$, "value", "get").mockReturnValue(true);
const sources = (wait?: "preparing"): string[] =>
initialPublishRequests(
muteStates,
mockMediaDevices({}),
{},
{
supported: true,
processor: undefined,
wait,
},
).map((request) => request.source);
expect(sources("preparing")).not.toContain("camera");
expect(sources()).toContain("camera");
});
it("waits to make a camera track until it is known the processor builds", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "preparing",
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
const camera = participation!.localMember$.value?.tracks$.value?.find(
(track) => track.source === "camera",
) as LocalVideoMediaTrack;
const turnedOn = cameraHandler(setHandler)(true);
await flushPromises();
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
effects$.next({ supported: true, processor });
await turnedOn;
expect(camera.setEnabled).toHaveBeenCalledWith(true);
expect(participation!.publish).not.toHaveBeenCalled();
});
it("turns the camera on only once its processor is on the track", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
wait: "preparing",
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
const camera = participation!.localMember$.value?.tracks$.value?.find(
(track) => track.source === "camera",
) as LocalVideoMediaTrack;
// Attaching takes its time, as the pipeline is built on the track
let attached!: () => void;
vi.mocked(camera.setProcessor).mockReturnValueOnce(
new Promise<void>((resolve) => {
attached = resolve;
}),
);
const turnedOn = cameraHandler(setHandler)(true);
effects$.next({ supported: true, processor });
await flushPromises();
expect(camera.setProcessor).toHaveBeenCalledWith(processor);
expect(camera.setEnabled).not.toHaveBeenCalledWith(true);
attached();
await turnedOn;
expect(camera.setEnabled).toHaveBeenCalledWith(true);
});
// The SDK builds the processor on the camera's restart, before the room
// sees a frame, so the track is unmuted rather than published anew
it("turns on a camera turned off before the processor was chosen", async () => {
const setHandler = vi.spyOn(MuteState.prototype, "setHandler");
onTestFinished(() => setHandler.mockRestore());
const effects$ = new BehaviorSubject<BackgroundEffectsState>({
supported: true,
processor: undefined,
});
let participation: RTCParticipation | undefined;
withCallViewModel(
{},
(_vm, _session, { rtcParticipation }) => {
participation = rtcParticipation;
},
{ backgroundEffectsState$: effects$ },
);
effects$.next({ supported: true, processor });
await cameraHandler(setHandler)(true);
await flushPromises();
expect(participation!.unpublish).not.toHaveBeenCalled();
expect(participation!.publish).not.toHaveBeenCalled();
});
});
+144 -117
View File
@@ -14,7 +14,6 @@ import {
type EncryptionSystem,
generateItems,
type LocalAudioMediaTrack,
type LocalMediaTrack,
type LocalRTCMember,
type LocalVideoMediaTrack,
type RTCParticipation,
@@ -38,6 +37,7 @@ import {
combineLatest,
distinctUntilChanged,
filter,
firstValueFrom,
fromEvent,
map,
merge,
@@ -78,14 +78,20 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals as shallowObjectEquals } from "../../utils/object";
import { type MediaDevice, type MediaDevices } from "../MediaDevices";
import { type Handler, type MuteStates } from "../MuteStates";
import { type BackgroundEffectsState } from "../BackgroundEffects";
import { shallowEquals as arrayShallowEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices";
import { type MuteStates } from "../MuteStates";
import {
followDevice,
followMuteState,
followProcessor,
type TrackSupply,
} from "../localMediaControls";
import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
type Alignment,
layoutShallowEquals,
type Layout,
type LayoutMedia,
type WindowMode,
@@ -126,6 +132,7 @@ import {
type RingingMediaViewModel,
} from "../media/RingingMediaViewModel.ts";
import { type GridTileViewModel } from "../TileViewModel.ts";
import { createFloatingTile } from "../FloatingTile.ts";
/** How a call captures and encodes the tracks it publishes. */
export interface CaptureSettings {
@@ -135,14 +142,16 @@ export interface CaptureSettings {
}
/**
* What to publish for a microphone or camera: the device the user picked and
* the host's capture settings. The video processor is not in here; the call
* attaches it to the camera track once that exists.
* What to publish for a microphone or camera: the device the user picked, the
* host's capture settings and, for the camera, the video processor, so that
* the track starts with it rather than letting the room through while it is
* built.
*/
export function publishRequest(
source: "microphone" | "camera",
mediaDevices: MediaDevices,
capture: CaptureSettings,
processor?: VideoProcessor,
): PublishRequest {
return source === "microphone"
? {
@@ -154,6 +163,7 @@ export function publishRequest(
source,
deviceId: mediaDevices.videoInput.selected$.value?.id,
capture: capture.camera,
...(processor && { processor }),
};
}
@@ -162,12 +172,16 @@ export function initialPublishRequests(
muteStates: MuteStates,
mediaDevices: MediaDevices,
capture: CaptureSettings,
effects?: BackgroundEffectsState,
): PublishRequest[] {
const requests: PublishRequest[] = [];
if (muteStates.audio.enabled$.value)
requests.push(publishRequest("microphone", mediaDevices, capture));
if (muteStates.video.enabled$.value)
requests.push(publishRequest("camera", mediaDevices, capture));
// Left out while the processor is prepared; the call publishes it after
if (muteStates.video.enabled$.value && effects?.wait !== "preparing")
requests.push(
publishRequest("camera", mediaDevices, capture, effects?.processor),
);
return requests;
}
@@ -178,8 +192,8 @@ export interface CallViewModelOptions {
* defaults otherwise.
*/
capture?: CaptureSettings;
/** Background blur and the like, applied to the camera as it changes. */
videoProcessor$?: Behavior<VideoProcessor | undefined>;
/** The background effects state (preparing, supported...) including the processor itself. */
backgroundEffectsState$?: Behavior<BackgroundEffectsState>;
/**
* The application hosting Element Call, which can ask it to hang up and wants
* to know when the user joins or leaves. Defaults to no host.
@@ -354,6 +368,8 @@ export interface CallViewModel {
keyRotationSuppressed$: Behavior<boolean>;
/** The local member, once our membership has been seen in the room. */
localMember$: Behavior<LocalRTCMember | null>;
/** Our own camera track, while one is published. */
camera$: Behavior<LocalVideoMediaTrack | undefined>;
remoteMembers$: Behavior<RemoteRTCMember[]>;
/** The transports the client holds a connection to, for the developer panel. */
connectedTransports$: Behavior<TransportMetadata[]>;
@@ -479,7 +495,21 @@ export function createCallViewModel$(
sentCallNotification$ = of(null),
} = options;
const { capture = {}, videoProcessor$ = constant(undefined) } = options;
const {
capture = {},
backgroundEffectsState$ = constant<BackgroundEffectsState>({
supported: false,
processor: undefined,
}),
} = options;
const videoProcessor$ = scope.behavior(
backgroundEffectsState$.pipe(map(({ processor }) => processor)),
);
// Resolves once it is known whether the processor builds
const processorKnown = async (): Promise<unknown> =>
firstValueFrom(
backgroundEffectsState$.pipe(filter(({ wait }) => wait !== "preparing")),
);
const localTracks$ = scope.behavior<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>(
@@ -503,72 +533,78 @@ export function createCallViewModel$(
"screenShare",
) as Behavior<LocalVideoMediaTrack | undefined>;
// The mute switches drive our tracks, which report back what the device
// allowed, so a denied permission flips the switch back. A source is muted
// and unmuted while its track exists and published when it does not.
const followMuteState = (
muteState: {
setHandler: (handler: Handler) => void;
enabled$: Behavior<boolean>;
},
track$: Behavior<LocalMediaTrack | undefined>,
source: "microphone" | "camera",
): void => {
// What the switch asks for, ahead of what it shows: the switch follows
// only once the handler has resolved
let desired = muteState.enabled$.value;
muteState.setHandler(async (enabled) => {
desired = enabled;
const track = track$.value;
if (track !== undefined) {
desired = await track.setEnabled(enabled);
return desired;
}
if (!enabled) return false;
try {
await rtcParticipation.publish(
publishRequest(source, mediaDevices, capture),
);
return true;
} catch (e) {
logger.error(`Failed to publish the ${source}`, e);
desired = false;
return false;
}
});
// A track that arrives after its switch was flipped off catches up with it
track$.pipe(scope.bind()).subscribe((track) => {
if (track !== undefined && !desired)
track.setEnabled(false).catch((e) => {
logger.error(`Failed to mute the ${source}`, e);
});
});
};
followMuteState(muteStates.audio, microphone$, "microphone");
followMuteState(muteStates.video, camera$, "camera");
scope.onEnd(() => {
muteStates.audio.unsetHandler();
muteStates.video.unsetHandler();
});
const processorAttached = followProcessor(
scope,
camera$,
videoProcessor$,
logger,
);
// The device the user picked follows onto the track as it is made and as the
// choice changes; the SDK does not watch the settings itself
const followDevice = (
track$: Behavior<LocalMediaTrack | undefined>,
device: MediaDevice<unknown, { id: string }>,
source: "microphone" | "camera",
): void => {
combineLatest([track$, device.selected$])
.pipe(scope.bind())
.subscribe(([track, selected]) => {
if (track === undefined || selected === undefined) return;
track.setDevice(selected.id).catch((e) => {
logger.error(`Failed to switch the ${source} device`, e);
});
});
// A camera turned on waits for its processor to be known, then to be on
// the track: LiveKit restarts the track with whatever processor it has then.
const cameraReady = async (): Promise<void> => {
await processorKnown();
await processorAttached();
};
followDevice(microphone$, mediaDevices.audioInput, "microphone");
followDevice(camera$, mediaDevices.videoInput, "camera");
const supply = (source: "microphone" | "camera"): TrackSupply => ({
publish: async () =>
rtcParticipation.publish(
publishRequest(
source,
mediaDevices,
capture,
source === "camera" ? videoProcessor$.value : undefined,
),
),
ready: source === "camera" ? cameraReady : undefined,
});
followMuteState(
scope,
muteStates.audio,
microphone$,
supply("microphone"),
"microphone",
logger,
);
followMuteState(
scope,
muteStates.video,
camera$,
supply("camera"),
"camera",
logger,
);
followDevice(
scope,
microphone$,
mediaDevices.audioInput,
"microphone",
logger,
);
followDevice(scope, camera$, mediaDevices.videoInput, "camera", logger);
// A camera wanted at the join was left out of it while the processor was
// prepared (see initialPublishRequests), and is published once that is known
if (
backgroundEffectsState$.value.wait === "preparing" &&
muteStates.video.enabled$.value
)
processorKnown()
.then(async () => {
if (camera$.value !== undefined || !muteStates.video.enabled$.value)
return;
await rtcParticipation.publish(
publishRequest(
"camera",
mediaDevices,
capture,
videoProcessor$.value,
),
);
})
.catch((e) => {
logger.error("Failed to publish the camera", e);
});
// The output device follows the selection onto the client the same way. A
// host that routes audio itself picks the output outside the browser, so the
@@ -583,16 +619,6 @@ export function createCallViewModel$(
});
});
// Attached once the camera track exists rather than carried in the publish
// request, so that a new track and a change of processor take the same path
combineLatest([camera$, videoProcessor$])
.pipe(scope.bind())
.subscribe(([camera, processor]) => {
camera?.setProcessor(processor).catch((e) => {
logger.error("Failed to set the video processor", e);
});
});
const members$ = scope.behavior<(LocalRTCMember | RemoteRTCMember)[]>(
combineLatest(
[rtcParticipation.localMember$, rtcParticipation.remoteMembers$],
@@ -830,7 +856,7 @@ export function createCallViewModel$(
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
distinctUntilChanged(shallowArrayEquals),
distinctUntilChanged(arrayShallowEquals),
),
);
@@ -862,13 +888,18 @@ export function createCallViewModel$(
[ringingMedia$, screenShares$, spotlightSpeaker$, localUserMediaForPip$],
(ringing, screenShares, speaker, localPip) =>
computeSpotlight({ ringing, screenShares, speaker, localPip }),
).pipe(distinctUntilChanged(shallowObjectEquals)),
).pipe(
distinctUntilChanged(
(a, b) =>
arrayShallowEquals(a.spotlight, b.spotlight) && a.pip === b.pip,
),
),
);
const spotlight$ = scope.behavior<MediaViewModel[]>(
spotlightAndPip$.pipe(
map(({ spotlight }) => spotlight),
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
distinctUntilChanged<MediaViewModel[]>(arrayShallowEquals),
),
);
@@ -1029,7 +1060,7 @@ export function createCallViewModel$(
desktop: platform === "desktop",
}),
),
distinctUntilChanged(shallowObjectEquals<LayoutMedia>),
distinctUntilChanged(layoutShallowEquals),
),
);
@@ -1221,32 +1252,28 @@ export function createCallViewModel$(
);
/**
* The alignment of the floating spotlight tile, if present.
* State of the floating spotlight tile, if present.
*/
const spotlightAlignment$ = new BehaviorSubject<Alignment>({
const floatingSpotlightTile = createFloatingTile(scope, {
inline: "end",
block: "end",
});
/**
* The size of the small picture-in-picture tile, if present, when in portrait.
* State of the small picture-in-picture tile, if present, when in landscape.
*/
const portraitPipSize$ = scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
);
/**
* The alignment of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
/**
* The alignment of the small picture-in-picture tile, if present, when in landscape.
*/
const landscapePipAlignment$ = new BehaviorSubject<Alignment>({
const landscapePipTile = createFloatingTile(scope, {
inline: "end",
block: "start",
});
/**
* State of the small picture-in-picture tile, if present, when in portrait.
*/
const portraitPipTile = {
...createFloatingTile(scope, { inline: "end", block: "end" }),
size$: scope.behavior(
showFooter$.pipe(map((showFooter) => (showFooter ? "lg" : "sm"))),
),
};
// There is a cyclical dependency here: the layout algorithms want to know
// which tiles are on screen, but to know which tiles are on screen we have to
@@ -1276,7 +1303,7 @@ export function createCallViewModel$(
case "spotlight-portrait":
[layout, newTiles] = gridLikeLayout(
media,
spotlightAlignment$,
floatingSpotlightTile,
visibleTiles,
setVisibleTiles,
prevTiles,
@@ -1286,26 +1313,25 @@ export function createCallViewModel$(
case "spotlight-expanded":
[layout, newTiles] = spotlightExpandedLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
break;
case "one-on-one-desktop":
[layout, newTiles] = oneOnOneDesktopLayout(
media,
landscapePipAlignment$,
landscapePipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip.vm;
break;
case "one-on-one-mobile":
[layout, newTiles] = oneOnOneMobileLayout(
media,
portraitPipSize$,
portraitPipAlignment$,
portraitPipTile,
prevTiles,
);
pip = layout.pip;
pip = layout.pip?.vm;
break;
case "pip":
[layout, newTiles] = pipLayout(media, prevTiles);
@@ -1330,7 +1356,7 @@ export function createCallViewModel$(
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged(shallowObjectEquals<Layout>),
distinctUntilChanged(layoutShallowEquals),
),
);
@@ -1538,6 +1564,7 @@ export function createCallViewModel$(
leave,
toggleScreenSharing: toggleScreenSharing,
sharingScreen$: sharingScreen$,
camera$,
tapScreen: (): void => screenTap$.next(),
tapControls: (): void => controlsTap$.next(),
+38
View File
@@ -0,0 +1,38 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { test } from "vitest";
import { withTestScheduler } from "../utils/test";
import { createFloatingTile } from "./FloatingTile";
test("FloatingTile changes alignment on drag", () =>
withTestScheduler(({ scope, expectObservable, schedule }) => {
// Tile starts in the top left
const { alignment$, onDrag } = createFloatingTile(scope, {
inline: "start",
block: "start",
});
// Drag to all four corners, and then repeatedly into the top left.
schedule("-↗↘↙↖↖", {
"↗": () =>
onDrag({ x: 100, y: 0, xRatio: 1, yRatio: 0, endOfGesture: false }),
"↘": () =>
onDrag({ x: 100, y: 100, xRatio: 1, yRatio: 1, endOfGesture: false }),
"↙": () =>
onDrag({ x: 0, y: 100, xRatio: 0, yRatio: 1, endOfGesture: false }),
"↖": () =>
onDrag({ x: 0, y: 0, xRatio: 0, yRatio: 0, endOfGesture: false }),
});
// Alignment should change accordingly. Notably, the alignment should *not*
// update when repeatedly dragged into the same corner, for performance.
expectObservable(alignment$).toBe("↖↗↘↙↖-", {
"↗": { inline: "end", block: "start" },
"↘": { inline: "end", block: "end" },
"↙": { inline: "start", block: "end" },
"↖": { inline: "start", block: "start" },
});
}));
+43
View File
@@ -0,0 +1,43 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { distinctUntilChanged, map, startWith, Subject } from "rxjs";
import { type Behavior } from "./Behavior";
import { type DragCallback, type Alignment, type Drag } from "./layout-types";
import { type ObservableScope } from "./ObservableScope";
/**
* Represents a floating tile which may be dragged to different corners of the
* call interface.
*/
export interface FloatingTile {
alignment$: Behavior<Alignment>;
onDrag: DragCallback;
}
export function createFloatingTile(
scope: ObservableScope,
defaultAlignment: Alignment,
): FloatingTile {
const dragEvents$ = new Subject<Drag>();
return {
alignment$: scope.behavior(
dragEvents$.pipe(
map<Drag, Alignment>(({ xRatio, yRatio }) => ({
inline: xRatio < 0.5 ? "start" : "end",
block: yRatio < 0.5 ? "start" : "end",
})),
startWith(defaultAlignment),
distinctUntilChanged(
(a1, a2) => a1.inline === a2.inline && a1.block === a2.block,
),
),
),
onDrag: (drag) => dragEvents$.next(drag),
};
}
+7 -10
View File
@@ -5,14 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type BehaviorSubject } from "rxjs";
import {
type Alignment,
type Layout,
type LayoutMedia,
} from "./layout-types.ts";
import { type Layout, type LayoutMedia } from "./layout-types.ts";
import { type TileStore } from "./TileStore";
import { type FloatingTile } from "./FloatingTile.ts";
export type GridLikeLayoutType =
| "grid"
@@ -25,7 +20,7 @@ export type GridLikeLayoutType =
*/
export function gridLikeLayout(
media: LayoutMedia & { type: GridLikeLayoutType },
spotlightAlignment$: BehaviorSubject<Alignment>,
floatingSpotlightTile: FloatingTile,
visibleTiles: number,
setVisibleTiles: (value: number) => void,
prevTiles: TileStore,
@@ -42,9 +37,11 @@ export function gridLikeLayout(
return [
{
type: media.type,
spotlight: tiles.spotlightTile,
spotlight: tiles.spotlightTile && {
vm: tiles.spotlightTile,
...floatingSpotlightTile,
},
grid: tiles.gridTiles,
spotlightAlignment$,
setVisibleTiles,
} as Layout & { type: GridLikeLayoutType },
tiles,
+168
View File
@@ -0,0 +1,168 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject, of } from "rxjs";
import {
constant,
type LocalAudioMediaTrack,
type LocalMedia,
type LocalVideoMediaTrack,
type PublishRequest,
type VideoProcessor,
} from "@element-hq/matrixrtc-sdk";
import { createLobbyMediaViewModel } from "./LobbyMediaViewModel";
import { type MediaDevices } from "./MediaDevices";
import { MuteStates } from "./MuteStates";
import { nullHostBridge } from "../HostBridge";
import { flushPromises, mockMediaDevices, testScope } from "../utils/test";
import { type BackgroundEffectsState } from "./BackgroundEffects";
describe("LobbyMediaViewModel", () => {
it("opens what starts enabled in one capture and follows the devices", async () => {
const { media, vm, mediaDevices } = setup();
await flushPromises();
expect(media.capture).toHaveBeenCalledExactlyOnceWith([
expect.objectContaining({ source: "microphone", deviceId: "mic" }),
// With the processor, so that the camera starts with it
expect.objectContaining({ source: "camera", deviceId: "cam", processor }),
]);
const camera = vm.videoTrack$.value!;
expect(camera.source).toBe("camera");
expect(camera.setDevice).toHaveBeenCalledWith("cam");
expect(camera.setProcessor).toHaveBeenCalledWith(processor);
mediaDevices.videoInput.selected$ satisfies unknown;
});
it("closes the camera when its switch goes off and reopens it when on", async () => {
const { media, vm, muteStates } = setup();
await flushPromises();
muteStates.video.setEnabled$.value?.(false);
await flushPromises();
expect(media.release).toHaveBeenCalledWith("camera");
expect(vm.videoTrack$.value).toBeNull();
muteStates.video.setEnabled$.value?.(true);
await flushPromises();
expect(media.capture).toHaveBeenLastCalledWith([
expect.objectContaining({ source: "camera" }),
]);
expect(vm.videoTrack$.value?.source).toBe("camera");
});
it("mutes the microphone in place", async () => {
const { media, muteStates } = setup();
await flushPromises();
const microphone = media.tracks$.value.find(
(t) => t.source === "microphone",
)!;
muteStates.audio.setEnabled$.value?.(false);
await flushPromises();
expect(microphone.setEnabled).toHaveBeenCalledWith(false);
expect(media.release).not.toHaveBeenCalled();
expect(muteStates.audio.enabled$.value).toBe(false);
});
it("leaves both switches off where the devices cannot be opened", async () => {
const { muteStates } = setup(async () =>
Promise.reject(new Error("NotAllowedError")),
);
await flushPromises();
expect(muteStates.audio.enabled$.value).toBe(false);
expect(muteStates.video.enabled$.value).toBe(false);
});
});
const processor = { name: "blur" } as VideoProcessor;
function setup(
capture: (
requests: PublishRequest[],
) => Promise<(LocalAudioMediaTrack | LocalVideoMediaTrack)[]> = async (
requests,
) => Promise.resolve(requests.map(fakeTrack)),
): {
media: LocalMedia;
vm: ReturnType<typeof createLobbyMediaViewModel>;
muteStates: MuteStates;
mediaDevices: MediaDevices;
} {
const scope = testScope();
const tracks$ = new BehaviorSubject<
(LocalAudioMediaTrack | LocalVideoMediaTrack)[]
>([]);
const media: LocalMedia = {
requests: [],
tracks$,
capture: vi.fn(async (requests: PublishRequest[]) => {
const tracks = await capture(requests);
tracks$.next([
...tracks$.value.filter(
(t) => !tracks.some((n) => n.source === t.source),
),
...tracks,
]);
return tracks;
}),
release: vi.fn((source) =>
tracks$.next(tracks$.value.filter((t) => t.source !== source)),
),
stop: vi.fn(),
takeTracks: () => new Map(),
};
const mediaDevices = mockMediaDevices({
audioInput: device("mic"),
videoInput: device("cam"),
} as Partial<MediaDevices>);
const muteStates = new MuteStates(
scope,
mediaDevices,
{ audioEnabled: true, videoEnabled: true },
nullHostBridge,
);
const vm = createLobbyMediaViewModel(
scope,
media,
muteStates,
mediaDevices,
{},
constant<BackgroundEffectsState>({ supported: true, processor }),
);
return { media, vm, muteStates, mediaDevices };
}
function device(id: string): unknown {
return {
available$: of(new Map([[id, { type: "name", name: id }]])),
selected$: new BehaviorSubject({ id }),
select: (): void => {},
};
}
function fakeTrack(
request: PublishRequest,
): LocalAudioMediaTrack | LocalVideoMediaTrack {
const muted$ = new BehaviorSubject(false);
return {
source: request.source,
kind: request.source === "microphone" ? "audio" : "video",
id: request.source,
muted$,
setEnabled: vi.fn(async (enabled: boolean) => {
muted$.next(!enabled);
return Promise.resolve(enabled);
}),
setDevice: vi.fn(async () => Promise.resolve()),
setProcessor: vi.fn(async () => Promise.resolve()),
} as unknown as LocalAudioMediaTrack | LocalVideoMediaTrack;
}

Some files were not shown because too many files have changed in this diff Show More