Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 116f79c3b3 wip device in use join 2026-08-14 15:56:57 +02:00
24 changed files with 202 additions and 313 deletions
+2 -11
View File
@@ -8,18 +8,9 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Changed ### Added
- ✨(backend) accept form-urlencoded on the user token endpoint - 🚸(frontend) explain camera-in-use failures on the join screen
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
## [1.27.0] - 2026-08-14 ## [1.27.0] - 2026-08-14
+1 -1
View File
@@ -4,7 +4,7 @@
Security is very important to us. Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly 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.
-27
View File
@@ -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:
@@ -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):
@@ -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")
+3 -3
View File
@@ -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]]
+69 -72
View File
@@ -12,7 +12,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",
@@ -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"
+5 -5
View File
@@ -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",
@@ -137,6 +137,7 @@ export const captureMediaEvent = async (
| 'media-device-topology' | 'media-device-topology'
| 'media-device-success' | 'media-device-success'
| 'device-not-found' | 'device-not-found'
| 'device-in-use'
| 'permissions-denied' | 'permissions-denied'
| 'screen-share-permission-denied' | 'screen-share-permission-denied'
| 'silent-mic-detected' | 'silent-mic-detected'
@@ -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
@@ -28,6 +28,7 @@ import {
userChoicesStore, userChoicesStore,
} from '@/stores/userChoices' } from '@/stores/userChoices'
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice' import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing' import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
import { useJoinTracks } from '../livekit/hooks/useJoinTracks' import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
import { SilentMicDetector } from './SilentMicDetector' import { SilentMicDetector } from './SilentMicDetector'
@@ -218,12 +219,14 @@ const switchTrackDevice =
function getPreviewMessages({ function getPreviewMessages({
cameraFound, cameraFound,
cameraDenied, cameraDenied,
cameraInUse,
micDenied, micDenied,
videoEnabled, videoEnabled,
videoStarted, videoStarted,
}: { }: {
cameraFound: boolean cameraFound: boolean
cameraDenied: boolean cameraDenied: boolean
cameraInUse: boolean
micDenied: boolean micDenied: boolean
videoEnabled: boolean videoEnabled: boolean
videoStarted: boolean videoStarted: boolean
@@ -235,6 +238,9 @@ function getPreviewMessages({
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted' const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
return { hint: key, permissionsButtonLabel: key } return { hint: key, permissionsButtonLabel: key }
} }
if (cameraInUse) {
return { hint: 'cameraInUse', permissionsButtonLabel: null }
}
if (!videoEnabled) { if (!videoEnabled) {
return { hint: 'cameraDisabled', permissionsButtonLabel: null } return { hint: 'cameraDisabled', permissionsButtonLabel: null }
} }
@@ -328,18 +334,20 @@ const VideoPreview = ({
const cameraDenied = useCannotUseDevice('videoinput') const cameraDenied = useCannotUseDevice('videoinput')
const micDenied = useCannotUseDevice('audioinput') const micDenied = useCannotUseDevice('audioinput')
const cameraMissing = useDeviceMissing('videoinput') const cameraMissing = useDeviceMissing('videoinput')
const cameraInUse = useDeviceInUse('videoinput')
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled) const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
const { hint, permissionsButtonLabel } = getPreviewMessages({ const { hint, permissionsButtonLabel } = getPreviewMessages({
cameraFound: !cameraMissing, cameraFound: !cameraMissing,
cameraDenied, cameraDenied,
cameraInUse,
micDenied, micDenied,
videoEnabled, videoEnabled,
videoStarted, videoStarted,
}) })
const isError = cameraMissing || cameraDenied const isError = cameraMissing || cameraDenied || cameraInUse
return ( return (
<div className={styles.previewFrame}> <div className={styles.previewFrame}>
@@ -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.
@@ -20,6 +20,7 @@ import { openPermissionsDialog } from '@/stores/permissions'
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic' import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
import { useSnapshot } from 'valtio' import { useSnapshot } from 'valtio'
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice' import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
import { useDeviceMissing } from '../../../hooks/useDeviceMissing' import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
import { requestDevicePermission } from '../../../hooks/useJoinTracks' import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
@@ -97,21 +98,23 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind) const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind) const deviceMissing = useDeviceMissing(kind)
const deviceInUse = useDeviceInUse(kind)
const { status: silentMicStatus } = useSnapshot(silentMicStore) const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning = const silentMicWarning =
kind === 'audioinput' && kind === 'audioinput' &&
silentMicStatus === 'silent' && silentMicStatus === 'silent' &&
!cannotUseDevice && !cannotUseDevice &&
!deviceMissing !deviceMissing &&
!deviceInUse
const deviceShortcut = useDeviceShortcut(kind) const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false) const isRequestingPermission = useRef(false)
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false) const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
const onPress = async () => { const onPress = async () => {
if (!enabled && deviceMissing) { if (!enabled && deviceMissing) {
setShowDeviceNotFound(true) setAlertError(MediaDeviceFailure.NotFound)
return return
} }
if (!cannotUseDevice) { if (!cannotUseDevice) {
@@ -185,10 +188,18 @@ export const ToggleDevice = <T extends ToggleSource>({
<PermissionNeededButton <PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined} tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={ onPress={
deviceMissing ? () => setShowDeviceNotFound(true) : undefined deviceMissing
? () => setAlertError(MediaDeviceFailure.NotFound)
: undefined
} }
/> />
)} )}
{deviceInUse && (
<PermissionNeededButton
tooltip={t(`deviceInUse.${kind}`)}
onPress={() => setAlertError(MediaDeviceFailure.DeviceInUse)}
/>
)}
{silentMicWarning && ( {silentMicWarning && (
<PermissionNeededButton <PermissionNeededButton
tooltip={t('tooltip', { keyPrefix: 'silentMic' })} tooltip={t('tooltip', { keyPrefix: 'silentMic' })}
@@ -207,9 +218,11 @@ export const ToggleDevice = <T extends ToggleSource>({
tooltip={ tooltip={
deviceMissing deviceMissing
? t(`deviceNotFound.${kind}`) ? t(`deviceNotFound.${kind}`)
: cannotUseDevice : deviceInUse
? t('tooltip', { keyPrefix: 'permissionsButton' }) ? t(`deviceInUse.${kind}`)
: toggleLabel : cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
} }
{...computedToggleButtonProps} {...computedToggleButtonProps}
{...overrideToggleButtonProps} {...overrideToggleButtonProps}
@@ -217,9 +230,9 @@ export const ToggleDevice = <T extends ToggleSource>({
<Icon /> <Icon />
</ToggleButton> </ToggleButton>
<MediaDeviceErrorAlert <MediaDeviceErrorAlert
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null} error={alertError}
kind={kind} kind={kind}
onClose={() => setShowDeviceNotFound(false)} onClose={() => setAlertError(null)}
/> />
</div> </div>
) )
@@ -0,0 +1,15 @@
import { useSnapshot } from 'valtio'
import { deviceInUseStore } from '@/stores/deviceInUse'
import { PERMISSION_BY_DEVICE_KIND } from '@/stores/permissions'
import { useCannotUseDevice } from './useCannotUseDevice'
import { useDeviceMissing } from './useDeviceMissing'
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
const inUse = useSnapshot(deviceInUseStore)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
if (!permissionKind || cannotUseDevice || deviceMissing) return false
return inUse[permissionKind]
}
@@ -18,6 +18,7 @@ import {
noteSystemPermissionDenied, noteSystemPermissionDenied,
type PermissionKind, type PermissionKind,
} from '@/stores/permissions' } from '@/stores/permissions'
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceInUse'
import { getOS } from '@/utils/os' import { getOS } from '@/utils/os'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { import {
@@ -89,7 +90,15 @@ const onMediaPermissionError = (
return return
} }
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen. if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.DeviceInUse &&
path === 'join_preview'
) {
noteDeviceInUse(kind)
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
return
}
reportError( reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure', path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e, e,
@@ -97,6 +106,11 @@ const onMediaPermissionError = (
) )
} }
const noteDeviceReady = (kind?: PermissionKind) => {
noteGumSuccess(kind)
clearDeviceInUse(kind)
}
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false) const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false)
@@ -114,7 +128,7 @@ export const requestDevicePermission = async (
? await createLocalAudioTrack() ? await createLocalAudioTrack()
: await createLocalVideoTrack() : await createLocalVideoTrack()
track.stop() track.stop()
noteGumSuccess(PERMISSION_KIND[kind]) noteDeviceReady(PERMISSION_KIND[kind])
return true return true
} catch (error) { } catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path) onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
@@ -158,7 +172,7 @@ function useWarmupPermissions(): WarmupState {
video: true, video: true,
}) })
) )
noteGumSuccess() noteDeviceReady()
bothReady() bothReady()
} catch (error) { } catch (error) {
if ( if (
@@ -180,7 +194,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ audio: true }) .getUserMedia({ audio: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteGumSuccess('microphone') noteDeviceReady('microphone')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'microphone')) .catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() => .finally(() =>
@@ -190,7 +204,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ video: true }) .getUserMedia({ video: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteGumSuccess('camera') noteDeviceReady('camera')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'camera')) .catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() => .finally(() =>
@@ -227,7 +241,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
let cancelled = false let cancelled = false
create() create()
.then((newTrack) => { .then((newTrack) => {
noteGumSuccess(permissionKind) noteDeviceReady(permissionKind)
if (cancelled) { if (cancelled) {
newTrack.stop() newTrack.stop()
return return
@@ -291,6 +305,8 @@ export function useJoinTracks(): {
const { audioReady, videoReady } = useWarmupPermissions() const { audioReady, videoReady } = useWarmupPermissions()
useEffect(() => () => clearDeviceInUse(), [])
const createAudio = useCallback( const createAudio = useCallback(
() => () =>
createLocalAudioTrack({ createLocalAudioTrack({
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist." "audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
}, },
"deviceInUse": {
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
},
"settings": { "settings": {
"audio": "Audioeinstellungen", "audio": "Audioeinstellungen",
"video": "Videoeinstellungen" "video": "Videoeinstellungen"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Kamera ist deaktiviert.", "cameraDisabled": "Kamera ist deaktiviert.",
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.", "cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
"cameraStarting": "Kamera wird gestartet…", "cameraStarting": "Kamera wird gestartet…",
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?", "cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?", "cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "No camera detected. Check that it is properly wired.", "videoinput": "No camera detected. Check that it is properly wired.",
"audioinput": "No microphone detected. Check that it is properly wired." "audioinput": "No microphone detected. Check that it is properly wired."
}, },
"deviceInUse": {
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
},
"settings": { "settings": {
"audio": "Audio settings", "audio": "Audio settings",
"video": "Video settings" "video": "Video settings"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Camera is disabled.", "cameraDisabled": "Camera is disabled.",
"cameraNotFound": "No camera detected. Check that it is properly plugged in.", "cameraNotFound": "No camera detected. Check that it is properly plugged in.",
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
"cameraStarting": "Camera is starting…", "cameraStarting": "Camera is starting…",
"cameraNotGranted": "Would you like others to be able to see you during the meeting?", "cameraNotGranted": "Would you like others to be able to see you during the meeting?",
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?", "cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", "videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché." "audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
}, },
"deviceInUse": {
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
},
"settings": { "settings": {
"audio": "Paramètres audio", "audio": "Paramètres audio",
"video": "Paramètres video" "video": "Paramètres video"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "La caméra est désactivée.", "cameraDisabled": "La caméra est désactivée.",
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.", "cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
"cameraStarting": "La caméra va démarrer…", "cameraStarting": "La caméra va démarrer…",
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?", "cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?", "cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
+5
View File
@@ -16,6 +16,10 @@
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten." "audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
}, },
"deviceInUse": {
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
},
"settings": { "settings": {
"audio": "Audio-instellingen", "audio": "Audio-instellingen",
"video": "Video-instellingen" "video": "Video-instellingen"
@@ -67,6 +71,7 @@
}, },
"cameraDisabled": "Camera is uitgeschakeld.", "cameraDisabled": "Camera is uitgeschakeld.",
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.", "cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
"cameraStarting": "Camera wordt ingeschakeld…", "cameraStarting": "Camera wordt ingeschakeld…",
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?", "cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?", "cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
+21
View File
@@ -0,0 +1,21 @@
import { proxy } from 'valtio'
import type { PermissionKind } from './permissions'
export const deviceInUseStore = proxy<Record<PermissionKind, boolean>>({
camera: false,
microphone: false,
})
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
export const noteDeviceInUse = (kind?: PermissionKind) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceInUseStore[k] = true
}
}
export const clearDeviceInUse = (kind?: PermissionKind) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceInUseStore[k] = false
}
}
+8 -18
View File
@@ -5,25 +5,15 @@ import { visualizer } from 'rollup-plugin-visualizer'
import svgr from 'vite-plugin-svgr' import svgr from 'vite-plugin-svgr'
import { viteStaticCopy } from 'vite-plugin-static-copy' import { viteStaticCopy } from 'vite-plugin-static-copy'
const readPackageJson = (path: string) => const mediapipeVersion: string = JSON.parse(
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8')) readFileSync(
new URL(
const mediapipeVersion: string = readPackageJson( './node_modules/@mediapipe/tasks-vision/package.json',
'./node_modules/@mediapipe/tasks-vision/package.json' import.meta.url
).version ),
'utf-8'
const livekitMediapipeVersion: string = readPackageJson(
'./node_modules/@livekit/track-processors/package.json'
).dependencies['@mediapipe/tasks-vision']
if (mediapipeVersion !== livekitMediapipeVersion) {
throw new Error(
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
`"${livekitMediapipeVersion}" in package.json.`
) )
} ).version
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
-1
View File
@@ -83,7 +83,6 @@ class Settings(BaseSettings):
aws_s3_access_key_id: str aws_s3_access_key_id: str
aws_s3_secret_access_key: SecretStr aws_s3_secret_access_key: SecretStr
aws_s3_secure_access: bool = True aws_s3_secure_access: bool = True
aws_s3_region_name: str | None = None
aws_transcript_path: str = "transcripts" aws_transcript_path: str = "transcripts"
aws_summary_path: str = "summaries" aws_summary_path: str = "summaries"
-1
View File
@@ -282,7 +282,6 @@ class FileService:
access_key=settings.aws_s3_access_key_id, access_key=settings.aws_s3_access_key_id,
secret_key=settings.aws_s3_secret_access_key.get_secret_value(), secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
secure=settings.aws_s3_secure_access, secure=settings.aws_s3_secure_access,
region=settings.aws_s3_region_name,
) )
self._bucket_name = settings.aws_storage_bucket_name self._bucket_name = settings.aws_storage_bucket_name
+1 -1
View File
@@ -26,7 +26,7 @@ class RecordingMetadata(BaseModel):
cloud_storage_url: Url = Field( cloud_storage_url: Url = Field(
title="Cloud Storage URL", title="Cloud Storage URL",
description="The URL of the metadata file for speaker 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")