Compare commits

..

2 Commits

Author SHA1 Message Date
Valere Fedronic
1538d8a455 Merge pull request #4142 from element-hq/valere/backport/rounded_corner_glitch
[backport] fix: FF mirrored self video lose rounding
2026-08-03 18:03:14 +02:00
Valere
31026755a4 fix: FF mirrored self video lose rounding 2026-08-03 14:06:24 +02:00
82 changed files with 3580 additions and 4404 deletions

View File

@@ -9,7 +9,7 @@ on:
- opened
- labeled
push:
branches: [main]
branches: [livekit]
jobs:
versioning:

View File

@@ -4,12 +4,12 @@ on:
push:
branches:
- main
- livekit
paths:
- .github/labels.yml
- .github/workflows/sync-labels.yml
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
sync-labels:
@@ -20,4 +20,4 @@ jobs:
DELETE: true
WET: true
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}

View File

@@ -2,11 +2,15 @@ name: Test
on:
pull_request: {}
push:
branches: [main]
branches: [livekit]
jobs:
vitest:
name: Run unit tests
runs-on: ubuntu-latest
container:
# Make sure to grab the latest version of the Playwright image
# https://playwright.dev/docs/docker#pull-the-image
image: mcr.microsoft.com/playwright:v1.61.1-noble
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
@@ -22,18 +26,6 @@ jobs:
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright binaries
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Vitest
run: "pnpm run test:coverage"
- name: Upload to codecov
@@ -60,18 +52,8 @@ jobs:
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright binaries
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
- name: Run backend components
run: |
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull

View File

@@ -40,11 +40,6 @@ jobs:
- name: Fix the owner of the downloaded files
run: "sudo chown runner:docker -R locales"
# Localazy doesn't write file contents in the same order that i18next-cli's
# extractor uses. We re-run the extractor after downloading to fix the order.
- name: i18n
run: pnpm i18n
- name: Formatting
run: pnpm format
@@ -58,7 +53,7 @@ jobs:
title: Localazy Download
commit-message: Translations updates
labels: |
PR-Task
T-Task
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"

View File

@@ -2,7 +2,7 @@ name: Upload translation files to Localazy
on:
push:
branches:
- main
- livekit
paths-ignore:
- ".github/**"

View File

@@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈
on:
push:
branches: ["main", "full-mesh"]
branches: ["livekit", "full-mesh"]
pull_request: {}
permissions: {}

View File

