mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-20 15:22:18 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb20edda7 | |||
| 28826bf459 |
+2
-17
@@ -8,21 +8,9 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
### Added
|
||||
|
||||
- ✨(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
|
||||
|
||||
### 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
|
||||
|
||||
## [1.27.0] - 2026-08-14
|
||||
- ✨(frontend) introduce performance mode with auto-detection and telemetry
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -37,9 +25,6 @@ and this project adheres to
|
||||
- 🐛(frontend) implement hysteresis band for the control bar layout
|
||||
- 🐛(frontend) fix toolbar ResizeObserver loop and alignment drift
|
||||
- 🐛(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
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
We appreciate your effort to make Visio more secure.
|
||||
|
||||
|
||||
+1
-1
@@ -180,7 +180,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:22
|
||||
image: node:18
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
|
||||
@@ -50,24 +50,10 @@ paths:
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
requestBody:
|
||||
required: true
|
||||
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:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenRequest'
|
||||
@@ -131,19 +117,6 @@ paths:
|
||||
summary: Domain not authorized
|
||||
value:
|
||||
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:
|
||||
get:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.27.0"
|
||||
version = "1.26.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.27.0"
|
||||
version = "1.26.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -12,9 +12,6 @@ from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
parsers as drf_parsers,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
@@ -44,7 +41,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
parser_classes=[drf_parsers.FormParser, drf_parsers.JSONParser],
|
||||
)
|
||||
@FeatureFlag.require("application")
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
|
||||
@@ -5,7 +5,6 @@ Tests for external API /token endpoint
|
||||
# pylint: disable=W0621
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
@@ -89,155 +88,6 @@ 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():
|
||||
"""Invalid client_id should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.27.0"
|
||||
version = "1.26.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+4
-4
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.27.0"
|
||||
version = "1.26.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -2252,11 +2252,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlparse"
|
||||
version = "0.6.0"
|
||||
version = "0.5.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Generated
+71
-74
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.23",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
@@ -24,17 +24,17 @@
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.6",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.404.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.10",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
@@ -742,28 +742,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"version": "1.7.5",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
|
||||
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.5",
|
||||
"@floating-ui/utils": "^0.2.11"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
|
||||
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.7.3",
|
||||
"@floating-ui/utils": "^0.2.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"version": "0.2.11",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/atkinson-hyperlegible-next": {
|
||||
@@ -966,12 +966,12 @@
|
||||
"license": "GPL-3.0+"
|
||||
},
|
||||
"node_modules/@livekit/components-core": {
|
||||
"version": "0.12.14",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
||||
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
||||
"version": "0.12.13",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "1.7.6",
|
||||
"@floating-ui/dom": "1.7.4",
|
||||
"loglevel": "1.9.1",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
@@ -979,17 +979,17 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"livekit-client": "^2.20.1",
|
||||
"livekit-client": "^2.17.2",
|
||||
"tslib": "^2.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/components-react": {
|
||||
"version": "2.9.23",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
||||
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
||||
"version": "2.9.21",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
||||
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/components-core": "0.12.14",
|
||||
"@livekit/components-core": "0.12.13",
|
||||
"clsx": "2.1.1",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.0.12",
|
||||
@@ -999,8 +999,8 @@
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0 || ^0.4.0",
|
||||
"livekit-client": "^2.20.1",
|
||||
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||
"livekit-client": "^2.18.2",
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18",
|
||||
"tslib": "^2.6.2"
|
||||
@@ -1027,9 +1027,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
||||
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
||||
"version": "1.46.6",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
@@ -1720,18 +1720,18 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.48.3",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
||||
"version": "1.39.5",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
||||
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.405.0"
|
||||
"@posthog/types": "^1.392.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.405.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
||||
"version": "1.392.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
||||
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@react-aria/overlays": {
|
||||
@@ -4393,14 +4393,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
||||
"version": "3.39.0",
|
||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
@@ -6403,9 +6400,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -6422,7 +6419,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -8112,20 +8109,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.21.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
||||
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
||||
"version": "2.20.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.50.4",
|
||||
"@livekit/protocol": "1.46.6",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.6"
|
||||
"webrtc-adapter": "9.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
@@ -9123,17 +9120,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.404.1",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
||||
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"version": "1.395.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.43.1",
|
||||
"@posthog/types": "^1.397.0",
|
||||
"core-js": "^3.49.0",
|
||||
"@posthog/core": "^1.38.0",
|
||||
"@posthog/types": "^1.391.1",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.29.3",
|
||||
"preact": "^10.29.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.3.0"
|
||||
}
|
||||
@@ -9425,9 +9422,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.10",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -9437,7 +9434,7 @@
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
@@ -11648,9 +11645,9 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -19,7 +19,7 @@
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.23",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
@@ -31,17 +31,17 @@
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.3.6",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.404.1",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.10",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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,7 +23,6 @@ export type LogCode =
|
||||
| 'livekit_room_error'
|
||||
| 'device_switch_failure'
|
||||
| 'permission_poll_failure'
|
||||
| 'media_devices_error_event'
|
||||
// non-media families
|
||||
| 'participant_mute_api_failure'
|
||||
| 'permissions_api_failure'
|
||||
@@ -31,6 +30,7 @@ export type LogCode =
|
||||
| 'clipboard_failure'
|
||||
| 'fullscreen_failure'
|
||||
| 'publish_sources_failure'
|
||||
| 'performance_mode_failure'
|
||||
| 'disconnect_failure'
|
||||
| 'generic_failure'
|
||||
|
||||
@@ -138,7 +138,6 @@ export const captureMediaEvent = async (
|
||||
| 'media-device-success'
|
||||
| 'device-not-found'
|
||||
| 'permissions-denied'
|
||||
| 'screen-share-permission-denied'
|
||||
| 'silent-mic-detected'
|
||||
| 'silent-mic-analyser-unavailable'
|
||||
| 'silent-mic-recovered'
|
||||
|
||||
@@ -23,7 +23,7 @@ export const ChatProvider = () => {
|
||||
resetChatStore()
|
||||
}, [])
|
||||
|
||||
// Trigger the message notification (temporary)
|
||||
// Tigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||
|
||||
@@ -3,6 +3,7 @@ export enum ToastDuration {
|
||||
MEDIUM = 4000,
|
||||
LONG = 5000,
|
||||
EXTRA_LONG = 7000,
|
||||
UNDO_WINDOW = 30000,
|
||||
}
|
||||
|
||||
export const NotificationDuration = {
|
||||
@@ -15,4 +16,5 @@ export const NotificationDuration = {
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
ROLE_CHANGED: ToastDuration.LONG,
|
||||
CPU_CONSTRAINED: ToastDuration.UNDO_WINDOW,
|
||||
} as const
|
||||
|
||||
@@ -18,4 +18,5 @@ export enum NotificationType {
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
CpuConstrained = 'cpuConstrained',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
import { ToastCpuConstrained } from './ToastCpuConstrained'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -74,6 +75,9 @@ const renderToast = (
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.CpuConstrained:
|
||||
return <ToastCpuConstrained key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -15,6 +15,15 @@ export const notifyAutoMutedOnJoin = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const notifyCpuConstrained = () => {
|
||||
toastQueue.add(
|
||||
{
|
||||
type: NotificationType.CpuConstrained,
|
||||
},
|
||||
{ timeout: NotificationDuration.CPU_CONSTRAINED }
|
||||
)
|
||||
}
|
||||
|
||||
export const showLowerHandToast = (
|
||||
participant: Participant,
|
||||
onClose: () => void
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
@@ -225,10 +225,9 @@ export const Conference = ({
|
||||
backgroundColor: 'primaryDark.50 !important',
|
||||
})}
|
||||
onError={(e) => {
|
||||
const failure = MediaDeviceFailure.getFailure(e)
|
||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||
reportError('livekit_room_error', e, {
|
||||
path: 'connect_publish',
|
||||
failure: MediaDeviceFailure.getFailure(e) ?? 'not-a-device-error',
|
||||
})
|
||||
}}
|
||||
onConnected={async () => {
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { A, Button, Dialog, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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
|
||||
export const ScreenShareErrorModal = ({
|
||||
@@ -18,8 +11,7 @@ export const ScreenShareErrorModal = ({
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||
const os = getOS()
|
||||
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
|
||||
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -34,16 +26,15 @@ export const ScreenShareErrorModal = ({
|
||||
<>
|
||||
<P>
|
||||
{t('message')}{' '}
|
||||
{settingsHref && (
|
||||
{isMac && (
|
||||
<>
|
||||
{t('settingsInstructions')}{' '}
|
||||
{t('macInstructions')}{' '}
|
||||
<A
|
||||
href={settingsHref}
|
||||
target="_blank"
|
||||
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||
color="primary"
|
||||
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
||||
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||
>
|
||||
{t(`settingsLabel.${os}`)}
|
||||
{t('macSystemPreferences')}
|
||||
</A>
|
||||
.{' '}
|
||||
</>
|
||||
|
||||
+23
-13
@@ -9,15 +9,18 @@ import {
|
||||
} from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
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 = () => {
|
||||
const { videoSubscribeQuality } = useSnapshot(userChoicesStore)
|
||||
const { enabled: isPerformanceModeEnabled } =
|
||||
useSnapshot(performanceModeStore)
|
||||
const room = useRoomContext()
|
||||
|
||||
const effectiveQuality = isPerformanceModeEnabled
|
||||
? VideoQuality.LOW
|
||||
: (videoSubscribeQuality ?? VideoQuality.HIGH)
|
||||
|
||||
useEffect(() => {
|
||||
if (!room) return
|
||||
|
||||
@@ -25,18 +28,12 @@ export const VideoResolutionSubscription = () => {
|
||||
publication: RemoteTrackPublication,
|
||||
_participant: RemoteParticipant
|
||||
) => {
|
||||
// By default, the maximum quality is set to high
|
||||
if (
|
||||
videoSubscribeQuality === undefined ||
|
||||
videoSubscribeQuality === VideoQuality.HIGH
|
||||
)
|
||||
return
|
||||
|
||||
if (effectiveQuality === VideoQuality.HIGH) return
|
||||
if (
|
||||
publication.kind === Track.Kind.Video &&
|
||||
publication.source !== Track.Source.ScreenShare
|
||||
) {
|
||||
publication.setVideoQuality(videoSubscribeQuality)
|
||||
publication.setVideoQuality(effectiveQuality)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +41,20 @@ export const VideoResolutionSubscription = () => {
|
||||
return () => {
|
||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||
}
|
||||
}, [room, videoSubscribeQuality])
|
||||
}, [room, effectiveQuality])
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
+2
-2
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
videoElement?: HTMLVideoElement
|
||||
videoElementLoaded?: boolean
|
||||
|
||||
// Canvas containing the video processing result, of which we extract as stream.
|
||||
// Canvas containg the video processing result, of which we extract as stream.
|
||||
outputCanvas?: HTMLCanvasElement
|
||||
outputCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
segmentationMaskCanvas?: HTMLCanvasElement
|
||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||
|
||||
// Mask containing the inference result.
|
||||
// Mask containg the inference result.
|
||||
segmentationMask?: ImageData
|
||||
|
||||
// The resized image of the video source.
|
||||
|
||||
@@ -50,16 +50,15 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
||||
useEffect(() => {
|
||||
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
||||
const failure = MediaDeviceFailure.getFailure(error)
|
||||
if (!failure) return
|
||||
if (failure != MediaDeviceFailure.Other) {
|
||||
if (!failure || !kind) return
|
||||
|
||||
void captureMediaEvent('media-device-error', {
|
||||
log_code: 'media_devices_error_event',
|
||||
path: 'connect_publish',
|
||||
failure,
|
||||
kind: kind ?? 'unknown',
|
||||
kind,
|
||||
})
|
||||
}
|
||||
if (!kind) return
|
||||
|
||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||
switch (failure) {
|
||||
case MediaDeviceFailure.DeviceInUse:
|
||||
|
||||
@@ -11,9 +11,7 @@ import { SidePanel } from '../components/SidePanel'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||
import { getOS } from '@/utils/os'
|
||||
import { isFireFox } from '@/utils/livekit'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
@@ -29,6 +27,8 @@ import { PinAnnouncer } from '@/features/layout/components/PinAnnouncer'
|
||||
import { ChatProvider } from '@/features/chat/components/ChatProvider'
|
||||
import { SyncDevicePreferences } from '@/features/rooms/livekit/components/SyncDevicePreferences'
|
||||
import { RoomSilentMicDetector } from '@/features/rooms/components/SilentMicDetector'
|
||||
import { CpuConstrainedObserver } from '@/features/performance/components/CpuConstrainedObserver'
|
||||
import { PerformanceModeController } from '@/features/performance/components/PerformanceModeController'
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -38,20 +38,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
||||
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.
|
||||
* It provides functionality such as focusing on one participant, grid view with pagination to handle large numbers
|
||||
@@ -77,34 +63,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
reportError('device_switch_failure', error, {
|
||||
at: 'ControlBar.onDeviceError',
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomMetadataSynchronizer />
|
||||
@@ -114,6 +72,8 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<VideoResolutionSubscription />
|
||||
<PerformanceModeController />
|
||||
<CpuConstrainedObserver />
|
||||
<div
|
||||
className="lk-video-conference"
|
||||
{...props}
|
||||
@@ -136,7 +96,21 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
<StageLayout />
|
||||
)}
|
||||
</RoomContentArea>
|
||||
<ControlBar onDeviceError={handleDeviceError} />
|
||||
<ControlBar
|
||||
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 />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
} from '@/stores/userChoices'
|
||||
import { RowWrapper } from './layout/RowWrapper'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
disablePerformanceMode,
|
||||
enablePerformanceMode,
|
||||
performanceModeStore,
|
||||
} from '@/stores/performanceMode'
|
||||
|
||||
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -32,7 +37,7 @@ const EMPTY_PROPS = {}
|
||||
|
||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||
const { localParticipant } = useRoomContext()
|
||||
|
||||
const {
|
||||
videoDeviceId,
|
||||
@@ -41,6 +46,9 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
videoSubscribeQuality,
|
||||
} = useSnapshot(userChoicesStore)
|
||||
|
||||
const { enabled: isPerformanceModeEnabled } =
|
||||
useSnapshot(performanceModeStore)
|
||||
|
||||
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
||||
null
|
||||
)
|
||||
@@ -85,22 +93,6 @@ 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(() => {
|
||||
let videoTrack: LocalVideoTrack | null = null
|
||||
|
||||
@@ -217,10 +209,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
type="select"
|
||||
label={t('resolution.publish.label')}
|
||||
items={resolutionItems}
|
||||
selectedKey={videoPublishResolution}
|
||||
selectedKey={
|
||||
isPerformanceModeEnabled ? 'h360' : videoPublishResolution
|
||||
}
|
||||
onSelectionChange={async (key) => {
|
||||
await handleVideoResolutionChange(key as VideoResolution)
|
||||
}}
|
||||
isDisabled={isPerformanceModeEnabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
@@ -232,19 +227,43 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
type="select"
|
||||
label={t('resolution.subscribe.label')}
|
||||
items={videoQualityItems}
|
||||
selectedKey={videoSubscribeQuality?.toString()}
|
||||
selectedKey={
|
||||
isPerformanceModeEnabled
|
||||
? VideoQuality.LOW.toString()
|
||||
: videoSubscribeQuality?.toString()
|
||||
}
|
||||
onSelectionChange={(key) => {
|
||||
if (key == undefined) return
|
||||
const selectedQuality = Number(String(key))
|
||||
saveVideoSubscribeQuality(selectedQuality)
|
||||
updateExistingRemoteVideoQuality(selectedQuality)
|
||||
}}
|
||||
isDisabled={isPerformanceModeEnabled}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
<></>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"auto": "Sie haben an einer großen Besprechung teilgenommen. Ihr Mikrofon wurde automatisch stummgeschaltet.",
|
||||
"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": {
|
||||
"description": "{{name}} hat mit {{emoji}} reagiert"
|
||||
},
|
||||
|
||||
@@ -214,11 +214,8 @@
|
||||
"title": "Bildschirmfreigabe nicht möglich",
|
||||
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
||||
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
||||
"settingsInstructions": "Gehe zu den",
|
||||
"settingsLabel": {
|
||||
"macos": "Systemeinstellungen",
|
||||
"windows": "Windows-Datenschutzeinstellungen"
|
||||
},
|
||||
"macInstructions": "Gehe zu den",
|
||||
"macSystemPreferences": "Systemeinstellungen",
|
||||
"helpLinkText": "Weitere Informationen findest du unter",
|
||||
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
||||
"closeButton": "Schließen",
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"auto": "You have joined a large meeting. Your microphone has been automatically muted.",
|
||||
"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": {
|
||||
"description": "{{name}} reacted with {{emoji}}"
|
||||
},
|
||||
|
||||
@@ -214,11 +214,8 @@
|
||||
"title": "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.",
|
||||
"settingsInstructions": "Go to your",
|
||||
"settingsLabel": {
|
||||
"macos": "System Preferences",
|
||||
"windows": "Windows privacy settings"
|
||||
},
|
||||
"macInstructions": "Go to your",
|
||||
"macSystemPreferences": "System Preferences",
|
||||
"helpLinkText": "To learn more, see",
|
||||
"helpLinkLabel": "Presentation issue",
|
||||
"closeButton": "Dismiss",
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"auto": "Vous venez de rejoindre une grande réunion. Votre micro a été automatiquement coupé.",
|
||||
"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": {
|
||||
"description": "{{name}} a reagi avec {{emoji}}"
|
||||
},
|
||||
|
||||
@@ -214,11 +214,8 @@
|
||||
"title": "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.",
|
||||
"settingsInstructions": "Accédez à vos",
|
||||
"settingsLabel": {
|
||||
"macos": "Préférences système",
|
||||
"windows": "paramètres de confidentialité Windows"
|
||||
},
|
||||
"macInstructions": "Accèdez à vos",
|
||||
"macSystemPreferences": "Préférences système",
|
||||
"helpLinkText": "Pour en savoir plus, consulter",
|
||||
"helpLinkLabel": "Problème de présentation",
|
||||
"closeButton": "Ignorer",
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"performance": {
|
||||
"heading": "Performances",
|
||||
"label": "Privilégier les performances",
|
||||
"description": "Réduit la qualité de vos vidéos pour soulager votre appareil. S’active automatiquement en cas de surcharge. Désactivez-la pour retrouver la qualité maximale."
|
||||
},
|
||||
"permissionsRequired": "Autorisations nécessaires"
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -18,6 +18,10 @@
|
||||
"auto": "U bent zojuist lid geworden van een grote vergadering. Uw microfoon is automatisch gedempt.",
|
||||
"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": {
|
||||
"description": "{{name}} reageerde met {{emoji}}"
|
||||
},
|
||||
|
||||
@@ -214,11 +214,8 @@
|
||||
"title": "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.",
|
||||
"settingsInstructions": "Ga naar uw",
|
||||
"settingsLabel": {
|
||||
"macos": "Systeemvoorkeuren",
|
||||
"windows": "Windows-privacyinstellingen"
|
||||
},
|
||||
"macInstructions": "Ga naar uw",
|
||||
"macSystemPreferences": "Systeemvoorkeuren",
|
||||
"helpLinkText": "Meer informatie, zie",
|
||||
"helpLinkLabel": "Presentatieprobleem",
|
||||
"closeButton": "Negeren",
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"transcription": {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
Generated
+10
-20
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"mjml": "5.4.0"
|
||||
}
|
||||
},
|
||||
@@ -52,14 +52,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@html-to/text-cli": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
|
||||
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
|
||||
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||
"aspargvs": "~0.7.0",
|
||||
"deepmerge-ts": "^8.0.1",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"htmlparser2": "^10.1.0",
|
||||
"selderee": "~0.12.0"
|
||||
},
|
||||
@@ -656,19 +656,9 @@
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/deepmerge-ts": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"mjml": "5.4.0"
|
||||
},
|
||||
"private": true,
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.27.0",
|
||||
"version": "1.26.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.27.0"
|
||||
version = "1.26.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -83,7 +83,6 @@ class Settings(BaseSettings):
|
||||
aws_s3_access_key_id: str
|
||||
aws_s3_secret_access_key: SecretStr
|
||||
aws_s3_secure_access: bool = True
|
||||
aws_s3_region_name: str | None = None
|
||||
aws_transcript_path: str = "transcripts"
|
||||
aws_summary_path: str = "summaries"
|
||||
|
||||
|
||||
@@ -282,7 +282,6 @@ class FileService:
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
region=settings.aws_s3_region_name,
|
||||
)
|
||||
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
|
||||
@@ -26,7 +26,7 @@ class RecordingMetadata(BaseModel):
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
title="Cloud Storage URL",
|
||||
description="The URL of the metadata file for speaker assignment.",
|
||||
description="The URL of the metadata file for speaker assignement.",
|
||||
)
|
||||
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
||||
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||
|
||||
Reference in New Issue
Block a user