Compare commits

..

24 Commits

Author SHA1 Message Date
lebaudantoine 06494371cc fixup! wip handle device in use 2026-08-21 23:16:43 +02:00
lebaudantoine 4f5d5d1b9a wip handle device in use 2026-08-21 23:16:41 +02:00
lebaudantoine e5f0b1c202 🐛(frontend) fix joined notification tile no longer rendering properly
The toast is enqueued on `ParticipantConnected`, which livekit-client
fires before the participant's track publications exist. At that
point `getTrackPublication(Camera)` is `undefined`, and the track
ref used by the toast is frozen that way — the toast never
re-subscribes to the participant.

Downstream effects:

* LiveKit's `useIsMuted` initializes to `false` when there is no
  publication, so the tile first paints with
  `data-lk-video-muted="false"`, which the stock CSS maps to
  placeholder `opacity: 0` — a blank box.
* Its effect then subscribes to `mutedObserver`, which defaults to
  `true` for the same situation, triggering a second render and
  only then starting the 200 ms fade-in. That render-effect-render
  transition is the visible lag behind the text.
* Because the track ref never updates, no `VideoTrack` ever mounts.
  When the camera is later subscribed, the placeholder fades out
  again, leaving the tile empty.

Re-derive the track ref inside the toast when the participant's
publications become available, so `useIsMuted` sees the real state
from the start and the tile renders the video (or a stable
placeholder) instead of a blank box.
2026-08-21 22:55:28 +02:00
lebaudantoine 9e0d57a8c6 🐛(frontend) hoist mute confirmation dialog to VideoConference level
`MuteButton` owned the confirmation dialog state locally. On the
participant tile, the button lives inside `FadeOverlay`, which
conditionally mounts its children based on tile mouse activity
(3 s idle timeout). Once the user moved the pointer onto the
react-aria portal, mouse events on the tile stopped, the idle timer
expired, `FadeOverlay` unmounted `MuteButton`, and the dialog was
destroyed with it — hence the "closes randomly" behavior.

Render the dialog once at the VideoConference level via a shared
`MuteAlertDialogProvider`, so its lifetime is independent from any
button that opens it.
2026-08-21 22:55:28 +02:00
lebaudantoine 7ba0803b71 💄(frontend) increase the blur intensity
Bump the blur strength applied to virtual backgrounds so it obscures
the background more effectively.

Requested by users.
2026-08-21 22:55:28 +02:00
lebaudantoine beacfc3d3f 🐛(frontend) stop init_virtual_background from firing on blur updates
A regression introduced with the custom virtual background feature
(16daf7b8, March 26): every blur init on Firefox, and every update
call, was going through `init_virtual_background`, which raised an
error because there is no virtual background to initialize in that
code path.

The existing guard was incorrect. Replace it with an early return,
so `init_virtual_background` is only executed when there is actually
a virtual background to initialize.

This does not fix the other virtual background issues that currently
prevent some users from activating their camera; those will be
addressed separately.
2026-08-21 22:55:28 +02:00
lebaudantoine 52e5d99e83 📈(frontend) track errors when starting or stopping a recording
Send an analytics event whenever starting or stopping a recording
fails, so we can monitor how often it happens.

It also gives support the context needed to understand what went
wrong when inspecting a user's session.
2026-08-21 22:55:28 +02:00
lebaudantoine 15ca2b41b4 🐛(frontend) use state instead of a ref for MoreControls container
After the recent refactoring, `MoreControls` only renders once. On
that first render, the container ref is `null`, and when it later
gets a reference to the div, the ref update does not trigger a
re-render.

As a result, the additional controls were never showing up.

Switch from a ref to a state to track the container element. State
updates do trigger a re-render, so the controls now show as
expected once the container is available.
2026-08-21 22:55:28 +02:00
lebaudantoine e34f3dd219 🐛(frontend) treat client-initiated connect aborts as events
`connect()` calls can be aborted by a `disconnect()` before the
join completes — typically when the user leaves, refreshes,
navigates away early, or when the component remounts. This is
expected behavior, not a failure.

Stop reporting these as errors. Track them as analytics events
instead, so we can still monitor their volume without polluting
error dashboards.

Volume is relatively low, but keeping visibility helps troubleshoot
users who complain about difficulty connecting to a room, and could
also flag a component that is re-rendering and killing the
connection.
2026-08-21 22:55:28 +02:00
lebaudantoine feb573551f 💡(frontend) warn in vite config on MediaPipe asset/track-processor drift
Add a build-time warning in the Vite config that fires when the
MediaPipe assets copied by `vite-plugin-static-copy` fall out of
sync with the version used by `@livekit/track-processors`.

This catches at build time the kind of drift that would otherwise
only surface at runtime as broken virtual backgrounds.
2026-08-21 18:09:31 +02:00
lebaudantoine 1d0a0cc637 ⬆️(mail) upgrade Node image for mails-install to align with the frontend
Node 18 is now legacy, and running `make mails-install` was failing
with a "bad engine" error against the current image.

Bump the Node image used for `mails-install` to a more recent
version, aligned with the one used by the frontend, so the make
target runs cleanly again.

Note: the Alpine variant cannot be used here, as `bash` is required
to build the mail templates.
2026-08-20 16:09:03 +02:00
snyk-bot eae93f771f 🔒️(mail) fix vulnerabilities in src/mail/package.json
Upgrade dependencies to fix the following reported vulnerability:

* https://snyk.io/vuln/SNYK-JS-DEEPMERGETS-18912249
2026-08-20 16:09:03 +02:00
Florent Chehab 45175a2a54 ♻️(summary) uniformized S3 settings case
Quick commit to uniformize the S3 related settings case.
2026-08-20 12:50:26 +02:00
Florent Chehab 8b22059b18 (summary) configurable s3 region
When using other S3 providers than minio,
We neeed to configure the region
2026-08-20 12:41:48 +02:00
lebaudantoine 87dbd8069d ⬆️(frontend) upgrade livekit-client and @livekit/components-react
Bump livekit-client 2.20.0 -> 2.21.0 and
@livekit/components-react 2.9.21 -> 2.9.23. Patch/minor upgrade
with data streams v2, data channel flow control and reliability
fixes, iOS Safari double-playback fix, and a build-time type
resolution fix for `skipLibCheck: false`.

No breaking API changes.
2026-08-20 11:08:27 +02:00
lebaudantoine c7420c59a3 ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
Minor version upgrade with bug fixes and internal improvements. No
breaking changes, API deprecations or required actions on our side.

Highlights:

* Better handling of `sendBeacon` quota rejections so events are no
  longer silently dropped.
* Improved error catching for synchronous throws from a
  monkey-patched `window.fetch`.
* Source map improvements for easier debugging.
2026-08-20 11:08:27 +02:00
lebaudantoine f46babfcbd ⬆️(frontend) upgrade i18next and react-i18next patch versions
Bump i18next from 26.3.1 to 26.3.6 and react-i18next from 17.0.8 to
17.0.10. Both are patch upgrades with bug fixes and no breaking
changes, requiring no code modifications on our side.

i18next:

* 26.3.6: widen the optional TypeScript peer dependency range to
  include v7.
* 26.3.1: fix a regression in type definitions related to
  `keyPrefix` to prevent incorrect type pollution.

react-i18next:

* 17.0.10: improve warning messages for `useTranslation` and
  `Trans` to help debug common issues, especially in monorepos.
  Add a new development-only warning when `useTranslation`
  suspends while loading translations.
* 17.0.9: add TypeScript 7 support and fix related type-checking
  issues with the `<Trans>` component.
2026-08-20 11:08:27 +02:00
lebaudantoine 7c465f2148 ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
Update sqlparse to 0.6.0 to pick up fixes for the following high
severity CVEs, all reported against versions prior to 0.6.0:

* CVE-2026-54284: https://avd.aquasec.com/nvd/cve-2026-54284
* CVE-2026-59893: https://avd.aquasec.com/nvd/cve-2026-59893
* CVE-2026-71491: https://avd.aquasec.com/nvd/cve-2026-71491
2026-08-19 13:30:25 +02:00
lebaudantoine d005f202c6 (backend) accept form-urlencoded on the user token endpoint
Accept `application/x-www-form-urlencoded` requests on the user
token endpoint, in addition to the existing JSON support.

This aligns the endpoint with the OAuth 2.0 specification for token
endpoint requests (RFC 6749, sections 3.2 and 4.4.2), so standard
OAuth 2.0 client libraries can call it without any customization,
while keeping backward compatibility with existing JSON clients.
2026-08-19 13:30:25 +02:00
chaitanyaphatak a82023f8b0 📝(docs) fix minor typos in comments and docstrings 2026-08-17 10:30:16 +02:00
lebaudantoine cc9dae66db 🔖(minor) bump release to 1.27.0 2026-08-14 15:22:06 +02:00
lebaudantoine 8d000fc6d9 📈(frontend) stop double-reporting media device failures
Only report `Other` `MediaDeviceFailure` cases as they genuinely
need investigation.

Make sure we do not report the same situation both as a media event
and as a media exception when it is already handled.
2026-08-14 14:04:35 +02:00
lebaudantoine b7abd0ae6e 🐛(frontend) generalize screen-share error modal beyond macOS
The screen-share error modal was tailored to macOS and did not work
correctly on other operating systems.

Make it OS-aware so it also handles Windows properly, showing the
right guidance for each platform.

Also open the OS settings link in a new tab, so the user is not
disconnected from the ongoing meeting when following it.
2026-08-14 11:22:40 +02:00
lebaudantoine 40e4f17c65 🐛(frontend) stop reporting screen-share denials as errors
Add a small helper that classifies a `getDisplayMedia` failure as a
user, browser, or OS permission denial, or returns null when it is
a genuine error.

Chromium reports denials with explicit, non-localized messages:

* "Permission denied by user" when the user cancels or dismisses
  the source picker.
* "Permission denied by system" when the OS blocks capture (e.g.
  the macOS Screen Recording privacy setting).
* Plain "Permission denied" for browser-level blocks (site
  settings, enterprise policy, permissions-policy).

Firefox and Safari use generic `NotAllowedError` messages, which
fall into the "browser" bucket.