@@ -115,33 +115,30 @@ For more details on the packages, see the
For operating and deploying Element Call on your own server, refer to the
[**Self-Hosting Guide**](./docs/self_hosting.md).
## MatrixRTC Transports
## 🧭 MatrixRTC Backend Discovery and Selection
For proper operation of Element Call, each deployment needs to set up a
MatrixRTC transport in the form of a LiveKit server as outlined in the
[Self-Hosting Guide](./docs/self_hosting.md). A typical federated site
deployment for three different sites A, B and C is depicted below.
For proper Element Call operation each site deployment needs a MatrixRTC backend
setup as outlined in the [Self-Hosting Guide](./docs/self_hosting.md). A typical
federated site deployment for three different sites A, B and C is depicted below.
<p align="center">
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
</p>
### Transport Discovery
### Backend Discovery
Element Call discovers the available MatrixRTC transports (as defined by
[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/pull/4519)) by
hitting the `GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports`
endpoint of the Client-Server API. An example response:
The MatrixRTC backend (according to
[MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is
announced by the Matrix site's `.well-known/matrix/client` file and discovered
via the `org.matrix.msc4143.rtc_foci` key, e.g.:
```json
{
"rtc_transports": [
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
}
]
}
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
},
]
```
where the format for MatrixRTC using LiveKit backend is defined in
@@ -152,7 +149,7 @@ via `livekit_service_url`.
### Backend Selection
- Each call participant proposes their discovered MatrixRTC transport from
- Each call participant proposes their discovered MatrixRTC backend from
`org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event.
- For the **LiveKit** MatrixRTC backend
([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)),
@@ -247,9 +244,10 @@ pnpm backend
> [!NOTE]
> To ensure your local development frontend functions properly, youll need to
> add certificate exceptions in your browser for `https://localhost:3000` and
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz`. This can be done either
> by adding the minimum localhost CA
> add certificate exceptions in your browser for `https://localhost:3000`,
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz` and
> `https://synapse.m.localhost/.well-known/matrix/client`. This can be either
> done by adding the minimum localhost CA
> ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web
> browser's trusted certificates or by simply copying and pasting each URL into
> your browsers address bar and follow the prompts to add the exception.

View File

@@ -40,8 +40,6 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -56,6 +54,7 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -68,8 +67,3 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt

View File

@@ -40,8 +40,6 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -56,6 +54,7 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -68,8 +67,3 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt

View File

@@ -1,4 +1,4 @@
# Synapse reverse proxy
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.m.localhost
server {
listen 80;
@@ -11,6 +11,18 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
@@ -32,7 +44,7 @@ server {
}
# Synapse reverse proxy
# Synapse reverse proxy including .well-known/matrix/client
# domain synapse.othersite.m.localhost
server {
listen 80;
@@ -45,6 +57,18 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token

View File

@@ -40,8 +40,6 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -56,6 +54,7 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -85,8 +84,3 @@ rc_login:
rc_registration:
per_second: 10000
burst_count: 10000
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt

View File

@@ -40,8 +40,6 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -56,6 +54,7 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -85,8 +84,3 @@ rc_login:
rc_registration:
per_second: 10000
burst_count: 10000
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt

View File

@@ -47,7 +47,7 @@ services:
- ecbackend
livekit:
image: livekit/livekit-server:v1.13.4
image: livekit/livekit-server:v1.10.1
pull_policy: always
hostname: livekit-sfu
command: --dev --config /etc/livekit.yaml
@@ -70,7 +70,7 @@ services:
- ecbackend
livekit-1:
image: livekit/livekit-server:v1.13.4
image: livekit/livekit-server:v1.10.1
pull_policy: always
hostname: livekit-sfu-1
command: --dev --config /etc/livekit.yaml

View File

@@ -13,7 +13,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:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b
image: ghcr.io/element-hq/element-web:develop@sha256:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a
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:ee5b90cbc65f3c2f9f98629944f5712555df4ccf443597b886f70c69246f063b
image: ghcr.io/element-hq/element-web:develop@sha256:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a

View File

@@ -5,6 +5,5 @@ This folder contains documentation for setup, usage, and development of Element
- [Embedded vs standalone mode](./embedded_standalone.md)
- [Url format and parameters](./url_params.md)
- [Global JS controls](./controls.md)
- [MatrixRTC modes](./matrix_rtc_modes.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)

View File

@@ -1,60 +0,0 @@
# MatrixRTC modes
Element Call is in the middle of a transition of how a call session is
represented and how participants pick an SFU:
- **Membership events**: from room _state_ events
(`org.matrix.msc3401.call.member`) to _sticky_ events
([MSC4354](https://github.com/matrix-org/matrix-spec-proposals/pull/4354)),
which are a much better fit for the short lived, per-device nature of call
memberships.
- **SFU selection**: from "everyone connects to the SFU of the oldest member" to
**multi SFU**, where each participant uses its own homeserver's SFU and the
SFUs interconnect.
Not every homeserver supports sticky events yet. Multi SFU is supported on all current (August 2026)
element call clients. The three MatrixRTC modes are the steps of that transition,
so a deployment can pick the newest one its homeserver and its user base can
handle.
## The modes
| Mode | Membership events | SFU selection | JWT endpoint |
| --------------- | ----------------- | ------------- | ---------------------------- |
| `legacy` | state events | oldest member | legacy |
| `compatibility` | state events | multi SFU | legacy |
| `matrix_2_0` | sticky events | multi SFU | Matrix 2.0 (hashed identity) |
**`legacy`** — the lowest common denominator. Use it if calls need to work with
Element Call clients older than v0.17.0, which cannot handle multi SFU calls. (unused)
**`compatibility`** — multi SFU, but still state events. Use it when all Element
Call clients are v0.17.0 or later but the homeserver does not support sticky
events. This is the default. (default)
**`matrix_2_0`** — the target state. Requires a homeserver that advertises
MSC4354 and all clients on v0.17.0 or later. The local membership requests its
token from the Matrix 2.0 JWT endpoint of the
[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)
and identifies the room by a hashed identity instead of a `livekit_alias`.
(Remote memberships always try the new endpoint first and fall back to the
legacy one, so remote participants can be on either.)
## Selecting a mode
Users can choose a mode under **Settings → Developer → MatrixRTC mode**. The
Matrix 2.0 option is disabled if the homeserver does not support sticky events.
A deployment can pin the mode for all its clients in `config.json`, which
disables the Developer Settings choice:
```json
{
"matrix_rtc_mode": "compatibility"
}
```
Valid values are `legacy`, `compatibility` and `matrix_2_0`; an invalid value is
ignored (with a warning) and the user's choice applies. Pinning `matrix_2_0` on a
homeserver without sticky event support makes joining fail with a "sticky events
required" error.

View File

@@ -16,7 +16,7 @@
The following [MSCs](https://github.com/matrix-org/matrix-spec-proposals) are
required for Element Call to work properly:
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md)
- **[MSC3266](https://github.com/deepbluev7/matrix-doc/blob/room-summaries/proposals/3266-room-summary.md):
Room Summary API**: In Standalone mode Element Call is able to join rooms
over federation using knocking. In this context MSC3266 is required as it
allows to request a room summary of rooms you are not joined. The summary
@@ -29,26 +29,19 @@ required for Element Call to work properly:
signalling. If disabled it is very likely that you end up with stuck calls in
Matrix rooms.
- **[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/blob/travis/msc/voip-transports-registry/proposals/4519-rtc-transports-registry.md)
MatrixRTC Transports Registry**: Defines an endpoint that clients can use to
query the available MatrixRTC transports (i.e. find your LiveKit SFU).
- **[MSC4222](https://github.com/matrix-org/matrix-spec-proposals/blob/erikj/sync_v2_state_after/proposals/4222-sync-v2-state-after.md)
Adding `state_after` to sync v2**: Allow clients to opt-in to a change of the
sync v2 API that allows them to correctly track the state of the room. This is
required by Element Call to track room state reliably.
If you're using [Synapse](https://github.com/element-hq/synapse/) as your
homeserver, you can configure these features by adding the following entries to
`homeserver.yaml`:
homeserver, you'll need to additionally add the following config items to
`homeserver.yaml` to comply with Element Call:
```yaml
experimental_features:
# MSC3266: Room summary API. Used for knocking over federation
msc3266_enabled: true
# MSC4143: MatrixRTC. For historical reasons this flag enables the transports
# endpoint defined in MSC4519.
msc4143_enabled: true
# MSC4222 needed for syncv2 state_after. This allow clients to
# correctly track the state of the room.
msc4222_enabled: true
@@ -68,15 +61,6 @@ rc_delayed_event_mgmt:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2Hz
per_second: 1
burst_count: 20
matrix_rtc:
transports:
# The transport you specify will be made available to clients over the
# /_matrix/client/unstable/org.matrix.msc4143/rtc/transports endpoint as
# defined in MSC4519.
- type: livekit
# Replace this with the actual URL of your MatrixRTC Authorization Service
livekit_service_url: https://matrix-rtc.example.com/livekit/jwt
```
As a prerequisite for the
@@ -84,10 +68,10 @@ As a prerequisite for the
make sure that your Synapse server has either a `federation` or `openid`
[listener configured](https://element-hq.github.io/synapse/latest/usage/configuration/config_documentation.html#listeners).
### LiveKit backend
### MatrixRTC Backend
In order to **guarantee smooth operation** of Element Call, a dedicated LiveKit
backend is required for each site deployment.
In order to **guarantee smooth operation** of Element Call, a MatrixRTC backend is
required for each site deployment.
![MSC4195 compatible setup](MSC4195_setup.drawio.png)
@@ -102,11 +86,10 @@ to implement
In the context of MatrixRTC, we suggest using a single hostname for backend
communication by implementing endpoint routing within a reverse proxy setup. For
the example above, this results in:
| Service | Endpoint | Example |
| --------------------------------------------------------------------------------- | -------------- | ------------------------------------ |
| Service | Endpoint | Example |
| -------- | ------- | ------- |
| [Livekit SFU](https://github.com/livekit/livekit) WebSocket signalling connection | `/livekit/sfu` | `matrix-rtc.example.com/livekit/sfu` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
| [MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service) | `/livekit/jwt` | `matrix-rtc.example.com/livekit/jwt` |
Using Nginx, you can achieve this by:
@@ -181,7 +164,7 @@ Using Haproxy, you can achieve this by:
use_backend mxrtc_auth_backend if is_mxrtc_auth matrixrtc_domain
# Backend
## LiveKit backend
## MatrixRTC backend
backend sfu_backend
server livekit 127.0.0.1:7880
http-request set-path %[path,regsub(^/livekit/sfu/,/)]
@@ -203,6 +186,44 @@ backend mxrtc_auth_backend
```
#### MatrixRTC backend announcement
> [!IMPORTANT]
> As defined in
> [MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143),
> the MatrixRTC backend(s) must be announced to the client via your **Matrix site's
> `.well-known/matrix/client`** file (e.g.
> `example.com/.well-known/matrix/client` matching the site deployment example
> from above). The configuration is a list of Foci configs:
```json
"org.matrix.msc4143.rtc_foci": [
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
},
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc-2.example.com/livekit/jwt"
}
]
```
Make sure this file is served with the correct MIME type (`application/json`).
Additionally, ensure the appropriate CORS headers are set to allow web clients
to access it across origins. For more details, refer to the
[Matrix Client-Server API: 2. Web Browser Clients](https://spec.matrix.org/latest/client-server-api/#web-browser-clients).
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: X-Requested-With, Content-Type, Authorization
```
> [!NOTE]
> Most `org.matrix.msc4143.rtc_foci` configurations will only have one entry in
> the array.
## Building Element Call
> [!NOTE]
@@ -256,7 +277,7 @@ runtime. Documentation and default values for `public/config.json` can be found
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
> [!CAUTION]
> Please note configuring LiveKit backend via `config.json` of
> Please note configuring MatrixRTC backend via `config.json` of
> Element Call is only available for developing and debug purposes. Relying on
> it might break Element Call going forward!

View File

@@ -61,7 +61,7 @@
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
"from_config": "Currently, no overwrite is set. Url from config is used.",
"from_config": "Currently, no overwrite is set. Url from well-known or config is used.",
"label": "Custom Livekit-url",
"reset": "Reset overwrite",
"save": "Save",
@@ -196,23 +196,13 @@
"room_auth_view_ssla_caption": "By clicking \"Join call now\", you agree to our <2>Software and Services License Agreement (SSLA)</2>",
"screenshare_button_label": "Share screen",
"settings": {
"advanced_camera_description": "Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
"advanced_camera_label": "Advanced camera settings",
"advanced_screen_share_description": "Configure resolution, framerate, bitrate, and codec for screen sharing",
"advanced_screen_share_label": "Advanced screen share settings",
"audio_processing_description": "Changes apply on next call join.",
"audio_processing_header": "Audio processing",
"audio_tab": {
"effect_volume_description": "Adjust the volume at which reactions and hand raised effects play.",
"effect_volume_label": "Sound effect volume"
},
"auto_gain_control_label": "Automatic gain control",
"background_blur_header": "Background",
"background_blur_label": "Blur the background of the video",
"bitrate_label": "Bitrate",
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
"camera_header": "Camera quality",
"codec_label": "Codec",
"developer_tab_title": "Developer",
"devices": {
"activating": "Activating…",
@@ -228,15 +218,12 @@
"speaker": "Speaker",
"speaker_numbered": "Speaker {{n}}"
},
"echo_cancellation_label": "Echo cancellation",
"feedback_tab_body": "If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.",
"feedback_tab_description_label": "Your feedback",
"feedback_tab_h4": "Submit feedback",
"feedback_tab_send_logs_label": "Include debug logs",
"feedback_tab_thank_you": "Thanks, we received your feedback!",
"feedback_tab_title": "Feedback",
"framerate_label": "Framerate",
"noise_suppression_label": "Noise suppression",
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
"preferences_tab": {
"developer_mode_label": "Developer mode",
@@ -248,9 +235,7 @@
"reactions_show_label": "Show reactions",
"show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
"show_hand_raised_timer_label": "Show hand raise duration"
},
"resolution_label": "Resolution",
"screen_share_header": "Screen sharing"
}
},
"star_rating_input_label_one": "{{count}} star",
"star_rating_input_label_other": "{{count}} stars",

View File

@@ -54,7 +54,6 @@
"@radix-ui/react-slider": "^1.1.2",
"@radix-ui/react-visually-hidden": "^1.0.3",
"@react-spring/web": "^10.0.0",
"@rolldown/plugin-babel": "^0.2.3",
"@sentry/react": "^8.0.0",
"@sentry/vite-plugin": "^3.0.0",
"@storybook/addon-docs": "^10.3.6",
@@ -77,18 +76,17 @@
"@typescript-eslint/utils": "^8.61.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^10.0.0",
"@vector-im/compound-web": "^10.0.0",
"@vector-im/compound-web": "^9.3.0",
"@vitejs/plugin-react": "^6.0.2",
"@vitest/browser-playwright": "^4.1.5",
"@vitest/coverage-v8": "^4.0.18",
"@vitest/ui": "4.1.10",
"babel-plugin-react-compiler": "^1.0.0",
"@vitest/ui": "4.1.9",
"classnames": "^2.3.1",
"copy-to-clipboard": "^3.3.3",
"eslint-plugin-element-call": "link:eslint",
"eslint-plugin-storybook": "^10.3.6",
"fetch-mock": "11.1.5",
"i18next": "^26.0.0",
"i18next": "^25.0.0",
"i18next-browser-languagedetector": "^8.0.0",
"i18next-cli": "^1.61.0",
"jsdom": "^26.0.0",
@@ -97,17 +95,17 @@
"lodash-es": "^4.17.21",
"loglevel": "^1.9.1",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
"matrix-widget-api": "^1.18.0",
"matrix-widget-api": "^1.16.1",
"node-stdlib-browser": "^1.3.1",
"normalize.css": "^8.0.1",
"observable-hooks": "^4.2.3",
"oxfmt": "^0.61.0",
"oxfmt": "^0.56.0",
"oxlint": "^1.70.0",
"oxlint-tsgolint": "^0.25.0",
"oxlint-tsgolint": "^0.23.0",
"pako": "^2.0.4",
"postcss": "^8.4.41",
"postcss-preset-env": "^10.0.0",
"posthog-js": "1.408.2",
"posthog-js": "1.374.0",
"qrcode": "^1.5.4",
"react": "19",
"react-dom": "19",
@@ -131,5 +129,5 @@
"vitest": "^4.1.5",
"vitest-axe": "^1.0.0-pre.3"
},
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1"
"packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
}

View File

@@ -36,7 +36,6 @@ widgetTest("Footer interaction in PiP", async ({ addUser, browserName }) => {
await valere.page.waitForTimeout(600);
// Switch to the other room, the call should go to PIP
await TestHelpers.expandRoomList(valere.page);
await TestHelpers.switchToRoomNamed(valere.page, "OtherRoom");
// We should see the PIP overlay

View File

@@ -53,7 +53,6 @@ widgetTest("Put call in PIP", async ({ addUser, browserName }) => {
});
// Switch to the other room, the call should go to PIP
await TestHelpers.expandRoomList(valere.page);
await TestHelpers.switchToRoomNamed(valere.page, "DoubleTask");
// We should see the PIP overlay

View File

@@ -346,12 +346,6 @@ export class TestHelpers {
}
}
public static async expandRoomList(page: Page): Promise<void> {
await page
.getByRole("separator", { name: "Click or drag to expand" })
.click();
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page

4549
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ allowBuilds:
"@swc/core": true
"core-js": true
"esbuild": true
"matrix-js-sdk@git+https://github.com/matrix-org/matrix-js-sdk.git": true
"matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7": true
"protobufjs": true
overrides:
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but

View File

@@ -31,11 +31,6 @@ interface Props {
max: number;
step: number;
disabled?: boolean;
/**
* Custom formatter for the tooltip label. If not provided, the value is
* displayed as a percentage.
*/
tooltipFormatter?: (value: number) => string;
}
/**
@@ -51,7 +46,6 @@ export const Slider: FC<Props> = ({
max,
step,
disabled,
tooltipFormatter,
}) => {
const onValueChange = useCallback(
([v]: number[]) => onValueChangeProp(v),
@@ -77,14 +71,7 @@ export const Slider: FC<Props> = ({
<Range className={styles.highlight} />
</Track>
{/* Note: This is expected not to be visible on mobile.*/}
<Tooltip
placement="top"
label={
tooltipFormatter
? tooltipFormatter(value)
: Math.round(value * 100).toString() + "%"
}
>
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
<Thumb className={styles.handle} aria-label={label} />
</Tooltip>
</Root>

View File

@@ -16,15 +16,12 @@ import {
HeaderStyle,
getUrlParams,
} from "../src/UrlParams";
import { mockConfig } from "./utils/test";
const ROOM_NAME = "roomNameHere";
const ROOM_ID = "!d45f138fsd";
const ORIGIN = "https://call.element.io";
const HOMESERVER = "localhost";
mockConfig();
describe("UrlParams", () => {
describe("handles URL with /room/", () => {
it("and nothing else", () => {

View File

@@ -127,7 +127,6 @@ export const Default: Story = {
showFooter: true,
hideControls: false,
asOverlay: false,
showModals: true,
sharingScreen: false,
audioOutputSwitcher: undefined,
reactionIdentifier: undefined,

View File

@@ -26,7 +26,6 @@ import {
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
import { type LayoutSwitchViewModel } from "../state/LayoutSwitchViewModel";
@@ -77,7 +76,6 @@ export interface FooterState {
/** The footer should be used as an overlay.
* (Over the Call Grid) This saves spaces on small screens. */
asOverlay: boolean;
showModals: boolean;
buttonSize: "md" | "lg";
showLogo: boolean;
@@ -122,7 +120,6 @@ export const CallFooter: FC<FooterProps> = ({
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
const showModals = useBehavior(vm.showModals$);
const layoutSwitchVm = useBehavior(vm.layoutSwitchVm$);
const openSettings = useBehavior(vm.openSettings$);
const audioEnabled = useBehavior(vm.audioEnabled$);
@@ -138,6 +135,7 @@ export const CallFooter: FC<FooterProps> = ({
const audioOutputSwitcher = useBehavior(vm.audioOutputSwitcher$);
const hangup = useBehavior(vm.hangup$);
const debugTileLayout = useBehavior(vm.debugTileLayout$);
const tileStoreGeneration = useBehavior(vm.tileStoreGeneration$);
const videoOptions = useBehavior(vm.videoOptions$);
const selectedVideo = useBehavior(vm.selectedVideo$);
const audioOptions = useBehavior(vm.audioOptions$);
@@ -237,8 +235,7 @@ export const CallFooter: FC<FooterProps> = ({
);
}
// Reaction button contains a pretty large menu, so treat it like a modal
if (reactionIdentifier && reactionData && showModals) {
if (reactionIdentifier && reactionData) {
buttons.push(
<ReactionToggleButton
size={buttonSize}
@@ -286,9 +283,7 @@ export const CallFooter: FC<FooterProps> = ({
/>
</>
)}
{debugTileLayout ? (
<TilesDebugInfo generation$={vm.tileStoreGeneration$} />
) : undefined}
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
</div>
);
@@ -321,14 +316,3 @@ export const CallFooter: FC<FooterProps> = ({
</div>
);
};
interface TilesDebugInfoProps {
generation$: Behavior<number | undefined>;
}
// Isolated in its own component since the layout generation updates frequently
// and we can avoid re-rendering the footer this way
const TilesDebugInfo: FC<TilesDebugInfoProps> = ({ generation$ }) => {
const generation = useBehavior(generation$);
return `Tiles generation: ${generation}`;
};

View File

@@ -49,7 +49,6 @@ function buildMinimalCallViewModel(layout: Layout): CallViewModel {
reactions$: constant({}),
tileStoreGeneration$: constant(0),
showFooter$: constant(true),
showModals$: constant(true),
settingsOpen$: constant(false),
setSettingsOpen$: constant(() => {}),
} as unknown as CallViewModel;

View File

@@ -161,7 +161,6 @@ export function createCallFooterViewModel(
// candidat to move into the FooterViewModel
showFooter$: callModel.showFooter$,
hideControls$: constant(!showControls),
showModals$: callModel.showModals$,
asOverlay$: callModel.edgeToEdge$,
buttonSize$: scope.behavior(
isPip$.pipe(map<boolean, "md" | "lg">((pip) => (pip ? "md" : "lg"))),
@@ -169,12 +168,12 @@ export function createCallFooterViewModel(
openSettings$: scope.behavior(
combineLatest([
callModel.showModals$,
isPip$,
callModel.showHeader$,
callModel.setSettingsOpen$,
]).pipe(
map(([showModals, showHeader, setSettingsOpen]) =>
showModals && headerStyle !== HeaderStyle.AppBar && showControls
map(([isPip, showHeader, setSettingsOpen]) =>
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
? (): void => setSettingsOpen(true)
: undefined,
),
@@ -240,7 +239,6 @@ export function createLobbyFooterViewModel(
showLogo,
hideControls: false,
asOverlay: false,
showModals: true,
buttonSize: "lg",
openSettings,
hangup,

View File

@@ -74,7 +74,10 @@ export interface ConfigOptions {
livekit?: {
// The link to the service that returns a livekit url and token to use it.
// This is a fallback link in case the homeserver in use does not advertise
// a livekit service url over the transports endpoint.
// a livekit service url in the client well-known.
// The well known needs to be formatted like so:
// {"type":"livekit", "livekit_service_url":"https://livekit.example.com"}
// and stored under the key: "org.matrix.msc4143.rtc_foci"
livekit_service_url: string;
};
@@ -102,61 +105,6 @@ export interface ConfigOptions {
*/
ssla?: string;
/**
* Media quality settings for video and screen sharing.
* These override the hardcoded LiveKit defaults.
*/
media_quality?: {
/**
* Video codec preference. The server must also have the codec enabled.
* @default "vp8"
*/
video_codec?: "vp8" | "vp9" | "h264" | "av1";
/**
* Camera video settings.
*/
video?: {
/** Max resolution height in pixels (e.g. 720, 1080, 1440). @default 720 */
max_resolution?: number;
/** Max bitrate in bits per second. @default 1700000 */
max_bitrate?: number;
/** Max framerate. @default 30 */
max_framerate?: number;
/**
* Simulcast layers as an array of {height, bitrate} objects,
* ordered from lowest to highest quality.
* @default [{height: 180, bitrate: 160000}, {height: 360, bitrate: 450000}]
*/
simulcast_layers?: Array<{
height: number;
bitrate: number;
}>;
};
/**
* Screen share settings.
*/
screen_share?: {
/** Max resolution height in pixels. @default 1080 */
max_resolution?: number;
/** Max bitrate in bits per second. @default 5000000 */
max_bitrate?: number;
/** Max framerate. @default 30 */
max_framerate?: number;
/**
* Simulcast layers for screen sharing as an array of {height, bitrate, framerate} objects,
* ordered from lowest to highest quality. If omitted, LiveKit SDK defaults apply (1 extra
* layer at half resolution).
*/
simulcast_layers?: Array<{
height: number;
bitrate: number;
framerate?: number;
}>;
};
};
media_devices?: {
/**
* Defines whether participants should start with audio enabled by default.
@@ -179,8 +127,7 @@ export interface ConfigOptions {
/**
* Pins the {@link MatrixRTCMode} for all clients on this deployment,
* overriding any per-user choice from the Developer Settings. If unset,
* the user's Developer Settings choice (or its default of `Compatibility`)
* wins.
* the user's Developer Settings choice (or its default of `Legacy`) wins.
*/
matrix_rtc_mode?: MatrixRTCMode;
@@ -236,26 +183,14 @@ export interface ConfigOptions {
// Overrides members from ConfigOptions that are always provided by the
// default config and are therefore non-optional.
export interface ResolvedConfigOptions extends ConfigOptions {
default_server_config: {
["m.homeserver"]: {
base_url: string;
server_name: string;
};
};
sync_disconnect_grace_period_ms: number;
ssla: string;
media_quality: Required<
Pick<NonNullable<ConfigOptions["media_quality"]>, "video_codec">
> & {
video: Required<
Pick<
NonNullable<NonNullable<ConfigOptions["media_quality"]>["video"]>,
"max_resolution" | "max_bitrate" | "max_framerate"
>
>;
screen_share: Required<
Pick<
NonNullable<
NonNullable<ConfigOptions["media_quality"]>["screen_share"]
>,
"max_resolution" | "max_bitrate" | "max_framerate"
>
>;
};
matrix_rtc_session: {
wait_for_key_rotation_ms?: number;
delayed_leave_event_delay_ms: number;
@@ -267,24 +202,17 @@ export interface ResolvedConfigOptions extends ConfigOptions {
}
export const DEFAULT_CONFIG: ResolvedConfigOptions = {
default_server_config: {
["m.homeserver"]: {
base_url: "http://localhost:8008",
server_name: "localhost",
},
},
features: {
feature_use_device_session_member_events: true,
},
sync_disconnect_grace_period_ms: 10000,
ssla: "https://static.element.io/legal/element-software-and-services-license-agreement-uk-1.pdf",
media_quality: {
video_codec: "vp8",
video: {
max_resolution: 720,
max_bitrate: 1_700_000,
max_framerate: 30,
},
screen_share: {
max_resolution: 1080,
max_bitrate: 5_000_000,
max_framerate: 30,
},
},
matrix_rtc_session: {
delayed_leave_event_delay_ms: 10000,
network_error_retry_ms: 1000,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -0,0 +1,16 @@
<svg width="1440" height="800" viewBox="0 0 1440 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_f_4162_80259)">
<path d="M-37.0486 666.028C267.76 138.867 944.304 -46.1945 1477.05 260.929" stroke="url(#paint0_linear_4162_80259)" stroke-width="192" stroke-linecap="round"/>
</g>
<defs>
<filter id="filter0_f_4162_80259" x="-333.118" y="-183.694" width="2106.24" height="1145.68" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
<feGaussianBlur stdDeviation="100" result="effect1_foregroundBlur_4162_80259"/>
</filter>
<linearGradient id="paint0_linear_4162_80259" x1="1255.31" y1="320.254" x2="184.899" y2="607.497" gradientUnits="userSpaceOnUse">
<stop stop-color="#00C59E"/>
<stop offset="0.75" stop-color="#0044A5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 938 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="1200"
height="285"
viewBox="0 0 1200 285"
fill="none"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<g
filter="url(#filter0_f_3970_9366)"
id="g2"
transform="translate(422.00201)">
<path
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
stroke="url(#paint0_linear_3970_9366)"
stroke-width="235.517"
stroke-linecap="round"
id="path1"
style="stroke:url(#paint0_linear_3970_9366)" />
<path
d="m -164.342,495.134 c 188.2881,-188.288 494.684,-190 684.684,0"
stroke="url(#paint1_linear_3970_9366)"
style="mix-blend-mode:overlay;stroke:url(#paint1_linear_3970_9366)"
stroke-width="235.517"
stroke-linecap="round"
id="path2" />
</g>
<defs
id="defs5">
<filter
id="filter0_f_3970_9366"
x="-517.617"
y="-0.00012207"
width="1391.23"
height="848.409"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood
flood-opacity="0"
result="BackgroundImageFix"
id="feFlood2" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
id="feBlend2" />
<feGaussianBlur
stdDeviation="117.758"
result="effect1_foregroundBlur_3970_9366"
id="feGaussianBlur2" />
</filter>
<linearGradient
id="paint0_linear_3970_9366"
x1="349.17099"
y1="323.96301"
x2="6.82898"
y2="666.30499"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#0D5CBD"
id="stop2" />
<stop
offset="0.730863"
stop-color="#0DBDA8"
id="stop3" />
</linearGradient>
<linearGradient
id="paint1_linear_3970_9366"
x1="349.17099"
y1="323.96301"
x2="6.82898"
y2="666.30499"
gradientUnits="userSpaceOnUse">
<stop
stop-color="#0D5CBD"
id="stop4" />
<stop
offset="0.730863"
stop-color="#0DBDA8"
id="stop5" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -41,8 +41,6 @@ import { TileWrapper } from "./TileWrapper";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { useInitial } from "../useInitial";
const MAX_ANIMATED_TILES = 50; // Capped for performance reasons
interface Rect {
x: number;
y: number;
@@ -287,6 +285,7 @@ export function Grid<
const [visibleTilesCallback, setVisibleTilesCallback] =
useState<VisibleTilesCallback | null>(null);
const tiles = useInitial(() => new Map<string, Tile<TileModel>>());
const prefersReducedMotion = usePrefersReducedMotion();
const Slot: FC<SlotProps<TileModel>> = useMemo(
() =>
@@ -373,10 +372,6 @@ export function Grid<
// react-spring's imperative API during gestures to improve responsiveness
const dragState = useRef<DragState | null>(null);
// If true, disables animations
const immediate =
usePrefersReducedMotion() || placedTiles.length > MAX_ANIMATED_TILES;
const [tileTransitions, springRef] = useTransition(
placedTiles,
() => ({
@@ -394,9 +389,9 @@ export function Grid<
y,
width,
height,
immediate,
immediate: prefersReducedMotion,
}),
enter: { opacity: 1, scale: 1, immediate },
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
update: ({
id,
x,
@@ -411,9 +406,9 @@ export function Grid<
y,
width,
height,
immediate,
immediate: prefersReducedMotion,
},
leave: { opacity: 0, scale: 0, immediate },
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
config: { mass: 0.7, tension: 252, friction: 25 },
}),
// react-spring's types are bugged and can't infer the spring type
@@ -446,7 +441,8 @@ export function Grid<
y: tile.y,
width: tile.width,
height: tile.height,
immediate: immediate || ((key): boolean => key === "zIndex"),
immediate:
prefersReducedMotion || ((key): boolean => key === "zIndex"),
// Allow the tile's position to settle before pushing its
// z-index back down
delay: (key): number => (key === "zIndex" ? 500 : 0),
@@ -457,7 +453,7 @@ export function Grid<
x: tileX,
y: tileY,
immediate:
immediate ||
prefersReducedMotion ||
((key): boolean =>
key === "zIndex" || key === "x" || key === "y"),
},

View File

@@ -5,7 +5,7 @@ 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 ReactNode } from "react";
import { type ReactNode } from "react";
import { useObservableEagerState } from "observable-hooks";
import classNames from "classnames";
@@ -13,9 +13,6 @@ import { type CallLayout } from "./CallLayout";
import { type SpotlightLandscapeLayout as SpotlightLandscapeLayoutModel } from "../state/layout-types.ts";
import styles from "./SpotlightLandscapeLayout.module.css";
import { useUpdateLayout, useVisibleTiles } from "./Grid";
import { type MediaViewModel } from "../state/media/MediaViewModel.ts";
import { type Behavior } from "../state/Behavior.ts";
import { useBehavior } from "../useBehavior.ts";
/**
* An implementation of the "spotlight landscape" layout, in which the spotlight
@@ -57,10 +54,16 @@ export const makeSpotlightLandscapeLayout: CallLayout<
useUpdateLayout();
useVisibleTiles(model.setVisibleTiles);
useObservableEagerState(minBounds$);
const withIndicators =
useObservableEagerState(model.spotlight.media$).length > 1;
return (
<div ref={ref} className={styles.layer}>
<SpotlightSlot media$={model.spotlight.media$} />
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: withIndicators,
})}
/>
<div className={styles.grid}>
{model.grid.map((m) => (
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
@@ -70,20 +73,3 @@ export const makeSpotlightLandscapeLayout: CallLayout<
);
},
});
interface SpotlightSlotProps {
media$: Behavior<MediaViewModel[]>;
}
// This component isolates the subscription to the spotlight media so that it
// can change without causing the whole layout to re-render
const SpotlightSlot: FC<SpotlightSlotProps> = ({ media$ }) => {
const withIndicators = useBehavior(media$).length > 1;
return (
<div
className={classNames(styles.spotlight, {
[styles.withIndicators]: withIndicators,
})}
/>
);
};

View File

@@ -15,7 +15,8 @@ Please see LICENSE in the repository root for full details.
@import url("@fontsource/inconsolata/700.css");
@import url("normalize.css/normalize.css") layer(normalize);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css")
layer(compound);
@import url("@vector-im/compound-web/dist/style.css") layer(compound.components);
:root {
@@ -78,19 +79,16 @@ body {
body[data-background="gradient"]::before {
content: "";
position: fixed;
/* Chromium abruptly fades our images to fully transparent at the edge of
the element. If we just make the element a little bigger than the viewport,
this is no longer visible. */
inset: -20px;
background-image: url("graphics/mobile-gradient.png");
background-size: 1400px 305px;
inset: 0;
background-image: url("graphics/mobile-gradient.svg");
background-size: auto;
background-position: bottom;
background-repeat: no-repeat;
}
body[data-background="gradient"][data-platform="desktop"]::before {
background-image: url("graphics/desktop-gradient.png");
background-size: max(1440px, 100vw) max(1440px, 100vh);
background-image: url("graphics/desktop-gradient.svg");
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
background-position: center;
}
}

View File

@@ -30,7 +30,6 @@ import {
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch";
import { initializeWidget } from "./widget";
@@ -238,7 +237,6 @@ export class Initializer {
this.loadStates.config = LoadState.Loading;
Config.init().then(
() => {
seedSettingsFromConfig(Config.get().media_quality);
this.loadStates.config = LoadState.Loaded;
this.initStep(resolve);
},

View File

@@ -99,7 +99,7 @@ function renderTestComponent(
),
} as unknown as Room;
if ((explicitTracks?.length ?? 0) > 0) {
if (explicitTracks?.length ?? 0 > 0) {
tracks = explicitTracks!.map(({ participantId, source, kind }) => {
const participant =
liveKitParticipants.find((p) => p.identity === participantId) ??

View File

@@ -1,213 +0,0 @@
/*
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 { VideoPresets, type VideoPreset } from "livekit-client";
import { buildLiveKitOptions, getLiveKitOptions } from "./options";
import { Config } from "../config/Config";
vi.mock("../config/Config", () => ({
Config: {
get: vi.fn(),
},
}));
describe("buildLiveKitOptions", () => {
it("returns sensible defaults with no config", () => {
const opts = buildLiveKitOptions();
expect(opts.adaptiveStream).toBe(true);
expect(opts.dynacast).toBe(true);
expect(opts.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h720.resolution,
);
expect(opts.publishDefaults?.videoCodec).toBe("vp8");
expect(opts.publishDefaults?.videoEncoding).toEqual({
maxBitrate: 1_700_000,
maxFramerate: 30,
});
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
maxBitrate: 5_000_000,
maxFramerate: 30,
});
expect(opts.publishDefaults?.videoSimulcastLayers).toEqual([
VideoPresets.h180,
VideoPresets.h360,
]);
});
it("applies video codec from config", () => {
const opts = buildLiveKitOptions({ video_codec: "vp9" });
expect(opts.publishDefaults?.videoCodec).toBe("vp9");
});
it("applies video resolution and encoding from config", () => {
const baseVideoConfig = {
max_resolution: 1080,
max_bitrate: 3_000_000,
max_framerate: 60,
};
const opts1080 = buildLiveKitOptions({
video: baseVideoConfig,
});
const opts1440 = buildLiveKitOptions({
video: { ...baseVideoConfig, max_resolution: 1440 },
});
const opts2160 = buildLiveKitOptions({
video: { ...baseVideoConfig, max_resolution: 2160 },
});
expect(opts1080.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h1080.resolution,
);
expect(opts1440.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h1440.resolution,
);
expect(opts2160.videoCaptureDefaults?.resolution).toEqual(
VideoPresets.h2160.resolution,
);
expect(opts1080.publishDefaults?.videoEncoding).toEqual({
maxBitrate: 3_000_000,
maxFramerate: 60,
});
});
it("applies screen share encoding from config", () => {
const opts = buildLiveKitOptions({
screen_share: {
max_bitrate: 8_000_000,
max_framerate: 15,
},
});
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
maxBitrate: 8_000_000,
maxFramerate: 15,
});
});
it("uses DEFAULT_CONFIG defaults when only resolution is set", () => {
const opts = buildLiveKitOptions({
screen_share: {
max_resolution: 720,
},
});
// Bitrate and framerate fall back to DEFAULT_CONFIG, not the preset
expect(opts.publishDefaults?.screenShareEncoding).toEqual({
maxBitrate: 5_000_000,
maxFramerate: 30,
});
});
it("maps low resolutions to the closest preset, rounding up", () => {
const expectations: [number, VideoPreset][] = [
[180, VideoPresets.h180],
[360, VideoPresets.h360],
[480, VideoPresets.h540],
[540, VideoPresets.h540],
[720, VideoPresets.h720],
];
for (const [height, preset] of expectations) {
const opts = buildLiveKitOptions({ video: { max_resolution: height } });
expect(opts.videoCaptureDefaults?.resolution).toEqual(preset.resolution);
}
});
it("screen share layers fall back to max_framerate, then 30", () => {
const fromMax = buildLiveKitOptions({
screen_share: {
max_framerate: 15,
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
},
});
expect(
fromMax.publishDefaults?.screenShareSimulcastLayers?.[0],
).toMatchObject({ encoding: { maxFramerate: 15 } });
const fromDefault = buildLiveKitOptions({
screen_share: {
simulcast_layers: [{ height: 540, bitrate: 1_000_000 }],
},
});
expect(
fromDefault.publishDefaults?.screenShareSimulcastLayers?.[0],
).toMatchObject({ encoding: { maxFramerate: 30 } });
});
it("applies custom video simulcast layers", () => {
const opts = buildLiveKitOptions({
video: {
simulcast_layers: [
{ height: 180, bitrate: 100_000 },
{ height: 360, bitrate: 300_000 },
{ height: 540, bitrate: 600_000 },
],
max_framerate: 24,
},
});
const layers = opts.publishDefaults?.videoSimulcastLayers;
expect(layers).toHaveLength(3);
expect(layers?.[0]).toMatchObject({
width: 320,
height: 180,
encoding: { maxBitrate: 100_000, maxFramerate: 24 },
});
expect(layers?.[2]).toMatchObject({
width: 960,
height: 540,
encoding: { maxBitrate: 600_000, maxFramerate: 24 },
});
});
it("applies custom screen share simulcast layers", () => {
const opts = buildLiveKitOptions({
screen_share: {
simulcast_layers: [{ height: 540, bitrate: 1_000_000, framerate: 5 }],
},
});
const layers = opts.publishDefaults?.screenShareSimulcastLayers;
expect(layers).toHaveLength(1);
expect(layers?.[0]).toMatchObject({
width: 960,
height: 540,
encoding: { maxBitrate: 1_000_000, maxFramerate: 5 },
});
});
it("does not include screenShareSimulcastLayers when not configured", () => {
const opts = buildLiveKitOptions();
expect(opts.publishDefaults?.screenShareSimulcastLayers).toBeUndefined();
});
it("backupCodec always uses stock VP8 720p encoding", () => {
const opts = buildLiveKitOptions({
video_codec: "av1",
video: { max_bitrate: 10_000_000, max_framerate: 60 },
});
const backup = opts.publishDefaults?.backupCodec as {
codec: string;
encoding: { maxBitrate: number; maxFramerate: number };
};
expect(backup.codec).toBe("vp8");
expect(backup.encoding).toEqual(VideoPresets.h720.encoding);
});
});
describe("getLiveKitOptions", () => {
it("reads from Config singleton", () => {
vi.mocked(Config.get).mockReturnValue({
media_quality: { video_codec: "h264" },
} as ReturnType<typeof Config.get>);
const opts = getLiveKitOptions();
expect(opts.publishDefaults?.videoCodec).toBe("h264");
});
it("throws when Config is not initialized", () => {
vi.mocked(Config.get).mockImplementation(() => {
throw new Error("Config not initialized");
});
expect(() => getLiveKitOptions()).toThrow("Config not initialized");
});
});

View File

@@ -9,144 +9,46 @@ import {
AudioPresets,
DefaultReconnectPolicy,
type RoomOptions,
ScreenSharePresets,
type TrackPublishDefaults,
type VideoPreset,
VideoPresets,
VideoPreset as VideoPresetClass,
} from "livekit-client";
import { Config } from "../config/Config";
import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
const defaultLiveKitPublishOptions: TrackPublishDefaults = {
audioPreset: AudioPresets.music,
dtx: true,
// disable red because the livekit server strips out red packets for clients
// that don't support it (firefox) but of course that doesn't work with e2ee.
red: false,
forceStereo: false,
simulcast: true,
videoSimulcastLayers: [VideoPresets.h180, VideoPresets.h360] as VideoPreset[],
screenShareEncoding: ScreenSharePresets.h1080fps30.encoding,
stopMicTrackOnMute: false,
videoCodec: "vp8",
videoEncoding: VideoPresets.h720.encoding,
backupCodec: { codec: "vp8", encoding: VideoPresets.h720.encoding },
} as const;
/**
* Find the closest matching VideoPreset for a given height.
*/
function videoPresetForHeight(height: number): VideoPreset {
if (height <= 180) return VideoPresets.h180;
if (height <= 360) return VideoPresets.h360;
if (height <= 540) return VideoPresets.h540;
if (height <= 720) return VideoPresets.h720;
if (height <= 1080) return VideoPresets.h1080;
if (height <= 1440) return VideoPresets.h1440;
return VideoPresets.h2160;
}
export const defaultLiveKitOptions: RoomOptions = {
// automatically manage subscribed video quality
adaptiveStream: true,
/**
* Build LiveKit publish options from config, falling back to sensible defaults.
*/
function buildPublishOptions(
mediaQuality: ConfigOptions["media_quality"],
): TrackPublishDefaults {
const defaults = DEFAULT_CONFIG.media_quality;
const videoConf = mediaQuality?.video;
const screenConf = mediaQuality?.screen_share;
const codec = mediaQuality?.video_codec ?? defaults.video_codec;
// optimize publishing bandwidth and CPU for published tracks
dynacast: true,
// Camera video encoding
const videoEncoding = {
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
};
// capture settings
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution,
},
// Camera simulcast layers
let videoSimulcastLayers: VideoPreset[];
if (videoConf?.simulcast_layers) {
videoSimulcastLayers = videoConf.simulcast_layers.map(
(layer) =>
new VideoPresetClass(
Math.round((layer.height * 16) / 9),
layer.height,
layer.bitrate,
videoConf?.max_framerate ?? defaults.video.max_framerate,
),
);
} else {
videoSimulcastLayers = [VideoPresets.h180, VideoPresets.h360];
}
// publish settings
publishDefaults: defaultLiveKitPublishOptions,
// Screen share encoding
const screenShareEncoding = {
maxBitrate: screenConf?.max_bitrate ?? defaults.screen_share.max_bitrate,
maxFramerate:
screenConf?.max_framerate ?? defaults.screen_share.max_framerate,
};
// Screen share simulcast layers
let screenShareSimulcastLayers: VideoPreset[] | undefined;
if (screenConf?.simulcast_layers) {
screenShareSimulcastLayers = screenConf.simulcast_layers.map(
(layer) =>
new VideoPresetClass(
Math.round((layer.height * 16) / 9),
layer.height,
layer.bitrate,
layer.framerate ?? screenConf?.max_framerate ?? 30,
),
);
}
return {
audioPreset: AudioPresets.music,
dtx: true,
// disable red because the livekit server strips out red packets for clients
// that don't support it (firefox) but of course that doesn't work with e2ee.
red: false,
forceStereo: false,
simulcast: true,
videoSimulcastLayers: videoSimulcastLayers as VideoPreset[],
screenShareEncoding,
...(screenShareSimulcastLayers && {
screenShareSimulcastLayers: screenShareSimulcastLayers as VideoPreset[],
}),
stopMicTrackOnMute: false,
videoCodec: codec,
videoEncoding,
backupCodec: {
codec: "vp8",
encoding: VideoPresets.h720.encoding,
},
} as TrackPublishDefaults;
}
/**
* Build LiveKit RoomOptions from config.
* Call this after Config.init() has resolved.
*/
export function buildLiveKitOptions(
mediaQuality?: ConfigOptions["media_quality"],
): RoomOptions {
const videoHeight =
mediaQuality?.video?.max_resolution ??
DEFAULT_CONFIG.media_quality.video.max_resolution;
const basePreset = videoPresetForHeight(videoHeight);
return {
// automatically manage subscribed video quality
adaptiveStream: true,
// optimize publishing bandwidth and CPU for published tracks
dynacast: true,
// capture settings
videoCaptureDefaults: {
resolution: basePreset.resolution,
},
// publish settings
publishDefaults: buildPublishOptions(mediaQuality),
// default LiveKit options that seem to be sane
stopLocalTrackOnUnpublish: true,
reconnectPolicy: new DefaultReconnectPolicy(),
disconnectOnPageLeave: true,
webAudioMix: false,
};
}
/**
* Get LiveKit options, reading from the loaded Config singleton.
* Requires Config.init() to have resolved first.
*/
export function getLiveKitOptions(): RoomOptions {
return buildLiveKitOptions(Config.get().media_quality);
}
// default LiveKit options that seem to be sane
stopLocalTrackOnUnpublish: true,
reconnectPolicy: new DefaultReconnectPolicy(),
disconnectOnPageLeave: true,
webAudioMix: false,
};

View File

@@ -555,9 +555,6 @@ export const GroupCallView: FC<Props> = ({
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
// We need it here explicitly in case rtcSession.isJoined is false.
void widget?.api.setAlwaysOnScreen(false);
}}
>
{body}

View File

@@ -261,7 +261,6 @@ export const InCallView: FC<InCallViewProps> = ({
const overflowing = useBehavior(vm.overflowing$);
const showNameTags = useBehavior(vm.showNameTags$);
const showHeader = useBehavior(vm.showHeader$);
const showModals = useBehavior(vm.showModals$);
const settingsOpen = useBehavior(vm.settingsOpen$);
const setSettingsOpen = useBehavior(vm.setSettingsOpen$);
const earpieceMode = useBehavior(vm.earpieceMode$);
@@ -623,7 +622,7 @@ export const InCallView: FC<InCallViewProps> = ({
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
{showModals && (
{layout.type !== "pip" && (
<>
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal

View File

@@ -22,9 +22,6 @@ Please see LICENSE in the repository root for full details.
height: 100%;
object-fit: cover;
background-color: var(--video-tile-background);
/* In FF if you add a transform: scale/translate/matrix filter on an element,
it'll ignore the parents' border-radius, so force back the radius to avoid UI glitch*/
border-radius: inherit;
}
video.mirror {

View File

@@ -280,7 +280,7 @@ export const useLoadGroupCall = (
);
}
if (
roomSummary?.join_rule === undefined ||
roomSummary === undefined ||
roomSummary.join_rule === JoinRule.Public
) {
room = await client.joinRoom(roomId, {

View File

@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import {
type ChangeEvent,
type FC,
type ReactNode,
useCallback,
useEffect,
useMemo,
@@ -30,14 +29,12 @@ import {
InlineField,
Label,
RadioControl,
Separator,
} from "@vector-im/compound-web";
import { type Room as LivekitRoom } from "livekit-client";
import { FieldRow, InputField } from "../input/Input";
import { Config } from "../config/Config";
import {
type Setting,
useSetting,
duplicateTiles as duplicateTilesSetting,
debugTileLayout as debugTileLayoutSetting,
@@ -46,26 +43,10 @@ import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
matrixRTCMode as matrixRTCModeSetting,
customLivekitUrl as customLivekitUrlSetting,
advancedScreenShare as advancedScreenShareSetting,
screenShareResolution as screenShareResolutionSetting,
screenShareFramerate as screenShareFramerateSetting,
screenShareBitrate as screenShareBitrateSetting,
screenShareCodec as screenShareCodecSetting,
advancedCamera as advancedCameraSetting,
cameraResolution as cameraResolutionSetting,
cameraFramerate as cameraFramerateSetting,
cameraBitrate as cameraBitrateSetting,
cameraCodec as cameraCodecSetting,
echoCancellationSetting,
noiseSuppressionSetting,
autoGainControlSetting,
type VideoCodec,
enableExtendedLivekitLogs as enableExtendedLivekitLogsSetting,
} from "./settings";
import { MatrixRTCMode } from "../config/ConfigOptions";
import styles from "./DeveloperSettingsTab.module.css";
import settingsStyles from "./SettingsModal.module.css";
import { Slider } from "../Slider";
import { useUrlParams } from "../UrlParams";
import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
@@ -157,185 +138,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
return null;
}, [livekitRooms]);
const MediaQualitySettings: React.FC<{
id: string;
header: string;
toggleLabel: string;
description: string;
toggleSetting: Setting<boolean>;
resolutionSetting: Setting<string>;
framerateSetting: Setting<number>;
bitrateSetting: Setting<number>;
codecSetting: Setting<VideoCodec>;
resolutionOptions: { value: string; label: string }[];
bitrateRange: { min: number; max: number; step: number };
}> = ({
id,
header,
toggleLabel,
description,
toggleSetting,
resolutionSetting,
framerateSetting,
bitrateSetting,
codecSetting,
resolutionOptions,
bitrateRange,
}): ReactNode => {
const [advancedEnabled, setAdvancedEnabled] = useSetting(toggleSetting);
const [resolution, setResolution] = useSetting(resolutionSetting);
const [framerate, setFramerate] = useSetting(framerateSetting);
const [framerateRaw, setFramerateRaw] = useState(framerate);
const [bitrate, setBitrate] = useSetting(bitrateSetting);
const [bitrateRaw, setBitrateRaw] = useState(bitrate);
const [codec, setCodec] = useSetting(codecSetting);
return (
<>
<h4>{header}</h4>
<FieldRow>
<InputField
id={`${id}Toggle`}
label={toggleLabel}
description={description}
type="checkbox"
checked={advancedEnabled}
onChange={(e): void => setAdvancedEnabled(e.target.checked)}
/>
</FieldRow>
{advancedEnabled && (
<>
<div className={settingsStyles.volumeSlider}>
<label htmlFor={`${id}Resolution`}>
{t("settings.resolution_label", "Resolution")}
</label>
<select
id={`${id}Resolution`}
value={resolution}
onChange={(e): void => setResolution(e.target.value)}
>
{resolutionOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
<div className={settingsStyles.volumeSlider}>
<label>
{t("settings.framerate_label", "Framerate")}
{": "}
<span className={settingsStyles.settingValue}>
{framerateRaw} fps
</span>
</label>
<Slider
label={t("settings.framerate_label", "Framerate")}
value={framerateRaw}
onValueChange={setFramerateRaw}
onValueCommit={setFramerate}
min={5}
max={60}
step={5}
tooltipFormatter={(v): string => `${v} fps`}
/>
</div>
<div className={settingsStyles.volumeSlider}>
<label>
{t("settings.bitrate_label", "Bitrate")}
{": "}
<span className={settingsStyles.settingValue}>
{(bitrateRaw / 1_000_000).toFixed(1)} Mbps
</span>
</label>
<Slider
label={t("settings.bitrate_label", "Bitrate")}
value={bitrateRaw}
onValueChange={setBitrateRaw}
onValueCommit={setBitrate}
min={bitrateRange.min}
max={bitrateRange.max}
step={bitrateRange.step}
tooltipFormatter={(v): string =>
`${(v / 1_000_000).toFixed(1)} Mbps`
}
/>
</div>
<div className={settingsStyles.volumeSlider}>
<label htmlFor={`${id}Codec`}>
{t("settings.codec_label", "Codec")}
</label>
<select
id={`${id}Codec`}
value={codec}
onChange={(e): void => setCodec(e.target.value as VideoCodec)}
>
<option value="vp8">VP8</option>
<option value="vp9">VP9</option>
<option value="h264">H.264</option>
<option value="av1">AV1</option>
</select>
</div>
</>
)}
</>
);
};
const AudioProcessingSettings: React.FC = (): ReactNode => {
const [echoCancellation, setEchoCancellation] = useSetting(
echoCancellationSetting,
);
const [noiseSuppression, setNoiseSuppression] = useSetting(
noiseSuppressionSetting,
);
const [autoGainControl, setAutoGainControl] = useSetting(
autoGainControlSetting,
);
return (
<>
<h4>{t("settings.audio_processing_header", "Audio processing")}</h4>
<p>
{t(
"settings.audio_processing_description",
"Changes apply on next call join.",
)}
</p>
<FieldRow>
<InputField
id="echoCancellation"
label={t("settings.echo_cancellation_label", "Echo cancellation")}
type="checkbox"
checked={echoCancellation}
onChange={(e): void => setEchoCancellation(e.target.checked)}
/>
</FieldRow>
<FieldRow>
<InputField
id="noiseSuppression"
label={t("settings.noise_suppression_label", "Noise suppression")}
type="checkbox"
checked={noiseSuppression}
onChange={(e): void => setNoiseSuppression(e.target.checked)}
/>
</FieldRow>
<FieldRow>
<InputField
id="autoGainControl"
label={t(
"settings.auto_gain_control_label",
"Automatic gain control",
)}
type="checkbox"
checked={autoGainControl}
onChange={(e): void => setAutoGainControl(e.target.checked)}
/>
</FieldRow>
</>
);
};
return (
<>
<p>
@@ -364,7 +166,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
id: client.getDeviceId() || "unknown",
})}
</p>
<Separator />
<FieldRow>
<InputField
id="duplicateTiles"
@@ -514,7 +315,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
<ErrorMessage>{customLivekitUrlUpdateError}</ErrorMessage>
)}
</EditInPlace>
<Separator />
<Heading as="h3" type="body" weight="semibold" size="lg">
{t("developer_mode.matrixRTCMode.title")}
</Heading>
@@ -603,61 +403,6 @@ export const DeveloperSettingsTab: FC<Props> = ({
</ul>
</div>
))}
<Separator />
<MediaQualitySettings
id="camera"
header={t("settings.camera_header", "Camera quality")}
toggleLabel={t(
"settings.advanced_camera_label",
"Advanced camera settings",
)}
description={t(
"settings.advanced_camera_description",
"Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.",
)}
toggleSetting={advancedCameraSetting}
resolutionSetting={cameraResolutionSetting}
framerateSetting={cameraFramerateSetting}
bitrateSetting={cameraBitrateSetting}
codecSetting={cameraCodecSetting}
resolutionOptions={[
{ value: "640x360", label: "360p" },
{ value: "960x540", label: "540p" },
{ value: "1280x720", label: "720p" },
{ value: "1920x1080", label: "1080p" },
{ value: "2560x1440", label: "1440p" },
]}
bitrateRange={{ min: 200_000, max: 8_000_000, step: 100_000 }}
/>
<Separator />
<MediaQualitySettings
id="screenShare"
header={t("settings.screen_share_header", "Screen sharing")}
toggleLabel={t(
"settings.advanced_screen_share_label",
"Advanced screen share settings",
)}
description={t(
"settings.advanced_screen_share_description",
"Configure resolution, framerate, bitrate, and codec for screen sharing",
)}
toggleSetting={advancedScreenShareSetting}
resolutionSetting={screenShareResolutionSetting}
framerateSetting={screenShareFramerateSetting}
bitrateSetting={screenShareBitrateSetting}
codecSetting={screenShareCodecSetting}
resolutionOptions={[
{ value: "1024x576", label: "576p" },
{ value: "1280x720", label: "720p" },
{ value: "1920x1080", label: "1080p" },
{ value: "2560x1440", label: "1440p" },
{ value: "3840x2160", label: "4K" },
]}
bitrateRange={{ min: 500_000, max: 15_000_000, step: 500_000 }}
/>
<Separator />
<AudioProcessingSettings />
<Separator />
<p>{t("developer_mode.environment_variables")}</p>
<pre>{JSON.stringify(env, null, 2)}</pre>
<p>{t("developer_mode.url_params")}</p>

View File

@@ -33,27 +33,3 @@ Please see LICENSE in the repository root for full details.
.volumeSlider > p {
color: var(--cpd-color-text-secondary);
}
.volumeSlider > select {
display: block;
width: 100%;
padding: 8px 12px;
margin-top: var(--cpd-space-1x);
border: 1px solid var(--cpd-color-border-interactive-primary);
border-radius: 4px;
background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-text-primary);
font-size: var(--font-size-body);
font-family: inherit;
cursor: pointer;
}
.volumeSlider > select:focus {
outline: none;
border-color: var(--cpd-color-text-link-external);
}
.settingValue {
font-weight: normal;
color: var(--cpd-color-text-secondary);
}

View File

@@ -152,13 +152,7 @@ export const SettingsModal: FC<Props> = ({
/>
<div className={styles.volumeSlider}>
<label>
{t("settings.audio_tab.effect_volume_label")}
{": "}
<span className={styles.settingValue}>
{Math.round(soundVolumeRaw * 100)}%
</span>
</label>
<label>{t("settings.audio_tab.effect_volume_label")}</label>
<p>{t("settings.audio_tab.effect_volume_description")}</p>
<Slider
label={t("video_tile.volume")}

View File

@@ -17,12 +17,6 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<p>
Device ID: DEVICE123
</p>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<div
class="_fieldRow_1bd8c0"
>
@@ -257,16 +251,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
class="_message_1o4d9_86 _help-message_1o4d9_92"
id="radix-_r_8_"
>
Currently, no overwrite is set. Url from config is used.
Currently, no overwrite is set. Url from well-known or config is used.
</span>
</div>
</form>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h3
class="_typography_6v6n8_153 _font-body-lg-semibold_6v6n8_74"
>
@@ -475,242 +463,6 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
</p>
<ul />
</div>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h4>
Camera quality
</h4>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_k_"
id="cameraToggle"
type="checkbox"
/>
<label
for="cameraToggle"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Advanced camera settings
</label>
<p
class="_description_1bd8c0"
id="_r_k_"
>
Configure resolution, framerate, bitrate, and codec for camera video. Changes apply on next call join.
</p>
</div>
</div>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h4>
Screen sharing
</h4>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_l_"
id="screenShareToggle"
type="checkbox"
/>
<label
for="screenShareToggle"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Advanced screen share settings
</label>
<p
class="_description_1bd8c0"
id="_r_l_"
>
Configure resolution, framerate, bitrate, and codec for screen sharing
</p>
</div>
</div>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<h4>
Audio processing
</h4>
<p>
Changes apply on next call join.
</p>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_m_"
checked=""
id="echoCancellation"
type="checkbox"
/>
<label
for="echoCancellation"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Echo cancellation
</label>
</div>
</div>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_n_"
checked=""
id="noiseSuppression"
type="checkbox"
/>
<label
for="noiseSuppression"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Noise suppression
</label>
</div>
</div>
<div
class="_fieldRow_1bd8c0"
>
<div
class="_field_1bd8c0 _checkboxField_1bd8c0"
>
<input
aria-describedby="_r_o_"
checked=""
id="autoGainControl"
type="checkbox"
/>
<label
for="autoGainControl"
>
<div
class="_checkbox_1bd8c0"
>
<svg
fill="none"
height="24"
stroke="#000"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m20 6-11 11-5-5"
/>
</svg>
</div>
Automatic gain control
</label>
</div>
</div>
<div
class="_separator_13qwf_8"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<p>
Environment variables
</p>

View File

@@ -1,111 +0,0 @@
/*
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, beforeEach } from "vitest";
import {
Setting,
parseResolution,
seedSettingsFromConfig,
screenShareCodec,
screenShareResolution,
screenShareFramerate,
screenShareBitrate,
cameraCodec,
cameraResolution,
cameraFramerate,
cameraBitrate,
} from "./settings";
beforeEach(() => {
localStorage.clear();
});
describe("parseResolution", () => {
it("parses a WIDTHxHEIGHT string", () => {
expect(parseResolution("1920x1080")).toEqual({
width: 1920,
height: 1080,
});
});
it("parses non-standard resolutions", () => {
expect(parseResolution("640x360")).toEqual({ width: 640, height: 360 });
});
});
describe("Setting", () => {
describe("seedFromConfig", () => {
it("updates value when no localStorage value exists", () => {
const setting = new Setting<string>("test-seed", "default");
setting.seedFromConfig("from-config");
expect(setting.getValue()).toBe("from-config");
});
it("does not override an existing localStorage value", () => {
localStorage.setItem(
"matrix-setting-test-seed-existing",
JSON.stringify("user-set"),
);
const setting = new Setting<string>("test-seed-existing", "default");
expect(setting.getValue()).toBe("user-set");
setting.seedFromConfig("from-config");
expect(setting.getValue()).toBe("user-set");
});
});
});
describe("seedSettingsFromConfig", () => {
it("does nothing when config is undefined", () => {
const codecBefore = screenShareCodec.getValue();
seedSettingsFromConfig(undefined);
expect(screenShareCodec.getValue()).toBe(codecBefore);
});
it("seeds video codec to both camera and screen share", () => {
seedSettingsFromConfig({ video_codec: "av1" });
expect(screenShareCodec.getValue()).toBe("av1");
expect(cameraCodec.getValue()).toBe("av1");
});
it("seeds screen share settings from config", () => {
seedSettingsFromConfig({
screen_share: {
max_resolution: 720,
max_framerate: 15,
max_bitrate: 2_000_000,
},
});
expect(screenShareResolution.getValue()).toBe("1280x720");
expect(screenShareFramerate.getValue()).toBe(15);
expect(screenShareBitrate.getValue()).toBe(2_000_000);
});
it("seeds camera/video settings from config", () => {
seedSettingsFromConfig({
video: {
max_resolution: 1080,
max_framerate: 60,
max_bitrate: 4_000_000,
},
});
expect(cameraResolution.getValue()).toBe("1920x1080");
expect(cameraFramerate.getValue()).toBe(60);
expect(cameraBitrate.getValue()).toBe(4_000_000);
});
it("only seeds provided fields, leaves others at defaults", () => {
const defaultFramerate = screenShareFramerate.getValue();
seedSettingsFromConfig({
screen_share: {
max_bitrate: 3_000_000,
},
});
expect(screenShareBitrate.getValue()).toBe(3_000_000);
expect(screenShareFramerate.getValue()).toBe(defaultFramerate);
});
});

View File

@@ -21,7 +21,6 @@ export class Setting<T> {
this.key = `matrix-setting-${key}`;
const storedValue = localStorage.getItem(this.key);
this.hasStoredValue = storedValue !== null;
let initialValue = defaultValue;
if (storedValue !== null) {
try {
@@ -41,7 +40,6 @@ export class Setting<T> {
}
private readonly key: string;
private readonly hasStoredValue: boolean;
private readonly _value$: BehaviorSubject<T>;
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
@@ -56,17 +54,6 @@ export class Setting<T> {
public readonly getValue = (): T => {
return this._value$.getValue();
};
/**
* Update the setting's value from a config source, but only if the user
* hasn't explicitly set a value in localStorage. This lets admins set
* org-wide defaults in config.json that users can override.
*/
public seedFromConfig(value: T): void {
if (!this.hasStoredValue) {
this._value$.next(value);
}
}
}
/**
@@ -157,130 +144,3 @@ export const customLivekitUrl = new Setting<string | null>(
"custom-livekit-url",
null,
);
export type VideoCodec = "vp8" | "vp9" | "h264" | "av1";
/**
* Parse a "WIDTHxHEIGHT" resolution string into numeric width and height.
*/
export function parseResolution(res: string): {
width: number;
height: number;
} {
const [w, h] = res.split("x").map(Number);
return { width: w, height: h };
}
export const advancedScreenShare = new Setting<boolean>(
"advanced-screen-share",
false,
);
export const screenShareResolution = new Setting<string>(
"screen-share-resolution",
"1920x1080",
);
export const screenShareFramerate = new Setting<number>(
"screen-share-framerate",
30,
);
export const screenShareBitrate = new Setting<number>(
"screen-share-bitrate",
5_000_000,
);
export const screenShareCodec = new Setting<VideoCodec>(
"screen-share-codec",
"vp9",
);
// Camera video quality settings
export const advancedCamera = new Setting<boolean>("advanced-camera", false);
export const cameraResolution = new Setting<string>(
"camera-resolution",
"1280x720",
);
export const cameraFramerate = new Setting<number>("camera-framerate", 30);
export const cameraBitrate = new Setting<number>("camera-bitrate", 1_700_000);
export const cameraCodec = new Setting<VideoCodec>("camera-codec", "vp8");
// Audio processing settings
export const echoCancellationSetting = new Setting<boolean>(
"echo-cancellation",
true,
);
export const noiseSuppressionSetting = new Setting<boolean>(
"noise-suppression",
true,
);
export const autoGainControlSetting = new Setting<boolean>(
"auto-gain-control",
true,
);
/**
* Seed setting defaults from config.json's media_quality section.
* Call this after Config.init() has resolved.
* Only updates settings that the user hasn't explicitly set in localStorage.
*/
export function seedSettingsFromConfig(
mediaQuality:
| {
video_codec?: VideoCodec;
video?: {
max_resolution?: number;
max_bitrate?: number;
max_framerate?: number;
};
screen_share?: {
max_resolution?: number;
max_bitrate?: number;
max_framerate?: number;
};
}
| undefined,
): void {
if (!mediaQuality) return;
const codec = mediaQuality.video_codec;
if (codec) {
screenShareCodec.seedFromConfig(codec);
cameraCodec.seedFromConfig(codec);
}
const screen = mediaQuality.screen_share;
if (screen) {
if (screen.max_resolution) {
const width = Math.round((screen.max_resolution * 16) / 9);
screenShareResolution.seedFromConfig(`${width}x${screen.max_resolution}`);
}
if (screen.max_framerate) {
screenShareFramerate.seedFromConfig(screen.max_framerate);
}
if (screen.max_bitrate) {
screenShareBitrate.seedFromConfig(screen.max_bitrate);
}
}
const video = mediaQuality.video;
if (video) {
if (video.max_resolution) {
const width = Math.round((video.max_resolution * 16) / 9);
cameraResolution.seedFromConfig(`${width}x${video.max_resolution}`);
}
if (video.max_framerate) {
cameraFramerate.seedFromConfig(video.max_framerate);
}
if (video.max_bitrate) {
cameraBitrate.seedFromConfig(video.max_bitrate);
}
}
}

View File

@@ -19,10 +19,7 @@ import { useClient } from "../ClientContext";
import { Config } from "../config/Config";
import { type RageshakeRequestModal } from "../room/RageshakeRequestModal";
import { getUrlParams } from "../UrlParams";
import { deepCompare } from "matrix-js-sdk/lib/utils";
import { advancedCamera as advancedCameraSetting } from "./settings";
import { advancedScreenShare as advancedScreenShareSetting } from "./settings";
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
const gzip = async (text: string): Promise<Blob> => {
// pako is relatively large (200KB), so we only import it when needed
const { gzip: pakoGzip } = await import("pako");
@@ -248,20 +245,6 @@ export function useSubmitRageshake(
}
}
// Add custom media related information to the rageshake issue description.
// Used to quickly identify issues due to untested configurations.
if (
!deepCompare(Config.get().media_quality, DEFAULT_CONFIG.media_quality)
) {
body.append("custom_media_quality_in_config", "true");
}
if (advancedCameraSetting.getValue()) {
body.append("devTools_advancedCameraSettings", "true");
}
if (advancedScreenShareSetting.getValue()) {
body.append("devTools_advancedScreenShareSetting", "true");
}
if (navigator.storage && navigator.storage.estimate) {
try {
const estimate: {

View File

@@ -22,8 +22,6 @@ import { useSubmitRageshake, getRageshakeSubmitUrl } from "./submit-rageshake";
import { ClientContextProvider } from "../ClientContext";
import { getUrlParams } from "../UrlParams";
import { mockConfig } from "../utils/test";
import { DEFAULT_CONFIG } from "../config/ConfigOptions";
import { advancedCamera, advancedScreenShare } from "./settings";
vi.mock("../UrlParams", () => ({ getUrlParams: vi.fn() }));
@@ -203,60 +201,6 @@ describe("useSubmitRageshake", () => {
});
});
describe("media quality metadata", () => {
const submitAndGetBody = async (): Promise<FormData> => {
const fetchFn = vi.fn().mockResolvedValue({
status: 200,
});
vi.stubGlobal("fetch", fetchFn);
renderWithMockClient(() => "https://rageshake.localhost/foo", false);
screen.getByTestId("submit").click();
await waitFor(() => {
expect(screen.getByTestId("sent").textContent).toBe("true");
});
return fetchFn.mock.calls[0][1].body as FormData;
};
beforeEach(() => {
vi.unstubAllGlobals();
});
afterEach(() => {
advancedCamera.setValue(advancedCamera.defaultValue);
advancedScreenShare.setValue(advancedScreenShare.defaultValue);
vi.clearAllMocks();
});
it("omits media quality fields when config and settings are default", async () => {
mockConfig({});
const body = await submitAndGetBody();
expect(body.get("custom_media_quality_in_config")).toBeNull();
expect(body.get("devTools_advancedCameraSettings")).toBeNull();
expect(body.get("devTools_advancedScreenShareSetting")).toBeNull();
});
it("includes custom_media_quality_in_config when media_quality differs from default", async () => {
mockConfig({
media_quality: {
...DEFAULT_CONFIG.media_quality,
video_codec: "h264",
},
});
const body = await submitAndGetBody();
expect(body.get("custom_media_quality_in_config")).toBe("true");
});
it("includes devTools flags when advanced media settings are enabled", async () => {
mockConfig({});
advancedCamera.setValue(true);
advancedScreenShare.setValue(true);
const body = await submitAndGetBody();
expect(body.get("devTools_advancedCameraSettings")).toBe("true");
expect(body.get("devTools_advancedScreenShareSetting")).toBe("true");
});
});
describe("when rageshake is not available", () => {
it("starts unsent", () => {
renderWithMockClient(() => undefined, false);

View File

@@ -79,7 +79,7 @@ import {
type ReactionInfo,
type ReactionOption,
} from "../../reactions";
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
import { shallowEquals } from "../../utils/array";
import { type MediaDevices } from "../MediaDevices";
import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
@@ -89,7 +89,6 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import {
layoutShallowEquals,
type Alignment,
type GridLayoutMedia,
type Layout,
@@ -366,11 +365,6 @@ export interface CallViewModel {
*/
overflowing$: Behavior<boolean>;
/**
* Whether modals such as settings and reactions should be accessible at all.
*/
showModals$: Behavior<boolean>;
settingsOpen$: Behavior<boolean>;
setSettingsOpen$: Behavior<(open: boolean) => void>;
@@ -519,6 +513,8 @@ export function createCallViewModel$(
livekitKeyProvider,
getUrlParams().controlledAudioDevices,
options.livekitRoomFactory,
getUrlParams().echoCancellation,
getUrlParams().noiseSuppression,
);
const connectionManager = createConnectionManager$({
@@ -947,7 +943,7 @@ export function createCallViewModel$(
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
);
}),
distinctUntilChanged(shallowArrayEquals),
distinctUntilChanged(shallowEquals),
),
);
@@ -1006,7 +1002,7 @@ export function createCallViewModel$(
const spotlight$ = scope.behavior<MediaViewModel[]>(
spotlightAndPip$.pipe(
map(({ spotlight }) => spotlight),
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
),
);
@@ -1215,7 +1211,6 @@ export function createCallViewModel$(
}
return layout;
}),
distinctUntilChanged(),
scope.bind(),
)
.subscribe((orientation) => {
@@ -1455,11 +1450,6 @@ export function createCallViewModel$(
map((naturallyShowFooter) => naturallyShowFooter && showFooterUrlParams),
),
);
const showModals$ = scope.behavior(
windowMode$.pipe(map((mode) => mode !== "pip")),
);
const settingsOpen$ = new BehaviorSubject(false);
const setSettingsOpen$ = constant((open: boolean) => {
settingsOpen$.next(open);
@@ -1586,11 +1576,7 @@ export function createCallViewModel$(
* The layout of tiles in the call interface.
*/
const layout$ = scope.behavior<Layout>(
layoutInternals$.pipe(
map(({ layout }) => layout),
// Drop redundant layout updates before they would hit React.
distinctUntilChanged<Layout>(layoutShallowEquals),
),
layoutInternals$.pipe(map(({ layout }) => layout)),
);
const overflowing$ = scope.behavior<boolean>(
@@ -1836,7 +1822,6 @@ export function createCallViewModel$(
showNameTags$,
showHeader$: showHeader$,
showFooter$: showFooter$,
showModals$,
settingsOpen$: settingsOpen$,
setSettingsOpen$: setSettingsOpen$,
edgeToEdge$,

View File

@@ -138,6 +138,9 @@ export function withCallViewModel(mode: MatrixRTCMode) {
public getSyncState(): SyncState {
return syncState;
}
public getAccessToken(): string | null {
return "a-token";
}
})() as Partial<MatrixClient> as MatrixClient,
getMembers: () => roomMembers,
getMembersWithMembership: () => roomMembers,

View File

@@ -20,6 +20,7 @@ import {
afterAll,
beforeEach,
} from "vitest";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { BehaviorSubject, map, of } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LocalParticipant, type LocalTrack } from "livekit-client";
@@ -77,10 +78,34 @@ describe("LocalMembership", () => {
livekit_alias: "my-oldest-member-service-alias",
};
const focusConfigFromWellKnown = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
};
const focusConfigFromWellKnown2 = {
type: "livekit",
livekit_service_url: "http://my-well-known-service-url2.com",
};
const clientWellKnown = {
"org.matrix.msc4143.rtc_foci": [
focusConfigFromWellKnown,
focusConfigFromWellKnown2,
],
};
mockConfig({
livekit: { livekit_service_url: "http://my-default-service-url.com" },
});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockImplementation(
async (domain) => {
if (domain === "example.org") {
return Promise.resolve(clientWellKnown);
}
return Promise.resolve({});
},
);
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
@@ -107,7 +132,7 @@ describe("LocalMembership", () => {
ownMemberMock,
{
livekit_alias: "roomId",
livekit_service_url: "http://my-livekit-service-url.com",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
@@ -125,7 +150,7 @@ describe("LocalMembership", () => {
[
{
livekit_alias: "roomId",
livekit_service_url: "http://my-livekit-service-url.com",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
],
@@ -136,6 +161,50 @@ describe("LocalMembership", () => {
}),
);
});
it("It should not fail with configuration error if homeserver config has livekit url but not fallback", () => {
mockConfig({});
vi.spyOn(AutoDiscovery, "getRawClientConfig").mockResolvedValue({
"org.matrix.msc4143.rtc_foci": [
{
type: "livekit",
livekit_service_url: "http://my-well-known-service-url.com",
},
],
});
const mockedSession = vi.mocked({
room: {
roomId: "roomId",
client: {
getDomain: vi.fn().mockReturnValue("example.org"),
getOpenIdToken: vi.fn().mockResolvedValue({
access_token: "ACCCESS_TOKEN",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 10000,
}),
},
},
memberships: [],
getFocusInUse: vi.fn(),
joinRTCSession: vi.fn(),
}) as unknown as MatrixRTCSession;
enterRTCSession(
mockedSession,
ownMemberMock,
{
livekit_alias: "roomId",
livekit_service_url: "http://my-well-known-service-url.com",
type: "livekit",
},
{
encryptMedia: true,
matrixRTCMode: MATRIX_RTC_MODE,
},
);
});
});
const defaultCreateLocalMemberValues = {

View File

@@ -10,7 +10,6 @@ import {
ParticipantEvent,
type LocalParticipant,
type ScreenShareCaptureOptions,
type TrackPublishOptions,
RoomEvent,
MediaDeviceFailure,
} from "livekit-client";
@@ -54,14 +53,6 @@ import {
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
advancedScreenShare,
screenShareResolution,
screenShareFramerate,
screenShareBitrate,
screenShareCodec,
parseResolution,
} from "../../../settings/settings.ts";
import { MatrixRTCMode } from "../../../config/ConfigOptions.ts";
import { Config } from "../../../config/Config.ts";
import {
@@ -117,6 +108,7 @@ export type LocalMemberState =
};
/*
* - get well known
* - get oldest membership
* - get transport to use
* - get openId + jwt token
@@ -727,43 +719,6 @@ export const createLocalMembership$ = ({
surfaceSwitching: "include",
systemAudio: "include",
};
let publishOptions: TrackPublishOptions | undefined;
if (advancedScreenShare.getValue()) {
// User has advanced screen share settings enabled
const { width, height } = parseResolution(
screenShareResolution.getValue(),
);
const fps = screenShareFramerate.getValue();
const bps = screenShareBitrate.getValue();
const codec = screenShareCodec.getValue();
screenshareSettings.resolution = {
width,
height,
frameRate: fps,
};
publishOptions = {
screenShareEncoding: {
maxBitrate: bps,
maxFramerate: fps,
},
videoCodec: codec,
};
} else {
// Fall back to config.json settings if available
const screenConf = Config.get().media_quality?.screen_share;
if (screenConf?.max_resolution) {
screenshareSettings.resolution = {
width: Math.round((screenConf.max_resolution * 16) / 9),
height: screenConf.max_resolution,
frameRate: screenConf.max_framerate ?? 30,
};
}
}
const targetScreenshareState = !sharingScreen$.value;
logger.info(
`toggleScreenSharing called. Switching ${
@@ -779,11 +734,7 @@ export const createLocalMembership$ = ({
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
participant$.value
?.setScreenShareEnabled(
targetScreenshareState,
screenshareSettings,
publishOptions,
)
?.setScreenShareEnabled(targetScreenshareState, screenshareSettings)
.catch(logger.error);
};
}

View File

@@ -63,7 +63,8 @@ describe("LocalTransport", () => {
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getAccessToken: vi.fn().mockReturnValue("access_token"),
getDomain: () => "",
baseUrl: "example.org",
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
@@ -76,11 +77,9 @@ describe("LocalTransport", () => {
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
new MatrixRTCTransportMissingError(""),
);
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
});
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
@@ -104,8 +103,10 @@ describe("LocalTransport", () => {
useOldestMember: false,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
baseUrl: "https://example.org",
getDomain: () => "example.org",
baseUrl: "https://lk.example.org",
// Use empty domain to skip .well-known and use config directly
getDomain: () => "",
getAccessToken: vi.fn().mockReturnValue("access_token"),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getOpenIdToken: vi.fn(),
@@ -149,10 +150,11 @@ describe("LocalTransport", () => {
client: {
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getDomain: () => "example.org",
getDomain: () => "",
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://example.org",
baseUrl: "https://lk.example.org",
getAccessToken: vi.fn().mockReturnValue("access_token"),
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
@@ -219,12 +221,13 @@ describe("LocalTransport", () => {
useOldestMember: true,
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
client: {
getDomain: () => "example.org",
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getAccessToken: vi.fn().mockReturnValue("access_token"),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://example.org",
baseUrl: "https://lk.example.org",
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
@@ -275,13 +278,14 @@ describe("LocalTransport", () => {
useOldestMember: true,
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
client: {
getDomain: () => "example.org",
getDomain: () => "",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () =>
Promise.resolve([aliceTransport]),
getAccessToken: vi.fn().mockReturnValue("access_token"),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
baseUrl: "https://example.org",
baseUrl: "https://lk.example.org",
},
ownMembershipIdentity: ownMemberMock,
forceJwtEndpoint: JwtEndpointVersion.Legacy,
@@ -326,9 +330,10 @@ describe("LocalTransport", () => {
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
baseUrl: "https://example.org",
getDomain: vi.fn().mockReturnValue("example.org"),
getDomain: vi.fn().mockReturnValue(""),
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getAccessToken: vi.fn().mockReturnValue("access_token"),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
},
@@ -416,6 +421,42 @@ describe("LocalTransport", () => {
});
});
it("Should not call _unstable_getRTCTransports in widget mode but use well-known", async () => {
mockConfig({
livekit: { livekit_service_url: "https://do-not-use.lk.example.org" },
});
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{
type: "livekit",
livekit_service_url: "https://use-me.jwt.call.example.org",
},
],
});
localTransportOpts.client.getAccessToken.mockReturnValue(null);
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
expect(
localTransportOpts.client._unstable_getRTCTransports,
).not.toHaveBeenCalled();
const expectedTransport = {
type: "livekit",
livekit_service_url: "https://use-me.jwt.call.example.org",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
});
it("fails fast if the openID request fails for backend config", async () => {
localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
@@ -428,6 +469,51 @@ describe("LocalTransport", () => {
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
});
it("supports getting transport via well-known", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
});
const { advertised$, active$ } =
createLocalTransport$(localTransportOpts);
openIdResolver.resolve?.(openIdResponse);
expect(advertised$.value).toBe(null);
expect(active$.value).toBe(null);
await flushPromises();
const expectedTransport = {
livekit_service_url: "https://lk.example.org",
type: "livekit",
};
expect(advertised$.value).toStrictEqual(expectedTransport);
expect(active$.value).toStrictEqual({
transport: expectedTransport,
sfuConfig: {
jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
livekitAlias: "Akph4alDMhen",
livekitIdentity: "@lk_user:ABCDEF",
url: "https://lk.example.org",
},
});
expect(fetchMock.done()).toEqual(true);
});
it("fails fast if the openId request fails for the well-known config", async () => {
localTransportOpts.client.getDomain.mockReturnValue("example.org");
fetchMock.getOnce("https://example.org/.well-known/matrix/client", {
"org.matrix.msc4143.rtc_foci": [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
});
openIdResolver.reject(
new FailToGetOpenIdToken(new Error("Test driven error")),
);
await expect(async () =>
lastValueFrom(createLocalTransport$(localTransportOpts).active$),
).rejects.toThrow(expect.any(FailToGetOpenIdToken));
});
it("throws if no options are available", async () => {
const { advertised$, active$ } = createLocalTransport$({
scope: testScope(),
@@ -438,10 +524,11 @@ describe("LocalTransport", () => {
delayId$: constant(null),
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
getDomain: () => "",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getAccessToken: vi.fn().mockReturnValue("access_token"),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
@@ -450,10 +537,10 @@ describe("LocalTransport", () => {
await flushPromises();
expect(() => advertised$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
new MatrixRTCTransportMissingError(""),
);
expect(() => active$.value).toThrow(
new MatrixRTCTransportMissingError("example.org"),
new MatrixRTCTransportMissingError(""),
);
});
});
@@ -478,10 +565,11 @@ describe("LocalTransport", () => {
delayId$: delayId$,
memberships$: constant(new Epoch<CallMembership[]>([])),
client: {
getDomain: () => "example.org",
getDomain: () => "",
baseUrl: "https://example.org",
// eslint-disable-next-line @typescript-eslint/naming-convention
_unstable_getRTCTransports: async () => Promise.resolve([]),
getAccessToken: vi.fn().mockReturnValue("access_token"),
// These won't be called in this error path but satisfy the type
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),

View File

@@ -26,6 +26,7 @@ import {
tap,
} from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { AutoDiscovery } from "matrix-js-sdk/lib/autodiscovery";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { type Behavior } from "../../Behavior.ts";
@@ -56,7 +57,7 @@ interface Props {
memberships$: Behavior<Epoch<CallMembership[]>>;
client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
> &
OpenIDClientParts;
// Used by the jwt service to create the livekit room and compute the livekit alias.
@@ -149,6 +150,7 @@ export const createLocalTransport$ = ({
const transportDiscovery = new RtcTransportAutoDiscovery({
client: client,
resolvedConfig: Config.get(),
wellKnownFetcher: AutoDiscovery.getRawClientConfig.bind(AutoDiscovery),
logger: logger,
});
@@ -307,7 +309,7 @@ async function doOpenIdAndJWTFromUrl(
roomId: string,
client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
> &
OpenIDClientParts,
delayId?: string,
@@ -337,7 +339,7 @@ function observeLocalTransportForOldestMembership(
preferredTransport$: Observable<LocalTransportWithSFUConfig>,
client: Pick<
MatrixClient,
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
> &
OpenIDClientParts,
ownMembershipIdentity: CallMembershipIdentityParts,

View File

@@ -13,7 +13,7 @@ import {
type MockedObject,
vi,
} from "vitest";
import { MatrixError } from "matrix-js-sdk";
import { type IClientWellKnown, MatrixError } from "matrix-js-sdk";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
type LivekitTransportConfig,
@@ -33,9 +33,9 @@ const backendTransport: LivekitTransportConfig = {
livekit_service_url: "https://backend.example.org",
};
const configTransport: LivekitTransportConfig = {
const wellKnownTransport: LivekitTransportConfig = {
type: "livekit",
livekit_service_url: "https://config.example.org",
livekit_service_url: "https://well-known.example.org",
};
function makeClient(): MockedObject<DiscoveryClient> {
@@ -43,6 +43,7 @@ function makeClient(): MockedObject<DiscoveryClient> {
getDomain: vi.fn().mockReturnValue("example.org"),
baseUrl: "https://matrix.example.org",
_unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
getAccessToken: vi.fn().mockReturnValue("access_token"),
getOpenIdToken: vi.fn(),
getDeviceId: vi.fn(),
} as unknown as MockedObject<DiscoveryClient>;
@@ -58,6 +59,12 @@ function makeResolvedConfig(livekitServiceUrl?: string): ResolvedConfigOptions {
} as ResolvedConfigOptions;
}
function makeWellKnown(rtcFoci?: Transport[]): IClientWellKnown {
return {
"org.matrix.msc4143.rtc_foci": rtcFoci,
} as unknown as IClientWellKnown;
}
describe("RtcTransportAutoDiscovery", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -68,27 +75,33 @@ describe("RtcTransportAutoDiscovery", () => {
{ transports: [{ type: "not_livekit" }, backendTransport] },
];
it.each(VALID_TEST_CASES)(
"prefers backend transport other app config $transports",
"prefers backend transport over well-known and app config $transports",
async ({ transports }) => {
// it("prefers backend transport over well-known and app config", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discoveredTransport = await discovery.discoverPreferredTransport();
expect(discoveredTransport).toStrictEqual(backendTransport);
expect(discoveredTransport).not.toStrictEqual(configTransport);
await expect(
discovery.discoverPreferredTransport(),
).resolves.toStrictEqual(backendTransport);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
expect(wellKnownFetcher).not.toHaveBeenCalled();
},
);
it("Retries limit_exceeded backend transport", async () => {
it("Retries limit_exceeded backend transport over well-known", async () => {
const client = makeClient();
client._unstable_getRTCTransports
.mockRejectedValueOnce(
@@ -103,9 +116,14 @@ describe("RtcTransportAutoDiscovery", () => {
)
.mockResolvedValue([backendTransport]);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
@@ -114,6 +132,7 @@ describe("RtcTransportAutoDiscovery", () => {
);
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
expect(wellKnownFetcher).not.toHaveBeenCalled();
});
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
@@ -121,30 +140,91 @@ describe("RtcTransportAutoDiscovery", () => {
{ transports: [{ type: "not_livekit" }] },
];
it.each(INVALID_TEST_CASES)(
"falls back to config when backend has no (valid) livekit transports $transports",
"falls back to well-known when backend has no (valid) livekit transports $transports",
async ({ transports }) => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue(transports);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
const discoveredTransport = await discovery.discoverPreferredTransport();
expect(discoveredTransport).not.toStrictEqual(backendTransport);
expect(discoveredTransport).toStrictEqual(configTransport);
await expect(
discovery.discoverPreferredTransport(),
).resolves.toStrictEqual(wellKnownTransport);
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
},
);
it("returns null when backend and config are all unavailable", async () => {
it("skips backend discovery in widget mode and uses well-known", async () => {
const client = makeClient();
// widget mode is detected by the absence of an access token
client.getAccessToken.mockReturnValue(null);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue(makeWellKnown([wellKnownTransport]));
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
wellKnownTransport,
);
expect(client._unstable_getRTCTransports).not.toHaveBeenCalled();
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
});
it("falls back to app config when backend fails and well-known has no rtc_foci", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN" }, 404),
);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue({} as IClientWellKnown);
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig("https://config.example.org"),
wellKnownFetcher,
logger: rootLogger,
});
await expect(discovery.discoverPreferredTransport()).resolves.toStrictEqual(
{
type: "livekit",
livekit_service_url: "https://config.example.org",
},
);
});
it("returns null when backend, well-known and config are all unavailable", async () => {
const client = makeClient();
client._unstable_getRTCTransports.mockResolvedValue([]);
const wellKnownFetcher = vi
.fn<(domain: string) => Promise<IClientWellKnown>>()
.mockResolvedValue({} as IClientWellKnown);
const discovery = new RtcTransportAutoDiscovery({
client,
resolvedConfig: makeResolvedConfig(undefined),
wellKnownFetcher,
logger: rootLogger,
});

View File

@@ -8,7 +8,7 @@ import {
isLivekitTransportConfig,
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MatrixClient } from "matrix-js-sdk";
import { type IClientWellKnown, type MatrixClient } from "matrix-js-sdk";
import { type Logger } from "matrix-js-sdk/lib/logger";
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
@@ -16,27 +16,33 @@ import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts";
type TransportDiscoveryClient = Pick<
MatrixClient,
"getDomain" | "_unstable_getRTCTransports"
"getDomain" | "_unstable_getRTCTransports" | "getAccessToken"
>;
export interface RtcTransportAutoDiscoveryProps {
client: TransportDiscoveryClient;
resolvedConfig: ResolvedConfigOptions;
wellKnownFetcher: (domain: string) => Promise<IClientWellKnown>;
logger: Logger;
}
export class RtcTransportAutoDiscovery {
private readonly client: TransportDiscoveryClient;
private readonly resolvedConfig: ResolvedConfigOptions;
private readonly wellKnownFetcher: (
domain: string,
) => Promise<IClientWellKnown>;
private readonly logger: Logger;
public constructor({
client,
resolvedConfig,
wellKnownFetcher,
logger,
}: RtcTransportAutoDiscoveryProps) {
this.client = client;
this.resolvedConfig = resolvedConfig;
this.wellKnownFetcher = wellKnownFetcher;
this.logger = logger.getChild("[RtcTransportAutoDiscovery]");
}
@@ -50,7 +56,21 @@ export class RtcTransportAutoDiscovery {
return backendTransport;
}
// 2) app config URL
this.logger.info("No backend transport found, falling back to well-known");
// 2) .well-known transports
const wellKnownTransport = await this.tryWellKnownTransports();
if (wellKnownTransport) {
this.logger.info(
`Found .well-known transport: ${wellKnownTransport.livekit_service_url}`,
);
return wellKnownTransport;
}
this.logger.info(
"No .well-known transport found, falling back to app config",
);
// 3) app config URL
const configTransport = this.tryConfigTransport();
if (configTransport) {
this.logger.info(
@@ -70,23 +90,72 @@ export class RtcTransportAutoDiscovery {
private async tryBackendTransports(): Promise<LivekitTransportConfig | null> {
const client = this.client;
// MSC4143: Attempt to fetch transports from backend.
this.logger.info("First try to use getRTCTransports end point ...");
try {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(isLivekitTransportConfig);
if (first) {
return first;
// TODO: Workaround for an issue in the js-sdk RoomWidgetClient that
// is not yet implementing _unstable_getRTCTransports properly (via widget API new action).
// For now we just skip this call if we are in a widget.
// In widget mode the client is a `RoomWidgetClient` which has no access token (it is using the widget API).
// Could be removed once the js-sdk is fixed (https://github.com/matrix-org/matrix-js-sdk/issues/5245)
const isSPA = !!client.getAccessToken();
if (isSPA && "_unstable_getRTCTransports" in client) {
this.logger.info("First try to use getRTCTransports end point ...");
try {
const transportList = await doNetworkOperationWithRetry(async () =>
client._unstable_getRTCTransports(),
);
const first = transportList.find(isLivekitTransportConfig);
if (first) {
return first;
} else {
this.logger.info(
`No livekit transport found in getRTCTransports end point`,
transportList,
);
}
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
}
} else {
this.logger.debug(`getRTCTransports end point not available`);
}
return null;
}
/**
* Fetches the first rtc_foci from the .well-known/matrix/client.
* This will not throw errors, but instead just log them and return null if the expected config is not found or malformed.
* @private
*/
private async tryWellKnownTransports(): Promise<LivekitTransportConfig | null> {
// Legacy MSC4143 (to be removed) WELL_KNOWN: Prioritize the .well-known/matrix/client, if available.
const client = this.client;
const domain = client.getDomain();
if (domain) {
// we use AutoDiscovery instead of relying on the MatrixClient having already
// been fully configured and started
const wellKnownFoci = await this.wellKnownFetcher(domain);
const fociConfig = wellKnownFoci["org.matrix.msc4143.rtc_foci"];
if (fociConfig) {
if (!Array.isArray(fociConfig)) {
this.logger.warn(
`org.matrix.msc4143.rtc_foci is not an array in .well-known`,
);
} else {
return fociConfig[0];
}
} else {
this.logger.info(
`No livekit transport found in getRTCTransports end point`,
transportList,
`No .well-known "org.matrix.msc4143.rtc_foci" found for ${domain}`,
wellKnownFoci,
);
}
} catch (ex) {
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
} else {
// Should never happen, but just in case
this.logger.warn(`No domain configured for client`);
}
return null;
}

View File

@@ -12,7 +12,7 @@ import {
type E2EEManagerOptions,
type BaseE2EEManager,
} from "livekit-client";
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
import { type Logger } from "matrix-js-sdk/lib/logger";
// imported as inline to support worker when loaded from a cdn (cross domain)
import E2EEWorker from "livekit-client/e2ee-worker?worker&inline";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
@@ -27,18 +27,7 @@ import type {
import type { MediaDevices } from "../../MediaDevices.ts";
import type { Behavior } from "../../Behavior.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { getLiveKitOptions } from "../../../livekit/options.ts";
import {
advancedCamera,
cameraResolution,
cameraFramerate,
cameraBitrate,
cameraCodec,
parseResolution,
echoCancellationSetting,
noiseSuppressionSetting,
autoGainControlSetting,
} from "../../../settings/settings.ts";
import { defaultLiveKitOptions } from "../../../livekit/options.ts";
// TODO evaluate if this should be done like the Publisher Factory
export interface ConnectionFactory {
@@ -64,6 +53,8 @@ export class ECConnectionFactory implements ConnectionFactory {
* @param livekitKeyProvider - Optional key provider for end-to-end encryption.
* @param controlledAudioDevices - Option to indicate whether audio output device is controlled externally (native mobile app).
* @param livekitRoomFactory - Optional factory function (for testing) to create LivekitRoom instances. If not provided, a default factory is used.
* @param echoCancellation - Whether to enable echo cancellation for audio capture.
* @param noiseSuppression - Whether to enable noise suppression for audio capture.
*/
public constructor(
private client: OpenIDClientParts,
@@ -73,22 +64,25 @@ export class ECConnectionFactory implements ConnectionFactory {
livekitKeyProvider: BaseKeyProvider | undefined,
private controlledAudioDevices: boolean,
livekitRoomFactory?: () => LivekitRoom,
echoCancellation: boolean = true,
noiseSuppression: boolean = true,
) {
const defaultFactory = (): LivekitRoom => {
const roomOptions = generateRoomOption({
devices: this.devices,
processorState: this.processorState$.value,
e2eeLivekitOptions: livekitKeyProvider && {
keyProvider: livekitKeyProvider,
// It's important that every room use a separate E2EE worker.
// They get confused if given streams from multiple rooms.
worker: new E2EEWorker(),
},
controlledAudioDevices: this.controlledAudioDevices,
});
logger.info("[ECConnectionFactory] livekit room options: ", roomOptions);
return new LivekitRoom(roomOptions);
};
const defaultFactory = (): LivekitRoom =>
new LivekitRoom(
generateRoomOption({
devices: this.devices,
processorState: this.processorState$.value,
e2eeLivekitOptions: livekitKeyProvider && {
keyProvider: livekitKeyProvider,
// It's important that every room use a separate E2EE worker.
// They get confused if given streams from multiple rooms.
worker: new E2EEWorker(),
},
controlledAudioDevices: this.controlledAudioDevices,
echoCancellation,
noiseSuppression,
}),
);
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
}
@@ -125,13 +119,14 @@ export class ECConnectionFactory implements ConnectionFactory {
/**
* Generate the initial LiveKit RoomOptions based on the current media devices and processor state.
* Reads audio processing and camera quality settings directly from Settings.
*/
function generateRoomOption({
devices,
processorState,
e2eeLivekitOptions,
controlledAudioDevices,
echoCancellation,
noiseSuppression,
}: {
devices: MediaDevices;
processorState: ProcessorState;
@@ -140,44 +135,21 @@ function generateRoomOption({
| { e2eeManager: BaseE2EEManager }
| undefined;
controlledAudioDevices: boolean;
echoCancellation: boolean;
noiseSuppression: boolean;
}): RoomOptions {
const liveKitOptions = getLiveKitOptions();
// Apply advanced camera settings if enabled
let videoCaptureDefaults = {
...liveKitOptions.videoCaptureDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
};
let publishDefaults = liveKitOptions.publishDefaults;
if (advancedCamera.getValue()) {
const { width, height } = parseResolution(cameraResolution.getValue());
const fps = cameraFramerate.getValue();
const bps = cameraBitrate.getValue();
const codec = cameraCodec.getValue();
videoCaptureDefaults = {
...videoCaptureDefaults,
resolution: { width, height, frameRate: fps },
};
publishDefaults = {
...publishDefaults,
videoEncoding: { maxBitrate: bps, maxFramerate: fps },
videoCodec: codec,
};
}
return {
...liveKitOptions,
videoCaptureDefaults,
publishDefaults,
...defaultLiveKitOptions,
videoCaptureDefaults: {
...defaultLiveKitOptions.videoCaptureDefaults,
deviceId: devices.videoInput.selected$.value?.id,
processor: processorState.processor,
},
audioCaptureDefaults: {
...liveKitOptions.audioCaptureDefaults,
...defaultLiveKitOptions.audioCaptureDefaults,
deviceId: devices.audioInput.selected$.value?.id,
echoCancellation: echoCancellationSetting.getValue(),
noiseSuppression: noiseSuppressionSetting.getValue(),
autoGainControl: autoGainControlSetting.getValue(),
echoCancellation,
noiseSuppression,
},
audioOutput: {
// When using controlled audio devices, we don't want to set the

View File

@@ -22,16 +22,6 @@ import {
} from "../../../utils/test.ts";
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
import { constant } from "../../Behavior";
import {
echoCancellationSetting,
noiseSuppressionSetting,
autoGainControlSetting,
advancedCamera,
cameraResolution,
cameraFramerate,
cameraBitrate,
cameraCodec,
} from "../../../settings/settings.ts";
// At the top of your test file, after imports
vi.mock("livekit-client", async (importOriginal) => {
@@ -68,14 +58,11 @@ describe("ECConnectionFactory - Audio inputs options", () => {
{ echo: false, noise: true },
{ echo: false, noise: false },
])(
"it sets echoCancellation=$echo and noiseSuppression=$noise based on settings",
"it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters",
({ echo, noise }) => {
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
// Set audio processing settings
echoCancellationSetting.setValue(echo);
noiseSuppressionSetting.setValue(noise);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
"!roomid:example.org",
@@ -86,6 +73,9 @@ describe("ECConnectionFactory - Audio inputs options", () => {
}),
undefined,
false,
undefined,
echo,
noise,
);
ecConnectionFactory.createConnection(
testScope,
@@ -111,13 +101,9 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
test.each([{ controlled: true }, { controlled: false }])(
"it sets controlledAudioDevice=$controlled then uses deviceId accordingly",
({ controlled }) => {
// test("it sets echoCancellation and noiseSuppression based on constructor parameters", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
// Explicitly set audio settings so the test doesn't depend on defaults
echoCancellationSetting.setValue(true);
noiseSuppressionSetting.setValue(true);
autoGainControlSetting.setValue(true);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
"!roomid:example.org",
@@ -134,6 +120,9 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
}),
undefined,
controlled,
undefined,
false,
false,
);
ecConnectionFactory.createConnection(
testScope,
@@ -154,114 +143,6 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
);
});
describe("ECConnectionFactory - Camera quality settings", () => {
test("it uses default video options when advancedCamera is disabled", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
advancedCamera.setValue(false);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
"!roomid:example.org",
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
);
ecConnectionFactory.createConnection(
testScope,
exampleTransport,
ownMemberMock,
logger,
);
// publishDefaults should use config defaults (vp8), not custom settings
expect(RoomConstructor).toHaveBeenCalledWith(
expect.objectContaining({
publishDefaults: expect.objectContaining({
videoCodec: "vp8",
}),
}),
);
});
test("it applies custom camera resolution, encoding, and codec when advancedCamera is enabled", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
advancedCamera.setValue(true);
cameraResolution.setValue("1920x1080");
cameraFramerate.setValue(60);
cameraBitrate.setValue(4_000_000);
cameraCodec.setValue("vp9");
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
"!roomid:example.org",
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
);
ecConnectionFactory.createConnection(
testScope,
exampleTransport,
ownMemberMock,
logger,
);
expect(RoomConstructor).toHaveBeenCalledWith(
expect.objectContaining({
videoCaptureDefaults: expect.objectContaining({
resolution: { width: 1920, height: 1080, frameRate: 60 },
}),
publishDefaults: expect.objectContaining({
videoEncoding: { maxBitrate: 4_000_000, maxFramerate: 60 },
videoCodec: "vp9",
}),
}),
);
});
test("it applies autoGainControl from settings", () => {
const RoomConstructor = vi.mocked(LivekitRoom);
autoGainControlSetting.setValue(false);
echoCancellationSetting.setValue(true);
noiseSuppressionSetting.setValue(true);
const ecConnectionFactory = new ECConnectionFactory(
mockClient,
"!roomid:example.org",
mockMediaDevices({}),
new BehaviorSubject<ProcessorState>({
supported: true,
processor: undefined,
}),
undefined,
false,
);
ecConnectionFactory.createConnection(
testScope,
exampleTransport,
ownMemberMock,
logger,
);
expect(RoomConstructor).toHaveBeenCalledWith(
expect.objectContaining({
audioCaptureDefaults: expect.objectContaining({
autoGainControl: false,
}),
}),
);
});
});
afterEach(() => {
testScope.end();
fetchMock.reset();

View File

@@ -1,74 +0,0 @@
/*
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, expect } from "vitest";
import {
type Alignment,
layoutShallowEquals,
type Layout,
} from "./layout-types";
import {
type SpotlightTileViewModel,
type GridTileViewModel,
} from "./TileViewModel";
import { BehaviorSubject } from "rxjs";
const spotlightTile = {} as unknown as SpotlightTileViewModel;
const gridTile = {} as unknown as GridTileViewModel;
const pipAlignment$ = new BehaviorSubject<Alignment>({
inline: "end",
block: "end",
});
const spotlightExpanded: Layout = {
type: "spotlight-expanded",
spotlight: spotlightTile,
pipAlignment$,
};
const spotlightPortrait: Layout = {
type: "spotlight-portrait",
spotlight: spotlightTile,
grid: [gridTile],
setVisibleTiles: () => {},
};
test("layoutShallowEquals considers a layout to be equal to its shallow clone", () =>
expect(layoutShallowEquals(spotlightExpanded, { ...spotlightExpanded })).toBe(
true,
));
test("layoutShallowEquals detects a missing key", () => {
expect(
layoutShallowEquals(spotlightExpanded, {
...spotlightExpanded,
pip: gridTile,
}),
).toBe(false);
expect(
layoutShallowEquals(
{ ...spotlightExpanded, pip: gridTile },
spotlightExpanded,
),
).toBe(false);
});
test("layoutShallowEquals considers grid arrays with equal contents to be equal", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid],
}),
).toBe(true));
test("layoutShallowEquals detects grid arrays with different contents", () =>
expect(
layoutShallowEquals(spotlightPortrait, {
...spotlightPortrait,
grid: [...spotlightPortrait.grid, gridTile],
}),
).toBe(false));

View File

@@ -16,7 +16,6 @@ import {
type SpotlightTileViewModel,
} from "./TileViewModel.ts";
import { type Behavior } from "./Behavior.ts";
import { shallowEquals as arrayShallowEquals } from "../utils/array.ts";
export interface GridLayoutMedia {
type: "grid";
@@ -141,30 +140,3 @@ export type Layout =
| OneOnOneDesktopLayout
| OneOnOneMobileLayout
| PipLayout;
/**
* Tests whether the top-level properties and array elements of layout `a` are
* equal to those of layout `b`. Useful for deduping redundant layout updates.
*/
export function layoutShallowEquals(a: Layout, b: Layout): boolean {
// If a and b have the same number of keys and every key in a is also in b,
// then they have the same keys.
const aKeys = Object.keys(a);
const bKeys = Object.keys(b);
if (aKeys.length !== bKeys.length) return false;
for (const key of aKeys) {
if (!(key in b)) return false;
// Now check that they have the same values.
const aValue = (a as any)[key];
const bValue = (b as any)[key];
if (Array.isArray(aValue) && Array.isArray(bValue)) {
// Special case for arrays so we can detect when the grid tiles arrays are
// essentially the same.
if (!arrayShallowEquals(aValue, bValue)) return false;
} else if (aValue !== bValue) return false;
}
return true;
}

View File

@@ -33,6 +33,7 @@ import { type RemoteUserMediaViewModel } from "./RemoteUserMediaViewModel";
import { type ObservableScope } from "../ObservableScope";
import { showConnectionStats } from "../../settings/settings";
import { observeRtpStreamStats$ } from "./observeRtpStreamStats";
import { videoFit$, videoSizeFromParticipant$ } from "../../utils/videoFit.ts";
/**
* A participant's user media (i.e. their microphone and camera feed).
@@ -46,6 +47,7 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
speaking$: Behavior<boolean>;
audioEnabled$: Behavior<boolean>;
videoEnabled$: Behavior<boolean>;
videoFit$: Behavior<"cover" | "contain">;
videoOrientation$: Behavior<"landscape" | "portrait">;
toggleCropVideo: () => void;
/**
@@ -61,9 +63,12 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
>;
/**
* Set the aspect ratio of the video track to determine the orientation.
* Set the target dimensions of the HTML element (final dimension after anim).
* This can be used to determine the best video fit (fit to frame / keep ratio).
* @param targetWidth - The target width of the HTML element displaying the video.
* @param targetHeight - The target height of the HTML element displaying the video.
*/
setVideoAspectRatio: (ratio: number) => void;
setTargetDimensions: (targetWidth: number, targetHeight: number) => void;
}
export interface BaseUserMediaInputs extends Omit<
@@ -93,8 +98,14 @@ export function createBaseUserMedia(
),
);
const toggleCropVideo$ = new Subject<void>();
const videoAspectRatio$ = new BehaviorSubject(NaN);
// The target size of the video element, used to determine the best video fit.
// The target size is the final size of the HTML element after any animations have completed.
const targetSize$ = new BehaviorSubject<
{ width: number; height: number } | undefined
>(undefined);
const videoSize$ = videoSizeFromParticipant$(participant$);
return {
...createMemberMedia(scope, {
...inputs,
@@ -121,11 +132,13 @@ export function createBaseUserMedia(
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
),
videoOrientation$: scope.behavior(
videoAspectRatio$.pipe(
videoSize$.pipe(
map((s) => (s ? s.width / s.height : 1)),
map((aspect) => (aspect > 1 ? "landscape" : "portrait")),
),
"portrait",
),
videoFit$: videoFit$(scope, videoSize$, targetSize$),
toggleCropVideo: () => toggleCropVideo$.next(),
rtcBackendIdentity,
handRaised$,
@@ -149,6 +162,8 @@ export function createBaseUserMedia(
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
}),
),
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
setTargetDimensions: (targetWidth: number, targetHeight: number): void => {
targetSize$.next({ width: targetWidth, height: targetHeight });
},
};
}

View File

@@ -19,15 +19,10 @@ import {
startWith,
switchMap,
map,
share,
} from "rxjs";
import { observeTrackReference$ } from "../observeTrackReference";
// Use a shared timer for all the stats observers so that we don't clog up the
// event loop with hundreds of timers in large calls
const refreshStats$ = interval(1000).pipe(share());
export function observeRtpStreamStats$(
participant: Participant,
source: Track.Source,
@@ -37,7 +32,9 @@ export function observeRtpStreamStats$(
> {
return combineLatest([
observeTrackReference$(participant, source),
refreshStats$.pipe(startWith(0)),
// The update frequency is high because we use this value to update the PiP orientation and the fit/fill video tile props based on that
// We want it to be responsive. For just the debug tools 1s would be sufficient.
interval(350).pipe(startWith(0)),
]).pipe(
switchMap(async ([trackReference]) => {
const track = trackReference?.publication?.track;
@@ -72,3 +69,12 @@ export function observeInboundRtpStreamStats$(
map((x) => x as RTCInboundRtpStreamStats | undefined),
);
}
export function observeOutboundRtpStreamStats$(
participant: Participant,
source: Track.Source,
): Observable<RTCOutboundRtpStreamStats | undefined> {
return observeRtpStreamStats$(participant, source, "outbound-rtp").pipe(
map((x) => x as RTCOutboundRtpStreamStats | undefined),
);
}

View File

@@ -5,10 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type LocalTrackPublication,
type RemoteTrackPublication,
} from "livekit-client";
import { type RemoteTrackPublication } from "livekit-client";
import { test, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { axe } from "vitest-axe";
@@ -20,9 +17,6 @@ import {
mockRtcMembership,
mockRemoteMedia,
mockRemoteParticipant,
mockLocalMedia,
mockLocalParticipant,
mockMediaDevices,
} from "../utils/test";
import { GridTileViewModel } from "../state/TileViewModel";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
@@ -60,7 +54,7 @@ const callVm = {
handsRaised$: constant({}),
} as Partial<CallViewModel> as CallViewModel;
test("GridTile displays remote media", async () => {
test("GridTile is accessible", async () => {
const vm = mockRemoteMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{
@@ -94,40 +88,6 @@ test("GridTile displays remote media", async () => {
screen.getByText("Alice");
});
test("GridTile displays local media", async () => {
const vm = mockLocalMedia(
mockRtcMembership("@alice:example.org", "AAAA"),
{
rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://adfsg",
},
mockLocalParticipant({
getTrackPublication: () =>
({}) as Partial<LocalTrackPublication> as LocalTrackPublication,
}),
mockMediaDevices({}),
);
const { container } = render(
<ReactionsSenderProvider vm={callVm} rtcSession={fakeRtcSession}>
<GridTile
vm={new GridTileViewModel(constant(vm))}
onOpenProfile={() => {}}
targetWidth={300}
targetHeight={200}
showSpeakingIndicators
showNameTags
showRingingStatus
showOutline
focusable
/>
</ReactionsSenderProvider>,
);
expect(await axe(container)).toHaveNoViolations();
// Name should be visible
screen.getByText("Alice");
});
test("GridTile displays ringing media", async () => {
const pickupState$ = new BehaviorSubject<
RingingMediaViewModel["pickupState$"]["value"]

View File

@@ -11,9 +11,9 @@ import {
type ReactNode,
type Ref,
useCallback,
useEffect,
useRef,
useState,
useMemo,
} from "react";
import { type animated } from "@react-spring/web";
import classNames from "classnames";
@@ -91,6 +91,7 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
{...props}
/>
@@ -104,22 +105,20 @@ interface UserMediaTileProps extends TileProps {
playbackMuted: boolean;
waitingForMedia?: boolean;
primaryButton?: ReactNode;
menuStart?: ReactNode;
menuEnd?: ReactNode;
focusUrl: string | undefined;
}
/**
* A user media tile without a context menu.
*/
// The context menu is kept separate from this component for performance
// reasons (c.f. UserMediaTile)
const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
const UserMediaTile: FC<UserMediaTileProps> = ({
ref,
vm,
showSpeakingIndicators,
playbackMuted,
waitingForMedia,
primaryButton,
menu,
menuStart,
menuEnd,
className,
focusUrl,
displayName,
@@ -142,11 +141,19 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
const audioEnabled = useBehavior(vm.audioEnabled$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useBehavior(vm.speaking$);
const videoFit = useBehavior(vm.videoFit$);
const rtcBackendIdentity = vm.rtcBackendIdentity;
const handRaised = useBehavior(vm.handRaised$);
const reaction = useBehavior(vm.reaction$);
// Whenever bounds change, inform the viewModel
useEffect(() => {
if (targetWidth > 0 && targetHeight > 0) {
vm.setTargetDimensions(targetWidth, targetHeight);
}
}, [targetWidth, targetHeight, vm]);
const AudioIcon = playbackMuted
? VolumeOffSolidIcon
: audioEnabled
@@ -159,32 +166,31 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
: t("microphone_off");
const [menuOpen, setMenuOpen] = useState(false);
const menuTrigger = useMemo(
() => (
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
),
[t, focusable],
const menu = (
<>
{menuStart}
{/*
No additional menu item (used to be the manual fit to frame.
Placeholder for future menu items that should be placed here.
*/}
{menuEnd}
</>
);
const raisedHandOnClick = useMemo(
() => (vm.local ? (): void => void toggleRaisedHand() : undefined),
[vm.local, toggleRaisedHand],
);
const raisedHandOnClick = vm.local
? (): void => void toggleRaisedHand()
: undefined;
const showSpeaking = showSpeakingIndicators && speaking;
return (
const tile = (
<MediaView
ref={ref}
video={video}
userId={vm.userId}
unencryptedWarning={unencryptedWarning}
videoEnabled={videoEnabled}
videoFit={videoFit}
className={classNames(className, styles.tile, {
[styles.speaking]: showSpeaking,
[styles.handRaised]: !showSpeaking && handRaised,
@@ -207,7 +213,14 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
open={menuOpen}
onOpenChange={setMenuOpen}
title={displayName}
trigger={menuTrigger}
trigger={
<button
aria-label={t("common.options")}
tabIndex={focusable ? undefined : -1}
>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
@@ -220,7 +233,6 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
raisedHandOnClick={raisedHandOnClick}
waitingForMedia={waitingForMedia}
focusUrl={focusUrl}
setVideoAspectRatio={vm.setVideoAspectRatio}
audioStreamStats={audioStreamStats}
videoStreamStats={videoStreamStats}
rtcBackendIdentity={rtcBackendIdentity}
@@ -229,37 +241,9 @@ const UserMediaTileInner: FC<UserMediaTileProps & { menu: ReactNode }> = ({
{...props}
/>
);
};
/**
* A user media tile enhanced with a context menu.
*/
const UserMediaTile: FC<
UserMediaTileProps & { menuStart?: ReactNode; menuEnd?: ReactNode }
> = ({ menuStart, menuEnd, ...props }) => {
const menu = useMemo(
() => (
<>
{menuStart}
{/*
No additional menu item (used to be the manual fit to frame.
Placeholder for future menu items that should be placed here.
*/}
{menuEnd}
</>
),
[menuStart, menuEnd],
);
// ContextMenu is expensive to render, so we avoid subscribing to any
// frequently-changing behaviors here and instead keep them isolated in the
// UserMediaTileInner component
return (
<ContextMenu
title={props.displayName}
trigger={<UserMediaTileInner {...props} menu={menu} />}
hasAccessibleAlternative
>
<ContextMenu title={displayName} trigger={tile} hasAccessibleAlternative>
{menu}
</ContextMenu>
);
@@ -295,29 +279,6 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
[vm, latestAlwaysShow],
);
const menuStart = useMemo(
() => (
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
),
[t, alwaysShow, onSelectAlwaysShow],
);
const menuEnd = useMemo(
() =>
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
),
[t, onOpenProfile],
);
return (
<UserMediaTile
ref={ref}
@@ -336,8 +297,23 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
</button>
)
}
menuStart={menuStart}
menuEnd={menuEnd}
menuStart={
<ToggleMenuItem
Icon={VisibilityOnIcon}
label={t("video_tile.always_show")}
checked={alwaysShow}
onSelect={onSelectAlwaysShow}
/>
}
menuEnd={
onOpenProfile && (
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
)
}
focusable={focusable}
focusUrl={focusUrl}
{...props}

View File

@@ -33,6 +33,7 @@ describe("MediaView", () => {
const baseProps: ComponentProps<typeof MediaView> = {
displayName: "some name",
videoEnabled: true,
videoFit: "contain",
targetWidth: 300,
targetHeight: 200,
mirror: false,

View File

@@ -7,13 +7,7 @@ Please see LICENSE in the repository root for full details.
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
import { animated } from "@react-spring/web";
import {
type FC,
type ComponentProps,
type ReactNode,
type SyntheticEvent,
useState,
} from "react";
import { type FC, type ComponentProps, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import classNames from "classnames";
import { VideoTrack } from "@livekit/components-react";
@@ -32,7 +26,6 @@ import { type ReactionOption } from "../reactions";
import { ReactionIndicator } from "../reactions/ReactionIndicator";
import { RTCConnectionStats } from "../RTCConnectionStats";
import videoPlaceholder from "../graphics/video-placeholder.gif";
import { autoVideoFit } from "../utils/videoFit";
interface Props extends ComponentProps<typeof animated.div> {
className?: string;
@@ -40,11 +33,7 @@ interface Props extends ComponentProps<typeof animated.div> {
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder | undefined;
/**
* How to fit the video content inside the tile. When undefined, MediaView
* chooses a smart default based on the aspect ratios of the tile and video.
*/
videoFit?: "cover" | "contain";
videoFit: "cover" | "contain";
mirror: boolean;
soundWaves?: boolean;
userId: string;
@@ -66,15 +55,8 @@ interface Props extends ComponentProps<typeof animated.div> {
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
rtcBackendIdentity?: string;
/**
* The focus url, mainly for debugging purposes.
*/
// The focus url, mainly for debugging purposes
focusUrl?: string;
/**
* Called whenever the aspect ratio of the video content becomes known or
* otherwise changes.
*/
setVideoAspectRatio?: (ratio: number) => void;
}
export const MediaView: FC<Props> = ({
@@ -107,7 +89,6 @@ export const MediaView: FC<Props> = ({
videoStreamStats,
rtcBackendIdentity,
focusUrl,
setVideoAspectRatio: setTheirVideoAspectRatio,
...props
}) => {
const { t } = useTranslation();
@@ -119,22 +100,6 @@ export const MediaView: FC<Props> = ({
(soundWaves === undefined ? 0.5 : 0.38),
);
const [videoAspectRatio, setOurVideoAspectRatio] = useState<number>(NaN);
const tileAspectRatio = targetWidth / targetHeight;
// Propagate video dimensions
const setVideoAspectRatio = (ratio: number) => {
setOurVideoAspectRatio(ratio);
setTheirVideoAspectRatio?.(ratio);
};
const videoRef = (el: HTMLVideoElement | null) => {
if (el !== null) setVideoAspectRatio(el.videoWidth / el.videoHeight);
};
const onResize = (ev: SyntheticEvent<HTMLVideoElement>) =>
setVideoAspectRatio(
ev.currentTarget.videoWidth / ev.currentTarget.videoHeight,
);
const warnings = unencryptedWarning && (
<Tooltip
label={t("common.unencrypted")}
@@ -161,9 +126,7 @@ export const MediaView: FC<Props> = ({
ref={ref}
data-testid="videoTile"
data-video-enabled={video && videoEnabled}
data-video-fit={
videoFit ?? autoVideoFit(videoAspectRatio, tileAspectRatio)
}
data-video-fit={videoFit}
data-background={background}
{...props}
>
@@ -195,8 +158,6 @@ export const MediaView: FC<Props> = ({
// Set the placeholder to a small transparent image. (On Android web
// views the default poster image is particularly ugly.)
poster={videoPlaceholder}
ref={videoRef}
onResize={onResize}
/>
)}
</div>

View File

@@ -32,7 +32,6 @@ import {
global.IntersectionObserver = class MockIntersectionObserver {
public observe(): void {}
public unobserve(): void {}
public disconnect(): void {}
} as unknown as typeof IntersectionObserver;
test("SpotlightTile is accessible", async () => {

View File

@@ -68,7 +68,6 @@ interface SpotlightItemBaseProps {
background: "solid" | "transparent";
focusable: boolean;
"aria-hidden"?: boolean;
setVideoAspectRatio?: (ratio: number) => void;
}
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
@@ -78,6 +77,7 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
}
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
videoFit: "contain" | "cover";
videoEnabled: boolean;
soundWaves: boolean | undefined;
}
@@ -120,12 +120,20 @@ const SpotlightUserMediaItem: FC<SpotlightUserMediaItemProps> = ({
targetHeight,
...props
}) => {
const videoFit = useBehavior(vm.videoFit$);
const videoEnabled = useBehavior(vm.videoEnabled$);
const speaking = useBehavior(vm.speaking$);
// Whenever target bounds change, inform the viewModel
useEffect(() => {
if (targetWidth > 0 && targetHeight > 0) {
vm.setTargetDimensions(targetWidth, targetHeight);
}
}, [targetWidth, targetHeight, vm]);
const baseProps: SpotlightUserMediaItemBaseProps &
RefAttributes<HTMLDivElement> = {
setVideoAspectRatio: vm.setVideoAspectRatio,
videoFit,
videoEnabled,
soundWaves: props.background === "transparent" ? speaking : undefined,
targetWidth,
@@ -219,6 +227,7 @@ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
}
avatarStyle="translucent"
videoEnabled={false}
videoFit="cover"
mirror={false}
{...props}
/>

View File

@@ -28,10 +28,6 @@ test("useTypedEventEmitterState reacts to events", async () => {
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler to work around the following bug:
// https://github.com/react/react/issues/34901
"use no memo";
const value = useTypedEventEmitterState(
emitter,
"change",
@@ -55,10 +51,6 @@ test("useTypedEventEmitterState reacts to changes made by an effect mounted on t
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler to work around the following bug:
// https://github.com/react/react/issues/34901
"use no memo";
useEffect(() => emitter.setState(2), []);
const value = useTypedEventEmitterState(
emitter,
@@ -77,10 +69,6 @@ test("useTypedEventEmitterState reacts to changes in getState", async () => {
const emitter = new TestEmitter();
const Test: FC = () => {
// Disable the React Compiler to work around the following bug:
// https://github.com/react/react/issues/34901
"use no memo";
const [fn, setFn] = useState(() => emitter.getState);
const value = useTypedEventEmitterState(emitter, "change", fn);
return (

View File

@@ -12,7 +12,7 @@ import { i18nKey } from "./i18n";
export enum ErrorCode {
/**
* Configuration problem due to no MatrixRTC transport provided by homeserver and no fallback configured.
* Configuration problem due to no MatrixRTC backend/SFU is exposed via .well-known and no fallback configured.
*/
MISSING_MATRIX_RTC_TRANSPORT = "MISSING_MATRIX_RTC_TRANSPORT",
CONNECTION_LOST_ERROR = "CONNECTION_LOST_ERROR",
@@ -67,7 +67,7 @@ export class ElementCallError extends Error {
}
/**
* Configuration problem due to no MatrixRTC transport provided by homeserver and no fallback configured.
* Configuration problem due to no MatrixRTC backend/SFU is exposed via .well-known and no fallback configured.
*/
export class MatrixRTCTransportMissingError extends ElementCallError {
public domain: string;

View File

@@ -439,12 +439,6 @@ export function mockConfig(
const spy = vi.spyOn(Config, "get").mockReturnValue({
...DEFAULT_CONFIG,
...config,
default_server_config: {
["m.homeserver"]: {
base_url: "http://localhost:8008",
server_name: "localhost",
},
},
});
// simulate loading the config
vi.spyOn(Config, "init").mockResolvedValue(void 0);

View File

@@ -5,106 +5,259 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import {
LocalTrack,
type LocalTrackPublication,
type RemoteTrackPublication,
Track,
} from "livekit-client";
import { autoVideoFit } from "./videoFit";
import { ObservableScope } from "../state/ObservableScope";
import { videoFit$, videoSizeFromParticipant$ } from "./videoFit";
import { constant } from "../state/Behavior";
import {
flushPromises,
mockLocalParticipant,
mockRemoteParticipant,
} from "./test";
describe("videoFit$ defaults", () => {
test.each([
{
videoAspectRatio: 1920 / 1080,
tileAspectRatio: NaN,
videoSize: { width: 1920, height: 1080 },
tileSize: undefined,
},
{
videoAspectRatio: 1080 / 1920,
tileAspectRatio: NaN,
videoSize: { width: 1080, height: 1920 },
tileSize: undefined,
},
{
videoAspectRatio: NaN,
tileAspectRatio: 1920 / 1080,
videoSize: undefined,
tileSize: { width: 1920, height: 1080 },
},
{
videoAspectRatio: NaN,
tileAspectRatio: 1080 / 1920,
videoSize: undefined,
tileSize: { width: 1080, height: 1920 },
},
])(
"videoFit$ returns `cover` when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio",
({ videoAspectRatio, tileAspectRatio }) =>
expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe("cover"),
"videoFit$ returns `cover` when videoSize is $videoSize and tileSize is $tileSize",
({ videoSize, tileSize }) => {
const scope = new ObservableScope();
const videoSize$ = constant(videoSize);
const tileSize$ = constant(tileSize);
const fit = videoFit$(scope, videoSize$, tileSize$);
expect(fit.value).toBe("cover");
},
);
});
const VIDEO_480_L = 640 / 480;
const VIDEO_720_L = 1280 / 720;
const VIDEO_1080_L = 1920 / 1080;
const VIDEO_480_L = { width: 640, height: 480 };
const VIDEO_720_L = { width: 1280, height: 720 };
const VIDEO_1080_L = { width: 1920, height: 1080 };
// Some sizes from real world testing, which don't match the standard video sizes exactly
const TILE_SIZE_1_L = 180 / 135;
const TILE_SIZE_3_P = 379 / 542;
const TILE_SIZE_4_L = 957 / 542;
const TILE_SIZE_1_L = { width: 180, height: 135 };
const TILE_SIZE_3_P = { width: 379, height: 542 };
const TILE_SIZE_4_L = { width: 957, height: 542 };
// This is the size of an iPhone Xr in portrait mode
const TILE_SIZE_5_P = 414 / 896;
const TILE_SIZE_5_P = { width: 414, height: 896 };
function inverse(ratio: number): number {
return 1 / ratio;
export function invertSize(size: { width: number; height: number }): {
width: number;
height: number;
} {
return {
width: size.height,
height: size.width,
};
}
test.each([
{
videoAspectRatio: VIDEO_480_L,
tileAspectRatio: TILE_SIZE_1_L,
videoSize: VIDEO_480_L,
tileSize: TILE_SIZE_1_L,
expected: "cover",
},
{
videoAspectRatio: inverse(VIDEO_480_L),
tileAspectRatio: TILE_SIZE_1_L,
videoSize: invertSize(VIDEO_480_L),
tileSize: TILE_SIZE_1_L,
expected: "contain",
},
{
videoAspectRatio: VIDEO_720_L,
tileAspectRatio: TILE_SIZE_4_L,
videoSize: VIDEO_720_L,
tileSize: TILE_SIZE_4_L,
expected: "cover",
},
{
videoAspectRatio: inverse(VIDEO_720_L),
tileAspectRatio: TILE_SIZE_4_L,
videoSize: invertSize(VIDEO_720_L),
tileSize: TILE_SIZE_4_L,
expected: "contain",
},
{
videoAspectRatio: inverse(VIDEO_1080_L),
tileAspectRatio: TILE_SIZE_3_P,
videoSize: invertSize(VIDEO_1080_L),
tileSize: TILE_SIZE_3_P,
expected: "cover",
},
{
videoAspectRatio: VIDEO_1080_L,
tileAspectRatio: TILE_SIZE_5_P,
videoSize: VIDEO_1080_L,
tileSize: TILE_SIZE_5_P,
expected: "contain",
},
{
videoAspectRatio: inverse(VIDEO_1080_L),
tileAspectRatio: TILE_SIZE_5_P,
videoSize: invertSize(VIDEO_1080_L),
tileSize: TILE_SIZE_5_P,
expected: "cover",
},
{
// square video
videoAspectRatio: 400 / 400,
tileAspectRatio: VIDEO_480_L,
videoSize: { width: 400, height: 400 },
tileSize: VIDEO_480_L,
expected: "contain",
},
{
// Should default to cover if the initial size is 0:0.
// Or else it will cause a flash of "contain" mode until the real size is loaded, which can be jarring.
videoAspectRatio: VIDEO_480_L,
tileAspectRatio: 0 / 0,
videoSize: VIDEO_480_L,
tileSize: { width: 0, height: 0 },
expected: "cover",
},
{
videoAspectRatio: 0 / 0,
tileAspectRatio: VIDEO_480_L,
videoSize: { width: 0, height: 0 },
tileSize: VIDEO_480_L,
expected: "cover",
},
])(
"videoFit$ returns $expected when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio",
({ videoAspectRatio, tileAspectRatio, expected }) =>
expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe(expected),
"videoFit$ returns $expected when videoSize is $videoSize and tileSize is $tileSize",
({ videoSize, tileSize, expected }) => {
const scope = new ObservableScope();
const videoSize$ = constant(videoSize);
const tileSize$ = constant(tileSize);
const fit = videoFit$(scope, videoSize$, tileSize$);
expect(fit.value).toBe(expected);
},
);
describe("extracting video size from participant stats", () => {
function createMockRtpStats(
isInbound: boolean,
props: Partial<RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats> = {},
): RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats {
const baseStats = {
id: "mock-stats-id",
timestamp: Date.now(),
type: isInbound ? "inbound-rtp" : "outbound-rtp",
kind: "video",
...props,
};
return baseStats as RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
}
test("get stats for local user", async () => {
const localParticipant = mockLocalParticipant({
identity: "@local:example.org:AAAAAA",
});
const mockReport: RTCStatsReport = new Map([
[
"OT01V639885149",
createMockRtpStats(false, {
frameWidth: 1280,
frameHeight: 720,
}),
],
]);
const track = {
source: Track.Source.Camera,
getRTCStatsReport: vi
.fn()
.mockImplementation(async () => Promise.resolve(mockReport)),
} as Partial<LocalTrack> as LocalTrack;
// Set up the prototype chain (there is an instanceof check in getRTCStatsReport)
Object.setPrototypeOf(track, LocalTrack.prototype);
localParticipant.getTrackPublication = vi
.fn()
.mockImplementation((source: Track.Source) => {
if (source === Track.Source.Camera) {
return {
track,
} as unknown as LocalTrackPublication;
} else {
return undefined;
}
});
const videoDimensions$ = videoSizeFromParticipant$(
constant(localParticipant),
);
const publishedDimensions: { width: number; height: number }[] = [];
videoDimensions$.subscribe((dimensions) => {
if (dimensions) publishedDimensions.push(dimensions);
});
await flushPromises();
const dimension = publishedDimensions.pop();
expect(dimension).toEqual({ width: 1280, height: 720 });
});
test("get stats for remote user", async () => {
// vi.useFakeTimers()
const remoteParticipant = mockRemoteParticipant({
identity: "@bob:example.org:AAAAAA",
});
const mockReport: RTCStatsReport = new Map([
[
"OT01V639885149",
createMockRtpStats(true, {
frameWidth: 480,
frameHeight: 640,
}),
],
]);
const track = {
source: Track.Source.Camera,
getRTCStatsReport: vi
.fn()
.mockImplementation(async () => Promise.resolve(mockReport)),
} as Partial<LocalTrack> as LocalTrack;
// Set up the prototype chain (there is an instanceof check in getRTCStatsReport)
Object.setPrototypeOf(track, LocalTrack.prototype);
remoteParticipant.getTrackPublication = vi
.fn()
.mockImplementation((source: Track.Source) => {
if (source === Track.Source.Camera) {
return {
track,
} as unknown as RemoteTrackPublication;
} else {
return undefined;
}
});
const videoDimensions$ = videoSizeFromParticipant$(
constant(remoteParticipant),
);
const publishedDimensions: { width: number; height: number }[] = [];
videoDimensions$.subscribe((dimensions) => {
if (dimensions) publishedDimensions.push(dimensions);
});
await flushPromises();
const dimension = publishedDimensions.pop();
expect(dimension).toEqual({ width: 480, height: 640 });
});
});

View File

@@ -5,27 +5,107 @@ 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, of, switchMap } from "rxjs";
import {
type LocalParticipant,
type RemoteParticipant,
Track,
} from "livekit-client";
import { type ObservableScope } from "../state/ObservableScope.ts";
import { type Behavior } from "../state/Behavior.ts";
import {
observeInboundRtpStreamStats$,
observeOutboundRtpStreamStats$,
} from "../state/media/observeRtpStreamStats";
type Size = {
width: number;
height: number;
};
/**
* Computes the appropriate video fit mode ("cover" or "contain") based on the aspect ratios of the video and the tile.
* - If the video and tile have the same orientation (both landscape or both portrait), we use "cover" to fill the tile, even if it means cropping.
* - If the video and tile have different orientations, we use "contain" to ensure the entire video is visible, even if it means letterboxing (black bars).
* @param scope - the ObservableScope to create the Behavior in
* @param videoSize$ - an Observable of the video size (width and height) or undefined if the size is not yet known (no data yet received).
* @param tileSize$ - an Observable of the tile size (width and height) or undefined if the size is not yet known (not yet rendered).
*/
export function autoVideoFit(
videoAspectRatio: number,
tileAspectRatio: number,
): "cover" | "contain" {
if (Number.isNaN(videoAspectRatio) || Number.isNaN(tileAspectRatio)) {
// If we have invalid sizes (e.g. useMeasure returns 0×0 on an initial render),
// default to cover to avoid black bars.
return "cover";
}
export function videoFit$(
scope: ObservableScope,
videoSize$: Observable<Size | undefined>,
tileSize$: Observable<Size | undefined>,
): Behavior<"cover" | "contain"> {
const fit$ = combineLatest([videoSize$, tileSize$]).pipe(
map(([videoSize, tileSize]) => {
if (!videoSize || !tileSize) {
// If we don't have the sizes, default to cover to avoid black bars.
// This is a reasonable default as it will ensure the video fills the tile, even if it means cropping.
return "cover";
}
if (
videoSize.width === 0 ||
videoSize.height === 0 ||
tileSize.width === 0 ||
tileSize.height === 0
) {
// If we have invalid sizes (e.g. width or height is 0), default to cover to avoid black bars.
return "cover";
}
const videoAspectRatio = videoSize.width / videoSize.height;
const tileAspectRatio = tileSize.width / tileSize.height;
// If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa,
// we want to use "contain" (fit) mode to avoid excessive cropping
const videoIsLandscape = videoAspectRatio > 1;
const tileIsLandscape = tileAspectRatio > 1;
// If video is landscape (ratio > 1) and tile is portrait (ratio < 1) or vice versa,
// we want to use "contain" (fit) mode to avoid excessive cropping
const videoIsLandscape = videoAspectRatio > 1;
const tileIsLandscape = tileAspectRatio > 1;
// If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.)
// If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.)
return videoIsLandscape === tileIsLandscape ? "cover" : "contain";
// If the orientations are the same, use the cover mode (Preserves the aspect ratio, and the image fills the container.)
// If they're not the same orientation, use the contain mode (Preserves the aspect ratio, but the image is letterboxed - black bars- to fit within the container.)
return videoIsLandscape === tileIsLandscape ? "cover" : "contain";
}),
);
return scope.behavior(fit$, "cover");
}
/**
* Helper function to get the video size from a participant.
* It observes the participant's video track stats and extracts the frame width and height.
* @param participant$ - an Observable of a LocalParticipant or RemoteParticipant, or null if no participant is selected.
* @returns an Observable of the video size (width and height) or undefined if the size cannot be determined.
*/
export function videoSizeFromParticipant$(
participant$: Observable<LocalParticipant | RemoteParticipant | null>,
): Observable<{ width: number; height: number } | undefined> {
return participant$
.pipe(
// If we have a participant, observe their video track stats. If not, return undefined.
switchMap((p) => {
if (!p) return of(undefined);
if (p.isLocal) {
return observeOutboundRtpStreamStats$(p, Track.Source.Camera);
} else {
return observeInboundRtpStreamStats$(p, Track.Source.Camera);
}
}),
)
.pipe(
// Extract the frame width and height from the stats. If we don't have valid stats, return undefined.
map((stats) => {
if (!stats) return undefined;
if (
// For video tracks, frameWidth and frameHeight should be numbers. If they're not, we can't determine the size.
typeof stats.frameWidth !== "number" ||
typeof stats.frameHeight !== "number"
) {
return undefined;
}
return {
width: stats.frameWidth,
height: stats.frameHeight,
};
}),
);
}

View File

@@ -19,7 +19,6 @@ const createRoomWidgetClientSpy = vi.mocked(createRoomWidgetClient);
vi.mock("./config/Config", () => ({
Config: {
init: vi.fn().mockImplementation(async () => Promise.resolve()),
get: vi.fn().mockReturnValue({}),
},
}));
const configInitSpy = vi.mocked(Config.init);
@@ -110,7 +109,6 @@ describe("widget", () => {
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: false,
rtcTransports: true,
sendDelayedEvents: true,
updateDelayedEvents: true,
sendSticky: true,

View File

@@ -21,7 +21,6 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { ElementCallReactionEventType } from "./reactions";
// Subset of the actions in element-web
@@ -174,7 +173,6 @@ export const initializeWidget = (
sendToDevice: sendRecvToDevice,
receiveToDevice: sendRecvToDevice,
turnServers: false,
rtcTransports: true,
sendDelayedEvents: true,
updateDelayedEvents: true,
sendSticky: true,
@@ -197,8 +195,7 @@ export const initializeWidget = (
// Wait for the config file to be ready (we load very early on so it might not
// be otherwise)
await Config.init();
seedSettingsFromConfig(Config.get().media_quality);
await client.startClient();
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
return client;
};

View File

@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
import {
loadEnv,
type PluginOption,
PluginOption,
searchForWorkspaceRoot,
type ConfigEnv,
type UserConfig,
@@ -19,8 +19,8 @@ import { codecovVitePlugin } from "@codecov/vite-plugin";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { nodePolyfills } from "vite-plugin-node-polyfills";
import wasm from "vite-plugin-wasm";
import babel from "@rolldown/plugin-babel";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import react from "@vitejs/plugin-react";
import { realpathSync } from "fs";
import * as fs from "node:fs";
@@ -29,9 +29,6 @@ export const vitePluginsConfig = ({
}: Pick<ConfigEnv, "mode">): UserConfig => {
const env = loadEnv(mode, process.cwd());
const plugins: PluginOption[] = [
babel({
presets: [reactCompilerPreset()],
}),
react(),
wasm(),
nodePolyfills({