mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-21 15:47:09 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1eb20edda7 | |||
| 28826bf459 |
+1
-18
@@ -10,21 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(backend) add OpenShift-compatible recording download endpoint
|
- ✨(frontend) introduce performance mode with auto-detection and telemetry
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- ✨(backend) accept form-urlencoded on the user token endpoint
|
|
||||||
- ⬆️(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
|
|
||||||
|
|
||||||
## [1.27.0] - 2026-08-14
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -39,9 +25,6 @@ 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
@@ -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 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.
|
We appreciate your effort to make Visio more secure.
|
||||||
|
|
||||||
|
|||||||
@@ -50,24 +50,10 @@ 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'
|
||||||
@@ -131,19 +117,6 @@ 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,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.27.0"
|
version = "1.26.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.27.0"
|
version = "1.26.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "livekit-agents" },
|
{ name = "livekit-agents" },
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ from django.core.exceptions import ValidationError as DjangoValidationError
|
|||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404, StreamingHttpResponse
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from botocore.exceptions import BotoCoreError, ClientError
|
|
||||||
from django_filters import rest_framework as django_filters
|
from django_filters import rest_framework as django_filters
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
decorators,
|
decorators,
|
||||||
@@ -114,14 +113,6 @@ from .feature_flag import FeatureFlag
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class StorageUnavailable(drf_exceptions.APIException):
|
|
||||||
"""Exception raised when recording storage cannot be reached."""
|
|
||||||
|
|
||||||
status_code = drf_status.HTTP_503_SERVICE_UNAVAILABLE
|
|
||||||
default_detail = "The recording storage is temporarily unavailable."
|
|
||||||
default_code = "storage_unavailable"
|
|
||||||
|
|
||||||
|
|
||||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
A generic Viewset aims to be used in a nested route context.
|
A generic Viewset aims to be used in a nested route context.
|
||||||
@@ -1229,87 +1220,6 @@ class RecordingViewSet(
|
|||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|
||||||
@decorators.action(detail=False, methods=["get"], url_path="download")
|
|
||||||
def media_download(self, request, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Stream a recording file directly from S3 to the browser.
|
|
||||||
|
|
||||||
This endpoint is registered at /media/recordings/<uuid>.<ext> in core/urls.py
|
|
||||||
and handles the download flow on OpenShift where the Nginx auth_request
|
|
||||||
mechanism is not available.
|
|
||||||
|
|
||||||
Django acts as a streaming proxy: bytes flow from S3 (internal cluster URL)
|
|
||||||
directly to the client response. This avoids any page navigation or redirect,
|
|
||||||
so the browser triggers a native download without leaving the current page.
|
|
||||||
|
|
||||||
Access control mirrors media_auth: the user must have the "retrieve" ability
|
|
||||||
on the recording and the recording must be in a saved state.
|
|
||||||
"""
|
|
||||||
recording_id = self.kwargs.get("recording_id")
|
|
||||||
extension = self.kwargs.get("extension")
|
|
||||||
|
|
||||||
if extension not in [file.value for file in FileExtension]:
|
|
||||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
|
||||||
|
|
||||||
try:
|
|
||||||
recording = models.Recording.objects.get(id=recording_id)
|
|
||||||
except models.Recording.DoesNotExist as e:
|
|
||||||
raise drf_exceptions.NotFound("No recording found for this id.") from e
|
|
||||||
|
|
||||||
if extension != recording.extension:
|
|
||||||
raise drf_exceptions.NotFound("No recording found with this extension.")
|
|
||||||
|
|
||||||
abilities = recording.get_abilities(request.user)
|
|
||||||
|
|
||||||
if not abilities["retrieve"]:
|
|
||||||
logger.debug(
|
|
||||||
"User '%s' lacks permission for recording download", request.user.id
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
if not recording.is_saved:
|
|
||||||
logger.debug("Recording '%s' has not been saved", recording)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
|
||||||
|
|
||||||
s3_client = default_storage.connection.meta.client
|
|
||||||
try:
|
|
||||||
s3_object = s3_client.get_object(
|
|
||||||
Bucket=default_storage.bucket_name,
|
|
||||||
Key=recording.key,
|
|
||||||
)
|
|
||||||
except ClientError as error:
|
|
||||||
error_code = error.response.get("Error", {}).get("Code")
|
|
||||||
if error_code in {"404", "NoSuchKey", "NoSuchObject"}:
|
|
||||||
raise drf_exceptions.NotFound(
|
|
||||||
"No recording file found in storage."
|
|
||||||
) from error
|
|
||||||
logger.exception("Unable to retrieve recording from storage")
|
|
||||||
raise StorageUnavailable() from error
|
|
||||||
except BotoCoreError as error:
|
|
||||||
logger.exception("Unable to retrieve recording from storage")
|
|
||||||
raise StorageUnavailable() from error
|
|
||||||
|
|
||||||
content_type = s3_object.get("ContentType") or "video/mp4"
|
|
||||||
content_length = s3_object.get("ContentLength")
|
|
||||||
filename = f"{recording_id}.{extension}"
|
|
||||||
body = s3_object["Body"]
|
|
||||||
|
|
||||||
def stream():
|
|
||||||
try:
|
|
||||||
yield from body.iter_chunks(chunk_size=65536)
|
|
||||||
finally:
|
|
||||||
body.close()
|
|
||||||
|
|
||||||
streaming_response = StreamingHttpResponse(
|
|
||||||
stream(),
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
streaming_response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
|
||||||
if content_length:
|
|
||||||
streaming_response["Content-Length"] = content_length
|
|
||||||
|
|
||||||
return streaming_response
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
class FileViewSet(
|
class FileViewSet(
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -44,7 +41,6 @@ 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):
|
||||||
|
|||||||
@@ -3,12 +3,8 @@ Test recordings API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
from django.core.files.storage import default_storage
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from botocore.exceptions import ClientError, EndpointConnectionError
|
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -18,11 +14,6 @@ from ...models import RecordingStatusChoices
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
def media_recording_url(recording):
|
|
||||||
"""Return the direct media URL for a recording."""
|
|
||||||
return f"/media/recordings/{recording.id!s}.{recording.extension}"
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_anonymous():
|
def test_api_recording_retrieve_anonymous():
|
||||||
"""Anonymous users should not be able to retrieve recordings."""
|
"""Anonymous users should not be able to retrieve recordings."""
|
||||||
recording = RecordingFactory()
|
recording = RecordingFactory()
|
||||||
@@ -34,9 +25,6 @@ def test_api_recording_retrieve_anonymous():
|
|||||||
"detail": "Authentication credentials were not provided."
|
"detail": "Authentication credentials were not provided."
|
||||||
}
|
}
|
||||||
|
|
||||||
response = client.get(media_recording_url(recording))
|
|
||||||
assert response.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_authenticated():
|
def test_api_recording_retrieve_authenticated():
|
||||||
"""Authenticated users without access receive 404 when requesting recordings.
|
"""Authenticated users without access receive 404 when requesting recordings.
|
||||||
@@ -56,9 +44,6 @@ def test_api_recording_retrieve_authenticated():
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert response.json() == {"detail": "No Recording matches the given query."}
|
assert response.json() == {"detail": "No Recording matches the given query."}
|
||||||
|
|
||||||
response = client.get(media_recording_url(recording))
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_members():
|
def test_api_recording_retrieve_members():
|
||||||
"""
|
"""
|
||||||
@@ -78,11 +63,6 @@ def test_api_recording_retrieve_members():
|
|||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
recording.status = RecordingStatusChoices.SAVED
|
|
||||||
recording.save(update_fields=["status"])
|
|
||||||
response = client.get(media_recording_url(recording))
|
|
||||||
assert response.status_code == 403
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_administrators(settings):
|
def test_api_recording_retrieve_administrators(settings):
|
||||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||||
@@ -157,78 +137,6 @@ def test_api_recording_retrieve_owners(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
|
||||||
def test_api_recording_media_download_authorized(role):
|
|
||||||
"""Owners and administrators can download a saved recording."""
|
|
||||||
user = UserFactory()
|
|
||||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
|
||||||
UserRecordingAccessFactory(recording=recording, user=user, role=role)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
body = mock.Mock(iter_chunks=mock.Mock(return_value=[b"recording content"]))
|
|
||||||
s3_object = {
|
|
||||||
"Body": body,
|
|
||||||
"ContentType": "video/mp4",
|
|
||||||
"ContentLength": len(b"recording content"),
|
|
||||||
}
|
|
||||||
with mock.patch.object(
|
|
||||||
default_storage.connection.meta.client,
|
|
||||||
"get_object",
|
|
||||||
return_value=s3_object,
|
|
||||||
) as get_object:
|
|
||||||
response = client.get(media_recording_url(recording))
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
get_object.assert_called_once_with(
|
|
||||||
Bucket=default_storage.bucket_name,
|
|
||||||
Key=recording.key,
|
|
||||||
)
|
|
||||||
assert response["Content-Type"] == "video/mp4"
|
|
||||||
assert response["Content-Disposition"] == (
|
|
||||||
f'attachment; filename="{recording.id}.{recording.extension}"'
|
|
||||||
)
|
|
||||||
assert b"".join(response.streaming_content) == b"recording content"
|
|
||||||
body.close.assert_called_once_with()
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_media_download_missing_object():
|
|
||||||
"""A missing recording object should return 404."""
|
|
||||||
user = UserFactory()
|
|
||||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
|
||||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
error = ClientError(
|
|
||||||
{"Error": {"Code": "NoSuchKey", "Message": "The object does not exist."}},
|
|
||||||
"GetObject",
|
|
||||||
)
|
|
||||||
with mock.patch.object(
|
|
||||||
default_storage.connection.meta.client, "get_object", side_effect=error
|
|
||||||
):
|
|
||||||
response = client.get(media_recording_url(recording))
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_media_download_storage_unavailable():
|
|
||||||
"""A storage connection failure should return 503."""
|
|
||||||
user = UserFactory()
|
|
||||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
|
||||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
error = EndpointConnectionError(endpoint_url="https://storage.test")
|
|
||||||
with mock.patch.object(
|
|
||||||
default_storage.connection.meta.client, "get_object", side_effect=error
|
|
||||||
):
|
|
||||||
response = client.get(media_recording_url(recording))
|
|
||||||
|
|
||||||
assert response.status_code == 503
|
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||||
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ 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
|
||||||
@@ -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():
|
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")
|
||||||
|
|||||||
@@ -60,13 +60,6 @@ urlpatterns = [
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
# The request is handled by RecordingViewSet.media_download which validates
|
|
||||||
# access rights and streams the recording file directly from MinIO to the browser.
|
|
||||||
path(
|
|
||||||
"media/recordings/<uuid:recording_id>.<slug:extension>",
|
|
||||||
viewsets.RecordingViewSet.as_view({"get": "media_download"}),
|
|
||||||
name="recording_media_download",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.EXTERNAL_API_ENABLED:
|
if settings.EXTERNAL_API_ENABLED:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.27.0"
|
version = "1.26.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",
|
||||||
|
|||||||
Generated
+4
-4
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.27.0"
|
version = "1.26.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -2252,11 +2252,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlparse"
|
name = "sqlparse"
|
||||||
version = "0.6.0"
|
version = "0.5.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
Generated
+71
-74
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.27.0",
|
"version": "1.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.27.0",
|
"version": "1.26.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.23",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@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.6",
|
"i18next": "26.3.1",
|
||||||
"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.21.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.404.1",
|
"posthog-js": "1.395.0",
|
||||||
"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.10",
|
"react-i18next": "17.0.8",
|
||||||
"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.8.0",
|
"version": "1.7.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
|
||||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"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"
|
"@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": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
|
||||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
|
||||||
"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.14",
|
"version": "0.12.13",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.14.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.13.tgz",
|
||||||
"integrity": "sha512-6OKP/1Ok2fCZewDLKd3SzaTb7KvfZl/6hjggI+TgUdhp0Y7HBg3+tHA7YmhZRc0BO8wIyVy4VgTPn7qkLHt2nQ==",
|
"integrity": "sha512-DQmi84afHoHjZ62wm8y+XPNIDHTwFHAltjd3lmyXj8UZHOY7wcza4vFt1xnghJOD5wLRY58L1dkAgAw59MgWvw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/dom": "1.7.6",
|
"@floating-ui/dom": "1.7.4",
|
||||||
"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.20.1",
|
"livekit-client": "^2.17.2",
|
||||||
"tslib": "^2.6.2"
|
"tslib": "^2.6.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/components-react": {
|
"node_modules/@livekit/components-react": {
|
||||||
"version": "2.9.23",
|
"version": "2.9.21",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.23.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-react/-/components-react-2.9.21.tgz",
|
||||||
"integrity": "sha512-clO+0g/u3YBpuOvnAjUSAJBH/o7w+RpUAseswjiSML9rHrXlTUhwBNm8LPk+qN5GOU3A6lzsNnFYVpUHUBVOkg==",
|
"integrity": "sha512-6hU9VucJJL+gAhilNGe4MBCDCZVk64qyjP9Ck86krvOIdVU76WeWksddg1MYUP10AlUwwrfD7davz41pJTcMJw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-core": "0.12.14",
|
"@livekit/components-core": "0.12.13",
|
||||||
"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 || ^0.4.0",
|
"@livekit/krisp-noise-filter": "^0.2.12 || ^0.3.0",
|
||||||
"livekit-client": "^2.20.1",
|
"livekit-client": "^2.18.2",
|
||||||
"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.50.4",
|
"version": "1.46.6",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.50.4.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||||
"integrity": "sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA==",
|
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||||
"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.48.3",
|
"version": "1.39.5",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.3.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.39.5.tgz",
|
||||||
"integrity": "sha512-kwVDVvwtCTXctApA2tpnwDjDDan8LrkwCW1Wv6PABaVs/s5ahbQ9W3pvdXcqr71HCuqukSA1jp7MySczebubGg==",
|
"integrity": "sha512-M8Imv7ZmmrT6derMOFXhX3c5VKKO2oG6OX70ozLnNpKJV73sNIhYmRX/raa90saF7OA0YmWWJlpKje3irGAyQQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.405.0"
|
"@posthog/types": "^1.392.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.405.0",
|
"version": "1.392.0",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.392.0.tgz",
|
||||||
"integrity": "sha512-4rZ/taVXKQxs9Jrf7ZjlCRgrOSL69oKAgIWJQa5kRNJ6wll1UANbrJTSY+Su1e88LIG4zZVjKKKjyQHCkHdHcw==",
|
"integrity": "sha512-nctNujXL3FC1v99FktaTMSugSD9ZOZekEpahUSafkU2TSvW+XGKNkQZbokuJtiWvPBK208dwMJva8UfBkChqpw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -4393,14 +4393,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.50.0",
|
"version": "3.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
|
||||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
|
||||||
"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"
|
||||||
@@ -6403,9 +6400,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -6422,7 +6419,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
@@ -8112,20 +8109,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.21.0",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||||
"integrity": "sha512-RBUhPkV/sl1nzl8lokVlK5uATPwn0AlsudCBZXissw/kDl9yz8ac4pNJ43iPpMVoHOeBYH/BZ3vUC1adqa/zFQ==",
|
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.50.4",
|
"@livekit/protocol": "1.46.6",
|
||||||
"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.6"
|
"webrtc-adapter": "9.0.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/dom-mediacapture-record": "^1"
|
"@types/dom-mediacapture-record": "^1"
|
||||||
@@ -9123,17 +9120,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.404.1",
|
"version": "1.395.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.404.1.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||||
"integrity": "sha512-ck/HOMKuZ6yefUk5OX+h2s9mUWBsnu0mGDUAWjIwAmQQrloZPShzOhOWuEuZQuMnCKORBFRVGsBAuzsl66cBJA==",
|
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||||
"license": "(Apache-2.0 AND MIT)",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.43.1",
|
"@posthog/core": "^1.38.0",
|
||||||
"@posthog/types": "^1.397.0",
|
"@posthog/types": "^1.391.1",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.3",
|
"preact": "^10.29.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.3.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
@@ -9425,9 +9422,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.10",
|
"version": "17.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
@@ -9437,7 +9434,7 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"i18next": ">= 26.2.0",
|
"i18next": ">= 26.2.0",
|
||||||
"react": ">= 16.8.0",
|
"react": ">= 16.8.0",
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"react-dom": {
|
"react-dom": {
|
||||||
@@ -11648,9 +11645,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
"version": "9.0.6",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||||
"integrity": "sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog==",
|
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sdp": "^3.2.0"
|
"sdp": "^3.2.0"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.27.0",
|
"version": "1.26.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.23",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@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.6",
|
"i18next": "26.3.1",
|
||||||
"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.21.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.404.1",
|
"posthog-js": "1.395.0",
|
||||||
"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.10",
|
"react-i18next": "17.0.8",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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'
|
| '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'
|
||||||
@@ -31,6 +30,7 @@ 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'
|
||||||
|
|
||||||
@@ -138,7 +138,6 @@ export const captureMediaEvent = async (
|
|||||||
| 'media-device-success'
|
| 'media-device-success'
|
||||||
| 'device-not-found'
|
| 'device-not-found'
|
||||||
| '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()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Trigger the message notification (temporary)
|
// Tigger 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,6 +3,7 @@ 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 = {
|
||||||
@@ -15,4 +16,5 @@ 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,4 +18,5 @@ export enum NotificationType {
|
|||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
RoleChanged = 'roleChanged',
|
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 { 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>
|
||||||
@@ -74,6 +75,9 @@ 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,6 +15,15 @@ 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
|
||||||
|
|||||||
@@ -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',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const failure = MediaDeviceFailure.getFailure(e)
|
|
||||||
if (failure && failure !== MediaDeviceFailure.Other) 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 () => {
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
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 = ({
|
||||||
@@ -18,8 +11,7 @@ export const ScreenShareErrorModal = ({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'error.screenShare' })
|
||||||
const os = getOS()
|
const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1
|
||||||
const settingsHref = SCREEN_CAPTURE_SETTINGS_LINKS[os]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
@@ -34,16 +26,15 @@ export const ScreenShareErrorModal = ({
|
|||||||
<>
|
<>
|
||||||
<P>
|
<P>
|
||||||
{t('message')}{' '}
|
{t('message')}{' '}
|
||||||
{settingsHref && (
|
{isMac && (
|
||||||
<>
|
<>
|
||||||
{t('settingsInstructions')}{' '}
|
{t('macInstructions')}{' '}
|
||||||
<A
|
<A
|
||||||
href={settingsHref}
|
href="x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"
|
||||||
target="_blank"
|
|
||||||
color="primary"
|
color="primary"
|
||||||
aria-label={t(`settingsLabel.${os}`) + '-' + t('newTab')}
|
aria-label={t('macSystemPreferences') + '-' + t('newTab')}
|
||||||
>
|
>
|
||||||
{t(`settingsLabel.${os}`)}
|
{t('macSystemPreferences')}
|
||||||
</A>
|
</A>
|
||||||
.{' '}
|
.{' '}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+23
-13
@@ -9,15 +9,18 @@ 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
|
||||||
|
|
||||||
@@ -25,18 +28,12 @@ export const VideoResolutionSubscription = () => {
|
|||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
_participant: RemoteParticipant
|
_participant: RemoteParticipant
|
||||||
) => {
|
) => {
|
||||||
// By default, the maximum quality is set to high
|
if (effectiveQuality === VideoQuality.HIGH) return
|
||||||
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(videoSubscribeQuality)
|
publication.setVideoQuality(effectiveQuality)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +41,20 @@ export const VideoResolutionSubscription = () => {
|
|||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -44,7 +44,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
videoElement?: HTMLVideoElement
|
videoElement?: HTMLVideoElement
|
||||||
videoElementLoaded?: boolean
|
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
|
outputCanvas?: HTMLCanvasElement
|
||||||
outputCanvasCtx?: CanvasRenderingContext2D
|
outputCanvasCtx?: CanvasRenderingContext2D
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
segmentationMaskCanvas?: HTMLCanvasElement
|
segmentationMaskCanvas?: HTMLCanvasElement
|
||||||
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
segmentationMaskCanvasCtx?: CanvasRenderingContext2D
|
||||||
|
|
||||||
// Mask containing the inference result.
|
// Mask containg the inference result.
|
||||||
segmentationMask?: ImageData
|
segmentationMask?: ImageData
|
||||||
|
|
||||||
// The resized image of the video source.
|
// The resized image of the video source.
|
||||||
|
|||||||
@@ -50,16 +50,15 @@ 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) return
|
if (!failure || !kind) 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 ?? 'unknown',
|
kind,
|
||||||
})
|
})
|
||||||
}
|
|
||||||
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:
|
||||||
|
|||||||
@@ -11,9 +11,7 @@ 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 { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
import { 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'
|
||||||
@@ -29,6 +27,8 @@ 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,20 +38,6 @@ 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
|
||||||
@@ -77,34 +63,6 @@ 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reportError('device_switch_failure', error, {
|
|
||||||
at: 'ControlBar.onDeviceError',
|
|
||||||
source,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
@@ -114,6 +72,8 @@ 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}
|
||||||
@@ -136,7 +96,21 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<StageLayout />
|
<StageLayout />
|
||||||
)}
|
)}
|
||||||
</RoomContentArea>
|
</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 />
|
<SidePanel />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ 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'>
|
||||||
@@ -32,7 +37,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, remoteParticipants } = useRoomContext()
|
const { localParticipant } = useRoomContext()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
@@ -41,6 +46,9 @@ 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
|
||||||
)
|
)
|
||||||
@@ -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(() => {
|
useEffect(() => {
|
||||||
let videoTrack: LocalVideoTrack | null = null
|
let videoTrack: LocalVideoTrack | null = null
|
||||||
|
|
||||||
@@ -217,10 +209,13 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
type="select"
|
type="select"
|
||||||
label={t('resolution.publish.label')}
|
label={t('resolution.publish.label')}
|
||||||
items={resolutionItems}
|
items={resolutionItems}
|
||||||
selectedKey={videoPublishResolution}
|
selectedKey={
|
||||||
|
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%',
|
||||||
}}
|
}}
|
||||||
@@ -232,19 +227,43 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
|||||||
type="select"
|
type="select"
|
||||||
label={t('resolution.subscribe.label')}
|
label={t('resolution.subscribe.label')}
|
||||||
items={videoQualityItems}
|
items={videoQualityItems}
|
||||||
selectedKey={videoSubscribeQuality?.toString()}
|
selectedKey={
|
||||||
|
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,6 +18,10 @@
|
|||||||
"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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,11 +214,8 @@
|
|||||||
"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.",
|
||||||
"settingsInstructions": "Gehe zu den",
|
"macInstructions": "Gehe zu den",
|
||||||
"settingsLabel": {
|
"macSystemPreferences": "Systemeinstellungen",
|
||||||
"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,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"
|
"permissionsRequired": "Berechtigungen erforderlich"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"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}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,11 +214,8 @@
|
|||||||
"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.",
|
||||||
"settingsInstructions": "Go to your",
|
"macInstructions": "Go to your",
|
||||||
"settingsLabel": {
|
"macSystemPreferences": "System Preferences",
|
||||||
"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,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"
|
"permissionsRequired": "Permissions required"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"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}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,11 +214,8 @@
|
|||||||
"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.",
|
||||||
"settingsInstructions": "Accédez à vos",
|
"macInstructions": "Accèdez à vos",
|
||||||
"settingsLabel": {
|
"macSystemPreferences": "Préférences système",
|
||||||
"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,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"
|
"permissionsRequired": "Autorisations nécessaires"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"transcription": {
|
||||||
|
|||||||
@@ -18,6 +18,10 @@
|
|||||||
"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}}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -214,11 +214,8 @@
|
|||||||
"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.",
|
||||||
"settingsInstructions": "Ga naar uw",
|
"macInstructions": "Ga naar uw",
|
||||||
"settingsLabel": {
|
"macSystemPreferences": "Systeemvoorkeuren",
|
||||||
"macos": "Systeemvoorkeuren",
|
|
||||||
"windows": "Windows-privacyinstellingen"
|
|
||||||
},
|
|
||||||
"helpLinkText": "Meer informatie, zie",
|
"helpLinkText": "Meer informatie, zie",
|
||||||
"helpLinkLabel": "Presentatieprobleem",
|
"helpLinkLabel": "Presentatieprobleem",
|
||||||
"closeButton": "Negeren",
|
"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"
|
"permissionsRequired": "Machtigingen vereist"
|
||||||
},
|
},
|
||||||
"transcription": {
|
"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
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.27.0",
|
"version": "1.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.27.0",
|
"version": "1.26.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.0",
|
"@html-to/text-cli": "0.6.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"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",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.27.0",
|
"version": "1.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.27.0",
|
"version": "1.26.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.27.0",
|
"version": "1.26.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.27.0"
|
version = "1.26.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
"uvicorn>=0.24.0",
|
"uvicorn>=0.24.0",
|
||||||
|
|||||||
@@ -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 assignment.",
|
description="The URL of the metadata file for speaker assignement.",
|
||||||
)
|
)
|
||||||
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user