mirror of
https://github.com/vector-im/element-call.git
synced 2026-08-20 20:49:20 +00:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3abc5a0435 | ||
|
|
8e20d48b1d | ||
|
|
ff57c217aa | ||
|
|
b60e7452b7 | ||
|
|
1c03ad335c | ||
|
|
87873b726f | ||
|
|
3448a3a2ef | ||
|
|
c625fb8845 | ||
|
|
cab9698fc7 | ||
|
|
7410bd8a82 | ||
|
|
f17670b731 | ||
|
|
33a0653ea5 | ||
|
|
b1c29e9a41 | ||
|
|
ad92892063 | ||
|
|
1a827e0930 | ||
|
|
a1b37bb3c9 | ||
|
|
061d25a54c | ||
|
|
b554fa63d1 | ||
|
|
b5bcb9aeed | ||
|
|
3d3e6dcdcc | ||
|
|
43ce68e80a | ||
|
|
6d5f698357 | ||
|
|
64a5b2ee5c | ||
|
|
c0d5f2d470 | ||
|
|
4a5141406e | ||
|
|
b036d1c0c0 | ||
|
|
0cfe6cb03d | ||
|
|
8be8cf08bf | ||
|
|
9a772131b6 | ||
|
|
1cdd5d8db0 | ||
|
|
fc9e13c922 | ||
|
|
580897730a | ||
|
|
6f7dac31d6 | ||
|
|
78e4881c37 | ||
|
|
1bb342e9e1 | ||
|
|
7ca68fb5bd | ||
|
|
72e7e0f406 | ||
|
|
94a1d87813 | ||
|
|
fee54a09c3 | ||
|
|
72a327f12f | ||
|
|
7f8d8027dc | ||
|
|
cbc12b0365 | ||
|
|
414ae266ab | ||
|
|
ecca5e1e13 | ||
|
|
1d12e10628 | ||
|
|
89cabf9fa2 | ||
|
|
7ca8059b0f | ||
|
|
29ea90f800 | ||
|
|
426ea30fe1 | ||
|
|
7d6a0da6a9 | ||
|
|
ea31f073a7 | ||
|
|
b0d08cb0b5 | ||
|
|
88e5a5badb | ||
|
|
296c735500 | ||
|
|
341c508a42 | ||
|
|
78e585c5f4 | ||
|
|
81da4c9093 | ||
|
|
064a88b9c7 | ||
|
|
14fd8c179b | ||
|
|
4a146ebada | ||
|
|
a6a02778a1 | ||
|
|
662259154a | ||
|
|
978ffee183 | ||
|
|
3ace4ab5a3 | ||
|
|
2d64cf4b30 | ||
|
|
87aee1c02c | ||
|
|
f7cf674152 | ||
|
|
9d5ca297e3 | ||
|
|
c90d1d8dda | ||
|
|
dd59519a85 | ||
|
|
33fc8997f8 | ||
|
|
8aead74ab9 | ||
|
|
1e128c4da0 | ||
|
|
eb65a28a75 | ||
|
|
9d400323d0 | ||
|
|
6d7d3093b3 | ||
|
|
2b07e9e822 | ||
|
|
7ae1fc6cd8 | ||
|
|
e1df71817a | ||
|
|
608695fc95 | ||
|
|
a08a577e18 | ||
|
|
55b2d2b130 | ||
|
|
5b84b2c04d | ||
|
|
3af0744324 | ||
|
|
07567ed31d | ||
|
|
d6e097476e | ||
|
|
540eb306f4 | ||
|
|
f62428ba0d | ||
|
|
d579169d54 | ||
|
|
8daaa649eb | ||
|
|
2d5a4eefec | ||
|
|
d550430c8a | ||
|
|
78724acfcf | ||
|
|
20c7112812 | ||
|
|
a7c996f3ea | ||
|
|
4fbcbb4849 | ||
|
|
74102ac6c2 | ||
|
|
11a6fa9b45 | ||
|
|
507a6f29a5 | ||
|
|
e725bc5de3 | ||
|
|
c15d4c50a1 | ||
|
|
e1bc4a096b | ||
|
|
b524de9298 | ||
|
|
bfb5fb922f | ||
|
|
3f4d55343a | ||
|
|
73d8d2c2c3 | ||
|
|
fcfe73c50d | ||
|
|
ffb6b0a5f6 | ||
|
|
5564015a8b | ||
|
|
46cb89b232 | ||
|
|
9ef3499418 | ||
|
|
08d9982981 | ||
|
|
79b9335192 | ||
|
|
dedf4e158e | ||
|
|
8642646fa7 | ||
|
|
98f63c7172 | ||
|
|
297470ac2e | ||
|
|
b043230680 | ||
|
|
bc38d15b6d | ||
|
|
342b61f633 | ||
|
|
390dc22c96 | ||
|
|
2a3271b8b3 | ||
|
|
f45aa85aef |
4
.github/workflows/sync-labels.yml
vendored
4
.github/workflows/sync-labels.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
- .github/labels.yml
|
||||
- .github/workflows/sync-labels.yml
|
||||
|
||||
permissions: {} # We use ELEMENT_BOT_TOKEN instead
|
||||
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
@@ -20,4 +20,4 @@ jobs:
|
||||
DELETE: true
|
||||
WET: true
|
||||
secrets:
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
|
||||
|
||||
30
.github/workflows/test.yaml
vendored
30
.github/workflows/test.yaml
vendored
@@ -7,10 +7,6 @@ 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
|
||||
@@ -26,6 +22,18 @@ 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
|
||||
@@ -52,8 +60,18 @@ 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: Install Playwright Browsers
|
||||
run: pnpm exec playwright install --with-deps
|
||||
- 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: Run backend components
|
||||
run: |
|
||||
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
|
||||
|
||||
7
.github/workflows/translations-download.yaml
vendored
7
.github/workflows/translations-download.yaml
vendored
@@ -40,6 +40,11 @@ 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
|
||||
|
||||
@@ -53,7 +58,7 @@ jobs:
|
||||
title: Localazy Download
|
||||
commit-message: Translations updates
|
||||
labels: |
|
||||
T-Task
|
||||
PR-Task
|
||||
|
||||
- name: Enable automerge
|
||||
run: gh pr merge --merge --auto "$PR_NUMBER"
|
||||
|
||||
40
README.md
40
README.md
@@ -115,30 +115,33 @@ 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 Backend Discovery and Selection
|
||||
## MatrixRTC Transports
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
|
||||
</p>
|
||||
|
||||
### Backend Discovery
|
||||
### Transport Discovery
|
||||
|
||||
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.:
|
||||
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:
|
||||
|
||||
```json
|
||||
"org.matrix.msc4143.rtc_foci": [
|
||||
{
|
||||
"rtc_transports": [
|
||||
{
|
||||
"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
|
||||
@@ -149,7 +152,7 @@ via `livekit_service_url`.
|
||||
|
||||
### Backend Selection
|
||||
|
||||
- Each call participant proposes their discovered MatrixRTC backend from
|
||||
- Each call participant proposes their discovered MatrixRTC transport 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)),
|
||||
@@ -244,10 +247,9 @@ pnpm backend
|
||||
|
||||
> [!NOTE]
|
||||
> To ensure your local development frontend functions properly, you’ll need to
|
||||
> 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
|
||||
> 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
|
||||
> ([./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 browser’s address bar and follow the prompts to add the exception.
|
||||
|
||||
@@ -40,6 +40,8 @@ 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
|
||||
@@ -54,7 +56,6 @@ 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).
|
||||
#
|
||||
@@ -67,3 +68,8 @@ 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
|
||||
|
||||
@@ -40,6 +40,8 @@ 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
|
||||
@@ -54,7 +56,6 @@ 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).
|
||||
#
|
||||
@@ -67,3 +68,8 @@ 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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# Synapse reverse proxy
|
||||
# domain synapse.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
@@ -11,18 +11,6 @@ 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
|
||||
@@ -44,7 +32,7 @@ server {
|
||||
|
||||
}
|
||||
|
||||
# Synapse reverse proxy including .well-known/matrix/client
|
||||
# Synapse reverse proxy
|
||||
# domain synapse.othersite.m.localhost
|
||||
server {
|
||||
listen 80;
|
||||
@@ -57,18 +45,6 @@ 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
|
||||
|
||||
@@ -40,6 +40,8 @@ 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
|
||||
@@ -54,7 +56,6 @@ 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).
|
||||
#
|
||||
@@ -84,3 +85,8 @@ 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
|
||||
|
||||
@@ -40,6 +40,8 @@ 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
|
||||
@@ -54,7 +56,6 @@ 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).
|
||||
#
|
||||
@@ -84,3 +85,8 @@ 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
|
||||
|
||||
@@ -47,7 +47,7 @@ services:
|
||||
- ecbackend
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.10.1
|
||||
image: livekit/livekit-server:v1.13.4
|
||||
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.10.1
|
||||
image: livekit/livekit-server:v1.13.4
|
||||
pull_policy: always
|
||||
hostname: livekit-sfu-1
|
||||
command: --dev --config /etc/livekit.yaml
|
||||
|
||||
@@ -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:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f
|
||||
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:d1841828a390d96da2ee64232949db641f11766fb506ec561374819d10533b9a
|
||||
image: ghcr.io/element-hq/element-web:develop@sha256:a416986d5e6086ef715bc6100a9fbb4547822ca285b8a13392d061b5c114742f
|
||||
|
||||
@@ -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,19 +29,26 @@ 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'll need to additionally add the following config items to
|
||||
`homeserver.yaml` to comply with Element Call:
|
||||
homeserver, you can configure these features by adding the following entries to
|
||||
`homeserver.yaml`:
|
||||
|
||||
```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
|
||||
@@ -61,6 +68,15 @@ 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
|
||||
@@ -68,10 +84,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).
|
||||
|
||||
### MatrixRTC Backend
|
||||
### LiveKit backend
|
||||
|
||||
In order to **guarantee smooth operation** of Element Call, a MatrixRTC backend is
|
||||
required for each site deployment.
|
||||
In order to **guarantee smooth operation** of Element Call, a dedicated LiveKit
|
||||
backend is required for each site deployment.
|
||||
|
||||

