Compare commits

..

2 Commits

Author SHA1 Message Date
Florent Chehab 1395db77ca fixup! (summary) add hostname to analytics properties 2026-08-20 10:41:56 +02:00
Florent Chehab 13c75697c7 (summary) add hostname to analytics properties
This helps track down what was the source of events.
This can be usefull when checking perf of different workers for instance.
2026-08-14 14:45:18 +02:00
48 changed files with 324 additions and 804 deletions
+1 -27
View File
@@ -8,37 +8,11 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
### Added
- 📈(frontend) track errors when starting or stopping a recording
- 🚸(frontend) explain camera-in-use failures on the join screen
### Changed
- ✨(backend) accept form-urlencoded on the user token endpoint
- ✨(summary) configurable s3 region
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
- 💄(frontend) increase the blur intensity
### Fixed
- 📝(docs) fix minor typos in comments and docstrings
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
- 🐛(frontend) treat client-initiated connect aborts as events
- 🐛(frontend) use state instead of a ref for MoreControls container
- 🐛(frontend) stop init_virtual_background from firing on blur updates
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
- 🐛(frontend) fix joined notification tile no longer rendering properly
## [1.27.0] - 2026-08-14
### Changed ### Changed
- 🔥(frontend) drop unused vendored ConnectionObserver - 🔥(frontend) drop unused vendored ConnectionObserver
- 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines - 🐛(frontend) vendor formatChatMessageLinks and trim surrounding newlines
- ✨(summary) add hostname to analytics properties
### Fixed ### Fixed
+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.
+1 -1
View File
@@ -180,7 +180,7 @@ services:
working_dir: /app working_dir: /app
node: node:
image: node:22 image: node:18
user: "${DOCKER_USER:-1000}" user: "${DOCKER_USER:-1000}"
environment: environment:
HOME: /tmp HOME: /tmp
-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:
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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,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")
+1 -1
View File
@@ -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",
+4 -4
View File
@@ -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]]
+71 -74
View File
@@ -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"
+6 -6
View File
@@ -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",
@@ -137,7 +137,6 @@ 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
@@ -1,5 +1,5 @@
import { useToast } from 'react-aria' import { useToast } from 'react-aria'
import { useMemo, useRef } from 'react' import { useRef } from 'react'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client' import { Track } from 'livekit-client'
import Source = Track.Source import Source = Track.Source
@@ -11,7 +11,6 @@ import { Div } from '@/primitives'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer' import { StyledToastContainer } from './StyledToastContainer'
import { setPinnedTrack } from '@/stores/layout' import { setPinnedTrack } from '@/stores/layout'
import { useParticipantTracks } from '@livekit/components-react'
const ClickableToast = styled(RACButton, { const ClickableToast = styled(RACButton, {
base: { base: {
@@ -31,18 +30,14 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
) )
const participant = props.toast.content.participant const participant = props.toast.content.participant
const [cameraTrack] = useParticipantTracks(
[Source.Camera],
participant?.identity
)
const trackReference = useMemo(
() => cameraTrack ?? { participant, source: Source.Camera },
[cameraTrack, participant]
)
if (!participant) return if (!participant) return
const trackReference = {
participant,
publication: participant.getTrackPublication(Source.Camera),
source: Source.Camera,
}
return ( return (
<StyledToastContainer {...toastProps} ref={ref}> <StyledToastContainer {...toastProps} ref={ref}>
<ClickableToast <ClickableToast
@@ -1,9 +1,11 @@
import { Participant, Track } from 'livekit-client' import { Participant, Track } from 'livekit-client'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useTrackMutedIndicator } from '@livekit/components-react' import { useTrackMutedIndicator } from '@livekit/components-react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useState } from 'react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { RiMicLine, RiMicOffLine } from '@remixicon/react' import { RiMicLine, RiMicOffLine } from '@remixicon/react'
import { openMuteDialog } from '@/stores/muteDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
export const MuteButton = ({ participant }: { participant: Participant }) => { export const MuteButton = ({ participant }: { participant: Participant }) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' }) const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
@@ -13,18 +15,31 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
source: Track.Source.Microphone, source: Track.Source.Microphone,
}) })
const { muteParticipant } = useMuteParticipant()
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
return ( return (
<Button <>
isDisabled={isMuted} <Button
size={'sm'} isDisabled={isMuted}
variant={'primaryTextDark'} size={'sm'}
square variant={'primaryTextDark'}
onPress={() => openMuteDialog(participant)} square
tooltip={t('muteParticipant', { name })} onPress={() => setIsAlertOpen(true)}
> tooltip={t('muteParticipant', { name })}
{!isMuted ? <RiMicLine /> : <RiMicOffLine />} >
</Button> {!isMuted ? <RiMicLine /> : <RiMicOffLine />}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -19,11 +19,13 @@ import {
import Source = Track.Source import Source = Track.Source
import { RiMicFill, RiMicOffFill } from '@remixicon/react' import { RiMicFill, RiMicOffFill } from '@remixicon/react'
import { Button } from '@/primitives' import { Button } from '@/primitives'
import { useState } from 'react'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute' import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
import { ParticipantMenuButton } from './menu/ParticipantMenuButton' import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
import { PinBadge } from './PinBadge' import { PinBadge } from './PinBadge'
import { UnauthenticatedBadge } from './UnauthenticatedBadge' import { UnauthenticatedBadge } from './UnauthenticatedBadge'
import { openMuteDialog } from '@/stores/muteDialog' import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
import { ParticipantName } from './ParticipantName' import { ParticipantName } from './ParticipantName'
type MicIndicatorProps = { type MicIndicatorProps = {
@@ -32,6 +34,7 @@ type MicIndicatorProps = {
const MicIndicator = ({ participant }: MicIndicatorProps) => { const MicIndicator = ({ participant }: MicIndicatorProps) => {
const { t } = useTranslation('rooms') const { t } = useTranslation('rooms')
const { muteParticipant } = useMuteParticipant()
const { isMuted } = useTrackMutedIndicator({ const { isMuted } = useTrackMutedIndicator({
participant: participant, participant: participant,
source: Source.Microphone, source: Source.Microphone,
@@ -39,6 +42,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
const canMute = useCanMute(participant) const canMute = useCanMute(participant)
const isSpeaking = useIsSpeaking(participant) const isSpeaking = useIsSpeaking(participant)
const [isAlertOpen, setIsAlertOpen] = useState(false)
const name = participant.name || participant.identity const name = participant.name || participant.identity
const label = isLocal(participant) const label = isLocal(participant)
@@ -48,34 +52,46 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
}) })
return ( return (
<Button <>
square <Button
variant="greyscale" square
size="sm" variant="greyscale"
tooltip={label} size="sm"
aria-label={label} tooltip={label}
isDisabled={isMuted || !canMute} aria-label={label}
onPress={async () => isDisabled={isMuted || !canMute}
!isMuted && isLocal(participant) onPress={async () =>
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false) !isMuted && isLocal(participant)
: openMuteDialog(participant) ? await (participant as LocalParticipant)?.setMicrophoneEnabled(
} false
data-attr="participants-mute" )
> : setIsAlertOpen(true)
{isMuted ? ( }
<RiMicOffFill color={'gray'} aria-hidden={true} /> data-attr="participants-mute"
) : ( >
<RiMicFill {isMuted ? (
className={css({ <RiMicOffFill color={'gray'} aria-hidden={true} />
color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50', ) : (
animation: isSpeaking <RiMicFill
? 'pulse_background 800ms infinite' className={css({
: undefined, color: isSpeaking ? 'primaryDark.300' : 'primaryDark.50',
})} animation: isSpeaking
aria-hidden={true} ? 'pulse_background 800ms infinite'
/> : undefined,
)} })}
</Button> aria-hidden={true}
/>
)}
</Button>
<MuteAlertDialog
isOpen={isAlertOpen}
onSubmit={() =>
muteParticipant(participant).then(() => setIsAlertOpen(false))
}
onClose={() => setIsAlertOpen(false)}
name={name}
/>
</>
) )
} }
@@ -1,20 +1,17 @@
import { useStartRecording, useStopRecording } from '@/features/recording' import { useStartRecording, useStopRecording } from '@/features/recording'
import { recordingStore } from '@/stores/recording' import { recordingStore } from '@/stores/recording'
import { captureEvent } from '@/features/analytics/telemetry'
export const useMutateRecording = () => { export const useMutateRecording = () => {
const { mutateAsync: startRecording, isPending: isPendingToStart } = const { mutateAsync: startRecording, isPending: isPendingToStart } =
useStartRecording({ useStartRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'start' recordingStore.isErrorDialogOpen = 'start'
captureEvent('error-starting-recording')
}, },
}) })
const { mutateAsync: stopRecording, isPending: isPendingToStop } = const { mutateAsync: stopRecording, isPending: isPendingToStop } =
useStopRecording({ useStopRecording({
onError: () => { onError: () => {
recordingStore.isErrorDialogOpen = 'stop' recordingStore.isErrorDialogOpen = 'stop'
captureEvent('error-stopping-recording')
}, },
}) })
@@ -6,8 +6,6 @@ import {
usePersistentUserChoices, usePersistentUserChoices,
} from '@livekit/components-react' } from '@livekit/components-react'
import { import {
ConnectionError,
ConnectionErrorReason,
DisconnectReason, DisconnectReason,
MediaDeviceFailure, MediaDeviceFailure,
Room, Room,
@@ -27,11 +25,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { BackgroundProcessorFactory } from '../livekit/components/blur' import { BackgroundProcessorFactory } from '../livekit/components/blur'
import { LocalUserChoices } from '@/stores/userChoices' import { LocalUserChoices } from '@/stores/userChoices'
import { import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
captureEvent,
captureMediaEvent,
reportError,
} from '@/features/analytics/telemetry'
import { useConfig } from '@/api/useConfig' import { useConfig } from '@/api/useConfig'
import { isFireFox } from '@/utils/livekit' import { isFireFox } from '@/utils/livekit'
import { useIsMobile } from '@/utils/useIsMobile' import { useIsMobile } from '@/utils/useIsMobile'
@@ -233,16 +227,6 @@ export const Conference = ({
onError={(e) => { onError={(e) => {
const failure = MediaDeviceFailure.getFailure(e) const failure = MediaDeviceFailure.getFailure(e)
if (failure && failure !== MediaDeviceFailure.Other) return if (failure && failure !== MediaDeviceFailure.Other) return
// connect() was aborted by a disconnect() before the join completed
if (
e instanceof ConnectionError &&
e.reason === ConnectionErrorReason.Cancelled
) {
void captureEvent('connection-cancelled')
return
}
reportError('livekit_room_error', e, { reportError('livekit_room_error', e, {
path: 'connect_publish', path: 'connect_publish',
}) })
@@ -28,7 +28,6 @@ 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'
@@ -219,14 +218,12 @@ 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
@@ -238,9 +235,6 @@ 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 }
} }
@@ -334,20 +328,18 @@ 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 || cameraInUse const isError = cameraMissing || cameraDenied
return ( return (
<div className={styles.previewFrame}> <div className={styles.previewFrame}>
@@ -1,30 +0,0 @@
import { useEffect } from 'react'
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
import type { PermissionKind } from '@/stores/permissions'
const RETRY_INTERVAL_MS = 30_000
/**
* There is no browser event for "another app released the device", so
* while a device is flagged in use, re-probe it periodically. Only clears
* the flag (the toggle becomes usable again); it never re-enables the
* device on the user's behalf.
*/
export function useWatchDeviceReleased() {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
useWatchKind('camera', cameraInUse)
useWatchKind('microphone', microphoneInUse)
}
function useWatchKind(kind: PermissionKind, inUse: boolean) {
useEffect(() => {
if (!inUse) return
const id = setInterval(
() => void probeDeviceReleased(kind),
RETRY_INTERVAL_MS
)
return () => clearInterval(id)
}, [kind, inUse])
}
@@ -1,28 +0,0 @@
import { useRef } from 'react'
import { useSnapshot } from 'valtio'
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
import { MuteAlertDialog } from './MuteAlertDialog'
export const MuteAlertDialogProvider = () => {
const { participant } = useSnapshot(muteDialogStore)
const { muteParticipant } = useMuteParticipant()
const lastNameRef = useRef('')
if (participant) {
lastNameRef.current = participant.name || participant.identity
}
return (
<MuteAlertDialog
isOpen={!!participant}
name={lastNameRef.current}
onClose={closeMuteDialog}
onSubmit={() => {
const target = muteDialogStore.participant
if (!target) return
muteParticipant(target).then(closeMuteDialog)
}}
/>
)
}
@@ -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.
@@ -105,7 +105,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
_initVirtualBackgroundImage() { _initVirtualBackgroundImage() {
if (this.options.type !== 'virtual') { if (this.options.type !== 'virtual') {
return throw new Error(
'Virtual background is only supported for virtual background'
)
} }
const needsUpdate = const needsUpdate =
@@ -20,9 +20,8 @@ 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 '../../../utils/mediaPermissions' import { requestDevicePermission } from '../../../hooks/useJoinTracks'
import { useDeviceIcons } from '../../../hooks/useDeviceIcons' import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut' import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
import type { import type {
@@ -98,42 +97,38 @@ export const ToggleDevice = <T extends ToggleSource>({
const deviceIcons = useDeviceIcons(kind) const deviceIcons = useDeviceIcons(kind)
const cannotUseDevice = useCannotUseDevice(kind) const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind) const deviceMissing = useDeviceMissing(kind)
const deviceInUse = useDeviceInUse(kind)
const explainDeviceInUse = deviceInUse && context === 'room'
const { status: silentMicStatus } = useSnapshot(silentMicStore) const { status: silentMicStatus } = useSnapshot(silentMicStore)
const silentMicWarning = const silentMicWarning =
kind === 'audioinput' && kind === 'audioinput' &&
silentMicStatus === 'silent' && silentMicStatus === 'silent' &&
!cannotUseDevice && !cannotUseDevice &&
!deviceMissing && !deviceMissing
!deviceInUse
const deviceShortcut = useDeviceShortcut(kind) const deviceShortcut = useDeviceShortcut(kind)
const announce = useScreenReaderAnnounce() const announce = useScreenReaderAnnounce()
const isRequestingPermission = useRef(false) const isRequestingPermission = useRef(false)
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null) const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
const mediaPath = context === 'join' ? 'join_preview' : 'room'
const onPress = async () => { const onPress = async () => {
if (!enabled && deviceMissing) { if (!enabled && deviceMissing) {
setAlertError(MediaDeviceFailure.NotFound) setShowDeviceNotFound(true)
return return
} }
if (!cannotUseDevice && !deviceInUse) { if (!cannotUseDevice) {
toggle() toggle()
return return
} }
if (isRequestingPermission.current) return if (isRequestingPermission.current) return
isRequestingPermission.current = true isRequestingPermission.current = true
try { try {
const acquired = await requestDevicePermission(kind, mediaPath) const granted = await requestDevicePermission(
if (acquired) { kind,
context === 'join' ? 'join_preview' : 'room'
)
if (granted) {
toggle() toggle()
} else if (cannotUseDevice) { } else {
openPermissionsDialog(kind) openPermissionsDialog(kind)
} else if (explainDeviceInUse) {
setAlertError(MediaDeviceFailure.DeviceInUse)
} }
} finally { } finally {
isRequestingPermission.current = false isRequestingPermission.current = false
@@ -184,33 +179,13 @@ export const ToggleDevice = <T extends ToggleSource>({
return <ActiveSpeakerWrapper /> return <ActiveSpeakerWrapper />
} }
const getToggleTooltip = () => {
if (deviceMissing) return t(`deviceNotFound.${kind}`)
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
return toggleLabel
}
const toggleTooltip = getToggleTooltip()
return ( return (
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
{(cannotUseDevice || deviceMissing) && ( {(cannotUseDevice || deviceMissing) && (
<PermissionNeededButton <PermissionNeededButton
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined} tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
onPress={ onPress={
deviceMissing deviceMissing ? () => setShowDeviceNotFound(true) : undefined
? () => setAlertError(MediaDeviceFailure.NotFound)
: undefined
}
/>
)}
{deviceInUse && (
<PermissionNeededButton
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : undefined}
onPress={
explainDeviceInUse
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
: undefined
} }
/> />
)} )}
@@ -229,16 +204,22 @@ export const ToggleDevice = <T extends ToggleSource>({
shySelected shySelected
onPress={onPress} onPress={onPress}
aria-label={toggleLabel} aria-label={toggleLabel}
tooltip={toggleTooltip} tooltip={
deviceMissing
? t(`deviceNotFound.${kind}`)
: cannotUseDevice
? t('tooltip', { keyPrefix: 'permissionsButton' })
: toggleLabel
}
{...computedToggleButtonProps} {...computedToggleButtonProps}
{...overrideToggleButtonProps} {...overrideToggleButtonProps}
> >
<Icon /> <Icon />
</ToggleButton> </ToggleButton>
<MediaDeviceErrorAlert <MediaDeviceErrorAlert
error={alertError} error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
kind={kind} kind={kind}
onClose={() => setAlertError(null)} onClose={() => setShowDeviceNotFound(false)}
/> />
</div> </div>
) )
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
enum BlurRadius { enum BlurRadius {
NONE = 0, NONE = 0,
LIGHT = 10, LIGHT = 5,
NORMAL = 20, NORMAL = 10,
} }
const isSupported = BackgroundProcessorFactory.isSupported() const isSupported = BackgroundProcessorFactory.isSupported()
@@ -1,21 +0,0 @@
import { useSnapshot } from 'valtio'
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
import { useCannotUseDevice } from './useCannotUseDevice'
import { useDeviceMissing } from './useDeviceMissing'
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
const cannotUseDevice = useCannotUseDevice(kind)
const deviceMissing = useDeviceMissing(kind)
if (cannotUseDevice || deviceMissing) return false
switch (kind) {
case 'videoinput':
return cameraInUse
case 'audioinput':
return microphoneInUse
default:
return false
}
}
@@ -10,13 +10,16 @@ import {
} from 'livekit-client' } from 'livekit-client'
import { BackgroundProcessorFactory } from '../components/blur' import { BackgroundProcessorFactory } from '../components/blur'
import { import {
classifyPermissionError,
isLikelySystemNotFound,
isSystemPermissionError, isSystemPermissionError,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind, type PermissionKind,
} from '@/stores/permissions' } from '@/stores/permissions'
import { import { getOS } from '@/utils/os'
noteDeviceReady, import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
onMediaPermissionError,
} from '../utils/mediaPermissions'
import { import {
saveAudioInputDeviceId, saveAudioInputDeviceId,
saveAudioInputEnabled, saveAudioInputEnabled,
@@ -36,6 +39,64 @@ const VOICE_AUDIO_CONSTRAINTS = {
sampleSize: 16, sampleSize: 16,
} as const } as const
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
audioinput: 'microphone',
videoinput: 'camera',
}
type MediaPath = 'join_preview' | 'room'
const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
if (
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
// Module-level: effect dependencies, must be referentially stable. // Module-level: effect dependencies, must be referentially stable.
const disableAudio = () => saveAudioInputEnabled(false) const disableAudio = () => saveAudioInputEnabled(false)
const disableVideo = () => saveVideoInputEnabled(false) const disableVideo = () => saveVideoInputEnabled(false)
@@ -43,6 +104,24 @@ const disableVideo = () => saveVideoInputEnabled(false)
const stopAll = (stream: MediaStream) => const stopAll = (stream: MediaStream) =>
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track) => track.stop())
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteGumSuccess(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
type WarmupState = { type WarmupState = {
audioReady: boolean audioReady: boolean
videoReady: boolean videoReady: boolean
@@ -79,7 +158,7 @@ function useWarmupPermissions(): WarmupState {
video: true, video: true,
}) })
) )
noteDeviceReady() noteGumSuccess()
bothReady() bothReady()
} catch (error) { } catch (error) {
if ( if (
@@ -101,7 +180,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ audio: true }) .getUserMedia({ audio: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteDeviceReady('microphone') noteGumSuccess('microphone')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'microphone')) .catch((e) => onMediaPermissionError(e as Error, 'microphone'))
.finally(() => .finally(() =>
@@ -111,7 +190,7 @@ function useWarmupPermissions(): WarmupState {
.getUserMedia({ video: true }) .getUserMedia({ video: true })
.then((stream) => { .then((stream) => {
stopAll(stream) stopAll(stream)
noteDeviceReady('camera') noteGumSuccess('camera')
}) })
.catch((e) => onMediaPermissionError(e as Error, 'camera')) .catch((e) => onMediaPermissionError(e as Error, 'camera'))
.finally(() => .finally(() =>
@@ -148,7 +227,7 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
let cancelled = false let cancelled = false
create() create()
.then((newTrack) => { .then((newTrack) => {
noteDeviceReady(permissionKind) noteGumSuccess(permissionKind)
if (cancelled) { if (cancelled) {
newTrack.stop() newTrack.stop()
return return
@@ -1,11 +1,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useRoomContext } from '@livekit/components-react' import { useRoomContext } from '@livekit/components-react'
import { import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
type LocalTrackPublication,
MediaDeviceFailure,
RoomEvent,
Track,
} from 'livekit-client'
import { import {
PERMISSION_BY_DEVICE_KIND, PERMISSION_BY_DEVICE_KIND,
type PermissionDeniedScope, type PermissionDeniedScope,
@@ -15,11 +10,7 @@ import {
notePermissionDeniedFromGum, notePermissionDeniedFromGum,
noteSystemPermissionDenied, noteSystemPermissionDenied,
} from '@/stores/permissions' } from '@/stores/permissions'
import { import { syncDeviceAvailability } from '@/stores/deviceAvailability'
clearDeviceInUse,
noteDeviceInUse,
syncDeviceAvailability,
} from '@/stores/deviceAvailability'
import { captureMediaEvent } from '@/features/analytics/telemetry' import { captureMediaEvent } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os' import { getOS } from '@/utils/os'
@@ -30,11 +21,6 @@ type MediaDeviceAlert = {
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null } const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
[Track.Source.Camera]: 'camera',
[Track.Source.Microphone]: 'microphone',
}
const capturePermissionsDenied = ( const capturePermissionsDenied = (
scope: PermissionDeniedScope, scope: PermissionDeniedScope,
kind?: PermissionKind kind?: PermissionKind
@@ -77,7 +63,6 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind] const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
switch (failure) { switch (failure) {
case MediaDeviceFailure.DeviceInUse: case MediaDeviceFailure.DeviceInUse:
noteDeviceInUse(permissionKind)
setAlert({ error: failure, kind }) setAlert({ error: failure, kind })
break break
case MediaDeviceFailure.NotFound: case MediaDeviceFailure.NotFound:
@@ -107,16 +92,9 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
break break
} }
} }
const onTrackPublished = (publication: LocalTrackPublication) => {
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
if (permissionKind) clearDeviceInUse(permissionKind)
}
room.on(RoomEvent.MediaDevicesError, onDeviceError) room.on(RoomEvent.MediaDevicesError, onDeviceError)
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
return () => { return () => {
room.off(RoomEvent.MediaDevicesError, onDeviceError) room.off(RoomEvent.MediaDevicesError, onDeviceError)
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
clearDeviceInUse()
} }
}, [room]) }, [room])
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton' import { OptionsButton } from '../../components/controls/Options/OptionsButton'
import { StartMediaButton } from '../../components/controls/StartMediaButton' import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions' import { MoreOptions } from './MoreOptions'
import { RefObject, useMemo, useState } from 'react' import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useFullScreen } from '../../hooks/useFullScreen' import { useFullScreen } from '../../hooks/useFullScreen'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl' import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
@@ -21,14 +21,7 @@ export function DesktopControlBar({
onDeviceError, onDeviceError,
}: Readonly<ControlBarAuxProps>) { }: Readonly<ControlBarAuxProps>) {
const browserSupportsScreenSharing = supportsScreenSharing() const browserSupportsScreenSharing = supportsScreenSharing()
const desktopControlBarEl = useRef<HTMLDivElement>(null)
const [controlBarElement, setControlBarElement] =
useState<HTMLDivElement | null>(null)
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
() => ({ current: controlBarElement }),
[controlBarElement]
)
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({}) const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
@@ -52,7 +45,7 @@ export function DesktopControlBar({
return ( return (
<div <div
ref={setControlBarElement} ref={desktopControlBarEl}
className={css({ className={css({
width: '100vw', width: '100vw',
display: 'flex', display: 'flex',
@@ -1,5 +1,5 @@
import { isWeb } from '@livekit/components-core' import { isWeb } from '@livekit/components-core'
import { MediaDeviceFailure, Track } from 'livekit-client' import { Track } from 'livekit-client'
import React, { useState } from 'react' import React, { useState } from 'react'
import { import {
ConnectionStateToast, ConnectionStateToast,
@@ -19,7 +19,6 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
import { useNoiseReduction } from '../hooks/useNoiseReduction' import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription' import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals' import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
import { RoomContentArea } from '@/features/layout/components/RoomContentArea' import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
@@ -100,11 +99,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
return return
} }
} }
if (MediaDeviceFailure.getFailure(error) != MediaDeviceFailure.Other) {
return
}
reportError('device_switch_failure', error, { reportError('device_switch_failure', error, {
at: 'ControlBar.onDeviceError', at: 'ControlBar.onDeviceError',
source, source,
@@ -150,7 +144,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
<ConnectionStateToast /> <ConnectionStateToast />
<RecordingProvider /> <RecordingProvider />
<SettingsDialogProvider /> <SettingsDialogProvider />
<MuteAlertDialogProvider />
<ReactionPortals /> <ReactionPortals />
</div> </div>
</> </>
@@ -1,135 +0,0 @@
import {
createLocalAudioTrack,
createLocalVideoTrack,
MediaDeviceFailure,
} from 'livekit-client'
import {
classifyPermissionError,
isLikelySystemNotFound,
noteGumSuccess,
notePermissionDeniedFromGum,
noteSystemPermissionDenied,
type PermissionKind,
} from '@/stores/permissions'
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import { getOS } from '@/utils/os'
/**
* Shared handling of getUserMedia() outcomes, used by both:
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
*/
export type MediaPath = 'join_preview' | 'room'
export const PERMISSION_KIND: Record<
'audioinput' | 'videoinput',
PermissionKind
> = {
audioinput: 'microphone',
videoinput: 'camera',
}
export const noteDeviceReady = (kind?: PermissionKind) => {
noteGumSuccess(kind)
clearDeviceInUse(kind)
}
export const onMediaPermissionError = (
e: Error,
kind?: PermissionKind,
path: MediaPath = 'join_preview'
) => {
const failure = MediaDeviceFailure.getFailure(e)
if (failure === MediaDeviceFailure.PermissionDenied) {
void classifyPermissionError(e, kind).then((scope) => {
if (scope === 'system') {
noteSystemPermissionDenied(kind)
} else {
notePermissionDeniedFromGum(kind)
}
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: scope,
os: getOS(),
})
})
return
}
if (failure === MediaDeviceFailure.NotFound) {
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
// settings, missing Android app permissions).
void isLikelySystemNotFound(e, kind).then((system) => {
if (system) {
noteSystemPermissionDenied(kind)
captureMediaEvent('permissions-denied', {
path,
kind,
denied_scope: 'system',
os: getOS(),
})
return
}
captureMediaEvent('device-not-found', { path, kind })
})
return
}
if (failure === MediaDeviceFailure.DeviceInUse) {
noteDeviceInUse(kind)
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
return
}
// "Other" is still reported as an error.
reportError(
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
e,
{ path, kind }
)
}
/**
* Silent availability check for a device that was reported "in use":
* acquires and releases it without any error reporting, so it can be
* polled. Clears the in-use flag on success.
*/
export const probeDeviceReleased = async (
kind: PermissionKind
): Promise<boolean> => {
try {
const stream = await navigator.mediaDevices.getUserMedia(
kind === 'camera' ? { video: true } : { audio: true }
)
stream.getTracks().forEach((track) => track.stop())
noteDeviceReady(kind)
return true
} catch {
return false
}
}
/**
* Triggers the browser permission prompt for one device kind by acquiring
* and immediately releasing a track. Resolves to whether access was granted.
*/
export const requestDevicePermission = async (
kind: 'audioinput' | 'videoinput',
path: MediaPath = 'join_preview'
): Promise<boolean> => {
try {
const track =
kind === 'audioinput'
? await createLocalAudioTrack()
: await createLocalVideoTrack()
track.stop()
noteDeviceReady(PERMISSION_KIND[kind])
return true
} catch (error) {
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
return false
}
}
@@ -16,7 +16,6 @@ import {
import { useConfig } from '@/api/useConfig.ts' import { useConfig } from '@/api/useConfig.ts'
import { LogLevel, setLogLevel } from 'livekit-client' import { LogLevel, setLogLevel } from 'livekit-client'
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability' import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle' import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
const BaseRoom = ({ children }: { children: ReactNode }) => { const BaseRoom = ({ children }: { children: ReactNode }) => {
@@ -50,7 +49,6 @@ const Room = () => {
useKeyboardShortcuts() useKeyboardShortcuts()
useWatchDeviceAvailability() useWatchDeviceAvailability()
useWatchDeviceReleased()
const clearRouterState = () => { const clearRouterState = () => {
if (window?.history?.state) { if (window?.history?.state) {
-5
View File
@@ -16,10 +16,6 @@
"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"
@@ -71,7 +67,6 @@
}, },
"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,10 +16,6 @@
"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"
@@ -71,7 +67,6 @@
}, },
"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,10 +16,6 @@
"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"
@@ -71,7 +67,6 @@
}, },
"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,10 +16,6 @@
"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"
@@ -71,7 +67,6 @@
}, },
"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?",
+2 -27
View File
@@ -1,39 +1,14 @@
import { proxy } from 'valtio' import { proxy } from 'valtio'
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry' import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
import type { PermissionKind } from './permissions'
// Device availability (not permission): // Device presence (not permission): enumerateDevices() exposes kinds
// - presence: enumerateDevices() exposes kinds before any grant. // before any grant. Optimistic defaults until the first sync.
// Optimistic defaults until the first sync.
// - in use: the device exists and is allowed, but getUserMedia() failed
// because another application or tab is holding it.
export const deviceAvailabilityStore = proxy({ export const deviceAvailabilityStore = proxy({
hasCamera: true, hasCamera: true,
hasMicrophone: true, hasMicrophone: true,
cameraInUse: false,
microphoneInUse: false,
synced: false, synced: false,
}) })
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
camera: 'cameraInUse',
microphone: 'microphoneInUse',
}
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
const setDeviceInUse = (inUse: boolean, kind?: PermissionKind) => {
for (const k of kind ? [kind] : ALL_KINDS) {
deviceAvailabilityStore[IN_USE_KEY[k]] = inUse
}
}
export const noteDeviceInUse = (kind?: PermissionKind) =>
setDeviceInUse(true, kind)
export const clearDeviceInUse = (kind?: PermissionKind) =>
setDeviceInUse(false, kind)
export const syncDeviceAvailability = async (): Promise<void> => { export const syncDeviceAvailability = async (): Promise<void> => {
try { try {
const devices = await navigator.mediaDevices.enumerateDevices() const devices = await navigator.mediaDevices.enumerateDevices()
-18
View File
@@ -1,18 +0,0 @@
import { proxy, ref } from 'valtio'
import type { Participant } from 'livekit-client'
type State = {
participant: Participant | null
}
export const muteDialogStore = proxy<State>({
participant: null,
})
export const openMuteDialog = (participant: Participant) => {
muteDialogStore.participant = ref(participant)
}
export const closeMuteDialog = () => {
muteDialogStore.participant = null
}
+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 }) => {
+10 -20
View File
@@ -1,15 +1,15 @@
{ {
"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.1", "@html-to/text-cli": "0.6.0",
"mjml": "5.4.0" "mjml": "5.4.0"
} }
}, },
@@ -52,14 +52,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@html-to/text-cli": { "node_modules/@html-to/text-cli": {
"version": "0.6.1", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz", "resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==", "integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@selderee/plugin-htmlparser2": "~0.12.0", "@selderee/plugin-htmlparser2": "~0.12.0",
"aspargvs": "~0.7.0", "aspargvs": "~0.7.0",
"deepmerge-ts": "^8.0.1", "deepmerge-ts": "^7.1.5",
"htmlparser2": "^10.1.0", "htmlparser2": "^10.1.0",
"selderee": "~0.12.0" "selderee": "~0.12.0"
}, },
@@ -656,19 +656,9 @@
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
"node_modules/deepmerge-ts": { "node_modules/deepmerge-ts": {
"version": "8.0.1", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {
"node": ">=16.0.0" "node": ">=16.0.0"
+2 -2
View File
@@ -1,10 +1,10 @@
{ {
"name": "mail_mjml", "name": "mail_mjml",
"version": "1.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": {
"@html-to/text-cli": "0.6.1", "@html-to/text-cli": "0.6.0",
"mjml": "5.4.0" "mjml": "5.4.0"
}, },
"private": true, "private": true,
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "sdk", "name": "sdk",
"version": "1.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 -1
View File
@@ -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 -1
View File
@@ -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",
+8 -1
View File
@@ -1,9 +1,10 @@
"""Analytics classes.""" """Analytics classes."""
import json import json
import socket
import time import time
from collections import Counter from collections import Counter
from functools import lru_cache from functools import cached_property, lru_cache
from urllib.parse import urlsplit, urlunsplit from urllib.parse import urlsplit, urlunsplit
import redis import redis
@@ -33,6 +34,10 @@ class Analytics:
logger.info("Initialize analytics client") logger.info("Initialize analytics client")
self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host) self._client = Posthog(settings.posthog_api_key, settings.posthog_api_host)
@cached_property
def _hostname(self):
return socket.gethostname()
@property @property
def is_disabled(self): def is_disabled(self):
"""Check if analytics client is disabled or not configured.""" """Check if analytics client is disabled or not configured."""
@@ -43,6 +48,8 @@ class Analytics:
if self.is_disabled: if self.is_disabled:
return return
properties = {**(properties or {}), "_hostname": self._hostname}
try: try:
self._client.capture( self._client.capture(
event_name, distinct_id=distinct_id, properties=properties event_name, distinct_id=distinct_id, properties=properties
-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")