Firefox additionally does not map macOS Screen Recording (TCC)
blocks to `NotAllowedError`: the OS silently returns no capturable
sources, so `getDisplayMedia` rejects with `NotFoundError` ("The
object can not be found here."). Same quirk as the mic/cam OS blocks
handled in `useWatchMediaDeviceErrors` via `isLikelySystemNotFound`.

Behavior on a denied screen-share permission:

* Denials are expected outcomes (picker cancelled by the user, OS
  privacy settings, enterprise policy…) and no longer surface as
  exceptions in error tracking; capture an analytics event instead.
* Only OS-level blocks get the modal, since it explains how to
  unblock them.
2026-08-14 11:22:40 +02:00
68 changed files with 927 additions and 946 deletions
+27 -1
View File
@@ -10,7 +10,30 @@ and this project adheres to
### Added ### Added
- (frontend) introduce performance mode with auto-detection and telemetry - 📈(frontend) track errors when starting or stopping a recording
- 🚸(frontend) explain camera-in-use failures on the join screen
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
## [1.27.0] - 2026-08-14
### Changed ### Changed
@@ -25,6 +48,9 @@ and this project adheres to
- 🐛(frontend) implement hysteresis band for the control bar layout - 🐛(frontend) implement hysteresis band for the control bar layout
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift - 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
- 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog - 🐛(analytics) filter benign ResizeObserver loop error in Sentry/PostHog
- 🐛(frontend) stop reporting screen-share denials as errors
- 🐛(frontend) generalize screen-share error modal beyond macOS
- 📈(frontend) stop double-reporting media device failures
## [1.26.0] - 2026-08-12 ## [1.26.0] - 2026-08-12
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us. Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr If you have any issue regarding security, please disclose the information responsibly by submitting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure. We appreciate your effort to make Visio more secure.
+1 -1
View File
@@ -180,7 +180,7 @@ services:
working_dir: /app working_dir: /app
node: node:
image: node:18 image: node:22
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
environment: environment:
HOME: /tmp HOME: /tmp
+27
View File
@@ -50,10 +50,24 @@ paths:
The application must be authorized for the user's email domain. The application must be authorized for the user's email domain.
The returned token expires after a configured duration and must be refreshed by calling this endpoint again. The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
Request parameters may be sent either as "application/x-www-form-urlencoded"
(as specified by RFC 6749 for OAuth 2.0 token endpoints) or as "application/json".
operationId: generateToken operationId: generateToken
requestBody: requestBody:
required: true required: true
content: content:
application/x-www-form-urlencoded:
schema:
$ref: '#/components/schemas/TokenRequest'
examples:
tokenRequest:
summary: Request token for user delegation
value:
client_id: "550e8400-e29b-41d4-a716-446655440000"
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
grant_type: "client_credentials"
scope: "user@example.com"
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/TokenRequest' $ref: '#/components/schemas/TokenRequest'
@@ -117,6 +131,19 @@ paths:
summary: Domain not authorized summary: Domain not authorized
value: value:
error: "This application is not authorized for this email domain." error: "This application is not authorized for this email domain."
'415':
description: |
Unsupported media type. The request body must be sent as
"application/x-www-form-urlencoded" or "application/json".
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
examples:
unsupportedMediaType:
summary: Unsupported request content type
value:
detail: 'Unsupported media type "text/plain" in request.'
/rooms: /rooms:
get: get:
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.26.0" version = "1.27.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.26.0" version = "1.27.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "livekit-agents" }, { name = "livekit-agents" },
@@ -12,6 +12,9 @@ from rest_framework import decorators, mixins, viewsets
from rest_framework import ( from rest_framework import (
exceptions as drf_exceptions, exceptions as drf_exceptions,
) )
from rest_framework import (
parsers as drf_parsers,
)
from rest_framework import ( from rest_framework import (
response as drf_response, response as drf_response,
) )
@@ -41,6 +44,7 @@ class ApplicationViewSet(viewsets.ViewSet):
methods=["post"], methods=["post"],
url_path="token", url_path="token",
url_name="token", url_name="token",
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
) )
@FeatureFlag.require("application") @FeatureFlag.require("application")
def generate_jwt_access_token(self, request, *args, **kwargs): def generate_jwt_access_token(self, request, *args, **kwargs):
@@ -5,6 +5,7 @@ Tests for external API /token endpoint
# pylint: disable=W0621 # pylint: disable=W0621
from unittest import mock from unittest import mock
from urllib.parse import urlencode
import jwt import jwt
import pytest import pytest
@@ -88,6 +89,155 @@ def test_api_applications_generate_token_success(settings):
} }
def test_api_applications_generate_token_form_urlencoded(settings):
"""The token endpoint should accept "application/x-www-form-urlencoded"
requests, as mandated by RFC 6749 (sections 3.2 and 4.4.2) for OAuth 2.0
token endpoints, so that standard OAuth 2.0 client libraries work
out of the box."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
(
f"client_id={application.client_id}"
f"&client_secret={plain_secret}"
"&grant_type=client_credentials"
"&scope=user%40example.com"
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
response.data.pop("access_token")
assert response.data == {
"token_type": "Bearer",
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
"scope": "rooms:list rooms:create",
}
def test_api_applications_generate_token_form_urlencoded_invalid_credentials():
"""Invalid credentials sent as form-urlencoded should be parsed and
rejected with 401, proving the request body is properly decoded."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
def test_api_applications_generate_token_form_urlencoded_missing_fields():
"""Missing required fields in a form-urlencoded request should return
a 400 validation error, like for JSON requests."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode({"grant_type": "client_credentials"}),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
for field in ("client_id", "client_secret", "scope"):
assert field in response.data
def test_api_applications_generate_token_form_urlencoded_invalid_grant_type():
"""An unsupported grant_type sent as form-urlencoded should return 400."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "authorization_code",
"scope": user.email,
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 400
assert "grant_type" in response.data
def test_api_applications_generate_token_form_urlencoded_special_characters():
"""Percent-encoded reserved characters ("&", "=", "+", "%") in the
client_secret should survive form-urlencoded decoding."""
UserFactory(email="user@example.com")
application = ApplicationFactory(
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
plain_secret = "s3cr3t&with=special+chars%42"
application.client_secret = plain_secret
application.save()
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
urlencode(
{
"client_id": application.client_id,
"client_secret": plain_secret,
"grant_type": "client_credentials",
"scope": "user@example.com",
}
),
content_type="application/x-www-form-urlencoded",
)
assert response.status_code == 200
assert "access_token" in response.data
def test_api_applications_generate_token_unsupported_media_type():
"""Content types other than JSON and form-urlencoded should still be
rejected with 415 Unsupported Media Type."""
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
"client_id=x&client_secret=y&grant_type=client_credentials&scope=a@b.co",
content_type="text/plain",
)
assert response.status_code == 415
def test_api_applications_generate_token_invalid_client_id(): def test_api_applications_generate_token_invalid_client_id():
"""Invalid client_id should return 401.""" """Invalid client_id should return 401."""
user = UserFactory(email="user@example.com") user = UserFactory(email="user@example.com")
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.26.0" version = "1.27.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+4 -4
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.26.0" version = "1.27.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
@@ -2252,11 +2252,11 @@ wheels = [
[[package]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.6.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/90/76/437d71068094df0726366574cf3432a4ed754217b436eb7429415cf2d480/sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e", size = 120815, upload-time = "2025-12-19T07:17:45.073Z" } sdist = { url = "https://files.pythonhosted.org/packages/5f/d3/3f06a1006f2261d1342aefb3c71eed02f5d4ca5bdbecd86ebc12ad38306e/sqlparse-0.6.0.tar.gz", hash = "sha256:113c35c75365ab9cc9c7231d68c6428fb11c085fc8e9eb1ad659b7ddbf6cd2b9", size = 178477, upload-time = "2026-08-13T19:16:06.396Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/49/4b/359f28a903c13438ef59ebeee215fb25da53066db67b305c125f1c6d2a25/sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba", size = 46138, upload-time = "2025-12-19T07:17:46.573Z" }, { url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070, upload-time = "2026-08-13T19:16:04.062Z" },
] ]
[[package]] [[package]]
+70 -67
View File
@@ -1,18 +1,18 @@
{ {
"name": "meet", "name": "meet",
"version": "1.26.0", "version": "1.27.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.26.0", "version": "1.27.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6", "@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21", "@livekit/components-react": "2.9.23",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.14",
@@ -24,17 +24,17 @@
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.20.0", "livekit-client": "2.21.0",
"posthog-js": "1.395.0", "posthog-js": "1.404.1",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.8", "react-i18next": "17.0.10",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -742,28 +742,28 @@
} }
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.5", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.11" "@floating-ui/utils": "^0.2.12"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
"version": "1.7.4", "version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/core": "^1.7.3", "@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.11"
} }
}, },
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.11", "version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": { "node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
@@ -966,12 +966,12 @@
"license": "GPL-3.0+" "license": "GPL-3.0+"
}, },
"node_modules/@livekit/components-core": { "node_modules/@livekit/components-core": {
"version": "0.12.13", "version": "0.12.14",
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==", "integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@floating-ui/dom": "1.7.4", "@floating-ui/dom": "1.7.6",
"loglevel": "1.9.1", "loglevel": "1.9.1",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
@@ -979,17 +979,17 @@
"node": ">=18" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"livekit-client": "^2.17.2", "livekit-client": "^2.20.1",
"tslib": "^2.6.2" "tslib": "^2.6.2"
} }
}, },
"node_modules/@livekit/components-react": { "node_modules/@livekit/components-react": {
"version": "2.9.21", "version": "2.9.23",
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz", "resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==", "integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/components-core": "0.12.13", "@livekit/components-core": "0.12.14",
"clsx": "2.1.1", "clsx": "2.1.1",
"events": "^3.3.0", "events": "^3.3.0",
"jose": "^6.0.12", "jose": "^6.0.12",
@@ -999,8 +999,8 @@
"node": ">=18" "node": ">=18"
}, },
"peerDependencies": { "peerDependencies": {
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0", "@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
"livekit-client": "^2.18.2", "livekit-client": "^2.20.1",
"react": ">=18", "react": ">=18",
"react-dom": ">=18", "react-dom": ">=18",
"tslib": "^2.6.2" "tslib": "^2.6.2"
@@ -1027,9 +1027,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@livekit/protocol": { "node_modules/@livekit/protocol": {
"version": "1.46.6", "version": "1.50.4",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz", "resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==", "integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@bufbuild/protobuf": "^1.10.0" "@bufbuild/protobuf": "^1.10.0"
@@ -1720,18 +1720,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@posthog/core": { "node_modules/@posthog/core": {
"version": "1.39.5", "version": "1.48.3",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==", "integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/types": "^1.392.0" "@posthog/types": "^1.405.0"
} }
}, },
"node_modules/@posthog/types": { "node_modules/@posthog/types": {
"version": "1.392.0", "version": "1.405.0",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz", "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==", "integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-aria/overlays": { "node_modules/@react-aria/overlays": {
@@ -4393,11 +4393,14 @@
} }
}, },
"node_modules/core-js": { "node_modules/core-js": {
"version": "3.39.0", "version": "3.50.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==", "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"engines": {
"node": "*"
},
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
"url": "https://opencollective.com/core-js" "url": "https://opencollective.com/core-js"
@@ -6400,9 +6403,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.3.1", "version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==", "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -6419,7 +6422,7 @@
], ],
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"typescript": "^5 || ^6" "typescript": "^5 || ^6 || ^7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"typescript": { "typescript": {
@@ -8109,20 +8112,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/livekit-client": { "node_modules/livekit-client": {
"version": "2.20.0", "version": "2.21.0",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz", "resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==", "integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@livekit/mutex": "1.1.1", "@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.46.6", "@livekit/protocol": "1.50.4",
"events": "^3.3.0", "events": "^3.3.0",
"jose": "^6.1.0", "jose": "^6.1.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"sdp-transform": "^2.15.0", "sdp-transform": "^2.15.0",
"tslib": "2.8.1", "tslib": "2.8.1",
"typed-emitter": "^2.1.0", "typed-emitter": "^2.1.0",
"webrtc-adapter": "9.0.5" "webrtc-adapter": "9.0.6"
}, },
"peerDependencies": { "peerDependencies": {
"@types/dom-mediacapture-record": "^1" "@types/dom-mediacapture-record": "^1"
@@ -9120,17 +9123,17 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.395.0", "version": "1.404.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==", "integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
"license": "SEE LICENSE IN LICENSE", "license": "(Apache-2.0 AND MIT)",
"dependencies": { "dependencies": {
"@posthog/core": "^1.38.0", "@posthog/core": "^1.43.1",
"@posthog/types": "^1.391.1", "@posthog/types": "^1.397.0",
"core-js": "^3.38.1", "core-js": "^3.49.0",
"dompurify": "^3.3.2", "dompurify": "^3.3.2",
"fflate": "^0.4.8", "fflate": "^0.4.8",
"preact": "^10.29.2", "preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1", "query-selector-shadow-dom": "^1.0.1",
"web-vitals": "^5.3.0" "web-vitals": "^5.3.0"
} }
@@ -9422,9 +9425,9 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.8", "version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==", "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.2", "@babel/runtime": "^7.29.2",
@@ -9434,7 +9437,7 @@
"peerDependencies": { "peerDependencies": {
"i18next": ">= 26.2.0", "i18next": ">= 26.2.0",
"react": ">= 16.8.0", "react": ">= 16.8.0",
"typescript": "^5 || ^6" "typescript": "^5 || ^6 || ^7"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
"react-dom": { "react-dom": {
@@ -11645,9 +11648,9 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/webrtc-adapter": { "node_modules/webrtc-adapter": {
"version": "9.0.5", "version": "9.0.6",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz", "resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==", "integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"sdp": "^3.2.0" "sdp": "^3.2.0"
+6 -6
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.26.0", "version": "1.27.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -19,7 +19,7 @@
"@fontsource-variable/lexend": "5.2.11", "@fontsource-variable/lexend": "5.2.11",
"@fontsource/opendyslexic": "5.2.5", "@fontsource/opendyslexic": "5.2.5",
"@libreaudio/la-call": "0.1.4", "@libreaudio/la-call": "0.1.4",
"@livekit/components-react": "2.9.21", "@livekit/components-react": "2.9.23",
"@livekit/components-styles": "1.2.0", "@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2", "@livekit/track-processors": "0.7.2",
"@mediapipe/tasks-vision": "0.10.14", "@mediapipe/tasks-vision": "0.10.14",
@@ -31,17 +31,17 @@
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.33.2", "humanize-duration": "3.33.2",
"i18next": "26.3.1", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1", "i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.20.0", "livekit-client": "2.21.0",
"posthog-js": "1.395.0", "posthog-js": "1.404.1",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.8", "react-i18next": "17.0.10",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -1,71 +0,0 @@
export interface HardwareSnapshot {
/** navigator.hardwareConcurrency — logical CPU cores. */
cpu_cores: number | null
/** navigator.deviceMemory — RAM in GiB, bucketed by the browser (Chromium only). */
device_memory_gb: number | null
/** performance.memory.jsHeapSizeLimit in MB (Chromium only, non-standard). */
js_heap_limit_mb: number | null
/** performance.memory.usedJSHeapSize in MB (Chromium only, non-standard). */
js_heap_used_mb: number | null
/** Battery level 0..1 via navigator.getBattery() (Chromium only). */
battery_level: number | null
/** Whether the device is plugged in, via navigator.getBattery(). */
battery_charging: boolean | null
}
const BATTERY_TIMEOUT_MS = 1_000
const toMb = (bytes: unknown): number | null =>
typeof bytes === 'number' ? Math.round(bytes / (1024 * 1024)) : null
export const collectHardwareSnapshot = async (): Promise<HardwareSnapshot> => {
const snapshot: HardwareSnapshot = {
cpu_cores: null,
device_memory_gb: null,
js_heap_limit_mb: null,
js_heap_used_mb: null,
battery_level: null,
battery_charging: null,
}
try {
snapshot.cpu_cores = navigator.hardwareConcurrency ?? null
const nav = navigator as Navigator & {
deviceMemory?: number
userAgentData?: { mobile?: boolean; platform?: string }
getBattery?: () => Promise<{ level: number; charging: boolean }>
}
snapshot.device_memory_gb = nav.deviceMemory ?? null
const memory = (
performance as Performance & {
memory?: { jsHeapSizeLimit?: number; usedJSHeapSize?: number }
}
).memory
snapshot.js_heap_limit_mb = toMb(memory?.jsHeapSizeLimit)
snapshot.js_heap_used_mb = toMb(memory?.usedJSHeapSize)
if (typeof nav.getBattery === 'function') {
// getBattery can hang on some platforms — don't let it delay the event.
let timeoutId: ReturnType<typeof setTimeout> | undefined
const battery = await Promise.race([
nav.getBattery(),
new Promise<null>((resolve) => {
timeoutId = setTimeout(() => resolve(null), BATTERY_TIMEOUT_MS)
}),
])
.catch(() => null)
.finally(() => clearTimeout(timeoutId))
if (battery) {
snapshot.battery_level = battery.level
snapshot.battery_charging = battery.charging
}
}
} catch {
// telemetry must never break the app
}
return snapshot
}
@@ -23,6 +23,7 @@ export type LogCode =
| 'livekit_room_error' | 'livekit_room_error'
| 'device_switch_failure' | 'device_switch_failure'
| 'permission_poll_failure' | 'permission_poll_failure'
| 'media_devices_error_event'
// non-media families // non-media families
| 'participant_mute_api_failure' | 'participant_mute_api_failure'
| 'permissions_api_failure' | 'permissions_api_failure'
@@ -30,7 +31,6 @@ export type LogCode =
| 'clipboard_failure' | 'clipboard_failure'
| 'fullscreen_failure' | 'fullscreen_failure'
| 'publish_sources_failure' | 'publish_sources_failure'
| 'performance_mode_failure'
| 'disconnect_failure' | 'disconnect_failure'
| 'generic_failure' | 'generic_failure'
@@ -137,7 +137,9 @@ export const captureMediaEvent = async (
| 'media-device-topology' | 'media-device-topology'
| 'media-device-success' | 'media-device-success'
| 'device-not-found' | 'device-not-found'
| 'device-in-use'
| 'permissions-denied' | 'permissions-denied'
| 'screen-share-permission-denied'
| 'silent-mic-detected' | 'silent-mic-detected'
| 'silent-mic-analyser-unavailable' | 'silent-mic-analyser-unavailable'
| 'silent-mic-recovered' | 'silent-mic-recovered'
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
resetChatStore() resetChatStore()
}, []) }, [])
// Tigger the message notification (temporary) // Trigger the message notification (temporary)
useEffect(() => { useEffect(() => {
// TEMPORARY: This is a brittle workaround that relies on message count tracking // TEMPORARY: This is a brittle workaround that relies on message count tracking
// due to recent LiveKit useChat changes breaking the previous implementation // due to recent LiveKit useChat changes breaking the previous implementation
@@ -3,7 +3,6 @@ export enum ToastDuration {
MEDIUM = 4000, MEDIUM = 4000,
LONG = 5000, LONG = 5000,
EXTRA_LONG = 7000, EXTRA_LONG = 7000,
UNDO_WINDOW = 30000,
} }
export const NotificationDuration = { export const NotificationDuration = {
@@ -16,5 +15,4 @@ export const NotificationDuration = {
REACTION_RECEIVED: ToastDuration.SHORT, REACTION_RECEIVED: ToastDuration.SHORT,
RECORDING_REQUESTED: ToastDuration.LONG, RECORDING_REQUESTED: ToastDuration.LONG,
ROLE_CHANGED: ToastDuration.LONG, ROLE_CHANGED: ToastDuration.LONG,
CPU_CONSTRAINED: ToastDuration.UNDO_WINDOW,
} as const } as const
@@ -18,5 +18,4 @@ export enum NotificationType {
RecordingSaving = 'recordingSaving', RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved', PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged', RoleChanged = 'roleChanged',
CpuConstrained = 'cpuConstrained',
} }
@@ -1,69 +0,0 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { RiCloseLine } from '@remixicon/react'
import { type ToastProps } from './Toast'
import { HStack, VStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { Button, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { StyledToastContainer } from './StyledToastContainer'
import { disablePerformanceMode } from '@/stores/performanceMode'
import { captureEvent } from '@/features/analytics/telemetry'
export function ToastCpuConstrained({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', {
keyPrefix: 'cpuConstrained',
})
const ref = useRef(null)
const { toastProps, contentProps, closeButtonProps } = useToast(
props,
state,
ref
)
const toast = props.toast
const handleKeepQuality = () => {
captureEvent('cpu-constrained-degradation-cancelled')
disablePerformanceMode({ declinedAuto: true })
state.close(toast.key)
}
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack alignItems="start" gap="0.5rem" padding={14}>
<VStack
justify="start"
alignItems="self-start"
{...contentProps}
maxWidth="370px"
gap="0.75rem"
>
<Text
margin={false}
className={css({
wordBreak: 'break-word',
overflowWrap: 'break-word',
whiteSpace: 'normal',
})}
>
{t('message')}
</Text>
<Button
size="sm"
variant="text"
className={css({
color: 'primary.300',
})}
onPress={() => handleKeepQuality()}
>
{t('keepQuality')}
</Button>
</VStack>
<Button square size="sm" invisible {...closeButtonProps}>
<RiCloseLine size={18} color="white" />
</Button>
</HStack>
</StyledToastContainer>
)
}
@@ -1,5 +1,5 @@
import { useToast } from 'react-aria' import { useToast } from 'react-aria'
import { useRef } from 'react' import { useMemo, useRef } from 'react'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import Source = Track.Source import Source = Track.Source
@@ -11,6 +11,7 @@ import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer' import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout' import { setPinnedTrack } from '@/stores/layout'
import { useParticipantTracks } from '@livekit/components-react'
const ClickableToast = styled(RACButton, { const ClickableToast = styled(RACButton, {
base: { base: {
@@ -30,13 +31,17 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
) )
const participant = props.toast.content.participant const participant = props.toast.content.participant
if (!participant) return const [cameraTrack] = useParticipantTracks(
[Source.Camera],
participant?.identity
)
const trackReference = { const trackReference = useMemo(
participant, () => cameraTrack ?? { participant, source: Source.Camera },
publication: participant.getTrackPublication(Source.Camera), [cameraTrack, participant]
source: Source.Camera, )
}
if (!participant) return
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
@@ -15,7 +15,6 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest' import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom' import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged' import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
import { ToastCpuConstrained } from './ToastCpuConstrained'
interface ToastRegionProps extends AriaToastRegionProps { interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData> state: ToastState<ToastData>
@@ -75,9 +74,6 @@ const renderToast = (
case NotificationType.RoleChanged: case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} /> return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
case NotificationType.CpuConstrained:
return <ToastCpuConstrained key={toast.key} toast={toast} state={state} />
default: default:
return <Toast key={toast.key} toast={toast} state={state} /> return <Toast key={toast.key} toast={toast} state={state} />
} }
@@ -15,15 +15,6 @@ export const notifyAutoMutedOnJoin = () => {
) )
} }
export const notifyCpuConstrained = () => {
toastQueue.add(
{
type: NotificationType.CpuConstrained,
},
{ timeout: NotificationDuration.CPU_CONSTRAINED }
)
}
export const showLowerHandToast = ( export const showLowerHandToast = (
participant: Participant, participant: Participant,
onClose: () => void onClose: () => void
@@ -1,11 +1,9 @@
import { Participant, Track } from 'livekit-client' import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react' import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react' import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { openMuteDialog } from '@/stores/muteDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => { export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -15,31 +13,18 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone, source: Track.Source.Microphone,
}) })
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<>
<Button <Button
isDisabled={isMuted} isDisabled={isMuted}
size={'sm'} size={'sm'}
variant={'primaryTextDark'} variant={'primaryTextDark'}
square square
onPress={() => setIsAlertOpen(true)} onPress={() => openMuteDialog(participant)}
tooltip={t('muteParticipant', { name })} tooltip={t('muteParticipant', { name })}
> >
{!isMuted ? <RiMicLine /> : <RiMicOffLine />} {!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button> </Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -19,13 +19,11 @@ import {
import Source = Track.Source import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react' import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge' import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog' import { openMuteDialog } from '@/stores/muteDialog'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
@@ -34,7 +32,6 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => { const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({ const { isMuted } = useTrackMutedIndicator({
participant: participant, participant: participant,
source: Source.Microphone, source: Source.Microphone,
@@ -42,7 +39,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const label = isLocal(participant) const label = isLocal(participant)
@@ -52,7 +48,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
}) })
return ( return (
<>
<Button <Button
square square
variant="greyscale" variant="greyscale"
@@ -62,10 +57,8 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
isDisabled={isMuted || !canMute} isDisabled={isMuted || !canMute}
onPress={async () => onPress={async () =>
!isMuted && isLocal(participant) !isMuted && isLocal(participant)
? await (participant as LocalParticipant)?.setMicrophoneEnabled( ? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
false : openMuteDialog(participant)
)
: setIsAlertOpen(true)
} }
data-attr="participants-mute" data-attr="participants-mute"
> >
@@ -83,15 +76,6 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
/> />
)} )}
</Button> </Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -1,71 +0,0 @@
import { useEffect, useRef } from 'react'
import { useRoomContext } from '@livekit/components-react'
import {
LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
Track,
} from 'livekit-client'
import { captureEvent } from '@/features/analytics/telemetry'
import { collectHardwareSnapshot } from '@/features/analytics/hardware'
import { notifyCpuConstrained } from '@/features/notifications/utils'
import { isFireFox } from '@/utils/livekit'
import {
enablePerformanceMode,
performanceModeStore,
} from '@/stores/performanceMode'
export const CpuConstrainedObserver = () => {
const room = useRoomContext()
const degradedTracksRef = useRef(new WeakSet<LocalVideoTrack>())
useEffect(() => {
const localParticipant = room.localParticipant
const handleCpuConstrained = (
track: LocalVideoTrack,
publication: LocalTrackPublication
) => {
const { enabled, userDeclinedAuto } = performanceModeStore
const shouldDegrade =
publication.source === Track.Source.Camera &&
!enabled &&
!userDeclinedAuto &&
!degradedTracksRef.current.has(track)
void collectHardwareSnapshot().then((hardware) => {
captureEvent('cpu-constrained', {
firefox: isFireFox(),
source: publication.source,
degraded: shouldDegrade,
trackOptions: publication.options,
performance_mode_enabled: enabled,
user_declined_auto: userDeclinedAuto,
...hardware,
})
})
if (!shouldDegrade) return
degradedTracksRef.current.add(track)
enablePerformanceMode('cpu')
notifyCpuConstrained()
}
localParticipant.on(
ParticipantEvent.LocalTrackCpuConstrained,
handleCpuConstrained
)
return () => {
localParticipant.off(
ParticipantEvent.LocalTrackCpuConstrained,
handleCpuConstrained
)
}
}, [room])
return null
}
@@ -1,108 +0,0 @@
import { useEffect } from 'react'
import { useRoomContext } from '@livekit/components-react'
import {
LocalTrackPublication,
LocalVideoTrack,
ParticipantEvent,
Track,
TrackEvent,
} from 'livekit-client'
import { useSnapshot } from 'valtio'
import { reportError } from '@/features/analytics/telemetry'
import {
disablePerformanceMode,
performanceModeStore,
} from '@/stores/performanceMode'
import { degradeVideoTrack, restoreVideoTrack } from '../degradation'
/** Delay to re-apply degradation after restart to avoid racing LiveKit's encoding recompute. */
const REAPPLY_AFTER_RESTART_MS = 1_000
/** Syncs performance mode store state to outbound camera track encoding settings. */
export const PerformanceModeController = () => {
const room = useRoomContext()
const { enabled } = useSnapshot(performanceModeStore)
// Manage degradation application and track lifecycle events
useEffect(() => {
const localParticipant = room.localParticipant
const getCameraTrack = () => {
const pub = localParticipant.getTrackPublication(Track.Source.Camera)
return pub?.track instanceof LocalVideoTrack ? pub.track : null
}
const track = getCameraTrack()
// Restore track quality if performance mode is disabled
if (!enabled) {
if (track) {
restoreVideoTrack(track).catch((err) =>
reportError('performance_mode_failure', err, { action: 'restore' })
)
}
return
}
const applyDegradation = (t: LocalVideoTrack, action = 'degrade') => {
degradeVideoTrack(t).catch((err) =>
reportError('performance_mode_failure', err, { action })
)
}
// Re-apply degradation after track restarts (e.g. device/resolution changes)
const watchTrackRestart = (t: LocalVideoTrack) => {
let timeoutId: ReturnType<typeof setTimeout>
const onRestarted = () => {
clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
if (performanceModeStore.enabled) applyDegradation(t, 'reapply')
}, REAPPLY_AFTER_RESTART_MS)
}
t.on(TrackEvent.Restarted, onRestarted)
return () => {
clearTimeout(timeoutId)
t.off(TrackEvent.Restarted, onRestarted)
}
}
let unwatchRestart: (() => void) | undefined
if (track) {
applyDegradation(track)
unwatchRestart = watchTrackRestart(track)
}
// Apply degradation to newly published camera tracks
const handlePublished = (pub: LocalTrackPublication) => {
if (
pub.source === Track.Source.Camera &&
pub.track instanceof LocalVideoTrack
) {
unwatchRestart?.()
applyDegradation(pub.track)
unwatchRestart = watchTrackRestart(pub.track)
}
}
localParticipant.on(ParticipantEvent.LocalTrackPublished, handlePublished)
return () => {
localParticipant.off(
ParticipantEvent.LocalTrackPublished,
handlePublished
)
unwatchRestart?.()
}
}, [room, enabled])
// Reset auto (CPU-triggered) performance mode on unmount/leave room
useEffect(() => {
return () => {
if (performanceModeStore.trigger === 'cpu') {
disablePerformanceMode()
}
}
}, [])
return null
}
@@ -1,126 +0,0 @@
import { LocalVideoTrack } from 'livekit-client'
import { isFireFox } from '@/utils/livekit'
/** Target degraded encoding for layer 0 (~360p @ 15fps, ≤300kbps). */
export const DEGRADED_MAX_HEIGHT = 360
export const DEGRADED_MAX_FRAMERATE = 15
export const DEGRADED_MAX_BITRATE = 300_000
/**
* Firefox ignores `active = false` on RTCRtpSender.
* We starve higher layers using LiveKit's sentinel workaround values instead.
*/
const FF_DISABLED_SCALE_DOWN = 4
const FF_DISABLED_MAX_BITRATE = 10
const FF_DISABLED_MAX_FRAMERATE = 2
type SavedEncoding = Pick<
RTCRtpEncodingParameters,
'active' | 'scaleResolutionDownBy' | 'maxBitrate' | 'maxFramerate'
>
/** Pre-degradation encoding snapshots keyed by track to prevent leaks. */
const savedEncodingsByTrack = new WeakMap<LocalVideoTrack, SavedEncoding[]>()
export const isTrackDegraded = (track: LocalVideoTrack) =>
savedEncodingsByTrack.has(track)
/**
* Revertibly degrades local video quality without unpublishing.
* Avoids `prioritizePerformance()` because its internal flag disables dynacast permanently.
*
* - Layer 0: Capped to 360p / 15fps / 300kbps.
* - Other layers: Set to `active: false` (or starved on Firefox).
*/
export const degradeVideoTrack = async (track: LocalVideoTrack) => {
const sender = track.sender
if (!sender) {
throw new Error('sender not found')
}
const params = sender.getParameters()
if (!params.encodings || params.encodings.length === 0) {
return
}
// Snapshot once so re-applications don't overwrite pristine encodings
if (!savedEncodingsByTrack.has(track)) {
savedEncodingsByTrack.set(
track,
params.encodings.map((e) => ({
active: e.active,
scaleResolutionDownBy: e.scaleResolutionDownBy,
maxBitrate: e.maxBitrate,
maxFramerate: e.maxFramerate,
}))
)
}
const captureHeight =
track.mediaStreamTrack.getSettings().height ?? DEGRADED_MAX_HEIGHT
params.encodings = params.encodings.map((encoding, idx) => {
if (idx === 0) {
return {
...encoding,
active: true,
scaleResolutionDownBy: Math.max(
1,
Math.ceil(captureHeight / DEGRADED_MAX_HEIGHT)
),
maxFramerate: DEGRADED_MAX_FRAMERATE,
maxBitrate: Math.min(
encoding.maxBitrate ?? DEGRADED_MAX_BITRATE,
DEGRADED_MAX_BITRATE
),
}
}
if (isFireFox()) {
const starved: RTCRtpEncodingParameters = {
...encoding,
// Firefox workaround: active=false prevents LiveKit re-encodes, while starved values limit bitrate
active: false,
scaleResolutionDownBy: FF_DISABLED_SCALE_DOWN,
maxBitrate: FF_DISABLED_MAX_BITRATE,
maxFramerate: FF_DISABLED_MAX_FRAMERATE,
}
// LiveKit legacy property fallback for Firefox
;(starved as Record<string, unknown>).maxFrameRate =
FF_DISABLED_MAX_FRAMERATE
return starved
}
return { ...encoding, active: false }
})
await sender.setParameters(params)
}
/** Restores encodings captured prior to degradation. */
export const restoreVideoTrack = async (track: LocalVideoTrack) => {
const saved = savedEncodingsByTrack.get(track)
savedEncodingsByTrack.delete(track)
const sender = track.sender
if (!saved || !sender) {
return
}
const params = sender.getParameters()
if (!params.encodings || params.encodings.length !== saved.length) {
return
}
params.encodings = params.encodings.map((encoding, idx) => {
const restored: RTCRtpEncodingParameters = {
...encoding,
...saved[idx],
}
// Clean up Firefox legacy property if set during degradation
;(restored as Record<string, unknown>).maxFrameRate = undefined
return restored
})
await sender.setParameters(params)
}
@@ -1,17 +1,20 @@
import { useStartRecording, useStopRecording } from '@/features/recording' import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording' import { recordingStore } from '@/stores/recording'
import { captureEvent } from '@/features/analytics/telemetry'
export const useMutateRecording = () => { export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } = const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({ useStartRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'start' recordingStore.isErrorDialogOpen = 'start'
captureEvent('error-starting-recording')
}, },
}) })
const { mutateAsync: stopRecording, isPending: isPendingToStop } = const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({ useStopRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'stop' recordingStore.isErrorDialogOpen = 'stop'
captureEvent('error-stopping-recording')
}, },
}) })
@@ -6,6 +6,8 @@ import {
usePersistentUserChoices, usePersistentUserChoices,
} from '@livekit/components-react' } from '@livekit/components-react'
import { import {
ConnectionError,
ConnectionErrorReason,
DisconnectReason, DisconnectReason,
MediaDeviceFailure, MediaDeviceFailure,
Room, Room,
@@ -25,7 +27,11 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import {
captureEvent,
captureMediaEvent,
reportError,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
@@ -225,9 +231,20 @@ export const Conference = ({
backgroundColor: 'primaryDark.50 !important', backgroundColor: 'primaryDark.50 !important',
})} })}
onError={(e) => { onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, { reportError('livekit_room_error', e, {
path: 'connect_publish', path: 'connect_publish',
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
}) })
}} }}
onConnected={async () => { onConnected={async () => {
@@ -28,6 +28,7 @@ import {
userChoicesStore, userChoicesStore,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing' import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
import { useJoinTracks } from '../livekit/hooks/useJoinTracks' import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
import { SilentMicDetector } from './SilentMicDetector' import { SilentMicDetector } from './SilentMicDetector'
@@ -218,12 +219,14 @@ const switchTrackDevice =
function getPreviewMessages({ function getPreviewMessages({
cameraFound, cameraFound,
cameraDenied, cameraDenied,
cameraInUse,
micDenied, micDenied,
videoEnabled, videoEnabled,
videoStarted, videoStarted,
}: { }: {
cameraFound: boolean cameraFound: boolean
cameraDenied: boolean cameraDenied: boolean
cameraInUse: boolean
micDenied: boolean micDenied: boolean
videoEnabled: boolean videoEnabled: boolean
videoStarted: boolean videoStarted: boolean
@@ -235,6 +238,9 @@ function getPreviewMessages({
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted' const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
return { hint: key, permissionsButtonLabel: key } return { hint: key, permissionsButtonLabel: key }
} }
if (cameraInUse) {
return { hint: 'cameraInUse', permissionsButtonLabel: null }
}
if (!videoEnabled) { if (!videoEnabled) {
return { hint: 'cameraDisabled', permissionsButtonLabel: null } return { hint: 'cameraDisabled', permissionsButtonLabel: null }
} }
@@ -328,18 +334,20 @@ const VideoPreview = ({
const cameraDenied = useCannotUseDevice('videoinput') const cameraDenied = useCannotUseDevice('videoinput')
const micDenied = useCannotUseDevice('audioinput') const micDenied = useCannotUseDevice('audioinput')
const cameraMissing = useDeviceMissing('videoinput') const cameraMissing = useDeviceMissing('videoinput')
const cameraInUse = useDeviceInUse('videoinput')
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled) const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
const { hint, permissionsButtonLabel } = getPreviewMessages({ const { hint, permissionsButtonLabel } = getPreviewMessages({
cameraFound: !cameraMissing, cameraFound: !cameraMissing,
cameraDenied, cameraDenied,
cameraInUse,
micDenied, micDenied,
videoEnabled, videoEnabled,
videoStarted, videoStarted,
}) })
const isError = cameraMissing || cameraDenied const isError = cameraMissing || cameraDenied || cameraInUse
return ( return (
<div className={styles.previewFrame}> <div className={styles.previewFrame}>
@@ -0,0 +1,30 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
import type { PermissionKind } from '@/stores/permissions'
const RETRY_INTERVAL_MS = 30_000
/**
* There is no browser event for "another app released the device", so
* while a device is flagged in use, re-probe it periodically. Only clears
* the flag (the toggle becomes usable again); it never re-enables the
* device on the user's behalf.
*/
export function useWatchDeviceReleased() {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
useWatchKind('camera', cameraInUse)
useWatchKind('microphone', microphoneInUse)
}
function useWatchKind(kind: PermissionKind, inUse: boolean) {
useEffect(() => {
if (!inUse) return
const id = setInterval(
() => void probeDeviceReleased(kind),
RETRY_INTERVAL_MS
)
return () => clearInterval(id)
}, [kind, inUse])
}
@@ -0,0 +1,28 @@
import { useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
import { MuteAlertDialog } from './MuteAlertDialog'
export const MuteAlertDialogProvider = () => {
const { participant } = useSnapshot(muteDialogStore)
const { muteParticipant } = useMuteParticipant()
const lastNameRef = useRef('')
if (participant) {
lastNameRef.current = participant.name || participant.identity
}
return (
<MuteAlertDialog
isOpen={!!participant}
name={lastNameRef.current}
onClose={closeMuteDialog}
onSubmit={() => {
const target = muteDialogStore.participant
if (!target) return
muteParticipant(target).then(closeMuteDialog)
}}
/>
)
}
@@ -1,6 +1,13 @@
import { A, Button, Dialog, P } from '@/primitives' import { A, Button, Dialog, P } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { getOS, type OS } from '@/utils/os'
const SCREEN_CAPTURE_SETTINGS_LINKS: Partial<Record<OS, string>> = {
macos:
'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture',
windows: 'ms-settings:privacy-graphicscaptureprogrammatic',
}
// todo - refactor it into a generic system // todo - refactor it into a generic system
export const ScreenShareErrorModal = ({ export const ScreenShareErrorModal = ({
@@ -11,7 +18,8 @@ export const ScreenShareErrorModal = ({
onClose: () => void onClose: () => void
}) => { }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' }) const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1 const os = getOS()
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
return ( return (
<Dialog <Dialog
@@ -26,15 +34,16 @@ export const ScreenShareErrorModal = ({
<> <>
<P> <P>
{t('message')}{' '} {t('message')}{' '}
{isMac && ( {settingsHref && (
<> <>
{t('macInstructions')}{' '} {t('settingsInstructions')}{' '}
<A <A
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture" href={settingsHref}
target="_blank"
color="primary" color="primary"
aria-label={t('macSystemPreferences') + '-' + t('newTab')} aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
> >
{t('macSystemPreferences')} {t(`settingsLabel.${os}`)}
</A> </A>
.{' '} .{' '}
</> </>
@@ -9,18 +9,15 @@ import {
} from 'livekit-client' } from 'livekit-client'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { userChoicesStore } from '@/stores/userChoices' import { userChoicesStore } from '@/stores/userChoices'
import { performanceModeStore } from '@/stores/performanceMode'
/**
* Sets initial video quality for new participants as they join.
* LiveKit doesn't allow handling video quality preferences at the room level.
*/
export const VideoResolutionSubscription = () => { export const VideoResolutionSubscription = () => {
const { videoSubscribeQuality } = useSnapshot(userChoicesStore) const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
const { enabled: isPerformanceModeEnabled } =
useSnapshot(performanceModeStore)
const room = useRoomContext() const room = useRoomContext()
const effectiveQuality = isPerformanceModeEnabled
? VideoQuality.LOW
: (videoSubscribeQuality ?? VideoQuality.HIGH)
useEffect(() => { useEffect(() => {
if (!room) return if (!room) return
@@ -28,12 +25,18 @@ export const VideoResolutionSubscription = () => {
publication: RemoteTrackPublication, publication: RemoteTrackPublication,
_participant: RemoteParticipant _participant: RemoteParticipant
) => { ) => {
if (effectiveQuality === VideoQuality.HIGH) return // By default, the maximum quality is set to high
if (
videoSubscribeQuality === undefined ||
videoSubscribeQuality === VideoQuality.HIGH
)
return
if ( if (
publication.kind === Track.Kind.Video && publication.kind === Track.Kind.Video &&
publication.source !== Track.Source.ScreenShare publication.source !== Track.Source.ScreenShare
) { ) {
publication.setVideoQuality(effectiveQuality) publication.setVideoQuality(videoSubscribeQuality)
} }
} }
@@ -41,20 +44,7 @@ export const VideoResolutionSubscription = () => {
return () => { return () => {
room.off(RoomEvent.TrackPublished, handleTrackPublished) room.off(RoomEvent.TrackPublished, handleTrackPublished)
} }
}, [room, effectiveQuality]) }, [room, videoSubscribeQuality])
useEffect(() => {
if (!room) return
room.remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.source === Track.Source.ScreenShare) return
if (publication.videoQuality !== effectiveQuality) {
publication.setVideoQuality(effectiveQuality)
}
})
})
}, [room, effectiveQuality])
return null return null
} }
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
videoElement?: HTMLVideoElement videoElement?: HTMLVideoElement
videoElementLoaded?: boolean videoElementLoaded?: boolean
// Canvas containg the video processing result, of which we extract as stream. // Canvas containing the video processing result, of which we extract as stream.
outputCanvas?: HTMLCanvasElement outputCanvas?: HTMLCanvasElement
outputCanvasCtx?: CanvasRenderingContext2D outputCanvasCtx?: CanvasRenderingContext2D
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
segmentationMaskCanvas?: HTMLCanvasElement segmentationMaskCanvas?: HTMLCanvasElement
segmentationMaskCanvasCtx?: CanvasRenderingContext2D segmentationMaskCanvasCtx?: CanvasRenderingContext2D
// Mask containg the inference result. // Mask containing the inference result.
segmentationMask?: ImageData segmentationMask?: ImageData
// The resized image of the video source. // The resized image of the video source.
@@ -105,9 +105,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual') {
throw new Error( return
'Virtual background is only supported for virtual background'
)
} }
const needsUpdate = const needsUpdate =
@@ -20,8 +20,9 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic' import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../hooks/useJoinTracks' import { requestDevicePermission } from '../../../utils/mediaPermissions'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type { import type {
@@ -97,38 +98,42 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind) const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind) const deviceMissing = useDeviceMissing(kind)
const deviceInUse = useDeviceInUse(kind)
const explainDeviceInUse = deviceInUse && context === 'room'
const { status: silentMicStatus } = useSnapshot(silentMicStore) const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning = const silentMicWarning =
kind === 'audioinput' && kind === 'audioinput' &&
silentMicStatus === 'silent' && silentMicStatus === 'silent' &&
!cannotUseDevice && !cannotUseDevice &&
!deviceMissing !deviceMissing &&
!deviceInUse
const deviceShortcut = useDeviceShortcut(kind) const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false) const isRequestingPermission = useRef(false)
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false) const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
const mediaPath = context === 'join' ? 'join_preview' : 'room'
const onPress = async () => { const onPress = async () => {
if (!enabled && deviceMissing) { if (!enabled && deviceMissing) {
setShowDeviceNotFound(true) setAlertError(MediaDeviceFailure.NotFound)
return return
} }
if (!cannotUseDevice) { if (!cannotUseDevice && !deviceInUse) {
toggle() toggle()
return return
} }
if (isRequestingPermission.current) return if (isRequestingPermission.current) return
isRequestingPermission.current = true isRequestingPermission.current = true
try { try {
const granted = await requestDevicePermission( const acquired = await requestDevicePermission(kind, mediaPath)
kind, if (acquired) {
context === 'join' ? 'join_preview' : 'room'
)
if (granted) {
toggle() toggle()
} else { } else if (cannotUseDevice) {
openPermissionsDialog(kind) openPermissionsDialog(kind)
} else if (explainDeviceInUse) {
setAlertError(MediaDeviceFailure.DeviceInUse)
} }
} finally { } finally {
isRequestingPermission.current = false isRequestingPermission.current = false
@@ -179,13 +184,33 @@ export const ToggleDevice = <T extends ToggleSource>({
return <ActiveSpeakerWrapper /> return <ActiveSpeakerWrapper />
} }
const getToggleTooltip = () => {
if (deviceMissing) return t(`deviceNotFound.${kind}`)
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
return toggleLabel
}
const toggleTooltip = getToggleTooltip()
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{(cannotUseDevice || deviceMissing) && ( {(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton <PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined} tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={ onPress={
deviceMissing ? () => setShowDeviceNotFound(true) : undefined deviceMissing
? () => setAlertError(MediaDeviceFailure.NotFound)
: undefined
}
/>
)}
{deviceInUse && (
<PermissionNeededButton
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : undefined}
onPress={
explainDeviceInUse
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
: undefined
} }
/> />
)} )}
@@ -204,22 +229,16 @@ export const ToggleDevice = <T extends ToggleSource>({
shySelected shySelected
onPress={onPress} onPress={onPress}
aria-label={toggleLabel} aria-label={toggleLabel}
tooltip={ tooltip={toggleTooltip}
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps} {...computedToggleButtonProps}
{...overrideToggleButtonProps} {...overrideToggleButtonProps}
> >
<Icon /> <Icon />
</ToggleButton> </ToggleButton>
<MediaDeviceErrorAlert <MediaDeviceErrorAlert
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null} error={alertError}
kind={kind} kind={kind}
onClose={() => setShowDeviceNotFound(false)} onClose={() => setAlertError(null)}
/> />
</div> </div>
) )
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
LIGHT = 5, LIGHT = 10,
NORMAL = 10, NORMAL = 20,
} }
const isSupported = BackgroundProcessorFactory.isSupported() const isSupported = BackgroundProcessorFactory.isSupported()
@@ -0,0 +1,21 @@
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { useCannotUseDevice } from './useCannotUseDevice'
import { useDeviceMissing } from './useDeviceMissing'
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
if (cannotUseDevice || deviceMissing) return false
switch (kind) {
case 'videoinput':
return cameraInUse
case 'audioinput':
return microphoneInUse
default:
return false
}
}
@@ -10,16 +10,13 @@ import {
} from 'livekit-client' } from 'livekit-client'
import { BackgroundProcessorFactory } from '../components/blur' import { BackgroundProcessorFactory } from '../components/blur'
import { import {
classifyPermissionError,
isLikelySystemNotFound,
isSystemPermissionError, isSystemPermissionError,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind, type PermissionKind,
} from '@/stores/permissions' } from '@/stores/permissions'
import { getOS } from '@/utils/os' import {
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' noteDeviceReady,
onMediaPermissionError,
} from '../utils/mediaPermissions'
import { import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
@@ -39,64 +36,6 @@ const VOICE_AUDIO_CONSTRAINTS = {
sampleSize: 16, sampleSize: 16,
} as const } as const
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
audioinput: 'microphone',
videoinput: 'camera',
}
type MediaPath = 'join_preview' | 'room'
const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false) const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false)
@@ -104,24 +43,6 @@ const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) => const stopAll = (stream: MediaStream) =>
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteGumSuccess(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
type WarmupState = { type WarmupState = {
audioReady: boolean audioReady: boolean
videoReady: boolean videoReady: boolean
@@ -158,7 +79,7 @@ function useWarmupPermissions(): WarmupState {
video: true, video: true,
}) })
) )
noteGumSuccess() noteDeviceReady()
bothReady() bothReady()
} catch (error) { } catch (error) {
if ( if (
@@ -180,7 +101,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ audio: true }) .getUserMedia({ audio: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteGumSuccess('microphone') noteDeviceReady('microphone')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'microphone')) .catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() => .finally(() =>
@@ -190,7 +111,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ video: true }) .getUserMedia({ video: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteGumSuccess('camera') noteDeviceReady('camera')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'camera')) .catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() => .finally(() =>
@@ -227,7 +148,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
let cancelled = false let cancelled = false
create() create()
.then((newTrack) => { .then((newTrack) => {
noteGumSuccess(permissionKind) noteDeviceReady(permissionKind)
if (cancelled) { if (cancelled) {
newTrack.stop() newTrack.stop()
return return
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { MediaDeviceFailure, RoomEvent } from 'livekit-client' import {
type LocalTrackPublication,
MediaDeviceFailure,
RoomEvent,
Track,
} from 'livekit-client'
import { import {
PERMISSION_BY_DEVICE_KIND, PERMISSION_BY_DEVICE_KIND,
type PermissionDeniedScope, type PermissionDeniedScope,
@@ -10,7 +15,11 @@ import {
notePermissionDeniedFromGum, notePermissionDeniedFromGum,
noteSystemPermissionDenied, noteSystemPermissionDenied,
} from '@/stores/permissions' } from '@/stores/permissions'
import { syncDeviceAvailability } from '@/stores/deviceAvailability' import {
clearDeviceInUse,
noteDeviceInUse,
syncDeviceAvailability,
} from '@/stores/deviceAvailability'
import { captureMediaEvent } from '@/features/analytics/telemetry' import { captureMediaEvent } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os' import { getOS } from '@/utils/os'
@@ -21,6 +30,11 @@ type MediaDeviceAlert = {
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null } const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
[Track.Source.Camera]: 'camera',
[Track.Source.Microphone]: 'microphone',
}
const capturePermissionsDenied = ( const capturePermissionsDenied = (
scope: PermissionDeniedScope, scope: PermissionDeniedScope,
kind?: PermissionKind kind?: PermissionKind
@@ -50,18 +64,20 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
useEffect(() => { useEffect(() => {
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => { const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
const failure = MediaDeviceFailure.getFailure(error) const failure = MediaDeviceFailure.getFailure(error)
if (!failure || !kind) return if (!failure) return
if (failure != MediaDeviceFailure.Other) {
void captureMediaEvent('media-device-error', { void captureMediaEvent('media-device-error', {
log_code: 'media_devices_error_event', log_code: 'media_devices_error_event',
path: 'connect_publish', path: 'connect_publish',
failure, failure,
kind, kind: kind ?? 'unknown',
}) })
}
if (!kind) return
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) { switch (failure) {
case MediaDeviceFailure.DeviceInUse: case MediaDeviceFailure.DeviceInUse:
noteDeviceInUse(permissionKind)
setAlert({ error: failure, kind }) setAlert({ error: failure, kind })
break break
case MediaDeviceFailure.NotFound: case MediaDeviceFailure.NotFound:
@@ -91,9 +107,16 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
break break
} }
} }
const onTrackPublished = (publication: LocalTrackPublication) => {
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
if (permissionKind) clearDeviceInUse(permissionKind)
}
room.on(RoomEvent.MediaDevicesError, onDeviceError) room.on(RoomEvent.MediaDevicesError, onDeviceError)
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
return () => { return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceError) room.off(RoomEvent.MediaDevicesError, onDeviceError)
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
clearDeviceInUse()
} }
}, [room]) }, [room])
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton' import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton' import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions' import { MoreOptions } from './MoreOptions'
import { useRef } from 'react' import { RefObject, useMemo, useState } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen' import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
@@ -21,7 +21,14 @@ export function DesktopControlBar({
onDeviceError, onDeviceError,
}: Readonly<ControlBarAuxProps>) { }: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const [controlBarElement, setControlBarElement] =
useState<HTMLDivElement | null>(null)
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
() => ({ current: controlBarElement }),
[controlBarElement]
)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({}) const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
@@ -45,7 +52,7 @@ export function DesktopControlBar({
return ( return (
<div <div
ref={desktopControlBarEl} ref={setControlBarElement}
className={css({ className={css({
width: '100vw', width: '100vw',
display: 'flex', display: 'flex',
@@ -1,5 +1,5 @@
import { isWeb } from '@livekit/components-core' import { isWeb } from '@livekit/components-core'
import { Track } from 'livekit-client' import { MediaDeviceFailure, Track } from 'livekit-client'
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
ConnectionStateToast, ConnectionStateToast,
@@ -11,12 +11,15 @@ import { SidePanel } from '../components/SidePanel'
import { RecordingProvider } from '@/features/recording' import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { ConnectionObserver } from '../components/ConnectionObserver' import { ConnectionObserver } from '../components/ConnectionObserver'
import { reportError } from '@/features/analytics/telemetry' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
import { isFireFox } from '@/utils/livekit'
import { MediaStateObserver } from '../components/MediaStateObserver' import { MediaStateObserver } from '../components/MediaStateObserver'
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer' import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription' import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals' import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea' import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
@@ -27,8 +30,6 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
import { ChatProvider } from '@/features/chat/components/ChatProvider' import { ChatProvider } from '@/features/chat/components/ChatProvider'
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences' import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector' import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
import { CpuConstrainedObserver } from '@/features/performance/components/CpuConstrainedObserver'
import { PerformanceModeController } from '@/features/performance/components/PerformanceModeController'
/** /**
* @public * @public
@@ -38,6 +39,20 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
SettingsComponent?: React.ComponentType SettingsComponent?: React.ComponentType
} }
const getScreenSharePermissionDeniedScope = (
error: Error
): 'system' | 'user' | 'browser' | null => {
if (error.name === 'NotAllowedError') {
if (/by system/i.test(error.message)) return 'system'
if (/by user/i.test(error.message)) return 'user'
return 'browser'
}
if (error.name === 'NotFoundError' && isFireFox() && getOS() === 'macos') {
return 'system'
}
return null
}
/** /**
* The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application. * The `VideoConference` ready-made component is your drop-in solution for a classic video conferencing application.
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers * It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
@@ -63,6 +78,39 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
const [isShareErrorVisible, setIsShareErrorVisible] = useState(false) const [isShareErrorVisible, setIsShareErrorVisible] = useState(false)
const handleDeviceError = ({
source,
error,
}: {
source: Track.Source
error: Error
}) => {
if (source === Track.Source.ScreenShare) {
const scope = getScreenSharePermissionDeniedScope(error)
if (scope) {
if (scope === 'system') setIsShareErrorVisible(true)
void captureMediaEvent('screen-share-permission-denied', {
at: 'ControlBar.onDeviceError',
source,
error_name: error.name,
error_message: error.message,
denied_scope: scope,
os: getOS(),
})
return
}
}
if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) {
return
}
reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError',
source,
})
}
return ( return (
<> <>
<RoomMetadataSynchronizer /> <RoomMetadataSynchronizer />
@@ -72,8 +120,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<MediaStateObserver /> <MediaStateObserver />
<ChatProvider /> <ChatProvider />
<VideoResolutionSubscription /> <VideoResolutionSubscription />
<PerformanceModeController />
<CpuConstrainedObserver />
<div <div
className="lk-video-conference" className="lk-video-conference"
{...props} {...props}
@@ -96,21 +142,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<StageLayout /> <StageLayout />
)} )}
</RoomContentArea> </RoomContentArea>
<ControlBar <ControlBar onDeviceError={handleDeviceError} />
onDeviceError={(e) => {
reportError('device_switch_failure', e.error, {
at: 'ControlBar.onDeviceError',
source: e.source,
})
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel /> <SidePanel />
</> </>
)} )}
@@ -118,6 +150,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast /> <ConnectionStateToast />
<RecordingProvider /> <RecordingProvider />
<SettingsDialogProvider /> <SettingsDialogProvider />
<MuteAlertDialogProvider />
<ReactionPortals /> <ReactionPortals />
</div> </div>
</> </>
@@ -0,0 +1,135 @@
import {
createLocalAudioTrack,
createLocalVideoTrack,
MediaDeviceFailure,
} from 'livekit-client'
import {
classifyPermissionError,
isLikelySystemNotFound,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
/**
* Shared handling of getUserMedia() outcomes, used by both:
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
*/
export type MediaPath = 'join_preview' | 'room'
export const PERMISSION_KIND: Record<
'audioinput' | 'videoinput',
PermissionKind
> = {
audioinput: 'microphone',
videoinput: 'camera',
}
export const noteDeviceReady = (kind?: PermissionKind) => {
noteGumSuccess(kind)
clearDeviceInUse(kind)
}
export const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure === MediaDeviceFailure.PermissionDenied) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (failure === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
if (failure === MediaDeviceFailure.DeviceInUse) {
noteDeviceInUse(kind)
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
return
}
// "Other" is still reported as an error.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
/**
* Silent availability check for a device that was reported "in use":
* acquires and releases it without any error reporting, so it can be
* polled. Clears the in-use flag on success.
*/
export const probeDeviceReleased = async (
kind: PermissionKind
): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia(
kind === 'camera' ? { video: true } : { audio: true }
)
stream.getTracks().forEach((track) => track.stop())
noteDeviceReady(kind)
return true
} catch {
return false
}
}
/**
* Triggers the browser permission prompt for one device kind by acquiring
* and immediately releasing a track. Resolves to whether access was granted.
*/
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteDeviceReady(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
@@ -16,6 +16,7 @@ import {
import { useConfig } from '@/api/useConfig.ts' import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client' import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability' import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle' import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
const BaseRoom = ({ children }: { children: ReactNode }) => { const BaseRoom = ({ children }: { children: ReactNode }) => {
@@ -49,6 +50,7 @@ const Room = () => {
useKeyboardShortcuts() useKeyboardShortcuts()
useWatchDeviceAvailability() useWatchDeviceAvailability()
useWatchDeviceReleased()
const clearRouterState = () => { const clearRouterState = () => {
if (window?.history?.state) { if (window?.history?.state) {
@@ -22,11 +22,6 @@ import {
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { RowWrapper } from './layout/RowWrapper' import { RowWrapper } from './layout/RowWrapper'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import {
disablePerformanceMode,
enablePerformanceMode,
performanceModeStore,
} from '@/stores/performanceMode'
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> & export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
Pick<TabPanelProps, 'id'> Pick<TabPanelProps, 'id'>
@@ -37,7 +32,7 @@ const EMPTY_PROPS = {}
export const VideoTab = ({ id }: VideoTabProps) => { export const VideoTab = ({ id }: VideoTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'video' }) const { t } = useTranslation('settings', { keyPrefix: 'video' })
const { localParticipant } = useRoomContext() const { localParticipant, remoteParticipants } = useRoomContext()
const { const {
videoDeviceId, videoDeviceId,
@@ -46,9 +41,6 @@ export const VideoTab = ({ id }: VideoTabProps) => {
videoSubscribeQuality, videoSubscribeQuality,
} = useSnapshot(userChoicesStore) } = useSnapshot(userChoicesStore)
const { enabled: isPerformanceModeEnabled } =
useSnapshot(performanceModeStore)
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null null
) )
@@ -93,6 +85,22 @@ export const VideoTab = ({ id }: VideoTabProps) => {
} }
} }
/**
* Updates video quality for all existing remote video tracks when user preference changes.
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
* so this function applies the selected quality to all existing remote video tracks.
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
*/
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
remoteParticipants.forEach((participant) => {
participant.videoTrackPublications.forEach((publication) => {
if (publication.videoQuality !== selectedQuality) {
publication.setVideoQuality(selectedQuality)
}
})
})
}
useEffect(() => { useEffect(() => {
let videoTrack: LocalVideoTrack | null = null let videoTrack: LocalVideoTrack | null = null
@@ -209,13 +217,10 @@ export const VideoTab = ({ id }: VideoTabProps) => {
type="select" type="select"
label={t('resolution.publish.label')} label={t('resolution.publish.label')}
items={resolutionItems} items={resolutionItems}
selectedKey={ selectedKey={videoPublishResolution}
isPerformanceModeEnabled ? 'h360' : videoPublishResolution
}
onSelectionChange={async (key) => { onSelectionChange={async (key) => {
await handleVideoResolutionChange(key as VideoResolution) await handleVideoResolutionChange(key as VideoResolution)
}} }}
isDisabled={isPerformanceModeEnabled}
style={{ style={{
width: '100%', width: '100%',
}} }}
@@ -227,43 +232,19 @@ export const VideoTab = ({ id }: VideoTabProps) => {
type="select" type="select"
label={t('resolution.subscribe.label')} label={t('resolution.subscribe.label')}
items={videoQualityItems} items={videoQualityItems}
selectedKey={ selectedKey={videoSubscribeQuality?.toString()}
isPerformanceModeEnabled
? VideoQuality.LOW.toString()
: videoSubscribeQuality?.toString()
}
onSelectionChange={(key) => { onSelectionChange={(key) => {
if (key == undefined) return if (key == undefined) return
const selectedQuality = Number(String(key)) const selectedQuality = Number(String(key))
saveVideoSubscribeQuality(selectedQuality) saveVideoSubscribeQuality(selectedQuality)
updateExistingRemoteVideoQuality(selectedQuality)
}} }}
isDisabled={isPerformanceModeEnabled}
style={{ style={{
width: '100%', width: '100%',
}} }}
/> />
<></> <></>
</RowWrapper> </RowWrapper>
<RowWrapper heading={t('performance.heading')}>
<Field
type="switch"
label={t('performance.label')}
description={t('performance.description')}
isSelected={isPerformanceModeEnabled}
onChange={(value) => {
if (value) {
enablePerformanceMode('manual')
} else {
disablePerformanceMode()
}
}}
wrapperProps={{
noMargin: true,
fullWidth: true,
}}
/>
<></>
</RowWrapper>
</TabPanel> </TabPanel>
) )
} }
@@ -18,10 +18,6 @@
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.", "auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
"dismiss": "Stummschaltung aufheben" "dismiss": "Stummschaltung aufheben"
}, },
"cpuConstrained": {
"message": "Ihr Gerät ist stark ausgelastet. Die Videoqualität wurde reduziert, um das Gespräch flüssig zu halten.",
"keepQuality": "Ursprüngliche Qualität beibehalten"
},
"reaction": { "reaction": {
"description": "{{name}} hat mit {{emoji}} reagiert" "description": "{{name}} hat mit {{emoji}} reagiert"
}, },
+10 -2
View File
@@ -16,6 +16,10 @@
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist." "audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
}, },
"deviceInUse": {
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
},
"settings": { "settings": {
"audio": "Audioeinstellungen", "audio": "Audioeinstellungen",
"video": "Videoeinstellungen" "video": "Videoeinstellungen"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Kamera ist deaktiviert.", "cameraDisabled": "Kamera ist deaktiviert.",
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
"cameraStarting": "Kamera wird gestartet…", "cameraStarting": "Kamera wird gestartet…",
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?", "cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?", "cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
@@ -214,8 +219,11 @@
"title": "Bildschirmfreigabe nicht möglich", "title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich", "ariaLabel": "Bildschirmfreigabe nicht möglich",
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.", "message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
"macInstructions": "Gehe zu den", "settingsInstructions": "Gehe zu den",
"macSystemPreferences": "Systemeinstellungen", "settingsLabel": {
"macos": "Systemeinstellungen",
"windows": "Windows-Datenschutzeinstellungen"
},
"helpLinkText": "Weitere Informationen findest du unter", "helpLinkText": "Weitere Informationen findest du unter",
"helpLinkLabel": "Bildschirmfreigabeproblem", "helpLinkLabel": "Bildschirmfreigabeproblem",
"closeButton": "Schließen", "closeButton": "Schließen",
@@ -70,11 +70,6 @@
} }
} }
}, },
"performance": {
"heading": "Leistung",
"label": "Leistung priorisieren",
"description": "Reduziert die Qualität Ihrer Videos, um Ihr Gerät zu entlasten. Wird bei Überlastung automatisch aktiviert. Deaktivieren Sie diese Funktion, um wieder die maximale Qualität zu erhalten."
},
"permissionsRequired": "Berechtigungen erforderlich" "permissionsRequired": "Berechtigungen erforderlich"
}, },
"transcription": { "transcription": {
@@ -18,10 +18,6 @@
"auto": "You have joined a large meeting. Your microphone has been automatically muted.", "auto": "You have joined a large meeting. Your microphone has been automatically muted.",
"dismiss": "Unmute" "dismiss": "Unmute"
}, },
"cpuConstrained": {
"message": "Your device is running low on processing power. Video quality has been reduced to keep the call smooth.",
"keepQuality": "Keep original quality"
},
"reaction": { "reaction": {
"description": "{{name}} reacted with {{emoji}}" "description": "{{name}} reacted with {{emoji}}"
}, },
+10 -2
View File
@@ -16,6 +16,10 @@
"videoinput": "No camera detected. Check that it is properly wired.", "videoinput": "No camera detected. Check that it is properly wired.",
"audioinput": "No microphone detected. Check that it is properly wired." "audioinput": "No microphone detected. Check that it is properly wired."
}, },
"deviceInUse": {
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
},
"settings": { "settings": {
"audio": "Audio settings", "audio": "Audio settings",
"video": "Video settings" "video": "Video settings"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Camera is disabled.", "cameraDisabled": "Camera is disabled.",
"cameraNotFound": "No camera detected. Check that it is properly plugged in.", "cameraNotFound": "No camera detected. Check that it is properly plugged in.",
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
"cameraStarting": "Camera is starting…", "cameraStarting": "Camera is starting…",
"cameraNotGranted": "Would you like others to be able to see you during the meeting?", "cameraNotGranted": "Would you like others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?", "cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
@@ -214,8 +219,11 @@
"title": "Unable to share your screen", "title": "Unable to share your screen",
"ariaLabel": "Unable to share your screen", "ariaLabel": "Unable to share your screen",
"message": "Your browser may not be allowed to record the screen on your computer.", "message": "Your browser may not be allowed to record the screen on your computer.",
"macInstructions": "Go to your", "settingsInstructions": "Go to your",
"macSystemPreferences": "System Preferences", "settingsLabel": {
"macos": "System Preferences",
"windows": "Windows privacy settings"
},
"helpLinkText": "To learn more, see", "helpLinkText": "To learn more, see",
"helpLinkLabel": "Presentation issue", "helpLinkLabel": "Presentation issue",
"closeButton": "Dismiss", "closeButton": "Dismiss",
@@ -70,11 +70,6 @@
} }
} }
}, },
"performance": {
"heading": "Performance",
"label": "Prioritize performance",
"description": "Reduces the quality of your videos to take the strain off your device. Automatically activates in case of overload. Disable it to restore maximum quality."
},
"permissionsRequired": "Permissions required" "permissionsRequired": "Permissions required"
}, },
"transcription": { "transcription": {
@@ -18,10 +18,6 @@
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.", "auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
"dismiss": "Rétablir le micro" "dismiss": "Rétablir le micro"
}, },
"cpuConstrained": {
"message": "Votre appareil manque de puissance. La qualité vidéo a été réduite pour préserver la fluidité de l'appel.",
"keepQuality": "Conserver la qualité d'origine"
},
"reaction": { "reaction": {
"description": "{{name}} a reagi avec {{emoji}}" "description": "{{name}} a reagi avec {{emoji}}"
}, },
+10 -2
View File
@@ -16,6 +16,10 @@
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", "videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché." "audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
}, },
"deviceInUse": {
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
},
"settings": { "settings": {
"audio": "Paramètres audio", "audio": "Paramètres audio",
"video": "Paramètres video" "video": "Paramètres video"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "La caméra est désactivée.", "cameraDisabled": "La caméra est désactivée.",
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", "cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
"cameraStarting": "La caméra va démarrer…", "cameraStarting": "La caméra va démarrer…",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?", "cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?", "cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
@@ -214,8 +219,11 @@
"title": "Impossible de partager votre écran", "title": "Impossible de partager votre écran",
"ariaLabel": "Impossible de partager votre écran", "ariaLabel": "Impossible de partager votre écran",
"message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.", "message": "Il se peut que votre navigateur ne soit pas autorisé à enregistrer l'écran sur votre ordinateur.",
"macInstructions": "Accèdez à vos", "settingsInstructions": "Accédez à vos",
"macSystemPreferences": "Préférences système", "settingsLabel": {
"macos": "Préférences système",
"windows": "paramètres de confidentialité Windows"
},
"helpLinkText": "Pour en savoir plus, consulter", "helpLinkText": "Pour en savoir plus, consulter",
"helpLinkLabel": "Problème de présentation", "helpLinkLabel": "Problème de présentation",
"closeButton": "Ignorer", "closeButton": "Ignorer",
@@ -70,11 +70,6 @@
} }
} }
}, },
"performance": {
"heading": "Performances",
"label": "Privilégier les performances",
"description": "Réduit la qualité de vos vidéos pour soulager votre appareil. Sactive automatiquement en cas de surcharge. Désactivez-la pour retrouver la qualité maximale."
},
"permissionsRequired": "Autorisations nécessaires" "permissionsRequired": "Autorisations nécessaires"
}, },
"transcription": { "transcription": {
@@ -18,10 +18,6 @@
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.", "auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
"dismiss": "Microfoon inschakelen" "dismiss": "Microfoon inschakelen"
}, },
"cpuConstrained": {
"message": "Uw apparaat is zwaar belast. De videokwaliteit is verlaagd om het gesprek soepel te laten verlopen.",
"keepQuality": "Oorspronkelijke kwaliteit behouden"
},
"reaction": { "reaction": {
"description": "{{name}} reageerde met {{emoji}}" "description": "{{name}} reageerde met {{emoji}}"
}, },
+10 -2
View File
@@ -16,6 +16,10 @@
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten." "audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
}, },
"deviceInUse": {
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
},
"settings": { "settings": {
"audio": "Audio-instellingen", "audio": "Audio-instellingen",
"video": "Video-instellingen" "video": "Video-instellingen"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Camera is uitgeschakeld.", "cameraDisabled": "Camera is uitgeschakeld.",
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
"cameraStarting": "Camera wordt ingeschakeld…", "cameraStarting": "Camera wordt ingeschakeld…",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?", "cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?", "cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
@@ -214,8 +219,11 @@
"title": "Kan uw scherm niet delen", "title": "Kan uw scherm niet delen",
"ariaLabel": "Kan uw scherm niet delen", "ariaLabel": "Kan uw scherm niet delen",
"message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.", "message": "Het is mogelijk dat uw browser geen toestemming heeft om het scherm op uw computer op te nemen.",
"macInstructions": "Ga naar uw", "settingsInstructions": "Ga naar uw",
"macSystemPreferences": "Systeemvoorkeuren", "settingsLabel": {
"macos": "Systeemvoorkeuren",
"windows": "Windows-privacyinstellingen"
},
"helpLinkText": "Meer informatie, zie", "helpLinkText": "Meer informatie, zie",
"helpLinkLabel": "Presentatieprobleem", "helpLinkLabel": "Presentatieprobleem",
"closeButton": "Negeren", "closeButton": "Negeren",
@@ -70,11 +70,6 @@
} }
} }
}, },
"performance": {
"heading": "Prestaties",
"label": "Prestaties prioriteren",
"description": "Vermindert de kwaliteit van je video's om je apparaat te ontlasten. Wordt automatisch geactiveerd bij overbelasting. Schakel het uit om de maximale kwaliteit te herstellen."
},
"permissionsRequired": "Machtigingen vereist" "permissionsRequired": "Machtigingen vereist"
}, },
"transcription": { "transcription": {
+27 -2
View File
@@ -1,14 +1,39 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import type { PermissionKind } from './permissions'
// Device presence (not permission): enumerateDevices() exposes kinds // Device availability (not permission):
// before any grant. Optimistic defaults until the first sync. // - presence: enumerateDevices() exposes kinds before any grant.
// Optimistic defaults until the first sync.
// - in use: the device exists and is allowed, but getUserMedia() failed
// because another application or tab is holding it.
export const deviceAvailabilityStore = proxy({ export const deviceAvailabilityStore = proxy({
hasCamera: true, hasCamera: true,
hasMicrophone: true, hasMicrophone: true,
cameraInUse: false,
microphoneInUse: false,
synced: false, synced: false,
}) })
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
camera: 'cameraInUse',
microphone: 'microphoneInUse',
}
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
const setDeviceInUse = (inUse: boolean, kind?: PermissionKind) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceAvailabilityStore[IN_USE_KEY[k]] = inUse
}
}
export const noteDeviceInUse = (kind?: PermissionKind) =>
setDeviceInUse(true, kind)
export const clearDeviceInUse = (kind?: PermissionKind) =>
setDeviceInUse(false, kind)
export const syncDeviceAvailability = async (): Promise<void> => { export const syncDeviceAvailability = async (): Promise<void> => {
try { try {
const devices = await navigator.mediaDevices.enumerateDevices() const devices = await navigator.mediaDevices.enumerateDevices()
+18
View File
@@ -0,0 +1,18 @@
import { proxy, ref } from 'valtio'
import type { Participant } from 'livekit-client'
type State = {
participant: Participant | null
}
export const muteDialogStore = proxy<State>({
participant: null,
})
export const openMuteDialog = (participant: Participant) => {
muteDialogStore.participant = ref(participant)
}
export const closeMuteDialog = () => {
muteDialogStore.participant = null
}
@@ -1,30 +0,0 @@
import { proxy } from 'valtio'
export type PerformanceModeTrigger = 'cpu' | 'manual'
export const performanceModeStore = proxy<{
enabled: boolean
trigger: PerformanceModeTrigger | null
userDeclinedAuto: boolean
}>({
enabled: false,
trigger: null,
userDeclinedAuto: false,
})
export const enablePerformanceMode = (trigger: PerformanceModeTrigger) => {
if (performanceModeStore.enabled) return
performanceModeStore.enabled = true
performanceModeStore.trigger = trigger
}
export const disablePerformanceMode = ({
declinedAuto = false,
}: { declinedAuto?: boolean } = {}) => {
if (declinedAuto) {
performanceModeStore.userDeclinedAuto = true
}
if (!performanceModeStore.enabled) return
performanceModeStore.enabled = false
performanceModeStore.trigger = null
}
+18 -8
View File
@@ -5,16 +5,26 @@ import { visualizer } from 'rollup-plugin-visualizer'
import svgr from 'vite-plugin-svgr' import svgr from 'vite-plugin-svgr'
import { viteStaticCopy } from 'vite-plugin-static-copy' import { viteStaticCopy } from 'vite-plugin-static-copy'
const mediapipeVersion: string = JSON.parse( const readPackageJson = (path: string) =>
readFileSync( JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
new URL(
'./node_modules/@mediapipe/tasks-vision/package.json', const mediapipeVersion: string = readPackageJson(
import.meta.url './node_modules/@mediapipe/tasks-vision/package.json'
),
'utf-8'
)
).version ).version
const livekitMediapipeVersion: string = readPackageJson(
'./node_modules/@livekit/track-processors/package.json'
).dependencies['@mediapipe/tasks-vision']
if (mediapipeVersion !== livekitMediapipeVersion) {
throw new Error(
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
`"${livekitMediapipeVersion}" in package.json.`
)
}
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd()) const env = loadEnv(mode, process.cwd())
+20 -10
View File
@@ -1,15 +1,15 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.26.0", "version": "1.27.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.26.0", "version": "1.27.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.0", "@html-to/text-cli": "0.6.1",
"mjml": "5.4.0" "mjml": "5.4.0"
} }
}, },
@@ -52,14 +52,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@html-to/text-cli": { "node_modules/@html-to/text-cli": {
"version": "0.6.0", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==", "integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@selderee/plugin-htmlparser2": "~0.12.0", "@selderee/plugin-htmlparser2": "~0.12.0",
"aspargvs": "~0.7.0", "aspargvs": "~0.7.0",
"deepmerge-ts": "^7.1.5", "deepmerge-ts": "^8.0.1",
"htmlparser2": "^10.1.0", "htmlparser2": "^10.1.0",
"selderee": "~0.12.0" "selderee": "~0.12.0"
}, },
@@ -656,9 +656,19 @@
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "7.1.5", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.26.0", "version": "1.27.0",
"description": "An util to generate html and text django's templates from mjml templates", "description": "An util to generate html and text django's templates from mjml templates",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@html-to/text-cli": "0.6.0", "@html-to/text-cli": "0.6.1",
"mjml": "5.4.0" "mjml": "5.4.0"
}, },
"private": true, "private": true,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.26.0", "version": "1.27.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "sdk", "name": "sdk",
"version": "1.26.0", "version": "1.27.0",
"license": "ISC", "license": "ISC",
"workspaces": [ "workspaces": [
"./library", "./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.26.0", "version": "1.27.0",
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "summary" name = "summary"
version = "1.26.0" version = "1.27.0"
dependencies = [ dependencies = [
"fastapi[standard]>=0.105.0", "fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0", "uvicorn>=0.24.0",
+1
View File
@@ -83,6 +83,7 @@ class Settings(BaseSettings):
aws_s3_access_key_id: str aws_s3_access_key_id: str
aws_s3_secret_access_key: SecretStr aws_s3_secret_access_key: SecretStr
aws_s3_secure_access: bool = True aws_s3_secure_access: bool = True
aws_s3_region_name: str | None = None
aws_transcript_path: str = "transcripts" aws_transcript_path: str = "transcripts"
aws_summary_path: str = "summaries" aws_summary_path: str = "summaries"
+1
View File
@@ -282,6 +282,7 @@ class FileService:
access_key=settings.aws_s3_access_key_id, access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(), secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access, secure=settings.aws_s3_secure_access,
region=settings.aws_s3_region_name,
) )
self._bucket_name = settings.aws_storage_bucket_name self._bucket_name = settings.aws_storage_bucket_name
+1 -1
View File
@@ -26,7 +26,7 @@ class RecordingMetadata(BaseModel):
cloud_storage_url: Url = Field( cloud_storage_url: Url = Field(
title="Cloud Storage URL", title="Cloud Storage URL",
description="The URL of the metadata file for speaker assignement.", description="The URL of the metadata file for speaker assignment.",
) )
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe") started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe") ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")