|
||||
|
||||
@@ -86,10 +102,11 @@ 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:
|
||||
|
||||
@@ -164,7 +181,7 @@ Using Haproxy, you can achieve this by:
|
||||
use_backend mxrtc_auth_backend if is_mxrtc_auth matrixrtc_domain
|
||||
|
||||
# Backend
|
||||
## MatrixRTC backend
|
||||
## LiveKit backend
|
||||
backend sfu_backend
|
||||
server livekit 127.0.0.1:7880
|
||||
http-request set-path %[path,regsub(^/livekit/sfu/,/)]
|
||||
@@ -186,44 +203,6 @@ 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]
|
||||
@@ -277,7 +256,7 @@ runtime. Documentation and default values for `public/config.json` can be found
|
||||
in [ConfigOptions.ts](../src/config/ConfigOptions.ts).
|
||||
|
||||
> [!CAUTION]
|
||||
> Please note configuring MatrixRTC backend via `config.json` of
|
||||
> Please note configuring LiveKit backend via `config.json` of
|
||||
> Element Call is only available for developing and debug purposes. Relying on
|
||||
> it might break Element Call going forward!
|
||||
|
||||
|
||||
@@ -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 well-known or config is used.",
|
||||
"from_config": "Currently, no overwrite is set. Url from config is used.",
|
||||
"label": "Custom Livekit-url",
|
||||
"reset": "Reset overwrite",
|
||||
"save": "Save",
|
||||
@@ -196,13 +196,23 @@
|
||||
"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…",
|
||||
@@ -218,12 +228,15 @@
|
||||
"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",
|
||||
@@ -235,7 +248,9 @@
|
||||
"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",
|
||||
|
||||
18
package.json
18
package.json
@@ -54,6 +54,7 @@
|
||||
"@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",
|
||||
@@ -76,17 +77,18 @@
|
||||
"@typescript-eslint/utils": "^8.61.0",
|
||||
"@use-gesture/react": "^10.2.11",
|
||||
"@vector-im/compound-design-tokens": "^10.0.0",
|
||||
"@vector-im/compound-web": "^9.3.0",
|
||||
"@vector-im/compound-web": "^10.0.0",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/browser-playwright": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"@vitest/ui": "4.1.9",
|
||||
"@vitest/ui": "4.1.10",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"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": "^25.0.0",
|
||||
"i18next": "^26.0.0",
|
||||
"i18next-browser-languagedetector": "^8.0.0",
|
||||
"i18next-cli": "^1.61.0",
|
||||
"jsdom": "^26.0.0",
|
||||
@@ -95,17 +97,17 @@
|
||||
"lodash-es": "^4.17.21",
|
||||
"loglevel": "^1.9.1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop",
|
||||
"matrix-widget-api": "^1.16.1",
|
||||
"matrix-widget-api": "^1.18.0",
|
||||
"node-stdlib-browser": "^1.3.1",
|
||||
"normalize.css": "^8.0.1",
|
||||
"observable-hooks": "^4.2.3",
|
||||
"oxfmt": "^0.56.0",
|
||||
"oxfmt": "^0.61.0",
|
||||
"oxlint": "^1.70.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"oxlint-tsgolint": "^0.25.0",
|
||||
"pako": "^2.0.4",
|
||||
"postcss": "^8.4.41",
|
||||
"postcss-preset-env": "^10.0.0",
|
||||
"posthog-js": "1.374.0",
|
||||
"posthog-js": "1.408.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19",
|
||||
"react-dom": "19",
|
||||
@@ -129,5 +131,5 @@
|
||||
"vitest": "^4.1.5",
|
||||
"vitest-axe": "^1.0.0-pre.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.6.0+sha512.9a36518224080c6fe5165afdcfe79bfa118c29be703f3f462b1e32efe1e98e47e8750b148e08286250aad4113cc7993ca413c4e2cd447752708c2ee5751bc95f"
|
||||
"packageManager": "pnpm@11.21.0+sha512.521705bce689924eac72f5a3587122f362689ef6571e55ba80076fd637c11132ecffada26fad4ea79c485bfddbfd3d5a2a5b05805a77e893de71ec8a6cca3bb1"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ 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
|
||||
|
||||
@@ -53,6 +53,7 @@ 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
|
||||
|
||||
@@ -346,6 +346,12 @@ 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
|
||||
|
||||
4547
pnpm-lock.yaml
generated
4547
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ allowBuilds:
|
||||
"@swc/core": true
|
||||
"core-js": true
|
||||
"esbuild": true
|
||||
"matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8c95727b6278fe7942c20d0b9485f984dd0694b7": true
|
||||
"matrix-js-sdk@git+https://github.com/matrix-org/matrix-js-sdk.git": true
|
||||
"protobufjs": true
|
||||
overrides:
|
||||
# We need compatible versions of RxJS in our dependencies and LiveKit's dependencies, but
|
||||
|
||||
@@ -31,6 +31,11 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +51,7 @@ export const Slider: FC<Props> = ({
|
||||
max,
|
||||
step,
|
||||
disabled,
|
||||
tooltipFormatter,
|
||||
}) => {
|
||||
const onValueChange = useCallback(
|
||||
([v]: number[]) => onValueChangeProp(v),
|
||||
@@ -71,7 +77,14 @@ export const Slider: FC<Props> = ({
|
||||
<Range className={styles.highlight} />
|
||||
</Track>
|
||||
{/* Note: This is expected not to be visible on mobile.*/}
|
||||
<Tooltip placement="top" label={Math.round(value * 100).toString() + "%"}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
label={
|
||||
tooltipFormatter
|
||||
? tooltipFormatter(value)
|
||||
: Math.round(value * 100).toString() + "%"
|
||||
}
|
||||
>
|
||||
<Thumb className={styles.handle} aria-label={label} />
|
||||
</Tooltip>
|
||||
</Root>
|
||||
|
||||
@@ -16,12 +16,15 @@ 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", () => {
|
||||
|
||||
@@ -127,6 +127,7 @@ export const Default: Story = {
|
||||
showFooter: true,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
sharingScreen: false,
|
||||
audioOutputSwitcher: undefined,
|
||||
reactionIdentifier: undefined,
|
||||
|
||||
@@ -26,6 +26,7 @@ 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";
|
||||
@@ -76,6 +77,7 @@ 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;
|
||||
@@ -120,6 +122,7 @@ 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$);
|
||||
@@ -135,7 +138,6 @@ 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$);
|
||||
@@ -235,7 +237,8 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (reactionIdentifier && reactionData) {
|
||||
// Reaction button contains a pretty large menu, so treat it like a modal
|
||||
if (reactionIdentifier && reactionData && showModals) {
|
||||
buttons.push(
|
||||
<ReactionToggleButton
|
||||
size={buttonSize}
|
||||
@@ -283,7 +286,9 @@ export const CallFooter: FC<FooterProps> = ({
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{debugTileLayout ? `Tiles generation: ${tileStoreGeneration}` : undefined}
|
||||
{debugTileLayout ? (
|
||||
<TilesDebugInfo generation$={vm.tileStoreGeneration$} />
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -316,3 +321,14 @@ 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}`;
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ 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;
|
||||
|
||||
@@ -161,6 +161,7 @@ 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"))),
|
||||
@@ -168,12 +169,12 @@ export function createCallFooterViewModel(
|
||||
|
||||
openSettings$: scope.behavior(
|
||||
combineLatest([
|
||||
isPip$,
|
||||
callModel.showModals$,
|
||||
callModel.showHeader$,
|
||||
callModel.setSettingsOpen$,
|
||||
]).pipe(
|
||||
map(([isPip, showHeader, setSettingsOpen]) =>
|
||||
!isPip && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
map(([showModals, showHeader, setSettingsOpen]) =>
|
||||
showModals && headerStyle !== HeaderStyle.AppBar && showControls
|
||||
? (): void => setSettingsOpen(true)
|
||||
: undefined,
|
||||
),
|
||||
@@ -239,6 +240,7 @@ export function createLobbyFooterViewModel(
|
||||
showLogo,
|
||||
hideControls: false,
|
||||
asOverlay: false,
|
||||
showModals: true,
|
||||
buttonSize: "lg",
|
||||
openSettings,
|
||||
hangup,
|
||||
|
||||
@@ -74,10 +74,7 @@ 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 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"
|
||||
// a livekit service url over the transports endpoint.
|
||||
livekit_service_url: string;
|
||||
};
|
||||
|
||||
@@ -105,6 +102,61 @@ 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.
|
||||
@@ -183,14 +235,26 @@ 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;
|
||||
@@ -202,17 +266,24 @@ 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,
|
||||
|
||||
BIN
src/graphics/desktop-gradient.png
Normal file
BIN
src/graphics/desktop-gradient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
@@ -1,16 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 938 B |
BIN
src/graphics/mobile-gradient.png
Normal file
BIN
src/graphics/mobile-gradient.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,86 +0,0 @@
|
||||
<?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>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -41,6 +41,8 @@ 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;
|
||||
@@ -285,7 +287,6 @@ 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(
|
||||
() =>
|
||||
@@ -372,6 +373,10 @@ 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,
|
||||
() => ({
|
||||
@@ -389,9 +394,9 @@ export function Grid<
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: prefersReducedMotion,
|
||||
immediate,
|
||||
}),
|
||||
enter: { opacity: 1, scale: 1, immediate: prefersReducedMotion },
|
||||
enter: { opacity: 1, scale: 1, immediate },
|
||||
update: ({
|
||||
id,
|
||||
x,
|
||||
@@ -406,9 +411,9 @@ export function Grid<
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
immediate: prefersReducedMotion,
|
||||
immediate,
|
||||
},
|
||||
leave: { opacity: 0, scale: 0, immediate: prefersReducedMotion },
|
||||
leave: { opacity: 0, scale: 0, immediate },
|
||||
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||
}),
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
@@ -441,8 +446,7 @@ export function Grid<
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate:
|
||||
prefersReducedMotion || ((key): boolean => key === "zIndex"),
|
||||
immediate: immediate || ((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),
|
||||
@@ -453,7 +457,7 @@ export function Grid<
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
prefersReducedMotion ||
|
||||
immediate ||
|
||||
((key): boolean =>
|
||||
key === "zIndex" || key === "x" || key === "y"),
|
||||
},
|
||||
|
||||
@@ -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 ReactNode } from "react";
|
||||
import { type FC, type ReactNode } from "react";
|
||||
import { useObservableEagerState } from "observable-hooks";
|
||||
import classNames from "classnames";
|
||||
|
||||
@@ -13,6 +13,9 @@ 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
|
||||
@@ -54,16 +57,10 @@ 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}>
|
||||
<div
|
||||
className={classNames(styles.spotlight, {
|
||||
[styles.withIndicators]: withIndicators,
|
||||
})}
|
||||
/>
|
||||
<SpotlightSlot media$={model.spotlight.media$} />
|
||||
<div className={styles.grid}>
|
||||
{model.grid.map((m) => (
|
||||
<Slot key={m.id} className={styles.slot} id={m.id} model={m} />
|
||||
@@ -73,3 +70,20 @@ 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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -15,8 +15,7 @@ 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 {
|
||||
@@ -79,16 +78,19 @@ body {
|
||||
body[data-background="gradient"]::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background-image: url("graphics/mobile-gradient.svg");
|
||||
background-size: auto;
|
||||
/* 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;
|
||||
background-position: bottom;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
body[data-background="gradient"][data-platform="desktop"]::before {
|
||||
background-image: url("graphics/desktop-gradient.svg");
|
||||
background-size: calc(max(1440px, 100vw)) calc(max(800px, 100vh));
|
||||
background-image: url("graphics/desktop-gradient.png");
|
||||
background-size: max(1440px, 100vw) max(1440px, 100vh);
|
||||
background-position: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ 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";
|
||||
@@ -237,6 +238,7 @@ export class Initializer {
|
||||
this.loadStates.config = LoadState.Loading;
|
||||
Config.init().then(
|
||||
() => {
|
||||
seedSettingsFromConfig(Config.get().media_quality);
|
||||
this.loadStates.config = LoadState.Loaded;
|
||||
this.initStep(resolve);
|
||||
},
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
213
src/livekit/options.test.ts
Normal file
213
src/livekit/options.test.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -9,46 +9,144 @@ import {
|
||||
AudioPresets,
|
||||
DefaultReconnectPolicy,
|
||||
type RoomOptions,
|
||||
ScreenSharePresets,
|
||||
type TrackPublishDefaults,
|
||||
type VideoPreset,
|
||||
VideoPresets,
|
||||
VideoPreset as VideoPresetClass,
|
||||
} from "livekit-client";
|
||||
|
||||
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;
|
||||
import { Config } from "../config/Config";
|
||||
import { DEFAULT_CONFIG, type ConfigOptions } from "../config/ConfigOptions";
|
||||
|
||||
export const defaultLiveKitOptions: RoomOptions = {
|
||||
// automatically manage subscribed video quality
|
||||
adaptiveStream: true,
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
// optimize publishing bandwidth and CPU for published tracks
|
||||
dynacast: 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;
|
||||
|
||||
// capture settings
|
||||
videoCaptureDefaults: {
|
||||
resolution: VideoPresets.h720.resolution,
|
||||
},
|
||||
// Camera video encoding
|
||||
const videoEncoding = {
|
||||
maxBitrate: videoConf?.max_bitrate ?? defaults.video.max_bitrate,
|
||||
maxFramerate: videoConf?.max_framerate ?? defaults.video.max_framerate,
|
||||
};
|
||||
|
||||
// publish settings
|
||||
publishDefaults: defaultLiveKitPublishOptions,
|
||||
// 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];
|
||||
}
|
||||
|
||||
// default LiveKit options that seem to be sane
|
||||
stopLocalTrackOnUnpublish: true,
|
||||
reconnectPolicy: new DefaultReconnectPolicy(),
|
||||
disconnectOnPageLeave: true,
|
||||
webAudioMix: false,
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -555,6 +555,9 @@ 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}
|
||||
|
||||
@@ -261,6 +261,7 @@ 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$);
|
||||
@@ -622,7 +623,7 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
{earpieceOverlay}
|
||||
<ReactionsOverlay vm={vm} />
|
||||
{footer}
|
||||
{layout.type !== "pip" && (
|
||||
{showModals && (
|
||||
<>
|
||||
<RageshakeRequestModal {...rageshakeRequestModalProps} />
|
||||
<SettingsModal
|
||||
|
||||
@@ -22,6 +22,9 @@ 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 {
|
||||
|
||||
@@ -280,7 +280,7 @@ export const useLoadGroupCall = (
|
||||
);
|
||||
}
|
||||
if (
|
||||
roomSummary === undefined ||
|
||||
roomSummary?.join_rule === undefined ||
|
||||
roomSummary.join_rule === JoinRule.Public
|
||||
) {
|
||||
room = await client.joinRoom(roomId, {
|
||||
|
||||
@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FC,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -29,12 +30,14 @@ 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,
|
||||
@@ -43,10 +46,26 @@ 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";
|
||||
|
||||
@@ -138,6 +157,185 @@ 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>
|
||||
@@ -166,6 +364,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
|
||||
id: client.getDeviceId() || "unknown",
|
||||
})}
|
||||
</p>
|
||||
<Separator />
|
||||
<FieldRow>
|
||||
<InputField
|
||||
id="duplicateTiles"
|
||||
@@ -315,6 +514,7 @@ 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>
|
||||
@@ -403,6 +603,61 @@ 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>
|
||||
|
||||
@@ -33,3 +33,27 @@ 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);
|
||||
}
|
||||
|
||||
@@ -152,7 +152,13 @@ export const SettingsModal: FC<Props> = ({
|
||||
/>
|
||||
|
||||
<div className={styles.volumeSlider}>
|
||||
<label>{t("settings.audio_tab.effect_volume_label")}</label>
|
||||
<label>
|
||||
{t("settings.audio_tab.effect_volume_label")}
|
||||
{": "}
|
||||
<span className={styles.settingValue}>
|
||||
{Math.round(soundVolumeRaw * 100)}%
|
||||
</span>
|
||||
</label>
|
||||
<p>{t("settings.audio_tab.effect_volume_description")}</p>
|
||||
<Slider
|
||||
label={t("video_tile.volume")}
|
||||
|
||||
@@ -17,6 +17,12 @@ 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"
|
||||
>
|
||||
@@ -251,10 +257,16 @@ 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 well-known or config is used.
|
||||
Currently, no overwrite is set. Url from 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"
|
||||
>
|
||||
@@ -463,6 +475,242 @@ 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>
|
||||
|
||||
111
src/settings/settings.test.ts
Normal file
111
src/settings/settings.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ 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 {
|
||||
@@ -40,6 +41,7 @@ export class Setting<T> {
|
||||
}
|
||||
|
||||
private readonly key: string;
|
||||
private readonly hasStoredValue: boolean;
|
||||
|
||||
private readonly _value$: BehaviorSubject<T>;
|
||||
private readonly _lastUpdateReason$: BehaviorSubject<string | null>;
|
||||
@@ -54,6 +56,17 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,3 +157,130 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ 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");
|
||||
@@ -245,6 +248,20 @@ 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: {
|
||||
|
||||
@@ -22,6 +22,8 @@ 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() }));
|
||||
|
||||
@@ -201,6 +203,60 @@ 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);
|
||||
|
||||
@@ -79,7 +79,7 @@ import {
|
||||
type ReactionInfo,
|
||||
type ReactionOption,
|
||||
} from "../../reactions";
|
||||
import { shallowEquals } from "../../utils/array";
|
||||
import { shallowEquals as shallowArrayEquals } from "../../utils/array";
|
||||
import { type MediaDevices } from "../MediaDevices";
|
||||
import { constant, type Behavior } from "../Behavior";
|
||||
import { E2eeType } from "../../e2ee/e2eeType";
|
||||
@@ -89,6 +89,7 @@ import { getUrlParams, HeaderStyle } from "../../UrlParams";
|
||||
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
|
||||
import { ElementWidgetActions, widget } from "../../widget";
|
||||
import {
|
||||
layoutShallowEquals,
|
||||
type Alignment,
|
||||
type GridLayoutMedia,
|
||||
type Layout,
|
||||
@@ -365,6 +366,11 @@ 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>;
|
||||
|
||||
@@ -513,8 +519,6 @@ export function createCallViewModel$(
|
||||
livekitKeyProvider,
|
||||
getUrlParams().controlledAudioDevices,
|
||||
options.livekitRoomFactory,
|
||||
getUrlParams().echoCancellation,
|
||||
getUrlParams().noiseSuppression,
|
||||
);
|
||||
|
||||
const connectionManager = createConnectionManager$({
|
||||
@@ -943,7 +947,7 @@ export function createCallViewModel$(
|
||||
bins.sort(([, bin1], [, bin2]) => bin1 - bin2).map(([m]) => m),
|
||||
);
|
||||
}),
|
||||
distinctUntilChanged(shallowEquals),
|
||||
distinctUntilChanged(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1002,7 +1006,7 @@ export function createCallViewModel$(
|
||||
const spotlight$ = scope.behavior<MediaViewModel[]>(
|
||||
spotlightAndPip$.pipe(
|
||||
map(({ spotlight }) => spotlight),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowEquals),
|
||||
distinctUntilChanged<MediaViewModel[]>(shallowArrayEquals),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1211,6 +1215,7 @@ export function createCallViewModel$(
|
||||
}
|
||||
return layout;
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
scope.bind(),
|
||||
)
|
||||
.subscribe((orientation) => {
|
||||
@@ -1450,6 +1455,11 @@ 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);
|
||||
@@ -1576,7 +1586,11 @@ export function createCallViewModel$(
|
||||
* The layout of tiles in the call interface.
|
||||
*/
|
||||
const layout$ = scope.behavior<Layout>(
|
||||
layoutInternals$.pipe(map(({ layout }) => layout)),
|
||||
layoutInternals$.pipe(
|
||||
map(({ layout }) => layout),
|
||||
// Drop redundant layout updates before they would hit React.
|
||||
distinctUntilChanged<Layout>(layoutShallowEquals),
|
||||
),
|
||||
);
|
||||
|
||||
const overflowing$ = scope.behavior<boolean>(
|
||||
@@ -1822,6 +1836,7 @@ export function createCallViewModel$(
|
||||
showNameTags$,
|
||||
showHeader$: showHeader$,
|
||||
showFooter$: showFooter$,
|
||||
showModals$,
|
||||
settingsOpen$: settingsOpen$,
|
||||
setSettingsOpen$: setSettingsOpen$,
|
||||
edgeToEdge$,
|
||||
|
||||
@@ -138,9 +138,6 @@ 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,
|
||||
|
||||
@@ -20,7 +20,6 @@ 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";
|
||||
@@ -78,34 +77,10 @@ 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",
|
||||
@@ -132,7 +107,7 @@ describe("LocalMembership", () => {
|
||||
ownMemberMock,
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-well-known-service-url.com",
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
{
|
||||
@@ -150,7 +125,7 @@ describe("LocalMembership", () => {
|
||||
[
|
||||
{
|
||||
livekit_alias: "roomId",
|
||||
livekit_service_url: "http://my-well-known-service-url.com",
|
||||
livekit_service_url: "http://my-livekit-service-url.com",
|
||||
type: "livekit",
|
||||
},
|
||||
],
|
||||
@@ -161,50 +136,6 @@ 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 = {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ParticipantEvent,
|
||||
type LocalParticipant,
|
||||
type ScreenShareCaptureOptions,
|
||||
type TrackPublishOptions,
|
||||
RoomEvent,
|
||||
MediaDeviceFailure,
|
||||
} from "livekit-client";
|
||||
@@ -53,6 +54,14 @@ 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 {
|
||||
@@ -108,7 +117,6 @@ export type LocalMemberState =
|
||||
};
|
||||
|
||||
/*
|
||||
* - get well known
|
||||
* - get oldest membership
|
||||
* - get transport to use
|
||||
* - get openId + jwt token
|
||||
@@ -719,6 +727,43 @@ 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 ${
|
||||
@@ -734,7 +779,11 @@ 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)
|
||||
?.setScreenShareEnabled(
|
||||
targetScreenshareState,
|
||||
screenshareSettings,
|
||||
publishOptions,
|
||||
)
|
||||
.catch(logger.error);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,8 +63,7 @@ describe("LocalTransport", () => {
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
getDomain: () => "",
|
||||
getDomain: () => "example.org",
|
||||
baseUrl: "example.org",
|
||||
// These won't be called in this error path but satisfy the type
|
||||
getOpenIdToken: vi.fn(),
|
||||
@@ -77,9 +76,11 @@ describe("LocalTransport", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(new MatrixRTCTransportMissingError(""));
|
||||
});
|
||||
|
||||
it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
|
||||
@@ -103,10 +104,8 @@ describe("LocalTransport", () => {
|
||||
useOldestMember: false,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
baseUrl: "https://lk.example.org",
|
||||
// Use empty domain to skip .well-known and use config directly
|
||||
getDomain: () => "",
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
baseUrl: "https://example.org",
|
||||
getDomain: () => "example.org",
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getOpenIdToken: vi.fn(),
|
||||
@@ -150,11 +149,10 @@ describe("LocalTransport", () => {
|
||||
client: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
_unstable_getRTCTransports: async () => Promise.resolve([]),
|
||||
getDomain: () => "",
|
||||
getDomain: () => "example.org",
|
||||
getOpenIdToken: vi.fn(),
|
||||
getDeviceId: vi.fn(),
|
||||
baseUrl: "https://lk.example.org",
|
||||
getAccessToken: vi.fn().mockReturnValue("access_token"),
|
||||
baseUrl: "https://example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
@@ -221,13 +219,12 @@ describe("LocalTransport", () => {
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
getDomain: () => "example.org",
|
||||
// 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://lk.example.org",
|
||||
baseUrl: "https://example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
@@ -278,14 +275,13 @@ describe("LocalTransport", () => {
|
||||
useOldestMember: true,
|
||||
memberships$: scope.behavior(memberships$.pipe(trackEpoch())),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
getDomain: () => "example.org",
|
||||
// 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://lk.example.org",
|
||||
baseUrl: "https://example.org",
|
||||
},
|
||||
ownMembershipIdentity: ownMemberMock,
|
||||
forceJwtEndpoint: JwtEndpointVersion.Legacy,
|
||||
@@ -330,10 +326,9 @@ describe("LocalTransport", () => {
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
baseUrl: "https://example.org",
|
||||
getDomain: vi.fn().mockReturnValue(""),
|
||||
getDomain: vi.fn().mockReturnValue("example.org"),
|
||||
// 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(),
|
||||
},
|
||||
@@ -421,42 +416,6 @@ 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" },
|
||||
@@ -469,51 +428,6 @@ 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(),
|
||||
@@ -524,11 +438,10 @@ describe("LocalTransport", () => {
|
||||
delayId$: constant(null),
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
getDomain: () => "example.org",
|
||||
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(),
|
||||
@@ -537,10 +450,10 @@ describe("LocalTransport", () => {
|
||||
await flushPromises();
|
||||
|
||||
expect(() => advertised$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
expect(() => active$.value).toThrow(
|
||||
new MatrixRTCTransportMissingError(""),
|
||||
new MatrixRTCTransportMissingError("example.org"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -565,11 +478,10 @@ describe("LocalTransport", () => {
|
||||
delayId$: delayId$,
|
||||
memberships$: constant(new Epoch<CallMembership[]>([])),
|
||||
client: {
|
||||
getDomain: () => "",
|
||||
getDomain: () => "example.org",
|
||||
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(),
|
||||
|
||||
@@ -26,7 +26,6 @@ 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";
|
||||
@@ -57,7 +56,7 @@ interface Props {
|
||||
memberships$: Behavior<Epoch<CallMembership[]>>;
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts;
|
||||
// Used by the jwt service to create the livekit room and compute the livekit alias.
|
||||
@@ -150,7 +149,6 @@ export const createLocalTransport$ = ({
|
||||
const transportDiscovery = new RtcTransportAutoDiscovery({
|
||||
client: client,
|
||||
resolvedConfig: Config.get(),
|
||||
wellKnownFetcher: AutoDiscovery.getRawClientConfig.bind(AutoDiscovery),
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
@@ -309,7 +307,7 @@ async function doOpenIdAndJWTFromUrl(
|
||||
roomId: string,
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
delayId?: string,
|
||||
@@ -339,7 +337,7 @@ function observeLocalTransportForOldestMembership(
|
||||
preferredTransport$: Observable<LocalTransportWithSFUConfig>,
|
||||
client: Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
"getDomain" | "baseUrl" | "_unstable_getRTCTransports"
|
||||
> &
|
||||
OpenIDClientParts,
|
||||
ownMembershipIdentity: CallMembershipIdentityParts,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type MockedObject,
|
||||
vi,
|
||||
} from "vitest";
|
||||
import { type IClientWellKnown, MatrixError } from "matrix-js-sdk";
|
||||
import { 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 wellKnownTransport: LivekitTransportConfig = {
|
||||
const configTransport: LivekitTransportConfig = {
|
||||
type: "livekit",
|
||||
livekit_service_url: "https://well-known.example.org",
|
||||
livekit_service_url: "https://config.example.org",
|
||||
};
|
||||
|
||||
function makeClient(): MockedObject<DiscoveryClient> {
|
||||
@@ -43,7 +43,6 @@ 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>;
|
||||
@@ -59,12 +58,6 @@ 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();
|
||||
@@ -75,33 +68,27 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
{ transports: [{ type: "not_livekit" }, backendTransport] },
|
||||
];
|
||||
it.each(VALID_TEST_CASES)(
|
||||
"prefers backend transport over well-known and app config $transports",
|
||||
"prefers backend transport other 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("https://config.example.org"),
|
||||
wellKnownFetcher,
|
||||
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
discovery.discoverPreferredTransport(),
|
||||
).resolves.toStrictEqual(backendTransport);
|
||||
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||
|
||||
expect(discoveredTransport).toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).not.toStrictEqual(configTransport);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(1);
|
||||
expect(wellKnownFetcher).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("Retries limit_exceeded backend transport over well-known", async () => {
|
||||
it("Retries limit_exceeded backend transport", async () => {
|
||||
const client = makeClient();
|
||||
client._unstable_getRTCTransports
|
||||
.mockRejectedValueOnce(
|
||||
@@ -116,14 +103,9 @@ 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,
|
||||
});
|
||||
|
||||
@@ -132,7 +114,6 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
);
|
||||
|
||||
expect(client._unstable_getRTCTransports).toHaveBeenCalledTimes(2);
|
||||
expect(wellKnownFetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const INVALID_TEST_CASES: Array<{ transports: Transport[] }> = [
|
||||
@@ -140,91 +121,30 @@ describe("RtcTransportAutoDiscovery", () => {
|
||||
{ transports: [{ type: "not_livekit" }] },
|
||||
];
|
||||
it.each(INVALID_TEST_CASES)(
|
||||
"falls back to well-known when backend has no (valid) livekit transports $transports",
|
||||
"falls back to config 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("https://config.example.org"),
|
||||
wellKnownFetcher,
|
||||
resolvedConfig: makeResolvedConfig(configTransport.livekit_service_url),
|
||||
logger: rootLogger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
discovery.discoverPreferredTransport(),
|
||||
).resolves.toStrictEqual(wellKnownTransport);
|
||||
|
||||
expect(wellKnownFetcher).toHaveBeenCalledWith("example.org");
|
||||
const discoveredTransport = await discovery.discoverPreferredTransport();
|
||||
expect(discoveredTransport).not.toStrictEqual(backendTransport);
|
||||
expect(discoveredTransport).toStrictEqual(configTransport);
|
||||
},
|
||||
);
|
||||
|
||||
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 () => {
|
||||
it("returns null when backend 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,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isLivekitTransportConfig,
|
||||
type LivekitTransportConfig,
|
||||
} from "matrix-js-sdk/lib/matrixrtc";
|
||||
import { type IClientWellKnown, type MatrixClient } from "matrix-js-sdk";
|
||||
import { type MatrixClient } from "matrix-js-sdk";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
|
||||
import type { ResolvedConfigOptions } from "../../../config/ConfigOptions.ts";
|
||||
@@ -16,33 +16,27 @@ import { doNetworkOperationWithRetry } from "../../../utils/matrix.ts";
|
||||
|
||||
type TransportDiscoveryClient = Pick<
|
||||
MatrixClient,
|
||||
"getDomain" | "_unstable_getRTCTransports" | "getAccessToken"
|
||||
"getDomain" | "_unstable_getRTCTransports"
|
||||
>;
|
||||
|
||||
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]");
|
||||
}
|
||||
|
||||
@@ -56,21 +50,7 @@ export class RtcTransportAutoDiscovery {
|
||||
return backendTransport;
|
||||
}
|
||||
|
||||
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
|
||||
// 2) app config URL
|
||||
const configTransport = this.tryConfigTransport();
|
||||
if (configTransport) {
|
||||
this.logger.info(
|
||||
@@ -90,72 +70,23 @@ export class RtcTransportAutoDiscovery {
|
||||
private async tryBackendTransports(): Promise<LivekitTransportConfig | null> {
|
||||
const client = this.client;
|
||||
// MSC4143: Attempt to fetch transports from backend.
|
||||
// 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];
|
||||
}
|
||||
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 .well-known "org.matrix.msc4143.rtc_foci" found for ${domain}`,
|
||||
wellKnownFoci,
|
||||
`No livekit transport found in getRTCTransports end point`,
|
||||
transportList,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Should never happen, but just in case
|
||||
this.logger.warn(`No domain configured for client`);
|
||||
} catch (ex) {
|
||||
this.logger.info(`Failed to use getRTCTransports end point: ${ex}`);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type E2EEManagerOptions,
|
||||
type BaseE2EEManager,
|
||||
} from "livekit-client";
|
||||
import { type Logger } from "matrix-js-sdk/lib/logger";
|
||||
import { logger, 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,7 +27,18 @@ import type {
|
||||
import type { MediaDevices } from "../../MediaDevices.ts";
|
||||
import type { Behavior } from "../../Behavior.ts";
|
||||
import type { ProcessorState } from "../../../livekit/TrackProcessorContext.tsx";
|
||||
import { defaultLiveKitOptions } from "../../../livekit/options.ts";
|
||||
import { getLiveKitOptions } from "../../../livekit/options.ts";
|
||||
import {
|
||||
advancedCamera,
|
||||
cameraResolution,
|
||||
cameraFramerate,
|
||||
cameraBitrate,
|
||||
cameraCodec,
|
||||
parseResolution,
|
||||
echoCancellationSetting,
|
||||
noiseSuppressionSetting,
|
||||
autoGainControlSetting,
|
||||
} from "../../../settings/settings.ts";
|
||||
|
||||
// TODO evaluate if this should be done like the Publisher Factory
|
||||
export interface ConnectionFactory {
|
||||
@@ -53,8 +64,6 @@ 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,
|
||||
@@ -64,25 +73,22 @@ export class ECConnectionFactory implements ConnectionFactory {
|
||||
livekitKeyProvider: BaseKeyProvider | undefined,
|
||||
private controlledAudioDevices: boolean,
|
||||
livekitRoomFactory?: () => LivekitRoom,
|
||||
echoCancellation: boolean = true,
|
||||
noiseSuppression: boolean = true,
|
||||
) {
|
||||
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,
|
||||
}),
|
||||
);
|
||||
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);
|
||||
};
|
||||
this.livekitRoomFactory = livekitRoomFactory ?? defaultFactory;
|
||||
}
|
||||
|
||||
@@ -119,14 +125,13 @@ 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;
|
||||
@@ -135,21 +140,44 @@ 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 {
|
||||
...defaultLiveKitOptions,
|
||||
videoCaptureDefaults: {
|
||||
...defaultLiveKitOptions.videoCaptureDefaults,
|
||||
deviceId: devices.videoInput.selected$.value?.id,
|
||||
processor: processorState.processor,
|
||||
},
|
||||
...liveKitOptions,
|
||||
videoCaptureDefaults,
|
||||
publishDefaults,
|
||||
audioCaptureDefaults: {
|
||||
...defaultLiveKitOptions.audioCaptureDefaults,
|
||||
...liveKitOptions.audioCaptureDefaults,
|
||||
deviceId: devices.audioInput.selected$.value?.id,
|
||||
echoCancellation,
|
||||
noiseSuppression,
|
||||
echoCancellation: echoCancellationSetting.getValue(),
|
||||
noiseSuppression: noiseSuppressionSetting.getValue(),
|
||||
autoGainControl: autoGainControlSetting.getValue(),
|
||||
},
|
||||
audioOutput: {
|
||||
// When using controlled audio devices, we don't want to set the
|
||||
|
||||
@@ -22,6 +22,16 @@ 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) => {
|
||||
@@ -58,11 +68,14 @@ describe("ECConnectionFactory - Audio inputs options", () => {
|
||||
{ echo: false, noise: true },
|
||||
{ echo: false, noise: false },
|
||||
])(
|
||||
"it sets echoCancellation=$echo and noiseSuppression=$noise based on constructor parameters",
|
||||
"it sets echoCancellation=$echo and noiseSuppression=$noise based on settings",
|
||||
({ 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",
|
||||
@@ -73,9 +86,6 @@ describe("ECConnectionFactory - Audio inputs options", () => {
|
||||
}),
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
echo,
|
||||
noise,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
@@ -101,9 +111,13 @@ 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",
|
||||
@@ -120,9 +134,6 @@ describe("ECConnectionFactory - ControlledAudioDevice", () => {
|
||||
}),
|
||||
undefined,
|
||||
controlled,
|
||||
undefined,
|
||||
false,
|
||||
false,
|
||||
);
|
||||
ecConnectionFactory.createConnection(
|
||||
testScope,
|
||||
@@ -143,6 +154,114 @@ 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();
|
||||
|
||||
74
src/state/layout-types.test.ts
Normal file
74
src/state/layout-types.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
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));
|
||||
@@ -16,6 +16,7 @@ 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";
|
||||
@@ -140,3 +141,30 @@ 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;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ 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).
|
||||
@@ -47,7 +46,6 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
||||
speaking$: Behavior<boolean>;
|
||||
audioEnabled$: Behavior<boolean>;
|
||||
videoEnabled$: Behavior<boolean>;
|
||||
videoFit$: Behavior<"cover" | "contain">;
|
||||
videoOrientation$: Behavior<"landscape" | "portrait">;
|
||||
toggleCropVideo: () => void;
|
||||
/**
|
||||
@@ -63,12 +61,9 @@ export interface BaseUserMediaViewModel extends BaseMemberMediaViewModel {
|
||||
RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats | undefined
|
||||
>;
|
||||
/**
|
||||
* 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.
|
||||
* Set the aspect ratio of the video track to determine the orientation.
|
||||
*/
|
||||
setTargetDimensions: (targetWidth: number, targetHeight: number) => void;
|
||||
setVideoAspectRatio: (ratio: number) => void;
|
||||
}
|
||||
|
||||
export interface BaseUserMediaInputs extends Omit<
|
||||
@@ -98,14 +93,8 @@ 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,
|
||||
@@ -132,13 +121,11 @@ export function createBaseUserMedia(
|
||||
media$.pipe(map((m) => m?.cameraTrack?.isMuted === false)),
|
||||
),
|
||||
videoOrientation$: scope.behavior(
|
||||
videoSize$.pipe(
|
||||
map((s) => (s ? s.width / s.height : 1)),
|
||||
videoAspectRatio$.pipe(
|
||||
map((aspect) => (aspect > 1 ? "landscape" : "portrait")),
|
||||
),
|
||||
"portrait",
|
||||
),
|
||||
videoFit$: videoFit$(scope, videoSize$, targetSize$),
|
||||
toggleCropVideo: () => toggleCropVideo$.next(),
|
||||
rtcBackendIdentity,
|
||||
handRaised$,
|
||||
@@ -162,8 +149,6 @@ export function createBaseUserMedia(
|
||||
return observeRtpStreamStats$(p, Track.Source.Camera, statsType);
|
||||
}),
|
||||
),
|
||||
setTargetDimensions: (targetWidth: number, targetHeight: number): void => {
|
||||
targetSize$.next({ width: targetWidth, height: targetHeight });
|
||||
},
|
||||
setVideoAspectRatio: (ratio) => videoAspectRatio$.next(ratio),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -19,10 +19,15 @@ 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,
|
||||
@@ -32,9 +37,7 @@ export function observeRtpStreamStats$(
|
||||
> {
|
||||
return combineLatest([
|
||||
observeTrackReference$(participant, source),
|
||||
// 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)),
|
||||
refreshStats$.pipe(startWith(0)),
|
||||
]).pipe(
|
||||
switchMap(async ([trackReference]) => {
|
||||
const track = trackReference?.publication?.track;
|
||||
@@ -69,12 +72,3 @@ 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type RemoteTrackPublication } from "livekit-client";
|
||||
import {
|
||||
type LocalTrackPublication,
|
||||
type RemoteTrackPublication,
|
||||
} from "livekit-client";
|
||||
import { test, expect } from "vitest";
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { axe } from "vitest-axe";
|
||||
@@ -17,6 +20,9 @@ import {
|
||||
mockRtcMembership,
|
||||
mockRemoteMedia,
|
||||
mockRemoteParticipant,
|
||||
mockLocalMedia,
|
||||
mockLocalParticipant,
|
||||
mockMediaDevices,
|
||||
} from "../utils/test";
|
||||
import { GridTileViewModel } from "../state/TileViewModel";
|
||||
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
|
||||
@@ -54,7 +60,7 @@ const callVm = {
|
||||
handsRaised$: constant({}),
|
||||
} as Partial<CallViewModel> as CallViewModel;
|
||||
|
||||
test("GridTile is accessible", async () => {
|
||||
test("GridTile displays remote media", async () => {
|
||||
const vm = mockRemoteMedia(
|
||||
mockRtcMembership("@alice:example.org", "AAAA"),
|
||||
{
|
||||
@@ -88,6 +94,40 @@ test("GridTile is accessible", 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"]
|
||||
|
||||
@@ -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,7 +91,6 @@ const RingingMediaTile: FC<RingingMediaTileProps> = ({
|
||||
}
|
||||
avatarStyle="translucent"
|
||||
videoEnabled={false}
|
||||
videoFit="cover"
|
||||
mirror={false}
|
||||
{...props}
|
||||
/>
|
||||
@@ -105,20 +104,22 @@ interface UserMediaTileProps extends TileProps {
|
||||
playbackMuted: boolean;
|
||||
waitingForMedia?: boolean;
|
||||
primaryButton?: ReactNode;
|
||||
menuStart?: ReactNode;
|
||||
menuEnd?: ReactNode;
|
||||
focusUrl: string | undefined;
|
||||
}
|
||||
|
||||
const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
/**
|
||||
* 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 }> = ({
|
||||
ref,
|
||||
vm,
|
||||
showSpeakingIndicators,
|
||||
playbackMuted,
|
||||
waitingForMedia,
|
||||
primaryButton,
|
||||
menuStart,
|
||||
menuEnd,
|
||||
menu,
|
||||
className,
|
||||
focusUrl,
|
||||
displayName,
|
||||
@@ -141,19 +142,11 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
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
|
||||
@@ -166,31 +159,32 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
: t("microphone_off");
|
||||
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
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 menuTrigger = useMemo(
|
||||
() => (
|
||||
<button
|
||||
aria-label={t("common.options")}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
),
|
||||
[t, focusable],
|
||||
);
|
||||
|
||||
const raisedHandOnClick = vm.local
|
||||
? (): void => void toggleRaisedHand()
|
||||
: undefined;
|
||||
const raisedHandOnClick = useMemo(
|
||||
() => (vm.local ? (): void => void toggleRaisedHand() : undefined),
|
||||
[vm.local, toggleRaisedHand],
|
||||
);
|
||||
|
||||
const showSpeaking = showSpeakingIndicators && speaking;
|
||||
|
||||
const tile = (
|
||||
return (
|
||||
<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,
|
||||
@@ -213,14 +207,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
open={menuOpen}
|
||||
onOpenChange={setMenuOpen}
|
||||
title={displayName}
|
||||
trigger={
|
||||
<button
|
||||
aria-label={t("common.options")}
|
||||
tabIndex={focusable ? undefined : -1}
|
||||
>
|
||||
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
|
||||
</button>
|
||||
}
|
||||
trigger={menuTrigger}
|
||||
side="left"
|
||||
align="start"
|
||||
>
|
||||
@@ -233,6 +220,7 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
raisedHandOnClick={raisedHandOnClick}
|
||||
waitingForMedia={waitingForMedia}
|
||||
focusUrl={focusUrl}
|
||||
setVideoAspectRatio={vm.setVideoAspectRatio}
|
||||
audioStreamStats={audioStreamStats}
|
||||
videoStreamStats={videoStreamStats}
|
||||
rtcBackendIdentity={rtcBackendIdentity}
|
||||
@@ -241,9 +229,37 @@ const UserMediaTile: FC<UserMediaTileProps> = ({
|
||||
{...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={displayName} trigger={tile} hasAccessibleAlternative>
|
||||
<ContextMenu
|
||||
title={props.displayName}
|
||||
trigger={<UserMediaTileInner {...props} menu={menu} />}
|
||||
hasAccessibleAlternative
|
||||
>
|
||||
{menu}
|
||||
</ContextMenu>
|
||||
);
|
||||
@@ -279,6 +295,29 @@ 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}
|
||||
@@ -297,23 +336,8 @@ const LocalUserMediaTile: FC<LocalUserMediaTileProps> = ({
|
||||
</button>
|
||||
)
|
||||
}
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
menuStart={menuStart}
|
||||
menuEnd={menuEnd}
|
||||
focusable={focusable}
|
||||
focusUrl={focusUrl}
|
||||
{...props}
|
||||
|
||||
@@ -33,6 +33,9 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
.media.mirror video {
|
||||
transform: scaleX(-1);
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.media[data-video-fit="cover"] video {
|
||||
|
||||
@@ -33,7 +33,6 @@ describe("MediaView", () => {
|
||||
const baseProps: ComponentProps<typeof MediaView> = {
|
||||
displayName: "some name",
|
||||
videoEnabled: true,
|
||||
videoFit: "contain",
|
||||
targetWidth: 300,
|
||||
targetHeight: 200,
|
||||
mirror: false,
|
||||
|
||||
@@ -7,7 +7,13 @@ 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 } from "react";
|
||||
import {
|
||||
type FC,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import { VideoTrack } from "@livekit/components-react";
|
||||
@@ -26,6 +32,7 @@ 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;
|
||||
@@ -33,7 +40,11 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
video: TrackReferenceOrPlaceholder | undefined;
|
||||
videoFit: "cover" | "contain";
|
||||
/**
|
||||
* 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";
|
||||
mirror: boolean;
|
||||
soundWaves?: boolean;
|
||||
userId: string;
|
||||
@@ -55,8 +66,15 @@ 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> = ({
|
||||
@@ -89,6 +107,7 @@ export const MediaView: FC<Props> = ({
|
||||
videoStreamStats,
|
||||
rtcBackendIdentity,
|
||||
focusUrl,
|
||||
setVideoAspectRatio: setTheirVideoAspectRatio,
|
||||
...props
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -100,6 +119,22 @@ 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")}
|
||||
@@ -126,7 +161,9 @@ export const MediaView: FC<Props> = ({
|
||||
ref={ref}
|
||||
data-testid="videoTile"
|
||||
data-video-enabled={video && videoEnabled}
|
||||
data-video-fit={videoFit}
|
||||
data-video-fit={
|
||||
videoFit ?? autoVideoFit(videoAspectRatio, tileAspectRatio)
|
||||
}
|
||||
data-background={background}
|
||||
{...props}
|
||||
>
|
||||
@@ -158,6 +195,8 @@ 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>
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
global.IntersectionObserver = class MockIntersectionObserver {
|
||||
public observe(): void {}
|
||||
public unobserve(): void {}
|
||||
public disconnect(): void {}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
|
||||
test("SpotlightTile is accessible", async () => {
|
||||
|
||||
@@ -68,6 +68,7 @@ interface SpotlightItemBaseProps {
|
||||
background: "solid" | "transparent";
|
||||
focusable: boolean;
|
||||
"aria-hidden"?: boolean;
|
||||
setVideoAspectRatio?: (ratio: number) => void;
|
||||
}
|
||||
|
||||
interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
@@ -77,7 +78,6 @@ interface SpotlightMemberMediaItemBaseProps extends SpotlightItemBaseProps {
|
||||
}
|
||||
|
||||
interface SpotlightUserMediaItemBaseProps extends SpotlightMemberMediaItemBaseProps {
|
||||
videoFit: "contain" | "cover";
|
||||
videoEnabled: boolean;
|
||||
soundWaves: boolean | undefined;
|
||||
}
|
||||
@@ -120,20 +120,12 @@ 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> = {
|
||||
videoFit,
|
||||
setVideoAspectRatio: vm.setVideoAspectRatio,
|
||||
videoEnabled,
|
||||
soundWaves: props.background === "transparent" ? speaking : undefined,
|
||||
targetWidth,
|
||||
@@ -227,7 +219,6 @@ const SpotlightRingingMediaItem: FC<SpotlightRingingMediaItemProps> = ({
|
||||
}
|
||||
avatarStyle="translucent"
|
||||
videoEnabled={false}
|
||||
videoFit="cover"
|
||||
mirror={false}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,10 @@ 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",
|
||||
@@ -51,6 +55,10 @@ 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,
|
||||
@@ -69,6 +77,10 @@ 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 (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { i18nKey } from "./i18n";
|
||||
|
||||
export enum ErrorCode {
|
||||
/**
|
||||
* Configuration problem due to no MatrixRTC backend/SFU is exposed via .well-known and no fallback configured.
|
||||
* Configuration problem due to no MatrixRTC transport provided by homeserver 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 backend/SFU is exposed via .well-known and no fallback configured.
|
||||
* Configuration problem due to no MatrixRTC transport provided by homeserver and no fallback configured.
|
||||
*/
|
||||
export class MatrixRTCTransportMissingError extends ElementCallError {
|
||||
public domain: string;
|
||||
|
||||
@@ -439,6 +439,12 @@ 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);
|
||||
|
||||
@@ -5,259 +5,106 @@ 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, vi } from "vitest";
|
||||
import {
|
||||
LocalTrack,
|
||||
type LocalTrackPublication,
|
||||
type RemoteTrackPublication,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ObservableScope } from "../state/ObservableScope";
|
||||
import { videoFit$, videoSizeFromParticipant$ } from "./videoFit";
|
||||
import { constant } from "../state/Behavior";
|
||||
import {
|
||||
flushPromises,
|
||||
mockLocalParticipant,
|
||||
mockRemoteParticipant,
|
||||
} from "./test";
|
||||
import { autoVideoFit } from "./videoFit";
|
||||
|
||||
describe("videoFit$ defaults", () => {
|
||||
test.each([
|
||||
{
|
||||
videoSize: { width: 1920, height: 1080 },
|
||||
tileSize: undefined,
|
||||
videoAspectRatio: 1920 / 1080,
|
||||
tileAspectRatio: NaN,
|
||||
},
|
||||
{
|
||||
videoSize: { width: 1080, height: 1920 },
|
||||
tileSize: undefined,
|
||||
videoAspectRatio: 1080 / 1920,
|
||||
tileAspectRatio: NaN,
|
||||
},
|
||||
{
|
||||
videoSize: undefined,
|
||||
tileSize: { width: 1920, height: 1080 },
|
||||
videoAspectRatio: NaN,
|
||||
tileAspectRatio: 1920 / 1080,
|
||||
},
|
||||
{
|
||||
videoSize: undefined,
|
||||
tileSize: { width: 1080, height: 1920 },
|
||||
videoAspectRatio: NaN,
|
||||
tileAspectRatio: 1080 / 1920,
|
||||
},
|
||||
])(
|
||||
"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");
|
||||
},
|
||||
"videoFit$ returns `cover` when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio",
|
||||
({ videoAspectRatio, tileAspectRatio }) =>
|
||||
expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).toBe("cover"),
|
||||
);
|
||||
});
|
||||
|
||||
const VIDEO_480_L = { width: 640, height: 480 };
|
||||
const VIDEO_720_L = { width: 1280, height: 720 };
|
||||
const VIDEO_1080_L = { width: 1920, height: 1080 };
|
||||
const VIDEO_480_L = 640 / 480;
|
||||
const VIDEO_720_L = 1280 / 720;
|
||||
const VIDEO_1080_L = 1920 / 1080;
|
||||
|
||||
// Some sizes from real world testing, which don't match the standard video sizes exactly
|
||||
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 };
|
||||
const TILE_SIZE_1_L = 180 / 135;
|
||||
const TILE_SIZE_3_P = 379 / 542;
|
||||
const TILE_SIZE_4_L = 957 / 542;
|
||||
// This is the size of an iPhone Xr in portrait mode
|
||||
const TILE_SIZE_5_P = { width: 414, height: 896 };
|
||||
const TILE_SIZE_5_P = 414 / 896;
|
||||
|
||||
export function invertSize(size: { width: number; height: number }): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
return {
|
||||
width: size.height,
|
||||
height: size.width,
|
||||
};
|
||||
function inverse(ratio: number): number {
|
||||
return 1 / ratio;
|
||||
}
|
||||
|
||||
test.each([
|
||||
{
|
||||
videoSize: VIDEO_480_L,
|
||||
tileSize: TILE_SIZE_1_L,
|
||||
videoAspectRatio: VIDEO_480_L,
|
||||
tileAspectRatio: TILE_SIZE_1_L,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_480_L),
|
||||
tileSize: TILE_SIZE_1_L,
|
||||
videoAspectRatio: inverse(VIDEO_480_L),
|
||||
tileAspectRatio: TILE_SIZE_1_L,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
videoSize: VIDEO_720_L,
|
||||
tileSize: TILE_SIZE_4_L,
|
||||
videoAspectRatio: VIDEO_720_L,
|
||||
tileAspectRatio: TILE_SIZE_4_L,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_720_L),
|
||||
tileSize: TILE_SIZE_4_L,
|
||||
videoAspectRatio: inverse(VIDEO_720_L),
|
||||
tileAspectRatio: TILE_SIZE_4_L,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_1080_L),
|
||||
tileSize: TILE_SIZE_3_P,
|
||||
videoAspectRatio: inverse(VIDEO_1080_L),
|
||||
tileAspectRatio: TILE_SIZE_3_P,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: VIDEO_1080_L,
|
||||
tileSize: TILE_SIZE_5_P,
|
||||
videoAspectRatio: VIDEO_1080_L,
|
||||
tileAspectRatio: TILE_SIZE_5_P,
|
||||
expected: "contain",
|
||||
},
|
||||
{
|
||||
videoSize: invertSize(VIDEO_1080_L),
|
||||
tileSize: TILE_SIZE_5_P,
|
||||
videoAspectRatio: inverse(VIDEO_1080_L),
|
||||
tileAspectRatio: TILE_SIZE_5_P,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
// square video
|
||||
videoSize: { width: 400, height: 400 },
|
||||
tileSize: VIDEO_480_L,
|
||||
videoAspectRatio: 400 / 400,
|
||||
tileAspectRatio: 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.
|
||||
videoSize: VIDEO_480_L,
|
||||
tileSize: { width: 0, height: 0 },
|
||||
videoAspectRatio: VIDEO_480_L,
|
||||
tileAspectRatio: 0 / 0,
|
||||
expected: "cover",
|
||||
},
|
||||
{
|
||||
videoSize: { width: 0, height: 0 },
|
||||
tileSize: VIDEO_480_L,
|
||||
videoAspectRatio: 0 / 0,
|
||||
tileAspectRatio: VIDEO_480_L,
|
||||
expected: "cover",
|
||||
},
|
||||
])(
|
||||
"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);
|
||||
},
|
||||
"videoFit$ returns $expected when videoAspectRatio is $videoAspectRatio and tileAspectRatio is $tileAspectRatio",
|
||||
({ videoAspectRatio, tileAspectRatio, expected }) =>
|
||||
expect(autoVideoFit(videoAspectRatio, tileAspectRatio)).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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,107 +5,27 @@ 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 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;
|
||||
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";
|
||||
}
|
||||
|
||||
// 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";
|
||||
}),
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
);
|
||||
// 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";
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ 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);
|
||||
@@ -109,6 +110,7 @@ describe("widget", () => {
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
rtcTransports: true,
|
||||
sendDelayedEvents: true,
|
||||
updateDelayedEvents: true,
|
||||
sendSticky: true,
|
||||
|
||||
@@ -21,6 +21,7 @@ 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
|
||||
@@ -173,6 +174,7 @@ export const initializeWidget = (
|
||||
sendToDevice: sendRecvToDevice,
|
||||
receiveToDevice: sendRecvToDevice,
|
||||
turnServers: false,
|
||||
rtcTransports: true,
|
||||
sendDelayedEvents: true,
|
||||
updateDelayedEvents: true,
|
||||
sendSticky: true,
|
||||
@@ -195,7 +197,8 @@ 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();
|
||||
await client.startClient({ clientWellKnownPollPeriod: 60 * 10 });
|
||||
seedSettingsFromConfig(Config.get().media_quality);
|
||||
await client.startClient();
|
||||
return client;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE in the repository root for full details.
|
||||
|
||||
import {
|
||||
loadEnv,
|
||||
PluginOption,
|
||||
type 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 react from "@vitejs/plugin-react";
|
||||
import babel from "@rolldown/plugin-babel";
|
||||
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
|
||||
import { realpathSync } from "fs";
|
||||
import * as fs from "node:fs";
|
||||
|
||||
@@ -29,6 +29,9 @@ export const vitePluginsConfig = ({
|
||||
}: Pick<ConfigEnv, "mode">): UserConfig => {
|
||||
const env = loadEnv(mode, process.cwd());
|
||||
const plugins: PluginOption[] = [
|
||||
babel({
|
||||
presets: [reactCompilerPreset()],
|
||||
}),
|
||||
react(),
|
||||
wasm(),
|
||||
nodePolyfills({
|
||||
|
||||
Reference in New Issue
Block a user