mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f996aa819 | |||
| 51ed277941 | |||
| f46a5dc157 | |||
| 4f4b4d3231 | |||
| 25a39a1fb6 | |||
| a5003b55e3 | |||
| bcf551a25c | |||
| c1c2d0260d | |||
| e80b9c2485 | |||
| 04f7412307 | |||
| ba422110f8 | |||
| a83afdbb0c | |||
| a5d8aae293 | |||
| 8a3a0d5759 | |||
| aa8362c470 | |||
| c2586a392c | |||
| ea3f0cc59e | |||
| 9aa9342054 | |||
| ca38c4851f | |||
| a97895c383 | |||
| e88aeedbf1 | |||
| 5f32a9e6a3 | |||
| aa09c15ecc | |||
| 7ec3e481ff | |||
| 42107f4698 | |||
| a49893696b | |||
| 6f3339fbdc | |||
| f17e0a3ba0 | |||
| 2367750395 | |||
| 5eb70384e3 | |||
| 22c68da2af | |||
| ebf676529f | |||
| 40cedba8ae | |||
| 59e0643dde | |||
| 7b89395f42 | |||
| 3d047b5124 | |||
| 89b1190bb4 | |||
| 8f28a46a5f | |||
| ac88c046dc | |||
| b7f55ac35d | |||
| 329a729bdc | |||
| 2215b621f4 | |||
| c330ec6ff4 | |||
| 8245270f28 | |||
| 9728603f72 | |||
| 803c94a80c | |||
| fd90d0b830 | |||
| f380d0342d | |||
| 4cdf257b6a | |||
| 338f8d8a69 | |||
| ab4f415d23 | |||
| e926b407b1 | |||
| 4e655a0a64 | |||
| d2bde299be | |||
| 9b44ed0974 | |||
| aeaa9b7ffd | |||
| da73424f72 | |||
| 2b9b977f57 | |||
| 355db6ef9a | |||
| 890e043d29 | |||
| 9eb412758a | |||
| fa27afdfdf | |||
| cb8b415ef9 | |||
| 7c6182cc4e | |||
| 2d47e90a1a | |||
| 56ec2dd8cb | |||
| 361de29780 | |||
| e4d5ca64b9 | |||
| bd139a1ef9 | |||
| f682f8feb3 | |||
| 5f1d59c753 | |||
| c45b91dc58 | |||
| 0e72f61650 | |||
| 4fae3c6c47 | |||
| 120bcdc720 | |||
| f1b20d7981 | |||
| 95190ec690 | |||
| adb99cc5d9 | |||
| 201069aa4c | |||
| b6a5b1a805 | |||
| f3af637fd6 | |||
| de3a5aa404 | |||
| 5e9d20e685 | |||
| b54445739a | |||
| 7c67bacd94 | |||
| 1fd1b184ee | |||
| adb517a336 | |||
| eec9ff9f26 | |||
| e0258a1765 | |||
| 872ce1ecc6 | |||
| e2c3b745ca | |||
| 965d823d08 | |||
| 1db189ace2 | |||
| 199e0908e9 | |||
| 8518f83211 | |||
| 0240d85837 | |||
| 162896c93c | |||
| 483a219ac4 | |||
| 1b26dea178 | |||
| bdaf4245da | |||
| be63993ba2 | |||
| 3d245c3bd4 | |||
| 66a36eff73 | |||
| 387bc2e1f4 | |||
| d44b45b6aa | |||
| 224b98fd9a | |||
| 031852d0b1 | |||
| 24915b0485 |
@@ -82,6 +82,7 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
run: pip install --user .[dev]
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
@@ -186,6 +187,7 @@ jobs:
|
|||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
- name: Install development dependencies
|
- name: Install development dependencies
|
||||||
run: pip install --user .[dev]
|
run: pip install --user .[dev]
|
||||||
|
|||||||
@@ -85,6 +85,14 @@ clean_old_images('localhost:5001/meet-livekit')
|
|||||||
|
|
||||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||||
|
|
||||||
|
k8s_resource('meet-backend', resource_deps=['dev-backend-redis', 'dev-backend-keycloak', 'dev-backend-postgres', 'livekit-livekit-server', 'dev-backend-minio:statefulset'])
|
||||||
|
k8s_resource('meet-backend-migrate', resource_deps=['dev-backend-postgres'])
|
||||||
|
k8s_resource('livekit-livekit-server', resource_deps=['dev-backend-redis'])
|
||||||
|
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||||
|
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||||
|
k8s_resource('dev-backend-keycloak', resource_deps=['dev-backend-keycloak-pg'])
|
||||||
|
k8s_resource('livekit-egress', resource_deps=['livekit-livekit-server'])
|
||||||
|
|
||||||
migration = '''
|
migration = '''
|
||||||
set -eu
|
set -eu
|
||||||
# get k8s pod name from tilt resource name
|
# get k8s pod name from tilt resource name
|
||||||
|
|||||||
@@ -35,9 +35,6 @@ backend:
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: meet
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
LIVEKIT_API_SECRET: secret
|
LIVEKIT_API_SECRET: secret
|
||||||
|
|||||||
@@ -217,9 +217,6 @@ DB_NAME: meet
|
|||||||
DB_USER: dinum
|
DB_USER: dinum
|
||||||
DB_PASSWORD: pass
|
DB_PASSWORD: pass
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: meet
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
@@ -339,6 +336,8 @@ These are the environmental options available on meet backend.
|
|||||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||||
|
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
|
||||||
|
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||||
| RECORDING_ENABLE | Record meeting option | false |
|
| RECORDING_ENABLE | Record meeting option | false |
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ def get_frontend_configuration(request):
|
|||||||
else None,
|
else None,
|
||||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||||
},
|
},
|
||||||
|
"livekit": {
|
||||||
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||||
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -492,6 +492,14 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||||
}
|
}
|
||||||
|
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
|
||||||
|
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
|
||||||
|
)
|
||||||
|
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
|
||||||
|
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
|
||||||
|
environ_prefix=None,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "0.1.30"
|
version = "0.1.33"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+24
-65
@@ -1,23 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.30",
|
"version": "0.1.33",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "0.1.30",
|
"version": "0.1.33",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "2.9.13",
|
"@livekit/components-react": "2.9.13",
|
||||||
"@livekit/components-styles": "1.1.6",
|
"@livekit/components-styles": "1.1.6",
|
||||||
"@livekit/track-processors": "0.5.7",
|
"@livekit/track-processors": "0.5.7",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.5",
|
"@react-aria/toast": "3.0.5",
|
||||||
"@react-hook/size": "2.1.2",
|
"@react-types/overlays": "3.9.0",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.25",
|
||||||
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.0",
|
"humanize-duration": "3.33.0",
|
||||||
"i18next": "25.3.1",
|
"i18next": "25.3.1",
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"libphonenumber-js": "1.12.10",
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.2",
|
"livekit-client": "2.15.5",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.256.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.10.1",
|
"react-aria-components": "1.10.1",
|
||||||
@@ -1211,12 +1212,6 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@juggle/resize-observer": {
|
|
||||||
"version": "3.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@juggle/resize-observer/-/resize-observer-3.4.0.tgz",
|
|
||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/@livekit/components-core": {
|
"node_modules/@livekit/components-core": {
|
||||||
"version": "0.12.8",
|
"version": "0.12.8",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/components-core/-/components-core-0.12.8.tgz",
|
||||||
@@ -2708,51 +2703,6 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-hook/latest": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-hook/latest/-/latest-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-hook/passive-layout-effect": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-hook/passive-layout-effect/-/passive-layout-effect-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-IwEphTD75liO8g+6taS+4oqz+nnroocNfWVHWz7j+N+ZO2vYrc6PV1q7GQhuahL0IOR7JccFTsFKQ/mb6iZWAg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-hook/resize-observer": {
|
|
||||||
"version": "1.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-hook/resize-observer/-/resize-observer-1.2.6.tgz",
|
|
||||||
"integrity": "sha512-DlBXtLSW0DqYYTW3Ft1/GQFZlTdKY5VAFIC4+km6IK5NiPPDFchGbEJm1j6pSgMqPRHbUQgHJX7RaR76ic1LWA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@juggle/resize-observer": "^3.3.1",
|
|
||||||
"@react-hook/latest": "^1.0.2",
|
|
||||||
"@react-hook/passive-layout-effect": "^1.2.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-hook/size": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@react-hook/size/-/size-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-BmE5asyRDxSuQ9p14FUKJ0iBRgV9cROjqNG9jT/EjCM+xHha1HVqbPoT+14FQg1K7xIydabClCibUY4+1tw/iw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@react-hook/passive-layout-effect": "^1.2.0",
|
|
||||||
"@react-hook/resize-observer": "^1.2.1"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": ">=16.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@react-stately/autocomplete": {
|
"node_modules/@react-stately/autocomplete": {
|
||||||
"version": "3.0.0-beta.2",
|
"version": "3.0.0-beta.2",
|
||||||
"resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz",
|
"resolved": "https://registry.npmjs.org/@react-stately/autocomplete/-/autocomplete-3.0.0-beta.2.tgz",
|
||||||
@@ -3429,12 +3379,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/overlays": {
|
"node_modules/@react-types/overlays": {
|
||||||
"version": "3.8.16",
|
"version": "3.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.8.16.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/overlays/-/overlays-3.9.0.tgz",
|
||||||
"integrity": "sha512-Aj9jIFwALk9LiOV/s3rVie+vr5qWfaJp/6aGOuc2StSNDTHvj1urSAr3T0bT8wDlkrqnlS4JjEGE40ypfOkbAA==",
|
"integrity": "sha512-T2DqMcDN5p8vb4vu2igoLrAtuewaNImLS8jsK7th7OjwQZfIWJn5Y45jSxHtXJUddEg1LkUjXYPSXCMerMcULw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-types/shared": "^3.30.0"
|
"@react-types/shared": "^3.31.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -3490,9 +3440,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.30.0",
|
"version": "3.31.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.30.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||||
"integrity": "sha512-COIazDAx1ncDg046cTJ8SFYsX8aS3lB/08LDnbkH/SkdYrFPWDlXMrO/sUam8j1WWM+PJ+4d1mj7tODIKNiFog==",
|
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -5346,6 +5296,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/derive-valtio": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/derive-valtio/-/derive-valtio-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-6slhaFHtfaL3t5dLYaQt6s4G2xZymhu0Ktdl7OMeVk8+46RgR8ft6FL0Tr4F31W+yPH03nJe1SSP4JFy2hSMRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"valtio": ">=2.0.0-rc.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/detect-libc": {
|
"node_modules/detect-libc": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz",
|
||||||
@@ -7501,9 +7460,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.15.2",
|
"version": "2.15.5",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.2.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.5.tgz",
|
||||||
"integrity": "sha512-hf0A0JFN7M0iVGZxMfTk6a3cW7TNTVdqxkykjKBweORlqhQX1ITVloh6aLvplLZOxpkUE5ZVLz1DeS3+ERglog==",
|
"integrity": "sha512-zn36akmDlqZxlrTOUgYXtxtj35HQ44aJ+mgKat9BTSPiZru4RjEHOtp8RJE6jGoN2miJlWiOeEKHB2+ae3YrSw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.30",
|
"version": "0.1.33",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -18,11 +18,12 @@
|
|||||||
"@livekit/track-processors": "0.5.7",
|
"@livekit/track-processors": "0.5.7",
|
||||||
"@pandacss/preset-panda": "0.54.0",
|
"@pandacss/preset-panda": "0.54.0",
|
||||||
"@react-aria/toast": "3.0.5",
|
"@react-aria/toast": "3.0.5",
|
||||||
"@react-hook/size": "2.1.2",
|
"@react-types/overlays": "3.9.0",
|
||||||
"@remixicon/react": "4.6.0",
|
"@remixicon/react": "4.6.0",
|
||||||
"@tanstack/react-query": "5.81.5",
|
"@tanstack/react-query": "5.81.5",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.0.25",
|
"crisp-sdk-web": "1.0.25",
|
||||||
|
"derive-valtio": "0.2.0",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.0",
|
"humanize-duration": "3.33.0",
|
||||||
"i18next": "25.3.1",
|
"i18next": "25.3.1",
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
"i18next-parser": "9.3.0",
|
"i18next-parser": "9.3.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"libphonenumber-js": "1.12.10",
|
"libphonenumber-js": "1.12.10",
|
||||||
"livekit-client": "2.15.2",
|
"livekit-client": "2.15.5",
|
||||||
"posthog-js": "1.256.2",
|
"posthog-js": "1.256.2",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria-components": "1.10.1",
|
"react-aria-components": "1.10.1",
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.st0, .st1, .st2, .st3, .st4, .st5 {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6 {
|
||||||
|
fill: #6dd58c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6, .st1, .st2, .st7, .st8, .st9, .st10 {
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6, .st1, .st2, .st7, .st8, .st10 {
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st6, .st1, .st7, .st8, .st4, .st5, .st10 {
|
||||||
|
stroke: #191c1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11, .st7, .st12, .st9 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11, .st3 {
|
||||||
|
stroke: #000;
|
||||||
|
stroke-miterlimit: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st11, .st3, .st9 {
|
||||||
|
stroke-width: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st2, .st9 {
|
||||||
|
stroke: #898989;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st8 {
|
||||||
|
fill: #ff8669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st13 {
|
||||||
|
fill: #e3e3fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st14 {
|
||||||
|
fill: #f7f7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st4, .st5 {
|
||||||
|
stroke-width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st5 {
|
||||||
|
stroke-linecap: square;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st15 {
|
||||||
|
fill: #ca3632;
|
||||||
|
}
|
||||||
|
|
||||||
|
.st10 {
|
||||||
|
fill: #ffb929;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<rect class="st12" x=".53" y=".53" width="1078.61" height="1080.36"/>
|
||||||
|
<path class="st12" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
|
||||||
|
<path class="st1" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
|
||||||
|
<path class="st12" d="M960.08,256.93h-110c-12.94,0-23.43-10.49-23.43-23.43v-71.56c0-12.94-10.48-23.43-23.42-23.44h-266.94c-12.94,0-23.43,10.49-23.43,23.44h0v71.56c0,12.94-10.49,23.43-23.43,23.43H120.5v137.62h839.58"/>
|
||||||
|
<path class="st8" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
|
||||||
|
<path class="st10" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
|
||||||
|
<path class="st6" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
|
||||||
|
<path class="st1" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
|
||||||
|
<path class="st7" d="M625.31,322.08h-27.15c-4.35,0-7.88,3.53-7.88,7.88h0v17.19c0,4.35,3.53,7.88,7.88,7.88h27.15c4.35,0,7.88-3.53,7.88-7.88h0v-17.19c0-4.35-3.53-7.88-7.88-7.88h0Z"/>
|
||||||
|
<path class="st12" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
|
||||||
|
<path class="st1" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
|
||||||
|
<g>
|
||||||
|
<ellipse class="st4" cx="707.63" cy="299.5" rx="18.37" ry="18.5"/>
|
||||||
|
<path class="st5" d="M624,299h63"/>
|
||||||
|
<circle class="st4" cx="643.5" cy="339.5" r="18.5"/>
|
||||||
|
<path class="st5" d="M728,339h-63"/>
|
||||||
|
</g>
|
||||||
|
<path class="st14" d="M192.29,138.5h767.79v118.43H120.5v-46.64c0-39.62,32.17-71.79,71.79-71.79h0Z"/>
|
||||||
|
<path class="st0" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
|
||||||
|
<path class="st2" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
|
||||||
|
<path class="st2" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
|
||||||
|
<path class="st2" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
|
||||||
|
<path class="st2" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
|
||||||
|
<path class="st2" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
|
||||||
|
<path class="st15" d="M382.41,636.74l39.92,39.92,13.47-13.47-188.53-188.53-13.47,13.47,11.35,11.35h-5.58c-5.26,0-9.52,4.26-9.52,9.52v133.31c0,5.26,4.26,9.52,9.52,9.52h133.31c5.26,0,9.52-4.26,9.52-9.52v-5.58h.01ZM363.37,617.69v15.1h-114.27v-114.27h15.1l99.17,99.17h0ZM439.55,633.17c0,2.02-1.19,3.6-2.78,4.33l-16.27-16.27v-75.65l-38.09,26.66v10.9l-19.04-19.04v-45.57h-45.57l-19.04-19.04h74.14c5.26,0,9.52,4.26,9.52,9.52v39.99l49.64-34.75c3.16-2.21,7.49.05,7.49,3.9v115.02h0Z"/>
|
||||||
|
<path class="st15" d="M375.25,866.34l43.58,43.58,12.93-12.93-180.97-180.97-12.93,12.93,51.25,51.25v14.49c0,25.24,20.46,45.7,45.7,45.7,4.41,0,8.67-.62,12.7-1.79l14.17,14.17c-8.17,3.79-17.28,5.9-26.87,5.9-32.23,0-58.9-23.84-63.33-54.84h-18.43c4.21,38.13,34.49,68.4,72.62,72.62v37.06h18.28v-37.06c11.28-1.25,21.87-4.77,31.3-10.11h0ZM330.71,821.81c-11.87-1.78-21.25-11.16-23.03-23.03l23.03,23.03ZM402.21,841.85l-13.18-13.18c4.65-7.4,7.82-15.82,9.11-24.84h18.43c-1.55,14.04-6.64,27.02-14.35,38.03h0ZM375.62,815.27l-14.15-14.15c.5-2.06.76-4.21.76-6.43v-36.56c0-15.14-12.28-27.42-27.42-27.42-11.83,0-21.91,7.49-25.76,17.99l-13.68-13.68c7.94-13.52,22.63-22.59,39.44-22.59,25.24,0,45.7,20.46,45.7,45.7v36.56c0,7.4-1.76,14.39-4.88,20.58h-.01Z"/>
|
||||||
|
<path d="M597.6,312.73c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM602.4,301.55c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM619.98,315.93h25.57v-6.39h-25.57v6.39ZM632.76,344.69c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM637.56,333.51c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM594.41,341.5v6.39h25.57v-6.39h-25.57Z"/>
|
||||||
|
<line class="st2" x1="960.08" y1="394.55" x2="836.91" y2="394.55"/>
|
||||||
|
<path class="st9" d="M527.76,394.55H120.5v-137.62h368.93c12.94,0,23.43-10.49,23.43-23.43v-71.56c0-12.95,10.49-23.44,23.43-23.44h266.94c12.94,0,23.43,10.5,23.42,23.44v71.56c0,2.09.27,4.12.79,6.05,1,3.77,2.92,7.17,5.51,9.94,4.28,4.58,10.37,7.44,17.13,7.44h110"/>
|
||||||
|
<path class="st13" d="M645.52,274.8h314.56v101.2h-314.56c-23.81,0-43.12-22.66-43.12-50.61h0c.01-27.94,19.31-50.59,43.12-50.59h0Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="st13" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71h-181.11c-43.65,0-79.03-35.38-79.03-79.03,0-21.82,8.86-41.56,23.16-55.86,14.3-14.29,34.06-23.13,55.87-23.13h179.74c12.93,23.89,20.27,51.24,20.27,80.31Z"/>
|
||||||
|
<path d="M656.78,350.02v9.98h39.93v-9.98h-39.93ZM724.16,337.54c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.46,17.47,17.46,17.47-7.82,17.47-17.46-7.82-17.47-17.47-17.47ZM724.16,362.49c-4.13,0-7.49-3.35-7.49-7.48s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.36,7.48-7.49,7.48ZM696.71,300.11v9.98h39.93v-9.98h-39.93ZM669.26,287.63c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.47,17.47,17.47,17.47-7.82,17.47-17.47-7.82-17.47-17.47-17.47ZM669.26,312.59c-4.13,0-7.49-3.36-7.49-7.49s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.35,7.49-7.49,7.49Z"/>
|
||||||
|
</g>
|
||||||
|
<line class="st2" x1="813.4" y1="432.85" x2="551.26" y2="432.85"/>
|
||||||
|
<path class="st3" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71-5.35,10.32-11.74,20.02-19.02,28.96-30.98,38.03-78.19,62.32-131.07,62.32s-100.09-24.29-131.07-62.32c-23.7-29.09-37.92-66.22-37.92-106.67,0-93.33,75.66-168.99,168.99-168.99,64.26,0,120.15,35.87,148.72,88.68,12.93,23.89,20.27,51.24,20.27,80.31h0Z"/>
|
||||||
|
<polygon class="st11" points="705.12 406.95 681.79 637.91 746.21 597.51 802.28 751.56 852.2 733.39 796.14 579.34 871.46 568.88 705.12 406.95"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -37,6 +37,11 @@ export interface ApiConfig {
|
|||||||
default_country?: string
|
default_country?: string
|
||||||
}
|
}
|
||||||
manifest_link?: string
|
manifest_link?: string
|
||||||
|
livekit: {
|
||||||
|
url: string
|
||||||
|
force_wss_protocol: boolean
|
||||||
|
enable_firefox_proxy_workaround: boolean
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -1,100 +1,187 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
import { Bold, Button, Dialog, type DialogProps, P, Text } from '@/primitives'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
|
import { RiCheckLine, RiFileCopyLine, RiSpam2Fill } from '@remixicon/react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { ApiAccessLevel, ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||||
|
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||||
|
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||||
|
|
||||||
// fixme - duplication with the InviteDialog
|
// fixme - duplication with the InviteDialog
|
||||||
export const LaterMeetingDialog = ({
|
export const LaterMeetingDialog = ({
|
||||||
roomId,
|
room,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
}: { room: null | ApiRoom } & Omit<DialogProps, 'title'>) => {
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home', { keyPrefix: 'laterMeetingDialog' })
|
||||||
const roomUrl = getRouteUrl('room', roomId)
|
|
||||||
|
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
const roomUrl = room && getRouteUrl('room', room?.slug)
|
||||||
|
const telephony = useTelephony()
|
||||||
useEffect(() => {
|
|
||||||
if (isCopied) {
|
|
||||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}, [isCopied])
|
|
||||||
|
|
||||||
const [isHovered, setIsHovered] = useState(false)
|
const [isHovered, setIsHovered] = useState(false)
|
||||||
|
|
||||||
|
const isTelephonyReadyForUse = useMemo(() => {
|
||||||
|
return telephony?.enabled && room?.pin_code
|
||||||
|
}, [telephony?.enabled, room?.pin_code])
|
||||||
|
|
||||||
|
const {
|
||||||
|
isCopied,
|
||||||
|
copyRoomToClipboard,
|
||||||
|
isRoomUrlCopied,
|
||||||
|
copyRoomUrlToClipboard,
|
||||||
|
} = useCopyRoomToClipboard(room || undefined)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog isOpen={!!room} {...dialogProps} title={t('heading')}>
|
||||||
isOpen={!!roomId}
|
<P>{t('description')}</P>
|
||||||
{...dialogProps}
|
{!!roomUrl && (
|
||||||
title={t('laterMeetingDialog.heading')}
|
<>
|
||||||
>
|
{isTelephonyReadyForUse ? (
|
||||||
<P>{t('laterMeetingDialog.description')}</P>
|
<div
|
||||||
<Button
|
className={css({
|
||||||
variant={isCopied ? 'success' : 'primary'}
|
width: '100%',
|
||||||
size="sm"
|
backgroundColor: 'gray.50',
|
||||||
fullWidth
|
borderRadius: '0.75rem',
|
||||||
aria-label={t('laterMeetingDialog.copy')}
|
display: 'flex',
|
||||||
style={{
|
flexDirection: 'column',
|
||||||
justifyContent: 'start',
|
padding: '1.75rem 1.5rem',
|
||||||
}}
|
marginTop: '0.5rem',
|
||||||
onPress={() => {
|
gap: '1rem',
|
||||||
navigator.clipboard.writeText(roomUrl)
|
overflow: 'hidden',
|
||||||
setIsCopied(true)
|
})}
|
||||||
}}
|
>
|
||||||
onHoverChange={setIsHovered}
|
|
||||||
data-attr="later-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
|
||||||
{t('laterMeetingDialog.copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine
|
|
||||||
size={18}
|
|
||||||
style={{ marginRight: '8px', minWidth: '18px' }}
|
|
||||||
/>
|
|
||||||
{isHovered ? (
|
|
||||||
t('laterMeetingDialog.copy')
|
|
||||||
) : (
|
|
||||||
<div
|
<div
|
||||||
style={{
|
className={css({
|
||||||
textOverflow: 'ellipsis',
|
display: 'flex',
|
||||||
overflow: 'hidden',
|
alignItems: 'center',
|
||||||
userSelect: 'none',
|
justifyContent: 'space-between',
|
||||||
textWrap: 'nowrap',
|
})}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{roomUrl.replace(/^https?:\/\//, '')}
|
<Text as="p" wrap="pretty">
|
||||||
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
{isTelephonyReadyForUse && (
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size={'sm'}
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={t('copyUrl')}
|
||||||
|
tooltip={t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div
|
||||||
</>
|
className={css({
|
||||||
)}
|
display: 'flex',
|
||||||
</Button>
|
flexDirection: 'column',
|
||||||
<HStack>
|
})}
|
||||||
<div
|
>
|
||||||
className={css({
|
<Text as="p" wrap="pretty">
|
||||||
backgroundColor: 'primary.200',
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
borderRadius: '50%',
|
{telephony?.internationalPhoneNumber}
|
||||||
padding: '4px',
|
</Text>
|
||||||
marginTop: '1rem',
|
<Text as="p" wrap="pretty">
|
||||||
})}
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
>
|
{formatPinCode(room?.pin_code)}
|
||||||
<RiSpam2Fill
|
</Text>
|
||||||
size={22}
|
</div>
|
||||||
className={css({
|
<Button
|
||||||
fill: 'primary.500',
|
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||||
})}
|
size="sm"
|
||||||
/>
|
fullWidth
|
||||||
</div>
|
aria-label={t('copy')}
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
style={{
|
||||||
{t('laterMeetingDialog.permissions')}
|
justifyContent: 'start',
|
||||||
</Text>
|
}}
|
||||||
</HStack>
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="later-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'primary'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
onHoverChange={setIsHovered}
|
||||||
|
data-attr="later-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
size={18}
|
||||||
|
style={{ marginRight: '8px', minWidth: '18px' }}
|
||||||
|
/>
|
||||||
|
{isHovered ? (
|
||||||
|
t('copy')
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
userSelect: 'none',
|
||||||
|
textWrap: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{room?.access_level == ApiAccessLevel.PUBLIC && (
|
||||||
|
<HStack>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill
|
||||||
|
size={22}
|
||||||
|
className={css({
|
||||||
|
fill: 'primary.500',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
|||||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
const Columns = ({ children }: { children?: ReactNode }) => {
|
const Columns = ({ children }: { children?: ReactNode }) => {
|
||||||
return (
|
return (
|
||||||
@@ -153,7 +154,7 @@ export const Home = () => {
|
|||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
const [laterRoomId, setLaterRoomId] = useState<null | string>(null)
|
const [laterRoom, setLaterRoom] = useState<null | ApiRoom>(null)
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
|
||||||
@@ -202,7 +203,7 @@ export const Home = () => {
|
|||||||
onAction={() => {
|
onAction={() => {
|
||||||
const slug = generateRoomId()
|
const slug = generateRoomId()
|
||||||
createRoom({ slug, username }).then((data) =>
|
createRoom({ slug, username }).then((data) =>
|
||||||
setLaterRoomId(data.slug)
|
setLaterRoom(data)
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
data-attr="create-option-later"
|
data-attr="create-option-later"
|
||||||
@@ -251,8 +252,8 @@ export const Home = () => {
|
|||||||
</RightColumn>
|
</RightColumn>
|
||||||
</Columns>
|
</Columns>
|
||||||
<LaterMeetingDialog
|
<LaterMeetingDialog
|
||||||
roomId={laterRoomId ?? ''}
|
room={laterRoom}
|
||||||
onOpenChange={() => setLaterRoomId(null)}
|
onOpenChange={() => setLaterRoom(null)}
|
||||||
/>
|
/>
|
||||||
</Screen>
|
</Screen>
|
||||||
</UserAware>
|
</UserAware>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
ANIMATION_DURATION,
|
ANIMATION_DURATION,
|
||||||
ReactionPortals,
|
ReactionPortals,
|
||||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
|
||||||
|
|
||||||
export const MainNotificationToast = () => {
|
export const MainNotificationToast = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -99,6 +98,7 @@ export const MainNotificationToast = () => {
|
|||||||
case NotificationType.ScreenRecordingLimitReached:
|
case NotificationType.ScreenRecordingLimitReached:
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
|
participant,
|
||||||
type: notification.type,
|
type: notification.type,
|
||||||
},
|
},
|
||||||
{ timeout: NotificationDuration.ALERT }
|
{ timeout: NotificationDuration.ALERT }
|
||||||
@@ -155,17 +155,14 @@ export const MainNotificationToast = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleNotificationReceived = (
|
const handleNotificationReceived = (
|
||||||
prevMetadataStr: string | undefined,
|
changedAttributes: Record<string, string>,
|
||||||
participant: Participant
|
participant: Participant
|
||||||
) => {
|
) => {
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
if (isMobileBrowser()) return
|
if (isMobileBrowser()) return
|
||||||
if (participant.isLocal) return
|
if (participant.isLocal) return
|
||||||
|
|
||||||
const prevMetadata = safeParseMetadata(prevMetadataStr)
|
if (!('handRaisedAt' in changedAttributes)) return
|
||||||
const metadata = safeParseMetadata(participant.metadata)
|
|
||||||
|
|
||||||
if (prevMetadata?.raised == metadata?.raised) return
|
|
||||||
|
|
||||||
const existingToast = toastQueue.visibleToasts.find(
|
const existingToast = toastQueue.visibleToasts.find(
|
||||||
(toast) =>
|
(toast) =>
|
||||||
@@ -173,12 +170,12 @@ export const MainNotificationToast = () => {
|
|||||||
toast.content.type === NotificationType.HandRaised
|
toast.content.type === NotificationType.HandRaised
|
||||||
)
|
)
|
||||||
|
|
||||||
if (existingToast && prevMetadata.raised && !metadata.raised) {
|
if (existingToast && !changedAttributes?.handRaisedAt) {
|
||||||
toastQueue.close(existingToast.key)
|
toastQueue.close(existingToast.key)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existingToast && !prevMetadata.raised && metadata.raised) {
|
if (!existingToast && !!changedAttributes?.handRaisedAt) {
|
||||||
triggerNotificationSound(NotificationType.HandRaised)
|
triggerNotificationSound(NotificationType.HandRaised)
|
||||||
toastQueue.add(
|
toastQueue.add(
|
||||||
{
|
{
|
||||||
@@ -190,10 +187,13 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
room.on(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
room.on(RoomEvent.ParticipantAttributesChanged, handleNotificationReceived)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.ParticipantMetadataChanged, handleNotificationReceived)
|
room.off(
|
||||||
|
RoomEvent.ParticipantAttributesChanged,
|
||||||
|
handleNotificationReceived
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [room, triggerNotificationSound])
|
}, [room, triggerNotificationSound])
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { LiveKitRoom } from '@livekit/components-react'
|
import {
|
||||||
|
LiveKitRoom,
|
||||||
|
usePersistentUserChoices,
|
||||||
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
@@ -9,6 +12,7 @@ import {
|
|||||||
RoomOptions,
|
RoomOptions,
|
||||||
supportsAdaptiveStream,
|
supportsAdaptiveStream,
|
||||||
supportsDynacast,
|
supportsDynacast,
|
||||||
|
VideoPresets,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
@@ -20,29 +24,38 @@ import { ApiRoom } from '../api/ApiRoom'
|
|||||||
import { useCreateRoom } from '../api/createRoom'
|
import { useCreateRoom } from '../api/createRoom'
|
||||||
import { InviteDialog } from './InviteDialog'
|
import { InviteDialog } from './InviteDialog'
|
||||||
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
import { VideoConference } from '../livekit/prefabs/VideoConference'
|
||||||
import posthog from 'posthog-js'
|
|
||||||
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 { navigateTo } from '@/navigation/navigateTo'
|
import { navigateTo } from '@/navigation/navigateTo'
|
||||||
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
import { MediaDeviceErrorAlert } from './MediaDeviceErrorAlert'
|
||||||
|
import { usePostHog } from 'posthog-js/react'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { isFireFox } from '@/utils/livekit'
|
||||||
|
|
||||||
export const Conference = ({
|
export const Conference = ({
|
||||||
roomId,
|
roomId,
|
||||||
userConfig,
|
|
||||||
initialRoomData,
|
initialRoomData,
|
||||||
mode = 'join',
|
mode = 'join',
|
||||||
}: {
|
}: {
|
||||||
roomId: string
|
roomId: string
|
||||||
userConfig: LocalUserChoices
|
|
||||||
mode?: 'join' | 'create'
|
mode?: 'join' | 'create'
|
||||||
initialRoomData?: ApiRoom
|
initialRoomData?: ApiRoom
|
||||||
}) => {
|
}) => {
|
||||||
|
const posthog = usePostHog()
|
||||||
|
const { data: apiConfig } = useConfig()
|
||||||
|
|
||||||
|
const { userChoices: userConfig } = usePersistentUserChoices() as {
|
||||||
|
userChoices: LocalUserChoices
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
posthog.capture('visit-room', { slug: roomId })
|
posthog.capture('visit-room', { slug: roomId })
|
||||||
}, [roomId])
|
}, [roomId, posthog])
|
||||||
const fetchKey = [keys.room, roomId]
|
const fetchKey = [keys.room, roomId]
|
||||||
|
|
||||||
|
const [isConnectionWarmedUp, setIsConnectionWarmedUp] = useState(false)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
mutateAsync: createRoom,
|
mutateAsync: createRoom,
|
||||||
status: createStatus,
|
status: createStatus,
|
||||||
@@ -74,25 +87,86 @@ export const Conference = ({
|
|||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isAdaptiveStreamSupported = supportsAdaptiveStream()
|
||||||
|
const isDynacastSupported = supportsDynacast()
|
||||||
|
|
||||||
const roomOptions = useMemo((): RoomOptions => {
|
const roomOptions = useMemo((): RoomOptions => {
|
||||||
return {
|
return {
|
||||||
adaptiveStream: supportsAdaptiveStream(),
|
adaptiveStream: isAdaptiveStreamSupported,
|
||||||
dynacast: supportsDynacast(),
|
dynacast: isDynacastSupported,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
videoCodec: 'vp9',
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
|
resolution: userConfig.videoPublishResolution
|
||||||
|
? VideoPresets[userConfig.videoPublishResolution].resolution
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
audioCaptureDefaults: {
|
audioCaptureDefaults: {
|
||||||
deviceId: userConfig.audioDeviceId ?? undefined,
|
deviceId: userConfig.audioDeviceId ?? undefined,
|
||||||
},
|
},
|
||||||
|
audioOutput: {
|
||||||
|
deviceId: userConfig.audioOutputDeviceId ?? undefined,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
// do not rely on the userConfig object directly as its reference may change on every render
|
// do not rely on the userConfig object directly as its reference may change on every render
|
||||||
}, [userConfig.videoDeviceId, userConfig.audioDeviceId])
|
}, [
|
||||||
|
userConfig.videoDeviceId,
|
||||||
|
userConfig.videoPublishResolution,
|
||||||
|
userConfig.audioDeviceId,
|
||||||
|
userConfig.audioOutputDeviceId,
|
||||||
|
isAdaptiveStreamSupported,
|
||||||
|
isDynacastSupported,
|
||||||
|
])
|
||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
/**
|
||||||
|
* Warm up connection to LiveKit server before joining room
|
||||||
|
* This prefetch helps reduce initial connection latency by establishing
|
||||||
|
* an early HTTP connection to the WebRTC signaling server
|
||||||
|
*
|
||||||
|
* It should cache DNS and TLS keys.
|
||||||
|
*/
|
||||||
|
const prepareConnection = async () => {
|
||||||
|
if (!apiConfig || isConnectionWarmedUp) return
|
||||||
|
await room.prepareConnection(apiConfig.livekit.url)
|
||||||
|
|
||||||
|
if (isFireFox() && apiConfig.livekit.enable_firefox_proxy_workaround) {
|
||||||
|
try {
|
||||||
|
const wssUrl =
|
||||||
|
apiConfig.livekit.url
|
||||||
|
.replace('https://', 'wss://')
|
||||||
|
.replace(/\/$/, '') + '/rtc'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FIREFOX + PROXY WORKAROUND:
|
||||||
|
*
|
||||||
|
* Issue: On Firefox behind proxy configurations, WebSocket signaling fails to establish.
|
||||||
|
* Symptom: Client receives HTTP 200 instead of expected 101 (Switching Protocols).
|
||||||
|
* Root Cause: Certificate/security issue where the initial request is considered unsecure.
|
||||||
|
*
|
||||||
|
* Solution: Pre-establish a WebSocket connection to the signaling server, which fails.
|
||||||
|
* This "primes" the connection, allowing subsequent WebSocket establishments to work correctly.
|
||||||
|
*
|
||||||
|
* Note: This issue is reproducible on LiveKit's demo app.
|
||||||
|
* Reference: livekit-examples/meet/issues/466
|
||||||
|
*/
|
||||||
|
const ws = new WebSocket(wssUrl)
|
||||||
|
// 401 unauthorized response is expected
|
||||||
|
ws.onerror = () => ws.readyState <= 1 && ws.close()
|
||||||
|
} catch (e) {
|
||||||
|
console.debug('Firefox WebSocket workaround failed.', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsConnectionWarmedUp(true)
|
||||||
|
}
|
||||||
|
prepareConnection()
|
||||||
|
}, [room, apiConfig, isConnectionWarmedUp])
|
||||||
|
|
||||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||||
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
const [mediaDeviceError, setMediaDeviceError] = useState<{
|
||||||
error: MediaDeviceFailure | null
|
error: MediaDeviceFailure | null
|
||||||
@@ -102,6 +176,20 @@ export const Conference = ({
|
|||||||
kind: null,
|
kind: null,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ensure stable WebSocket connection URL. This is critical for legacy browser compatibility
|
||||||
|
* (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in WebSocket() constructor
|
||||||
|
* may fail - the force_wss_protocol flag allows explicit WSS protocol conversion
|
||||||
|
*/
|
||||||
|
const serverUrl = useMemo(() => {
|
||||||
|
const livekit_url = apiConfig?.livekit.url
|
||||||
|
if (!livekit_url) return
|
||||||
|
if (apiConfig?.livekit.force_wss_protocol) {
|
||||||
|
return livekit_url.replace('https://', 'wss://')
|
||||||
|
}
|
||||||
|
return livekit_url
|
||||||
|
}, [apiConfig?.livekit])
|
||||||
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
if (isCreateError) {
|
if (isCreateError) {
|
||||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||||
@@ -125,9 +213,9 @@ export const Conference = ({
|
|||||||
<Screen header={false} footer={false}>
|
<Screen header={false} footer={false}>
|
||||||
<LiveKitRoom
|
<LiveKitRoom
|
||||||
room={room}
|
room={room}
|
||||||
serverUrl={data?.livekit?.url}
|
serverUrl={serverUrl}
|
||||||
token={data?.livekit?.token}
|
token={data?.livekit?.token}
|
||||||
connect={true}
|
connect={isConnectionWarmedUp}
|
||||||
audio={userConfig.audioEnabled}
|
audio={userConfig.audioEnabled}
|
||||||
video={
|
video={
|
||||||
userConfig.videoEnabled && {
|
userConfig.videoEnabled && {
|
||||||
@@ -140,6 +228,9 @@ export const Conference = ({
|
|||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
|
onError={(e) => {
|
||||||
|
posthog.captureException(e)
|
||||||
|
}}
|
||||||
onDisconnected={(e) => {
|
onDisconnected={(e) => {
|
||||||
if (e == DisconnectReason.CLIENT_INITIATED) {
|
if (e == DisconnectReason.CLIENT_INITIATED) {
|
||||||
navigateTo('feedback', { duplicateIdentity: false })
|
navigateTo('feedback', { duplicateIdentity: false })
|
||||||
@@ -158,7 +249,6 @@ export const Conference = ({
|
|||||||
<InviteDialog
|
<InviteDialog
|
||||||
isOpen={showInviteDialog}
|
isOpen={showInviteDialog}
|
||||||
onOpenChange={setShowInviteDialog}
|
onOpenChange={setShowInviteDialog}
|
||||||
roomId={roomId}
|
|
||||||
onClose={() => setShowInviteDialog(false)}
|
onClose={() => setShowInviteDialog(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
import { Div, Button, type DialogProps, P } from '@/primitives'
|
import { Div, Button, type DialogProps, P, Bold } from '@/primitives'
|
||||||
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
import { HStack, styled, VStack } from '@/styled-system/jsx'
|
||||||
import { Heading, Dialog } from 'react-aria-components'
|
import { Heading, Dialog } from 'react-aria-components'
|
||||||
import { Text, text } from '@/primitives/Text'
|
import { Text, text } from '@/primitives/Text'
|
||||||
@@ -10,8 +10,13 @@ import {
|
|||||||
RiFileCopyLine,
|
RiFileCopyLine,
|
||||||
RiSpam2Fill,
|
RiSpam2Fill,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
|
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useTelephony } from '@/features/rooms/livekit/hooks/useTelephony'
|
||||||
|
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||||
|
import { useCopyRoomToClipboard } from '@/features/rooms/livekit/hooks/useCopyRoomToClipboard'
|
||||||
|
|
||||||
// fixme - extract in a proper primitive this dialog without overlay
|
// fixme - extract in a proper primitive this dialog without overlay
|
||||||
const StyledRACDialog = styled(Dialog, {
|
const StyledRACDialog = styled(Dialog, {
|
||||||
@@ -34,24 +39,27 @@ const StyledRACDialog = styled(Dialog, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const InviteDialog = ({
|
export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||||
roomId,
|
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||||
...dialogProps
|
|
||||||
}: { roomId: string } & Omit<DialogProps, 'title'>) => {
|
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const roomUrl = getRouteUrl('room', roomId)
|
|
||||||
|
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
const roomData = useRoomData()
|
||||||
|
const roomUrl = getRouteUrl('room', roomData?.slug)
|
||||||
|
|
||||||
useEffect(() => {
|
const telephony = useTelephony()
|
||||||
if (isCopied) {
|
|
||||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
const isTelephonyReadyForUse = useMemo(() => {
|
||||||
return () => clearTimeout(timeout)
|
return telephony?.enabled && roomData?.pin_code
|
||||||
}
|
}, [telephony?.enabled, roomData?.pin_code])
|
||||||
}, [isCopied])
|
|
||||||
|
const {
|
||||||
|
isCopied,
|
||||||
|
copyRoomToClipboard,
|
||||||
|
isRoomUrlCopied,
|
||||||
|
copyRoomUrlToClipboard,
|
||||||
|
} = useCopyRoomToClipboard(roomData)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledRACDialog {...dialogProps}>
|
<StyledRACDialog {...props}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<VStack
|
<VStack
|
||||||
alignItems="left"
|
alignItems="left"
|
||||||
@@ -60,7 +68,7 @@ export const InviteDialog = ({
|
|||||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||||
>
|
>
|
||||||
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
<Heading slot="title" level={3} className={text({ variant: 'h2' })}>
|
||||||
{t('shareDialog.heading')}
|
{t('heading')}
|
||||||
</Heading>
|
</Heading>
|
||||||
<Div position="absolute" top="5" right="5">
|
<Div position="absolute" top="5" right="5">
|
||||||
<Button
|
<Button
|
||||||
@@ -68,7 +76,7 @@ export const InviteDialog = ({
|
|||||||
variant="tertiaryText"
|
variant="tertiaryText"
|
||||||
size="xs"
|
size="xs"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
dialogProps.onClose?.()
|
props.onClose?.()
|
||||||
close()
|
close()
|
||||||
}}
|
}}
|
||||||
aria-label={t('closeDialog')}
|
aria-label={t('closeDialog')}
|
||||||
@@ -76,49 +84,125 @@ export const InviteDialog = ({
|
|||||||
<RiCloseLine />
|
<RiCloseLine />
|
||||||
</Button>
|
</Button>
|
||||||
</Div>
|
</Div>
|
||||||
<P>{t('shareDialog.description')}</P>
|
<P>{t('description')}</P>
|
||||||
<Button
|
{isTelephonyReadyForUse ? (
|
||||||
variant={isCopied ? 'success' : 'tertiary'}
|
|
||||||
fullWidth
|
|
||||||
aria-label={t('shareDialog.copy')}
|
|
||||||
onPress={() => {
|
|
||||||
navigator.clipboard.writeText(roomUrl)
|
|
||||||
setIsCopied(true)
|
|
||||||
}}
|
|
||||||
data-attr="share-dialog-copy"
|
|
||||||
>
|
|
||||||
{isCopied ? (
|
|
||||||
<>
|
|
||||||
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('shareDialog.copied')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
|
||||||
{t('shareDialog.copyButton')}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<HStack>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
backgroundColor: 'primary.200',
|
width: '100%',
|
||||||
borderRadius: '50%',
|
display: 'flex',
|
||||||
padding: '4px',
|
flexDirection: 'column',
|
||||||
marginTop: '1rem',
|
marginTop: '0.5rem',
|
||||||
|
gap: '1rem',
|
||||||
|
overflow: 'hidden',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<RiSpam2Fill
|
<div
|
||||||
size={22}
|
|
||||||
className={css({
|
className={css({
|
||||||
fill: 'primary.500',
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
})}
|
})}
|
||||||
/>
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
{roomUrl?.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
|
{isTelephonyReadyForUse && roomUrl && (
|
||||||
|
<Button
|
||||||
|
variant={isRoomUrlCopied ? 'success' : 'tertiaryText'}
|
||||||
|
square
|
||||||
|
size={'sm'}
|
||||||
|
onPress={copyRoomUrlToClipboard}
|
||||||
|
aria-label={t('copyUrl')}
|
||||||
|
tooltip={t('copyUrl')}
|
||||||
|
>
|
||||||
|
{isRoomUrlCopied ? <RiCheckLine /> : <RiFileCopyLine />}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.call')}</Bold> ({telephony?.country}){' '}
|
||||||
|
{telephony?.internationalPhoneNumber}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" wrap="pretty">
|
||||||
|
<Bold>{t('phone.pinCode')}</Bold>{' '}
|
||||||
|
{formatPinCode(roomData?.pin_code)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant={isCopied ? 'success' : 'secondaryText'}
|
||||||
|
size="sm"
|
||||||
|
fullWidth
|
||||||
|
aria-label={t('copy')}
|
||||||
|
style={{
|
||||||
|
justifyContent: 'start',
|
||||||
|
}}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={18} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine
|
||||||
|
style={{ marginRight: '6px', minWidth: '18px' }}
|
||||||
|
/>
|
||||||
|
{t('copy')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
) : (
|
||||||
{t('shareDialog.permissions')}
|
<Button
|
||||||
</Text>
|
variant={isCopied ? 'success' : 'tertiary'}
|
||||||
</HStack>
|
fullWidth
|
||||||
|
aria-label={t('copy')}
|
||||||
|
onPress={copyRoomToClipboard}
|
||||||
|
data-attr="share-dialog-copy"
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<>
|
||||||
|
<RiCheckLine size={24} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copied')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RiFileCopyLine size={24} style={{ marginRight: '8px' }} />
|
||||||
|
{t('copyUrl')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{roomData?.access_level === ApiAccessLevel.PUBLIC && (
|
||||||
|
<HStack>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
backgroundColor: 'primary.200',
|
||||||
|
borderRadius: '50%',
|
||||||
|
padding: '4px',
|
||||||
|
marginTop: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiSpam2Fill
|
||||||
|
size={22}
|
||||||
|
className={css({
|
||||||
|
fill: 'primary.500',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Text variant="sm" style={{ marginTop: '1rem' }}>
|
||||||
|
{t('permissions')}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
</VStack>
|
</VStack>
|
||||||
)}
|
)}
|
||||||
</StyledRACDialog>
|
</StyledRACDialog>
|
||||||
|
|||||||
@@ -2,19 +2,26 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import { usePreviewTracks } from '@livekit/components-react'
|
import { usePreviewTracks } from '@livekit/components-react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
import {
|
||||||
|
createLocalVideoTrack,
|
||||||
|
createLocalAudioTrack,
|
||||||
|
LocalAudioTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
|
Track,
|
||||||
|
} from 'livekit-client'
|
||||||
import { H } from '@/primitives/H'
|
import { H } from '@/primitives/H'
|
||||||
import { SelectToggleDevice } from '../livekit/components/controls/SelectToggleDevice'
|
|
||||||
import { Field } from '@/primitives/Field'
|
import { Field } from '@/primitives/Field'
|
||||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||||
import { HStack, VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||||
import {
|
import {
|
||||||
EffectsConfiguration,
|
EffectsConfiguration,
|
||||||
EffectsConfigurationProps,
|
EffectsConfigurationProps,
|
||||||
} from '../livekit/components/effects/EffectsConfiguration'
|
} from '../livekit/components/effects/EffectsConfiguration'
|
||||||
|
import { SelectDevice } from '../livekit/components/controls/Device/SelectDevice'
|
||||||
|
import { ToggleDevice } from '../livekit/components/controls/Device/ToggleDevice'
|
||||||
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
import { usePersistentUserChoices } from '../livekit/hooks/usePersistentUserChoices'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { isMobileBrowser } from '@livekit/components-core'
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
@@ -27,7 +34,11 @@ import { ApiLobbyStatus, ApiRequestEntry } from '../api/requestEntry'
|
|||||||
import { Spinner } from '@/primitives/Spinner'
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
import { ApiAccessLevel } from '../api/ApiRoom'
|
import { ApiAccessLevel } from '../api/ApiRoom'
|
||||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
|
import { useResolveInitiallyDefaultDeviceId } from '../livekit/hooks/useResolveInitiallyDefaultDeviceId'
|
||||||
|
import { isSafari } from '@/utils/livekit'
|
||||||
|
import type { LocalUserChoices } from '@/stores/userChoices'
|
||||||
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
|
|
||||||
const onError = (e: Error) => console.error('ERROR', e)
|
const onError = (e: Error) => console.error('ERROR', e)
|
||||||
|
|
||||||
@@ -72,45 +83,23 @@ const Effects = ({
|
|||||||
</Text>
|
</Text>
|
||||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||||
</Dialog>
|
</Dialog>
|
||||||
<div
|
<Button
|
||||||
className={css({
|
variant="whiteCircle"
|
||||||
position: 'absolute',
|
onPress={openDialog}
|
||||||
right: 0,
|
tooltip={t('description')}
|
||||||
bottom: '0',
|
aria-label={t('description')}
|
||||||
padding: '1rem',
|
|
||||||
zIndex: '1',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
<Button
|
<RiImageCircleAiFill size={24} />
|
||||||
variant="whiteCircle"
|
</Button>
|
||||||
onPress={openDialog}
|
|
||||||
tooltip={t('description')}
|
|
||||||
aria-label={t('description')}
|
|
||||||
>
|
|
||||||
<RiImageCircleAiFill size={24} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
height: '20%',
|
|
||||||
backgroundImage:
|
|
||||||
'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(255,255,255,0) 100%)',
|
|
||||||
borderBottomRadius: '1rem',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Join = ({
|
export const Join = ({
|
||||||
onSubmit,
|
enterRoom,
|
||||||
roomId,
|
roomId,
|
||||||
}: {
|
}: {
|
||||||
onSubmit: (choices: LocalUserChoices) => void
|
enterRoom: () => void
|
||||||
roomId: string
|
roomId: string
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||||
@@ -120,11 +109,13 @@ export const Join = ({
|
|||||||
audioEnabled,
|
audioEnabled,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
audioDeviceId,
|
audioDeviceId,
|
||||||
|
audioOutputDeviceId,
|
||||||
videoDeviceId,
|
videoDeviceId,
|
||||||
processorSerialized,
|
processorSerialized,
|
||||||
username,
|
username,
|
||||||
},
|
},
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
|
saveAudioOutputDeviceId,
|
||||||
saveVideoInputEnabled,
|
saveVideoInputEnabled,
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveVideoInputDeviceId,
|
saveVideoInputDeviceId,
|
||||||
@@ -132,28 +123,44 @@ export const Join = ({
|
|||||||
saveProcessorSerialized,
|
saveProcessorSerialized,
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const [processor, setProcessor] = useState(
|
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized)
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (initialUserChoices.current === null) {
|
||||||
saveProcessorSerialized(processor?.serialize())
|
initialUserChoices.current = {
|
||||||
}, [
|
audioEnabled,
|
||||||
processor,
|
videoEnabled,
|
||||||
saveProcessorSerialized,
|
audioDeviceId,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
audioOutputDeviceId,
|
||||||
JSON.stringify(processor?.serialize()),
|
videoDeviceId,
|
||||||
])
|
processorSerialized,
|
||||||
|
username,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tracks = usePreviewTracks(
|
const tracks = usePreviewTracks(
|
||||||
{
|
{
|
||||||
audio: { deviceId: audioDeviceId },
|
audio: !!initialUserChoices.current &&
|
||||||
video: { deviceId: videoDeviceId },
|
initialUserChoices.current?.audioEnabled && {
|
||||||
|
deviceId: initialUserChoices.current.audioDeviceId,
|
||||||
|
},
|
||||||
|
video: !!initialUserChoices.current &&
|
||||||
|
initialUserChoices.current?.videoEnabled && {
|
||||||
|
deviceId: initialUserChoices.current.videoDeviceId,
|
||||||
|
processor:
|
||||||
|
BackgroundProcessorFactory.deserializeProcessor(
|
||||||
|
processorSerialized
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
onError
|
onError
|
||||||
)
|
)
|
||||||
|
|
||||||
const videoTrack = useMemo(
|
const [dynamicVideoTrack, setDynamicVideoTrack] =
|
||||||
|
useState<LocalVideoTrack | null>(null)
|
||||||
|
const [dynamicAudioTrack, setDynamicAudioTrack] =
|
||||||
|
useState<LocalAudioTrack | null>(null)
|
||||||
|
|
||||||
|
const previewVideoTrack = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tracks?.filter(
|
tracks?.filter(
|
||||||
(track) => track.kind === Track.Kind.Video
|
(track) => track.kind === Track.Kind.Video
|
||||||
@@ -161,56 +168,129 @@ export const Join = ({
|
|||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
const audioTrack = useMemo(
|
const previewAudioTrack = useMemo(
|
||||||
() =>
|
() =>
|
||||||
tracks?.filter(
|
tracks?.filter(
|
||||||
(track) => track.kind === Track.Kind.Audio
|
(track) => track.kind === Track.Kind.Audio
|
||||||
)[0] as LocalVideoTrack,
|
)[0] as LocalAudioTrack,
|
||||||
[tracks]
|
[tracks]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dynamic track creation strategy: Only create a dynamic track if the user initially disabled audio/video
|
||||||
|
* but now wants to enable it. This is a "just-in-time" acquisition pattern where we create the track
|
||||||
|
* on-demand. We avoid creating tracks when the user explicitly requested them to be disabled.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
const createVideoTrack = async () => {
|
||||||
|
try {
|
||||||
|
const track = await createLocalVideoTrack({
|
||||||
|
deviceId: { exact: videoDeviceId },
|
||||||
|
processor:
|
||||||
|
BackgroundProcessorFactory.deserializeProcessor(
|
||||||
|
processorSerialized
|
||||||
|
),
|
||||||
|
})
|
||||||
|
setDynamicVideoTrack(track)
|
||||||
|
} catch (error) {
|
||||||
|
onError(error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
videoEnabled &&
|
||||||
|
!initialUserChoices.current?.videoEnabled &&
|
||||||
|
!previewVideoTrack &&
|
||||||
|
!dynamicVideoTrack
|
||||||
|
) {
|
||||||
|
createVideoTrack()
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
videoEnabled,
|
||||||
|
videoDeviceId,
|
||||||
|
processorSerialized,
|
||||||
|
previewVideoTrack,
|
||||||
|
dynamicVideoTrack,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const createAudioTrack = async () => {
|
||||||
|
try {
|
||||||
|
const track = await createLocalAudioTrack({
|
||||||
|
deviceId: { exact: audioDeviceId },
|
||||||
|
})
|
||||||
|
setDynamicAudioTrack(track)
|
||||||
|
} catch (error) {
|
||||||
|
onError(error as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
audioEnabled &&
|
||||||
|
!initialUserChoices.current?.audioEnabled &&
|
||||||
|
!previewAudioTrack &&
|
||||||
|
!dynamicAudioTrack
|
||||||
|
) {
|
||||||
|
createAudioTrack()
|
||||||
|
}
|
||||||
|
}, [audioEnabled, audioDeviceId, previewAudioTrack, dynamicAudioTrack])
|
||||||
|
|
||||||
|
// Cleanup dynamic tracks
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dynamicVideoTrack?.stop()
|
||||||
|
}
|
||||||
|
}, [dynamicVideoTrack])
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
dynamicAudioTrack?.stop()
|
||||||
|
}
|
||||||
|
}, [dynamicAudioTrack])
|
||||||
|
|
||||||
|
// Final tracks (dynamic takes precedence over preview)
|
||||||
|
const videoTrack = dynamicVideoTrack || previewVideoTrack
|
||||||
|
const audioTrack = dynamicAudioTrack || previewAudioTrack
|
||||||
|
|
||||||
|
// LiveKit by default populates device choices with "default" value.
|
||||||
|
// Instead, use the current device id used by the preview track as a default
|
||||||
|
useResolveInitiallyDefaultDeviceId(
|
||||||
|
audioDeviceId,
|
||||||
|
audioTrack,
|
||||||
|
saveAudioInputDeviceId
|
||||||
|
)
|
||||||
|
useResolveInitiallyDefaultDeviceId(
|
||||||
|
videoDeviceId,
|
||||||
|
videoTrack,
|
||||||
|
saveVideoInputDeviceId
|
||||||
|
)
|
||||||
|
|
||||||
const videoEl = useRef(null)
|
const videoEl = useRef(null)
|
||||||
|
const isVideoInitiated = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoElement = videoEl.current as HTMLVideoElement | null
|
const videoElement = videoEl.current as HTMLVideoElement | null
|
||||||
|
|
||||||
const handleVideoLoaded = () => {
|
const handleVideoLoaded = () => {
|
||||||
if (videoElement) {
|
if (videoElement) {
|
||||||
|
isVideoInitiated.current = true
|
||||||
videoElement.style.opacity = '1'
|
videoElement.style.opacity = '1'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (videoElement && videoTrack && videoEnabled) {
|
if (videoElement && videoTrack && videoEnabled) {
|
||||||
videoTrack.unmute()
|
|
||||||
videoTrack.attach(videoElement)
|
videoTrack.attach(videoElement)
|
||||||
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
videoElement.addEventListener('loadedmetadata', handleVideoLoaded)
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
videoTrack?.detach()
|
videoTrack?.detach()
|
||||||
videoElement?.removeEventListener('loadedmetadata', handleVideoLoaded)
|
if (videoElement) {
|
||||||
|
videoElement.removeEventListener('loadedmetadata', handleVideoLoaded)
|
||||||
|
videoElement.style.opacity = '0'
|
||||||
|
}
|
||||||
|
isVideoInitiated.current = false
|
||||||
}
|
}
|
||||||
}, [videoTrack, videoEnabled])
|
}, [videoTrack, videoEnabled])
|
||||||
|
|
||||||
const enterRoom = useCallback(() => {
|
|
||||||
onSubmit({
|
|
||||||
audioEnabled,
|
|
||||||
videoEnabled,
|
|
||||||
audioDeviceId,
|
|
||||||
videoDeviceId,
|
|
||||||
username,
|
|
||||||
processorSerialized: processor?.serialize(),
|
|
||||||
})
|
|
||||||
}, [
|
|
||||||
onSubmit,
|
|
||||||
audioEnabled,
|
|
||||||
videoEnabled,
|
|
||||||
audioDeviceId,
|
|
||||||
videoDeviceId,
|
|
||||||
username,
|
|
||||||
processor,
|
|
||||||
])
|
|
||||||
|
|
||||||
// Room data strategy:
|
// Room data strategy:
|
||||||
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
// 1. Initial fetch is performed to check access and get LiveKit configuration
|
||||||
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
// 2. Data remains valid for 6 hours to avoid unnecessary refetches
|
||||||
@@ -269,15 +349,43 @@ export const Join = ({
|
|||||||
enterRoom()
|
enterRoom()
|
||||||
}
|
}
|
||||||
|
|
||||||
// This hook is used to setup the persisted user choice processor on initialization.
|
const isCameraDeniedOrPrompted = useCannotUseDevice('videoinput')
|
||||||
// So it's on purpose that processor is not included in the deps.
|
const isMicrophoneDeniedOrPrompted = useCannotUseDevice('audioinput')
|
||||||
// We just want to wait for the videoTrack to be loaded to apply the default processor.
|
|
||||||
useEffect(() => {
|
const hintMessage = useMemo(() => {
|
||||||
if (processor && videoTrack && !videoTrack.getProcessor()) {
|
if (isCameraDeniedOrPrompted) {
|
||||||
videoTrack.setProcessor(processor)
|
return isMicrophoneDeniedOrPrompted
|
||||||
|
? 'cameraAndMicNotGranted'
|
||||||
|
: 'cameraNotGranted'
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
if (!videoEnabled) {
|
||||||
}, [videoTrack])
|
return 'cameraDisabled'
|
||||||
|
}
|
||||||
|
if (!isVideoInitiated.current) {
|
||||||
|
return 'cameraStarting'
|
||||||
|
}
|
||||||
|
if (videoTrack && videoEnabled) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
videoTrack,
|
||||||
|
videoEnabled,
|
||||||
|
isCameraDeniedOrPrompted,
|
||||||
|
isMicrophoneDeniedOrPrompted,
|
||||||
|
])
|
||||||
|
|
||||||
|
const permissionsButtonLabel = useMemo(() => {
|
||||||
|
if (!isMicrophoneDeniedOrPrompted && !isCameraDeniedOrPrompted) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (isCameraDeniedOrPrompted && isMicrophoneDeniedOrPrompted) {
|
||||||
|
return 'cameraAndMicNotGranted'
|
||||||
|
}
|
||||||
|
if (isCameraDeniedOrPrompted && !isMicrophoneDeniedOrPrompted) {
|
||||||
|
return 'cameraNotGranted'
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [isMicrophoneDeniedOrPrompted, isCameraDeniedOrPrompted])
|
||||||
|
|
||||||
const renderWaitingState = () => {
|
const renderWaitingState = () => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
@@ -339,6 +447,7 @@ export const Join = ({
|
|||||||
type="text"
|
type="text"
|
||||||
onChange={saveUsername}
|
onChange={saveUsername}
|
||||||
label={t('usernameLabel')}
|
label={t('usernameLabel')}
|
||||||
|
aria-label={t('usernameLabel')}
|
||||||
defaultValue={username}
|
defaultValue={username}
|
||||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||||
wrapperProps={{
|
wrapperProps={{
|
||||||
@@ -361,141 +470,315 @@ export const Join = ({
|
|||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: '100%',
|
||||||
|
flexDirection: 'column',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
|
gap: { base: '1rem', sm: '2rem', lg: 0 },
|
||||||
lg: {
|
lg: {
|
||||||
alignItems: 'center',
|
flexDirection: 'row',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
height: 'auto',
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '2rem',
|
|
||||||
padding: '0 2rem',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minWidth: 0,
|
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '764px',
|
||||||
lg: {
|
lg: {
|
||||||
flexDirection: 'row',
|
height: '540px',
|
||||||
width: 'auto',
|
flexGrow: 1,
|
||||||
height: '570px',
|
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
width: '100%',
|
display: 'inline-flex',
|
||||||
lg: {
|
flexDirection: 'column',
|
||||||
width: '740px',
|
flexGrow: 1,
|
||||||
},
|
minWidth: 0,
|
||||||
|
flexShrink: { base: 0, sm: 1 },
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
borderRadius: '1rem',
|
borderRadius: '1rem',
|
||||||
|
flex: '0 1',
|
||||||
|
minWidth: '320px',
|
||||||
|
margin: {
|
||||||
|
base: '0.5rem',
|
||||||
|
sm: '1rem',
|
||||||
|
lg: '1rem 0.5rem 1rem 1rem',
|
||||||
|
},
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
display: 'flex',
|
||||||
height: 'auto',
|
flexDirection: 'column',
|
||||||
aspectRatio: 16 / 9,
|
alignItems: 'center',
|
||||||
'--tw-shadow':
|
|
||||||
'0 10px 15px -5px #00000010, 0 4px 5px -6px #00000010',
|
|
||||||
'--tw-shadow-colored':
|
|
||||||
'0 10px 15px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color)',
|
|
||||||
boxShadow:
|
|
||||||
'var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)',
|
|
||||||
backgroundColor: 'black',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{videoTrack && videoEnabled ? (
|
<div
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
className={css({
|
||||||
<video
|
position: 'absolute',
|
||||||
ref={videoEl}
|
top: 0,
|
||||||
width="1280"
|
height: '5rem',
|
||||||
height="720"
|
width: '100%',
|
||||||
className={css({
|
backgroundImage:
|
||||||
display: 'block',
|
'linear-gradient(to bottom, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 40%, rgba(0, 0, 0, 0.1) 80%, rgba(0, 0, 0, 0) 100%)',
|
||||||
width: '100%',
|
zIndex: 1,
|
||||||
height: '100%',
|
})}
|
||||||
objectFit: 'cover',
|
/>
|
||||||
transform: 'rotateY(180deg)',
|
<div
|
||||||
opacity: 0,
|
className={css({
|
||||||
transition: 'opacity 0.3s ease-in-out',
|
position: 'absolute',
|
||||||
borderRadius: '1rem',
|
bottom: 0,
|
||||||
})}
|
height: '5rem',
|
||||||
disablePictureInPicture
|
width: '100%',
|
||||||
disableRemotePlayback
|
backgroundImage:
|
||||||
/>
|
'linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.3) 35%, rgba(0, 0, 0, 0.1) 75%, rgba(0, 0, 0, 0) 100%)',
|
||||||
) : (
|
zIndex: 1,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
height: 'fit-content',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
backgroundColor: 'black',
|
||||||
|
position: 'absolute',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
top: 0,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
color: 'white',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<p
|
<div
|
||||||
|
aria-label={t(
|
||||||
|
`videoPreview.${videoEnabled ? 'enabled' : 'disabled'}`
|
||||||
|
)}
|
||||||
|
role="status"
|
||||||
className={css({
|
className={css({
|
||||||
fontSize: '24px',
|
position: 'absolute',
|
||||||
fontWeight: '300',
|
top: 0,
|
||||||
|
width: '100%',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{!videoEnabled && t('cameraDisabled')}
|
<div
|
||||||
{videoEnabled && !videoTrack && t('cameraStarting')}
|
className={css({
|
||||||
</p>
|
width: '100%',
|
||||||
|
height: 'auto',
|
||||||
|
aspectRatio: '16 / 9',
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-2px',
|
||||||
|
left: '-2px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
transform: 'scale(1.02)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
ref={videoEl}
|
||||||
|
width="1280"
|
||||||
|
height="720"
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
!videoEnabled || isCameraDeniedOrPrompted
|
||||||
|
? 'none'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
transform: 'rotateY(180deg)',
|
||||||
|
opacity: 0,
|
||||||
|
height: '100%',
|
||||||
|
transition: 'opacity 0.3s ease-in-out',
|
||||||
|
objectFit: 'cover',
|
||||||
|
})}
|
||||||
|
disablePictureInPicture
|
||||||
|
disableRemotePlayback
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.24rem',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
gap: '1rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={css({
|
||||||
|
fontWeight: '400',
|
||||||
|
fontSize: { base: '1rem', sm: '1.25rem', lg: '1.5rem' },
|
||||||
|
textWrap: 'balance',
|
||||||
|
color: 'white',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{hintMessage && t(hintMessage)}
|
||||||
|
</p>
|
||||||
|
{isCameraDeniedOrPrompted && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="tertiary"
|
||||||
|
onPress={openPermissionsDialog}
|
||||||
|
>
|
||||||
|
{t(`permissionsButton.${permissionsButtonLabel}`)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '1rem',
|
||||||
|
zIndex: '1',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1rem',
|
||||||
|
justifyContent: 'center',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ToggleDevice
|
||||||
|
kind="audioinput"
|
||||||
|
context="join"
|
||||||
|
enabled={audioEnabled}
|
||||||
|
toggle={async () => {
|
||||||
|
saveAudioInputEnabled(!audioEnabled)
|
||||||
|
if (audioEnabled) {
|
||||||
|
await audioTrack?.mute()
|
||||||
|
} else {
|
||||||
|
await audioTrack?.unmute()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ToggleDevice
|
||||||
|
kind="videoinput"
|
||||||
|
context="join"
|
||||||
|
enabled={videoEnabled}
|
||||||
|
toggle={async () => {
|
||||||
|
saveVideoInputEnabled(!videoEnabled)
|
||||||
|
if (videoEnabled) {
|
||||||
|
await videoTrack?.mute()
|
||||||
|
} else {
|
||||||
|
await videoTrack?.unmute()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
right: '1rem',
|
||||||
|
bottom: '1rem',
|
||||||
|
zIndex: '1',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Effects
|
||||||
|
videoTrack={videoTrack}
|
||||||
|
onSubmit={(processor) =>
|
||||||
|
saveProcessorSerialized(processor?.serialize())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: '2%',
|
||||||
|
width: '80%',
|
||||||
|
marginX: 'auto',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '30%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectDevice
|
||||||
|
kind="audioinput"
|
||||||
|
id={audioDeviceId}
|
||||||
|
onSubmit={async (id) => {
|
||||||
|
try {
|
||||||
|
saveAudioInputDeviceId(id)
|
||||||
|
if (audioTrack) {
|
||||||
|
await audioTrack.setDeviceId({ exact: id })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to switch microphone device', err)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!isSafari() && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '30%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectDevice
|
||||||
|
kind="audiooutput"
|
||||||
|
id={audioOutputDeviceId}
|
||||||
|
onSubmit={saveAudioOutputDeviceId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Effects videoTrack={videoTrack} onSubmit={setProcessor} />
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '30%',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<SelectDevice
|
||||||
|
kind="videoinput"
|
||||||
|
id={videoDeviceId}
|
||||||
|
onSubmit={async (id) => {
|
||||||
|
try {
|
||||||
|
saveVideoInputDeviceId(id)
|
||||||
|
if (videoTrack) {
|
||||||
|
await await videoTrack.setDeviceId({ exact: id })
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to switch camera device', err)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<HStack justify="center" padding={1.5}>
|
|
||||||
<SelectToggleDevice
|
|
||||||
source={Track.Source.Microphone}
|
|
||||||
initialState={audioEnabled}
|
|
||||||
track={audioTrack}
|
|
||||||
initialDeviceId={audioDeviceId}
|
|
||||||
onChange={(enabled) => saveAudioInputEnabled(enabled)}
|
|
||||||
onDeviceError={(error) => console.error(error)}
|
|
||||||
onActiveDeviceChange={(deviceId) =>
|
|
||||||
saveAudioInputDeviceId(deviceId ?? '')
|
|
||||||
}
|
|
||||||
variant="tertiary"
|
|
||||||
/>
|
|
||||||
<SelectToggleDevice
|
|
||||||
source={Track.Source.Camera}
|
|
||||||
initialState={videoEnabled}
|
|
||||||
track={videoTrack}
|
|
||||||
initialDeviceId={videoDeviceId}
|
|
||||||
onChange={(enabled) => saveVideoInputEnabled(enabled)}
|
|
||||||
onDeviceError={(error) => console.error(error)}
|
|
||||||
onActiveDeviceChange={(deviceId) =>
|
|
||||||
saveVideoInputDeviceId(deviceId ?? '')
|
|
||||||
}
|
|
||||||
variant="tertiary"
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
padding: '0',
|
|
||||||
sm: {
|
|
||||||
width: '448px',
|
|
||||||
padding: '0 3rem 9rem 3rem',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{renderWaitingState()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: '0 0 448px',
|
||||||
|
position: 'relative',
|
||||||
|
margin: '1rem 1rem 1rem 0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{renderWaitingState()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Screen>
|
</Screen>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useWatchPermissions } from '@/features/rooms/hooks/useWatchPermissions'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Dialog, H } from '@/primitives'
|
||||||
|
import { RiEqualizer2Line } from '@remixicon/react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { closePermissionsDialog, permissionsStore } from '@/stores/permissions'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { injectIconIntoTranslation } from '@/utils/translation'
|
||||||
|
import { isSafari } from '@/utils/livekit'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton component - ensures permissions sync runs only once across the app.
|
||||||
|
* WARNING: This component should only be instantiated once in the interface.
|
||||||
|
* Multiple instances may cause unexpected behavior or performance issues.
|
||||||
|
*/
|
||||||
|
export const Permissions = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'permissionErrorDialog' })
|
||||||
|
|
||||||
|
useWatchPermissions()
|
||||||
|
|
||||||
|
const permissions = useSnapshot(permissionsStore)
|
||||||
|
|
||||||
|
const permissionLabel = useMemo(() => {
|
||||||
|
if (permissions.isMicrophoneDenied && permissions.isCameraDenied) {
|
||||||
|
return 'cameraAndMicrophone'
|
||||||
|
} else if (permissions.isCameraDenied) {
|
||||||
|
return 'camera'
|
||||||
|
} else if (permissions.isMicrophoneDenied) {
|
||||||
|
return 'microphone'
|
||||||
|
} else {
|
||||||
|
return 'default'
|
||||||
|
}
|
||||||
|
}, [permissions])
|
||||||
|
|
||||||
|
const [descriptionBeforeIcon, descriptionAfterIcon] =
|
||||||
|
injectIconIntoTranslation(t('body.openMenu.others'))
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
permissions.isPermissionDialogOpen &&
|
||||||
|
permissions.isCameraGranted &&
|
||||||
|
permissions.isMicrophoneGranted
|
||||||
|
) {
|
||||||
|
closePermissionsDialog()
|
||||||
|
}
|
||||||
|
}, [permissions])
|
||||||
|
|
||||||
|
const appTitle = `${import.meta.env.VITE_APP_TITLE}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
isOpen={permissions.isPermissionDialogOpen}
|
||||||
|
role="dialog"
|
||||||
|
type="flex"
|
||||||
|
title=""
|
||||||
|
aria-label={t(`heading.${permissionLabel}`, {
|
||||||
|
appTitle,
|
||||||
|
})}
|
||||||
|
onClose={closePermissionsDialog}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="/assets/camera_mic_permission.svg"
|
||||||
|
alt=""
|
||||||
|
className={css({
|
||||||
|
minWidth: '290px',
|
||||||
|
minHeight: '290px',
|
||||||
|
maxWidth: '290px',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxWidth: '400px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<H lvl={2}>
|
||||||
|
{t(`heading.${permissionLabel}`, {
|
||||||
|
appTitle,
|
||||||
|
})}
|
||||||
|
</H>
|
||||||
|
<ol className={css({ listStyle: 'decimal', paddingLeft: '24px' })}>
|
||||||
|
<li>
|
||||||
|
{isSafari() ? (
|
||||||
|
t('body.openMenu.safari', {
|
||||||
|
appDomain: window.origin.replace('https://', ''),
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{descriptionBeforeIcon}
|
||||||
|
<span
|
||||||
|
style={{ display: 'inline-block', verticalAlign: 'middle' }}
|
||||||
|
>
|
||||||
|
<RiEqualizer2Line />
|
||||||
|
</span>
|
||||||
|
{descriptionAfterIcon}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>{t(`body.details.${permissionLabel}`)}</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { permissionsStore } from '@/stores/permissions'
|
||||||
|
import { isSafari } from '@/utils/livekit'
|
||||||
|
|
||||||
|
const POLLING_TIME = 500
|
||||||
|
|
||||||
|
export const useWatchPermissions = () => {
|
||||||
|
useEffect(() => {
|
||||||
|
let cleanup: (() => void) | undefined
|
||||||
|
let intervalId: NodeJS.Timeout | undefined
|
||||||
|
let isCancelled = false
|
||||||
|
|
||||||
|
const checkPermissions = async () => {
|
||||||
|
try {
|
||||||
|
if (!navigator.permissions) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
permissionsStore.cameraPermission = 'unavailable'
|
||||||
|
permissionsStore.microphonePermission = 'unavailable'
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cameraPermission, microphonePermission] = await Promise.all([
|
||||||
|
navigator.permissions.query({ name: 'camera' }),
|
||||||
|
navigator.permissions.query({ name: 'microphone' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safari Permission API Limitation Workaround
|
||||||
|
*
|
||||||
|
* Safari has a known issue where permission change events are not reliably fired
|
||||||
|
* when users interact with permission prompts. This is documented in Apple's forums:
|
||||||
|
* https://developer.apple.com/forums/thread/757353
|
||||||
|
*
|
||||||
|
* The problem:
|
||||||
|
* - When permissions are in 'prompt' state, Safari may not trigger 'change' events
|
||||||
|
* - Users can grant/deny permissions through system prompts, but our listeners won't detect it
|
||||||
|
* - This leaves the UI in an inconsistent state showing outdated permission status
|
||||||
|
*
|
||||||
|
* The solution:
|
||||||
|
* - Manually poll the Permissions API every 500ms when either permission is in 'prompt' state
|
||||||
|
* - Continue polling until both permissions are no longer in 'prompt' state
|
||||||
|
* - This ensures we catch permission changes even when Safari fails to fire events
|
||||||
|
*
|
||||||
|
* This polling is Safari-specific and only activates when needed to minimize performance impact.
|
||||||
|
*/
|
||||||
|
if (
|
||||||
|
isSafari() &&
|
||||||
|
(cameraPermission.state === 'prompt' ||
|
||||||
|
microphonePermission.state === 'prompt')
|
||||||
|
) {
|
||||||
|
// Start polling every 1 second if either permission is in 'prompt' state
|
||||||
|
if (!intervalId) {
|
||||||
|
intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const [updatedCamera, updatedMicrophone] = await Promise.all([
|
||||||
|
navigator.permissions.query({ name: 'camera' }),
|
||||||
|
navigator.permissions.query({ name: 'microphone' }),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (isCancelled) return
|
||||||
|
|
||||||
|
const cameraChanged =
|
||||||
|
permissionsStore.cameraPermission !== updatedCamera.state
|
||||||
|
const microphoneChanged =
|
||||||
|
permissionsStore.microphonePermission !==
|
||||||
|
updatedMicrophone.state
|
||||||
|
|
||||||
|
if (cameraChanged) {
|
||||||
|
permissionsStore.cameraPermission = updatedCamera.state
|
||||||
|
}
|
||||||
|
|
||||||
|
if (microphoneChanged) {
|
||||||
|
permissionsStore.microphonePermission =
|
||||||
|
updatedMicrophone.state
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
updatedCamera.state !== 'prompt' &&
|
||||||
|
updatedMicrophone.state !== 'prompt'
|
||||||
|
) {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Error polling permissions:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, POLLING_TIME)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
permissionsStore.cameraPermission = cameraPermission.state
|
||||||
|
permissionsStore.microphonePermission = microphonePermission.state
|
||||||
|
|
||||||
|
const handleCameraChange = (e: Event) => {
|
||||||
|
const target = e.target as PermissionStatus
|
||||||
|
permissionsStore.cameraPermission = target.state
|
||||||
|
|
||||||
|
if (
|
||||||
|
intervalId &&
|
||||||
|
target.state !== 'prompt' &&
|
||||||
|
microphonePermission.state !== 'prompt'
|
||||||
|
) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMicrophoneChange = (e: Event) => {
|
||||||
|
const target = e.target as PermissionStatus
|
||||||
|
permissionsStore.microphonePermission = target.state
|
||||||
|
|
||||||
|
if (
|
||||||
|
intervalId &&
|
||||||
|
target.state !== 'prompt' &&
|
||||||
|
microphonePermission.state !== 'prompt'
|
||||||
|
) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraPermission.addEventListener('change', handleCameraChange)
|
||||||
|
microphonePermission.addEventListener('change', handleMicrophoneChange)
|
||||||
|
|
||||||
|
cleanup = () => {
|
||||||
|
cameraPermission.removeEventListener('change', handleCameraChange)
|
||||||
|
microphonePermission.removeEventListener(
|
||||||
|
'change',
|
||||||
|
handleMicrophoneChange
|
||||||
|
)
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId)
|
||||||
|
intervalId = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCancelled) {
|
||||||
|
console.error('Error checking permissions:', error)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!isCancelled) {
|
||||||
|
permissionsStore.isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
checkPermissions()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isCancelled = true
|
||||||
|
cleanup?.()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { Participant } from 'livekit-client'
|
|||||||
import { fetchServerApi } from './fetchServerApi'
|
import { fetchServerApi } from './fetchServerApi'
|
||||||
import { buildServerApiUrl } from './buildServerApiUrl'
|
import { buildServerApiUrl } from './buildServerApiUrl'
|
||||||
import { useRoomData } from '../hooks/useRoomData'
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
|
||||||
|
|
||||||
export const useLowerHandParticipant = () => {
|
export const useLowerHandParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
@@ -11,8 +10,12 @@ export const useLowerHandParticipant = () => {
|
|||||||
if (!data || !data?.livekit) {
|
if (!data || !data?.livekit) {
|
||||||
throw new Error('Room data is not available')
|
throw new Error('Room data is not available')
|
||||||
}
|
}
|
||||||
const newMetadata = safeParseMetadata(participant.metadata) || {}
|
|
||||||
newMetadata.raised = !newMetadata.raised
|
const newAttributes = {
|
||||||
|
...participant.attributes,
|
||||||
|
handRaisedAt: '',
|
||||||
|
}
|
||||||
|
|
||||||
return fetchServerApi(
|
return fetchServerApi(
|
||||||
buildServerApiUrl(
|
buildServerApiUrl(
|
||||||
data.livekit.url,
|
data.livekit.url,
|
||||||
@@ -24,7 +27,7 @@ export const useLowerHandParticipant = () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
room: data.livekit.room,
|
room: data.livekit.room,
|
||||||
identity: participant.identity,
|
identity: participant.identity,
|
||||||
metadata: JSON.stringify(newMetadata),
|
attributes: newAttributes,
|
||||||
permission: participant.permissions,
|
permission: participant.permissions,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Button, Text } from '@/primitives'
|
import { Button, Text } from '@/primitives'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import { ScreenSharePreferenceStore } from '@/stores/ScreenSharePreferences'
|
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useLocalParticipant } from '@livekit/components-react'
|
import { useLocalParticipant } from '@livekit/components-react'
|
||||||
|
import { useSize } from '../hooks/useResizeObserver'
|
||||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSize from '@react-hook/size'
|
|
||||||
|
|
||||||
export const FullScreenShareWarning = ({
|
export const FullScreenShareWarning = ({
|
||||||
trackReference,
|
trackReference,
|
||||||
@@ -16,9 +16,9 @@ export const FullScreenShareWarning = ({
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'fullScreenWarning' })
|
||||||
|
|
||||||
const warningContainerRef = useRef<HTMLDivElement>(null)
|
const warningContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const containerWidth = useSize(warningContainerRef)[0]
|
const { width: containerWidth } = useSize(warningContainerRef)
|
||||||
const { localParticipant } = useLocalParticipant()
|
const { localParticipant } = useLocalParticipant()
|
||||||
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
|
const screenSharePreferences = useSnapshot(screenSharePreferenceStore)
|
||||||
|
|
||||||
const isFullScreenSharing = useMemo(() => {
|
const isFullScreenSharing = useMemo(() => {
|
||||||
if (trackReference?.source !== 'screen_share') return false
|
if (trackReference?.source !== 'screen_share') return false
|
||||||
@@ -62,7 +62,7 @@ export const FullScreenShareWarning = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleDismissWarning = () => {
|
const handleDismissWarning = () => {
|
||||||
ScreenSharePreferenceStore.enabled = false
|
screenSharePreferenceStore.enabled = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shouldShowWarning) return null
|
if (!shouldShowWarning) return null
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useEffect, useState } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { VStack } from '@/styled-system/jsx'
|
import { VStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
import { RiCheckLine, RiFileCopyLine } from '@remixicon/react'
|
||||||
@@ -8,24 +8,22 @@ import { getRouteUrl } from '@/navigation/getRouteUrl'
|
|||||||
import { useRoomData } from '../hooks/useRoomData'
|
import { useRoomData } from '../hooks/useRoomData'
|
||||||
import { formatPinCode } from '../../utils/telephony'
|
import { formatPinCode } from '../../utils/telephony'
|
||||||
import { useTelephony } from '../hooks/useTelephony'
|
import { useTelephony } from '../hooks/useTelephony'
|
||||||
|
import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||||
|
|
||||||
export const Info = () => {
|
export const Info = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||||
|
|
||||||
const [isCopied, setIsCopied] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isCopied) {
|
|
||||||
const timeout = setTimeout(() => setIsCopied(false), 3000)
|
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}
|
|
||||||
}, [isCopied])
|
|
||||||
|
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
const roomUrl = getRouteUrl('room', data?.slug)
|
const roomUrl = getRouteUrl('room', data?.slug)
|
||||||
|
|
||||||
const telephony = useTelephony()
|
const telephony = useTelephony()
|
||||||
|
|
||||||
|
const isTelephonyReadyForUse = useMemo(() => {
|
||||||
|
return telephony?.enabled && data?.pin_code
|
||||||
|
}, [telephony?.enabled, data?.pin_code])
|
||||||
|
|
||||||
|
const { isCopied, copyRoomToClipboard } = useCopyRoomToClipboard(data)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Div
|
<Div
|
||||||
display="flex"
|
display="flex"
|
||||||
@@ -53,9 +51,9 @@ export const Info = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Text as="p" variant="xsNote" wrap="pretty">
|
<Text as="p" variant="xsNote" wrap="pretty">
|
||||||
{roomUrl}
|
{roomUrl.replace(/^https?:\/\//, '')}
|
||||||
</Text>
|
</Text>
|
||||||
{telephony?.enabled && data?.pin_code && (
|
{isTelephonyReadyForUse && (
|
||||||
<>
|
<>
|
||||||
<Text as="p" variant="xsNote" wrap="pretty">
|
<Text as="p" variant="xsNote" wrap="pretty">
|
||||||
<Bold>{t('roomInformation.phone.call')}</Bold> (
|
<Bold>{t('roomInformation.phone.call')}</Bold> (
|
||||||
@@ -72,10 +70,7 @@ export const Info = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||||
aria-label={t('roomInformation.button.ariaLabel')}
|
aria-label={t('roomInformation.button.ariaLabel')}
|
||||||
onPress={() => {
|
onPress={copyRoomToClipboard}
|
||||||
navigator.clipboard.writeText(roomUrl)
|
|
||||||
setIsCopied(true)
|
|
||||||
}}
|
|
||||||
data-attr="copy-info-sidepannel"
|
data-attr="copy-info-sidepannel"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: '-8px',
|
marginLeft: '-8px',
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { styled } from '@/styled-system/jsx'
|
|||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
import { useIsSpeaking } from '@livekit/components-react'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import useSize from '@react-hook/size'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -28,7 +28,7 @@ export const ParticipantPlaceholder = ({
|
|||||||
const participantColor = getParticipantColor(participant)
|
const participantColor = getParticipantColor(participant)
|
||||||
|
|
||||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||||
const [width, height] = useSize(placeholderEl)
|
const { width, height } = useSize(placeholderEl)
|
||||||
|
|
||||||
const minDimension = Math.min(width, height)
|
const minDimension = Math.min(width, height)
|
||||||
const avatarSize = useMemo(
|
const avatarSize = useMemo(
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { useRaisedHand } from '../hooks/useRaisedHand'
|
import { useRaisedHand, useRaisedHandPosition } from '../hooks/useRaisedHand'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { MutedMicIndicator } from './MutedMicIndicator'
|
import { MutedMicIndicator } from './MutedMicIndicator'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
@@ -97,6 +97,10 @@ export const ParticipantTile: (
|
|||||||
participant: trackReference.participant,
|
participant: trackReference.participant,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { positionInQueue, firstInQueue } = useRaisedHandPosition({
|
||||||
|
participant: trackReference.participant,
|
||||||
|
})
|
||||||
|
|
||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -141,24 +145,32 @@ export const ParticipantTile: (
|
|||||||
style={{
|
style={{
|
||||||
padding: '0.1rem 0.25rem',
|
padding: '0.1rem 0.25rem',
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
isHandRaised && !isScreenShare ? 'white' : undefined,
|
isHandRaised && !isScreenShare
|
||||||
|
? firstInQueue
|
||||||
|
? '#fde047'
|
||||||
|
: 'white'
|
||||||
|
: undefined,
|
||||||
color:
|
color:
|
||||||
isHandRaised && !isScreenShare ? 'black' : undefined,
|
isHandRaised && !isScreenShare ? 'black' : undefined,
|
||||||
transition: 'background 200ms ease, color 400ms ease',
|
transition: 'background 200ms ease, color 400ms ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isHandRaised && !isScreenShare && (
|
{isHandRaised && !isScreenShare && (
|
||||||
<RiHand
|
<>
|
||||||
color="black"
|
<span>{positionInQueue}</span>
|
||||||
size={16}
|
<RiHand
|
||||||
style={{
|
color="black"
|
||||||
marginRight: '0.4rem',
|
size={16}
|
||||||
minWidth: '16px',
|
style={{
|
||||||
animationDuration: '300ms',
|
marginRight: '0.4rem',
|
||||||
animationName: 'wave_hand',
|
marginLeft: '0.1rem',
|
||||||
animationIterationCount: '2',
|
minWidth: '16px',
|
||||||
}}
|
animationDuration: '300ms',
|
||||||
/>
|
animationName: 'wave_hand',
|
||||||
|
animationIterationCount: '2',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{isScreenShare && (
|
{isScreenShare && (
|
||||||
<ScreenShareIcon
|
<ScreenShareIcon
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||||
|
import { Button, Popover } from '@/primitives'
|
||||||
|
import { RiArrowUpSLine } from '@remixicon/react'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
|
||||||
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||||
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
|
import Source = Track.Source
|
||||||
|
import * as React from 'react'
|
||||||
|
import { SelectDevice } from './SelectDevice'
|
||||||
|
import { SettingsButton } from './SettingsButton'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
|
||||||
|
type AudioDevicesControlProps = Omit<
|
||||||
|
UseTrackToggleProps<Source.Microphone>,
|
||||||
|
'source' | 'onChange'
|
||||||
|
> & {
|
||||||
|
hideMenu?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AudioDevicesControl = ({
|
||||||
|
hideMenu,
|
||||||
|
...props
|
||||||
|
}: AudioDevicesControlProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
|
const {
|
||||||
|
userChoices: { audioDeviceId, audioOutputDeviceId },
|
||||||
|
saveAudioInputDeviceId,
|
||||||
|
saveAudioInputEnabled,
|
||||||
|
saveAudioOutputDeviceId,
|
||||||
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
(enabled: boolean, isUserInitiated: boolean) =>
|
||||||
|
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
||||||
|
[saveAudioInputEnabled]
|
||||||
|
)
|
||||||
|
|
||||||
|
const trackProps = useTrackToggle({
|
||||||
|
source: Source.Microphone,
|
||||||
|
onChange,
|
||||||
|
...props,
|
||||||
|
})
|
||||||
|
|
||||||
|
const kind = 'audioinput'
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
const selectLabel = t(`settings.${SettingsDialogExtendedKey.AUDIO}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ToggleDevice
|
||||||
|
{...trackProps}
|
||||||
|
kind={kind}
|
||||||
|
toggle={trackProps.toggle as () => Promise<void>}
|
||||||
|
overrideToggleButtonProps={{
|
||||||
|
...(hideMenu
|
||||||
|
? {
|
||||||
|
groupPosition: undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!hideMenu && (
|
||||||
|
<Popover variant="dark" withArrow={false}>
|
||||||
|
<Button
|
||||||
|
tooltip={selectLabel}
|
||||||
|
aria-label={selectLabel}
|
||||||
|
groupPosition="right"
|
||||||
|
square
|
||||||
|
variant={
|
||||||
|
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RiArrowUpSLine />
|
||||||
|
</Button>
|
||||||
|
{({ close }) => (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxWidth: '36rem',
|
||||||
|
padding: '0.15rem',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectDevice
|
||||||
|
context="room"
|
||||||
|
kind={kind}
|
||||||
|
id={audioDeviceId}
|
||||||
|
onSubmit={saveAudioInputDeviceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectDevice
|
||||||
|
context="room"
|
||||||
|
kind="audiooutput"
|
||||||
|
id={audioOutputDeviceId}
|
||||||
|
onSubmit={saveAudioOutputDeviceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SettingsButton
|
||||||
|
settingTab={SettingsDialogExtendedKey.AUDIO}
|
||||||
|
onPress={close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+47
@@ -0,0 +1,47 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiErrorWarningFill } from '@remixicon/react'
|
||||||
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export const PermissionNeededButton = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 'auto',
|
||||||
|
left: '-.55rem',
|
||||||
|
top: '-.55rem',
|
||||||
|
zIndex: 1,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
aria-label={t('ariaLabel')}
|
||||||
|
tooltip={t('tooltip')}
|
||||||
|
onPress={openPermissionsDialog}
|
||||||
|
variant="permission"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<RiErrorWarningFill size={28} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
width: '18px',
|
||||||
|
height: '18px',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '4px',
|
||||||
|
left: '4px',
|
||||||
|
backgroundColor: 'black',
|
||||||
|
borderRadius: '100%',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useMediaDeviceSelect } from '@livekit/components-react'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import { Select, SelectProps } from '@/primitives/Select'
|
||||||
|
import { Placement } from '@react-types/overlays'
|
||||||
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
|
import { useDeviceIcons } from '@/features/rooms/livekit/hooks/useDeviceIcons'
|
||||||
|
|
||||||
|
type DeviceItems = Array<{ value: string; label: string }>
|
||||||
|
|
||||||
|
type SelectDeviceContext = {
|
||||||
|
variant?: 'light' | 'dark'
|
||||||
|
placement?: Placement
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectDeviceProps = {
|
||||||
|
id?: string
|
||||||
|
onSubmit?: (id: string) => void
|
||||||
|
kind: MediaDeviceKind
|
||||||
|
context?: 'join' | 'room'
|
||||||
|
}
|
||||||
|
|
||||||
|
type SelectDevicePermissionsProps<T> = SelectDeviceProps &
|
||||||
|
Pick<SelectProps<T>, 'placement' | 'variant' | 'iconComponent'>
|
||||||
|
|
||||||
|
const SelectDevicePermissions = <T extends string | number>({
|
||||||
|
id,
|
||||||
|
kind,
|
||||||
|
onSubmit,
|
||||||
|
iconComponent,
|
||||||
|
...props
|
||||||
|
}: SelectDevicePermissionsProps<T>) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
|
const { devices, activeDeviceId, setActiveMediaDevice } =
|
||||||
|
useMediaDeviceSelect({ kind: kind, requestPermissions: true })
|
||||||
|
|
||||||
|
const items: DeviceItems = devices
|
||||||
|
.filter((d) => !!d.deviceId)
|
||||||
|
.map((d) => ({
|
||||||
|
value: d.deviceId,
|
||||||
|
label: d.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FALLBACK AUDIO OUTPUT DEVICE SELECTION
|
||||||
|
* Auto-selects the only available audio output device when currently on 'default'
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
kind !== 'audiooutput' ||
|
||||||
|
items.length !== 1 ||
|
||||||
|
items[0].value === 'default' ||
|
||||||
|
activeDeviceId !== 'default'
|
||||||
|
)
|
||||||
|
return
|
||||||
|
onSubmit?.(items[0].value)
|
||||||
|
setActiveMediaDevice(items[0].value)
|
||||||
|
}, [items, onSubmit, kind, setActiveMediaDevice, activeDeviceId])
|
||||||
|
|
||||||
|
const selectedKey = id || activeDeviceId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={t(`${kind}.choose`)}
|
||||||
|
label=""
|
||||||
|
isDisabled={items.length === 0}
|
||||||
|
items={items}
|
||||||
|
iconComponent={iconComponent}
|
||||||
|
placeholder={items.length === 0 ? t('loading') : t('select')}
|
||||||
|
selectedKey={selectedKey}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
if (key === selectedKey) return
|
||||||
|
onSubmit?.(key as string)
|
||||||
|
setActiveMediaDevice(key as string)
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelectDevice = ({
|
||||||
|
id,
|
||||||
|
onSubmit,
|
||||||
|
kind,
|
||||||
|
context = 'join',
|
||||||
|
}: SelectDeviceProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
|
const contextProps = useMemo<SelectDeviceContext>(() => {
|
||||||
|
if (context == 'room') {
|
||||||
|
return { variant: 'dark', placement: 'top' }
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
}, [context])
|
||||||
|
|
||||||
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
|
||||||
|
if (cannotUseDevice) {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
aria-label={t(`${kind}.permissionsNeeded`)}
|
||||||
|
label=""
|
||||||
|
isDisabled={true}
|
||||||
|
items={[]}
|
||||||
|
placeholder={t('permissionsNeeded')}
|
||||||
|
iconComponent={deviceIcons.select}
|
||||||
|
{...contextProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SelectDevicePermissions
|
||||||
|
id={id}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
kind={kind}
|
||||||
|
iconComponent={deviceIcons.select}
|
||||||
|
{...contextProps}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiSettings3Line } from '@remixicon/react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
|
||||||
|
export const SettingsButton = ({
|
||||||
|
settingTab,
|
||||||
|
onPress,
|
||||||
|
}: {
|
||||||
|
settingTab: SettingsDialogExtendedKey
|
||||||
|
onPress?: () => void
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
square
|
||||||
|
tooltip={t(`settings.${settingTab}`)}
|
||||||
|
aria-label={t(`settings.${settingTab}`)}
|
||||||
|
variant="primaryDark"
|
||||||
|
onPress={() => {
|
||||||
|
openSettingsDialog(settingTab)
|
||||||
|
onPress?.()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiSettings3Line size={24} />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { ToggleButton } from '@/primitives'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { PermissionNeededButton } from './PermissionNeededButton'
|
||||||
|
import useLongPress from '@/features/shortcuts/useLongPress'
|
||||||
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
|
import {
|
||||||
|
useIsSpeaking,
|
||||||
|
useLocalParticipant,
|
||||||
|
useMaybeRoomContext,
|
||||||
|
} from '@livekit/components-react'
|
||||||
|
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
|
import { ToggleSource, CaptureOptionsBySource } from '@livekit/components-core'
|
||||||
|
|
||||||
|
type ToggleDeviceStyleProps = {
|
||||||
|
variant?: NonNullable<ButtonRecipeProps>['variant']
|
||||||
|
errorVariant?: NonNullable<ButtonRecipeProps>['variant']
|
||||||
|
toggleButtonProps?: Partial<ToggleButtonProps>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToggleDeviceProps<T extends ToggleSource> = {
|
||||||
|
enabled: boolean
|
||||||
|
toggle: (
|
||||||
|
forceState?: boolean,
|
||||||
|
captureOptions?: CaptureOptionsBySource<T>
|
||||||
|
) => Promise<void | boolean | undefined>
|
||||||
|
context?: 'room' | 'join'
|
||||||
|
kind: 'audioinput' | 'videoinput'
|
||||||
|
overrideToggleButtonProps?: Partial<ToggleButtonProps>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToggleDevice = <T extends ToggleSource>({
|
||||||
|
kind,
|
||||||
|
enabled,
|
||||||
|
toggle,
|
||||||
|
context = 'room',
|
||||||
|
overrideToggleButtonProps,
|
||||||
|
}: ToggleDeviceProps<T>) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
|
const {
|
||||||
|
variant,
|
||||||
|
errorVariant,
|
||||||
|
toggleButtonProps: computedToggleButtonProps,
|
||||||
|
} = useMemo<ToggleDeviceStyleProps>(() => {
|
||||||
|
if (context === 'join') {
|
||||||
|
return {
|
||||||
|
variant: 'whiteCircle',
|
||||||
|
errorVariant: 'errorCircle',
|
||||||
|
toggleButtonProps: {
|
||||||
|
groupPosition: undefined,
|
||||||
|
},
|
||||||
|
} as ToggleDeviceStyleProps
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
variant: 'primaryDark',
|
||||||
|
errorVariant: 'error2',
|
||||||
|
toggleButtonProps: {
|
||||||
|
groupPosition: 'left',
|
||||||
|
},
|
||||||
|
} as ToggleDeviceStyleProps
|
||||||
|
}, [context])
|
||||||
|
|
||||||
|
const [pushToTalk, setPushToTalk] = useState(false)
|
||||||
|
|
||||||
|
const onKeyDown = () => {
|
||||||
|
if (pushToTalk || enabled) return
|
||||||
|
toggle()
|
||||||
|
setPushToTalk(true)
|
||||||
|
}
|
||||||
|
const onKeyUp = () => {
|
||||||
|
if (!pushToTalk) return
|
||||||
|
toggle()
|
||||||
|
setPushToTalk(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
|
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
shortcut: deviceShortcut,
|
||||||
|
handler: async () => await toggle(),
|
||||||
|
isDisabled: cannotUseDevice,
|
||||||
|
})
|
||||||
|
useLongPress({
|
||||||
|
keyCode: kind === 'audioinput' ? 'Space' : undefined,
|
||||||
|
onKeyDown,
|
||||||
|
onKeyUp,
|
||||||
|
isDisabled: cannotUseDevice,
|
||||||
|
})
|
||||||
|
|
||||||
|
const toggleLabel = useMemo(() => {
|
||||||
|
const label = t(enabled ? 'disable' : 'enable', {
|
||||||
|
keyPrefix: `selectDevice.${kind}`,
|
||||||
|
})
|
||||||
|
return deviceShortcut ? appendShortcutLabel(label, deviceShortcut) : label
|
||||||
|
}, [enabled, kind, deviceShortcut, t])
|
||||||
|
|
||||||
|
const Icon =
|
||||||
|
enabled && !cannotUseDevice ? deviceIcons.toggleOn : deviceIcons.toggleOff
|
||||||
|
|
||||||
|
const roomContext = useMaybeRoomContext()
|
||||||
|
if (kind === 'audioinput' && pushToTalk && roomContext) {
|
||||||
|
return <ActiveSpeakerWrapper />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{cannotUseDevice && <PermissionNeededButton />}
|
||||||
|
<ToggleButton
|
||||||
|
isSelected={!enabled}
|
||||||
|
variant={enabled && !cannotUseDevice ? variant : errorVariant}
|
||||||
|
shySelected
|
||||||
|
onPress={() => (cannotUseDevice ? openPermissionsDialog() : toggle())}
|
||||||
|
aria-label={toggleLabel}
|
||||||
|
tooltip={
|
||||||
|
cannotUseDevice
|
||||||
|
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
|
: toggleLabel
|
||||||
|
}
|
||||||
|
{...computedToggleButtonProps}
|
||||||
|
{...overrideToggleButtonProps}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
</ToggleButton>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveSpeakerWrapper = () => {
|
||||||
|
const { localParticipant } = useLocalParticipant()
|
||||||
|
const isSpeaking = useIsSpeaking(localParticipant)
|
||||||
|
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
||||||
|
}
|
||||||
+164
@@ -0,0 +1,164 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useTrackToggle, UseTrackToggleProps } from '@livekit/components-react'
|
||||||
|
import { Button, Popover } from '@/primitives'
|
||||||
|
import { RiArrowUpSLine, RiImageCircleAiFill } from '@remixicon/react'
|
||||||
|
import { Track, VideoCaptureOptions } from 'livekit-client'
|
||||||
|
|
||||||
|
import { ToggleDevice } from './ToggleDevice'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { usePersistentUserChoices } from '../../../hooks/usePersistentUserChoices'
|
||||||
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
|
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||||
|
import { BackgroundProcessorFactory } from '../../blur'
|
||||||
|
import Source = Track.Source
|
||||||
|
import * as React from 'react'
|
||||||
|
import { SelectDevice } from './SelectDevice'
|
||||||
|
import { SettingsButton } from './SettingsButton'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
|
||||||
|
const EffectsButton = ({ onPress }: { onPress: () => void }) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
square
|
||||||
|
tooltip={t('effects')}
|
||||||
|
aria-label={t('effects')}
|
||||||
|
variant="primaryDark"
|
||||||
|
onPress={() => {
|
||||||
|
if (!isEffectsOpen) toggleEffects()
|
||||||
|
onPress()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RiImageCircleAiFill size={24} />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoDeviceControlProps = Omit<
|
||||||
|
UseTrackToggleProps<Source.Camera>,
|
||||||
|
'source' | 'onChange'
|
||||||
|
> & {
|
||||||
|
hideMenu?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VideoDeviceControl = ({
|
||||||
|
hideMenu,
|
||||||
|
...props
|
||||||
|
}: VideoDeviceControlProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'selectDevice' })
|
||||||
|
|
||||||
|
const { userChoices, saveVideoInputDeviceId, saveVideoInputEnabled } =
|
||||||
|
usePersistentUserChoices()
|
||||||
|
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
(enabled: boolean, isUserInitiated: boolean) =>
|
||||||
|
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
||||||
|
[saveVideoInputEnabled]
|
||||||
|
)
|
||||||
|
|
||||||
|
const trackProps = useTrackToggle({
|
||||||
|
source: Source.Camera,
|
||||||
|
onChange,
|
||||||
|
...props,
|
||||||
|
})
|
||||||
|
|
||||||
|
const kind = 'videoinput'
|
||||||
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
|
|
||||||
|
const toggleWithProcessor = async () => {
|
||||||
|
/**
|
||||||
|
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
||||||
|
* Before, we had the following bug:
|
||||||
|
* 1 - Configure a processor on join screen
|
||||||
|
* 2 - Turn off camera on join screen
|
||||||
|
* 3 - Join the room
|
||||||
|
* 4 - Turn on the camera
|
||||||
|
* 5 - No processor is applied to the camera
|
||||||
|
* Expected: The processor is applied.
|
||||||
|
*
|
||||||
|
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||||
|
*/
|
||||||
|
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||||
|
userChoices.processorSerialized
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggle = trackProps.toggle as (
|
||||||
|
forceState: boolean,
|
||||||
|
captureOptions: VideoCaptureOptions
|
||||||
|
) => Promise<void>
|
||||||
|
|
||||||
|
await toggle(!trackProps.enabled, {
|
||||||
|
processor: processor,
|
||||||
|
} as VideoCaptureOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectLabel = t(`settings.${SettingsDialogExtendedKey.VIDEO}`)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ToggleDevice
|
||||||
|
{...trackProps}
|
||||||
|
kind={kind}
|
||||||
|
toggle={toggleWithProcessor}
|
||||||
|
overrideToggleButtonProps={{
|
||||||
|
...(hideMenu
|
||||||
|
? {
|
||||||
|
groupPosition: undefined,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!hideMenu && (
|
||||||
|
<Popover variant="dark" withArrow={false}>
|
||||||
|
<Button
|
||||||
|
tooltip={selectLabel}
|
||||||
|
aria-label={selectLabel}
|
||||||
|
groupPosition="right"
|
||||||
|
square
|
||||||
|
variant={
|
||||||
|
trackProps.enabled && !cannotUseDevice ? 'primaryDark' : 'error2'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<RiArrowUpSLine />
|
||||||
|
</Button>
|
||||||
|
{({ close }) => (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
maxWidth: '36rem',
|
||||||
|
padding: '0.15rem',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 0',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectDevice
|
||||||
|
context="room"
|
||||||
|
kind={kind}
|
||||||
|
id={userChoices.videoDeviceId}
|
||||||
|
onSubmit={saveVideoInputDeviceId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<EffectsButton onPress={close} />
|
||||||
|
<SettingsButton
|
||||||
|
settingTab={SettingsDialogExtendedKey.VIDEO}
|
||||||
|
onPress={close}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+3
-3
@@ -2,16 +2,16 @@ import { RiSettings3Line } from '@remixicon/react'
|
|||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { useSettingsDialog } from '../SettingsDialogContext'
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
|
||||||
export const SettingsMenuItem = () => {
|
export const SettingsMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const { setDialogOpen } = useSettingsDialog()
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
onAction={() => setDialogOpen(true)}
|
onAction={() => openSettingsDialog()}
|
||||||
>
|
>
|
||||||
<RiSettings3Line size={20} />
|
<RiSettings3Line size={20} />
|
||||||
{t('settings')}
|
{t('settings')}
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import { createInteractingObservable } from '@livekit/components-core'
|
|
||||||
import { usePagination } from '@livekit/components-react'
|
|
||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
|
||||||
|
|
||||||
export interface PaginationControlProps
|
|
||||||
extends Pick<
|
|
||||||
ReturnType<typeof usePagination>,
|
|
||||||
'totalPageCount' | 'nextPage' | 'prevPage' | 'currentPage'
|
|
||||||
> {
|
|
||||||
/** Reference to an HTML element that holds the pages, while interacting (`mouseover`)
|
|
||||||
* with it, the pagination controls will appear for a while. */
|
|
||||||
pagesContainer?: React.RefObject<HTMLElement>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PaginationControl({
|
|
||||||
totalPageCount,
|
|
||||||
nextPage,
|
|
||||||
prevPage,
|
|
||||||
currentPage,
|
|
||||||
pagesContainer: connectedElement,
|
|
||||||
}: PaginationControlProps) {
|
|
||||||
const [interactive, setInteractive] = React.useState(false)
|
|
||||||
React.useEffect(() => {
|
|
||||||
let subscription:
|
|
||||||
| ReturnType<ReturnType<typeof createInteractingObservable>['subscribe']>
|
|
||||||
| undefined
|
|
||||||
if (connectedElement) {
|
|
||||||
subscription = createInteractingObservable(
|
|
||||||
connectedElement.current,
|
|
||||||
2000
|
|
||||||
).subscribe(setInteractive)
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
if (subscription) {
|
|
||||||
subscription.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [connectedElement])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="lk-pagination-control"
|
|
||||||
data-lk-user-interaction={interactive}
|
|
||||||
>
|
|
||||||
<button className="lk-button" onClick={prevPage}>
|
|
||||||
<RiArrowLeftSLine />
|
|
||||||
</button>
|
|
||||||
<span className="lk-pagination-count">{`${currentPage} of ${totalPageCount}`}</span>
|
|
||||||
<button className="lk-button" onClick={nextPage}>
|
|
||||||
<RiArrowRightSLine />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
|
|
||||||
export interface PaginationIndicatorProps {
|
|
||||||
totalPageCount: number
|
|
||||||
currentPage: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PaginationIndicator: (
|
|
||||||
props: PaginationIndicatorProps & React.RefAttributes<HTMLDivElement>
|
|
||||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
PaginationIndicatorProps
|
|
||||||
>(function PaginationIndicator(
|
|
||||||
{ totalPageCount, currentPage }: PaginationIndicatorProps,
|
|
||||||
ref
|
|
||||||
) {
|
|
||||||
const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
|
|
||||||
if (index + 1 === currentPage) {
|
|
||||||
return <span data-lk-active key={index} />
|
|
||||||
} else {
|
|
||||||
return <span key={index} />
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={ref} className="lk-pagination-indicator">
|
|
||||||
{bubbles}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
+9
-5
@@ -11,7 +11,6 @@ import { WaitingParticipantListItem } from './WaitingParticipantListItem'
|
|||||||
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
import { useWaitingParticipants } from '@/features/rooms/hooks/useWaitingParticipants'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
import { WaitingParticipant } from '@/features/rooms/api/listWaitingParticipants'
|
||||||
import { safeParseMetadata } from '@/features/rooms/utils/safeParseMetadata'
|
|
||||||
|
|
||||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||||
export const ParticipantsList = () => {
|
export const ParticipantsList = () => {
|
||||||
@@ -35,10 +34,15 @@ export const ParticipantsList = () => {
|
|||||||
...sortedRemoteParticipants,
|
...sortedRemoteParticipants,
|
||||||
]
|
]
|
||||||
|
|
||||||
const raisedHandParticipants = participants.filter((participant) => {
|
const raisedHandParticipants = participants
|
||||||
const data = safeParseMetadata(participant.metadata)
|
.filter((participant) => !!participant.attributes.handRaisedAt)
|
||||||
return data.raised
|
.sort((a, b) => {
|
||||||
})
|
const dateA = new Date(a.attributes.handRaisedAt)
|
||||||
|
const dateB = new Date(b.attributes.handRaisedAt)
|
||||||
|
const timeA = isNaN(dateA.getTime()) ? 0 : dateA.getTime()
|
||||||
|
const timeB = isNaN(dateB.getTime()) ? 0 : dateB.getTime()
|
||||||
|
return timeA - timeB
|
||||||
|
})
|
||||||
|
|
||||||
const { waitingParticipants, handleParticipantEntry } =
|
const { waitingParticipants, handleParticipantEntry } =
|
||||||
useWaitingParticipants()
|
useWaitingParticipants()
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import {
|
|
||||||
useMediaDeviceSelect,
|
|
||||||
useTrackToggle,
|
|
||||||
UseTrackToggleProps,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { Button, Menu, MenuList } from '@/primitives'
|
|
||||||
import {
|
|
||||||
RemixiconComponentType,
|
|
||||||
RiArrowDownSLine,
|
|
||||||
RiMicLine,
|
|
||||||
RiMicOffLine,
|
|
||||||
RiVideoOffLine,
|
|
||||||
RiVideoOnLine,
|
|
||||||
} from '@remixicon/react'
|
|
||||||
import {
|
|
||||||
LocalAudioTrack,
|
|
||||||
LocalVideoTrack,
|
|
||||||
Track,
|
|
||||||
VideoCaptureOptions,
|
|
||||||
} from 'livekit-client'
|
|
||||||
|
|
||||||
import { Shortcut } from '@/features/shortcuts/types'
|
|
||||||
|
|
||||||
import { ToggleDevice } from '@/features/rooms/livekit/components/controls/ToggleDevice.tsx'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
|
||||||
import { BackgroundProcessorFactory } from '../blur'
|
|
||||||
|
|
||||||
export type ToggleSource = Exclude<
|
|
||||||
Track.Source,
|
|
||||||
Track.Source.ScreenShareAudio | Track.Source.Unknown
|
|
||||||
>
|
|
||||||
|
|
||||||
type SelectToggleSource = Exclude<ToggleSource, Track.Source.ScreenShare>
|
|
||||||
|
|
||||||
export type SelectToggleDeviceConfig = {
|
|
||||||
kind: MediaDeviceKind
|
|
||||||
iconOn: RemixiconComponentType
|
|
||||||
iconOff: RemixiconComponentType
|
|
||||||
shortcut?: Shortcut
|
|
||||||
longPress?: Shortcut
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectToggleDeviceConfigMap = {
|
|
||||||
[key in SelectToggleSource]: SelectToggleDeviceConfig
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectToggleDeviceConfig: SelectToggleDeviceConfigMap = {
|
|
||||||
[Track.Source.Microphone]: {
|
|
||||||
kind: 'audioinput',
|
|
||||||
iconOn: RiMicLine,
|
|
||||||
iconOff: RiMicOffLine,
|
|
||||||
shortcut: {
|
|
||||||
key: 'd',
|
|
||||||
ctrlKey: true,
|
|
||||||
},
|
|
||||||
longPress: {
|
|
||||||
key: 'Space',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
[Track.Source.Camera]: {
|
|
||||||
kind: 'videoinput',
|
|
||||||
iconOn: RiVideoOnLine,
|
|
||||||
iconOff: RiVideoOffLine,
|
|
||||||
shortcut: {
|
|
||||||
key: 'e',
|
|
||||||
ctrlKey: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type SelectToggleDeviceProps<T extends ToggleSource> =
|
|
||||||
UseTrackToggleProps<T> & {
|
|
||||||
track?: LocalAudioTrack | LocalVideoTrack | undefined
|
|
||||||
initialDeviceId?: string
|
|
||||||
onActiveDeviceChange: (deviceId: string) => void
|
|
||||||
source: SelectToggleSource
|
|
||||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
|
||||||
menuVariant?: 'dark' | 'light'
|
|
||||||
hideMenu?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SelectToggleDevice = <T extends ToggleSource>({
|
|
||||||
track,
|
|
||||||
initialDeviceId,
|
|
||||||
onActiveDeviceChange,
|
|
||||||
hideMenu,
|
|
||||||
variant = 'primaryDark',
|
|
||||||
menuVariant = 'light',
|
|
||||||
...props
|
|
||||||
}: SelectToggleDeviceProps<T>) => {
|
|
||||||
const config = selectToggleDeviceConfig[props.source]
|
|
||||||
if (!config) {
|
|
||||||
throw new Error('Invalid source')
|
|
||||||
}
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
|
||||||
const trackProps = useTrackToggle(props)
|
|
||||||
|
|
||||||
const { userChoices } = usePersistentUserChoices()
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
if (props.source === Track.Source.Camera) {
|
|
||||||
/**
|
|
||||||
* We need to make sure that we apply the in-memory processor when re-enabling the camera.
|
|
||||||
* Before, we had the following bug:
|
|
||||||
* 1 - Configure a processor on join screen
|
|
||||||
* 2 - Turn off camera on join screen
|
|
||||||
* 3 - Join the room
|
|
||||||
* 4 - Turn on the camera
|
|
||||||
* 5 - No processor is applied to the camera
|
|
||||||
* Expected: The processor is applied.
|
|
||||||
*
|
|
||||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
|
||||||
*/
|
|
||||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
|
||||||
userChoices.processorSerialized
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggle = trackProps.toggle as (
|
|
||||||
forceState: boolean,
|
|
||||||
captureOptions: VideoCaptureOptions
|
|
||||||
) => Promise<void>
|
|
||||||
|
|
||||||
toggle(!trackProps.enabled, {
|
|
||||||
processor: processor,
|
|
||||||
} as VideoCaptureOptions)
|
|
||||||
} else {
|
|
||||||
trackProps.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const { devices, activeDeviceId, setActiveMediaDevice } =
|
|
||||||
useMediaDeviceSelect({ kind: config.kind, track })
|
|
||||||
|
|
||||||
/**
|
|
||||||
* When providing only track outside of a room context, activeDeviceId is undefined.
|
|
||||||
* So we need to initialize it with the initialDeviceId.
|
|
||||||
* nb: I don't understand why useMediaDeviceSelect cannot infer it from track device id.
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialDeviceId !== undefined) {
|
|
||||||
setActiveMediaDevice(initialDeviceId)
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [setActiveMediaDevice])
|
|
||||||
|
|
||||||
const selectLabel = t('choose', { keyPrefix: `join.${config.kind}` })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '1px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<ToggleDevice
|
|
||||||
{...trackProps}
|
|
||||||
config={config}
|
|
||||||
variant={variant}
|
|
||||||
toggle={toggle}
|
|
||||||
toggleButtonProps={{
|
|
||||||
...(hideMenu
|
|
||||||
? {
|
|
||||||
groupPosition: undefined,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!hideMenu && (
|
|
||||||
<Menu variant={menuVariant}>
|
|
||||||
<Button
|
|
||||||
tooltip={selectLabel}
|
|
||||||
aria-label={selectLabel}
|
|
||||||
groupPosition="right"
|
|
||||||
square
|
|
||||||
variant={trackProps.enabled ? variant : 'error2'}
|
|
||||||
>
|
|
||||||
<RiArrowDownSLine />
|
|
||||||
</Button>
|
|
||||||
<MenuList
|
|
||||||
items={devices.map((d) => ({
|
|
||||||
value: d.deviceId,
|
|
||||||
label: d.label,
|
|
||||||
}))}
|
|
||||||
selectedItem={activeDeviceId}
|
|
||||||
onAction={(value) => {
|
|
||||||
setActiveMediaDevice(value as string)
|
|
||||||
onActiveDeviceChange(value as string)
|
|
||||||
}}
|
|
||||||
variant={menuVariant}
|
|
||||||
/>
|
|
||||||
</Menu>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { SettingsDialogExtended } from '@/features/settings/components/SettingsDialogExtended'
|
|
||||||
import React, { createContext, useContext, useState } from 'react'
|
|
||||||
|
|
||||||
const SettingsDialogContext = createContext<
|
|
||||||
| {
|
|
||||||
dialogOpen: boolean
|
|
||||||
setDialogOpen: React.Dispatch<React.SetStateAction<boolean>>
|
|
||||||
}
|
|
||||||
| undefined
|
|
||||||
>(undefined)
|
|
||||||
|
|
||||||
export const SettingsDialogProvider: React.FC<{
|
|
||||||
children: React.ReactNode
|
|
||||||
}> = ({ children }) => {
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
|
||||||
return (
|
|
||||||
<SettingsDialogContext.Provider value={{ dialogOpen, setDialogOpen }}>
|
|
||||||
{children}
|
|
||||||
<SettingsDialogExtended
|
|
||||||
isOpen={dialogOpen}
|
|
||||||
onOpenChange={(v) => !v && setDialogOpen(false)}
|
|
||||||
/>
|
|
||||||
</SettingsDialogContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-refresh/only-export-components
|
|
||||||
export const useSettingsDialog = () => {
|
|
||||||
const context = useContext(SettingsDialogContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error(
|
|
||||||
'useSettingsDialog must be used within a SettingsDialogProvider'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { ToggleButton } from '@/primitives'
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { appendShortcutLabel } from '@/features/shortcuts/utils'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { SelectToggleDeviceConfig } from './SelectToggleDevice'
|
|
||||||
import useLongPress from '@/features/shortcuts/useLongPress'
|
|
||||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
|
||||||
import {
|
|
||||||
useIsSpeaking,
|
|
||||||
useLocalParticipant,
|
|
||||||
useMaybeRoomContext,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
|
||||||
|
|
||||||
export type ToggleDeviceProps = {
|
|
||||||
enabled: boolean
|
|
||||||
toggle: () => void
|
|
||||||
config: SelectToggleDeviceConfig
|
|
||||||
variant?: NonNullable<ButtonRecipeProps>['variant']
|
|
||||||
toggleButtonProps?: Partial<ToggleButtonProps>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ToggleDevice = ({
|
|
||||||
config,
|
|
||||||
enabled,
|
|
||||||
toggle,
|
|
||||||
variant = 'primaryDark',
|
|
||||||
toggleButtonProps,
|
|
||||||
}: ToggleDeviceProps) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
|
||||||
|
|
||||||
const { kind, shortcut, iconOn, iconOff, longPress } = config
|
|
||||||
|
|
||||||
const [pushToTalk, setPushToTalk] = useState(false)
|
|
||||||
|
|
||||||
const onKeyDown = () => {
|
|
||||||
if (pushToTalk || enabled) return
|
|
||||||
toggle()
|
|
||||||
setPushToTalk(true)
|
|
||||||
}
|
|
||||||
const onKeyUp = () => {
|
|
||||||
if (!pushToTalk) return
|
|
||||||
toggle()
|
|
||||||
setPushToTalk(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({ shortcut, handler: toggle })
|
|
||||||
useLongPress({ keyCode: longPress?.key, onKeyDown, onKeyUp })
|
|
||||||
|
|
||||||
const toggleLabel = useMemo(() => {
|
|
||||||
const label = t(enabled ? 'disable' : 'enable', {
|
|
||||||
keyPrefix: `join.${kind}`,
|
|
||||||
})
|
|
||||||
return shortcut ? appendShortcutLabel(label, shortcut) : label
|
|
||||||
}, [enabled, kind, shortcut, t])
|
|
||||||
|
|
||||||
const Icon = enabled ? iconOn : iconOff
|
|
||||||
|
|
||||||
const context = useMaybeRoomContext()
|
|
||||||
if (kind === 'audioinput' && pushToTalk && context) {
|
|
||||||
return <ActiveSpeakerWrapper />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ToggleButton
|
|
||||||
isSelected={!enabled}
|
|
||||||
variant={enabled ? variant : 'error2'}
|
|
||||||
shySelected
|
|
||||||
onPress={() => toggle()}
|
|
||||||
aria-label={toggleLabel}
|
|
||||||
tooltip={toggleLabel}
|
|
||||||
groupPosition="left"
|
|
||||||
{...toggleButtonProps}
|
|
||||||
>
|
|
||||||
<Icon />
|
|
||||||
</ToggleButton>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const ActiveSpeakerWrapper = () => {
|
|
||||||
const { localParticipant } = useLocalParticipant()
|
|
||||||
const isSpeaking = useIsSpeaking(localParticipant)
|
|
||||||
return <ActiveSpeaker isSpeaking={isSpeaking} pushToTalk />
|
|
||||||
}
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import { getScrollBarWidth } from '@livekit/components-core'
|
|
||||||
import * as React from 'react'
|
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
|
||||||
import useSize from '@react-hook/size'
|
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
|
||||||
const MIN_WIDTH = 140
|
|
||||||
const MIN_VISIBLE_TILES = 1
|
|
||||||
const ASPECT_RATIO = 16 / 10
|
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export interface CarouselLayoutProps
|
|
||||||
extends React.HTMLAttributes<HTMLMediaElement> {
|
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
|
||||||
children: React.ReactNode
|
|
||||||
/** Place the tiles vertically or horizontally next to each other.
|
|
||||||
* If undefined orientation is guessed by the dimensions of the container. */
|
|
||||||
orientation?: 'vertical' | 'horizontal'
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `CarouselLayout` component displays a list of tracks in a scroll container.
|
|
||||||
* It will display as many tiles as possible and overflow the rest.
|
|
||||||
* @remarks
|
|
||||||
* To ensure visual stability when tiles are reordered due to track updates,
|
|
||||||
* the component uses the `useVisualStableUpdate` hook.
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const tracks = useTracks([Track.Source.Camera]);
|
|
||||||
* <CarouselLayout tracks={tracks}>
|
|
||||||
* <ParticipantTile />
|
|
||||||
* </CarouselLayout>
|
|
||||||
* ```
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export function CarouselLayout({
|
|
||||||
tracks,
|
|
||||||
orientation,
|
|
||||||
...props
|
|
||||||
}: CarouselLayoutProps) {
|
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
|
||||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
|
||||||
const [width, height] = useSize(asideEl)
|
|
||||||
const carouselOrientation = orientation
|
|
||||||
? orientation
|
|
||||||
: height >= width
|
|
||||||
? 'vertical'
|
|
||||||
: 'horizontal'
|
|
||||||
|
|
||||||
const tileSpan =
|
|
||||||
carouselOrientation === 'vertical'
|
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
|
||||||
carouselOrientation === 'vertical'
|
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
|
||||||
|
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
|
||||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
|
||||||
maxVisibleTiles = Math.round(prevTiles)
|
|
||||||
} else if (prevTiles !== tilesThatFit) {
|
|
||||||
setPrevTiles(tilesThatFit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (asideEl.current) {
|
|
||||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
|
||||||
asideEl.current.style.setProperty(
|
|
||||||
'--lk-max-visible-tiles',
|
|
||||||
maxVisibleTiles.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [maxVisibleTiles, carouselOrientation])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<aside
|
|
||||||
key={carouselOrientation}
|
|
||||||
className="lk-carousel"
|
|
||||||
ref={asideEl}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
|
||||||
</aside>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import * as React from 'react'
|
|
||||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
|
||||||
import {
|
|
||||||
TrackLoop,
|
|
||||||
usePagination,
|
|
||||||
UseParticipantsOptions,
|
|
||||||
useSwipe,
|
|
||||||
} from '@livekit/components-react'
|
|
||||||
import { mergeProps } from '@/utils/mergeProps'
|
|
||||||
import { PaginationIndicator } from '../controls/PaginationIndicator'
|
|
||||||
import { useGridLayout } from '../../hooks/useGridLayout'
|
|
||||||
import { PaginationControl } from '../controls/PaginationControl'
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export interface GridLayoutProps
|
|
||||||
extends React.HTMLAttributes<HTMLDivElement>,
|
|
||||||
Pick<UseParticipantsOptions, 'updateOnlyOn'> {
|
|
||||||
children: React.ReactNode
|
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `GridLayout` component displays the nested participants in a grid where every participants has the same size.
|
|
||||||
* It also supports pagination if there are more participants than the grid can display.
|
|
||||||
* @remarks
|
|
||||||
* To ensure visual stability when tiles are reordered due to track updates,
|
|
||||||
* the component uses the `useVisualStableUpdate` hook.
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <LiveKitRoom>
|
|
||||||
* <GridLayout tracks={tracks}>
|
|
||||||
* <ParticipantTile />
|
|
||||||
* </GridLayout>
|
|
||||||
* <LiveKitRoom>
|
|
||||||
* ```
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
|
||||||
[props]
|
|
||||||
)
|
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
|
||||||
onLeftSwipe: pagination.nextPage,
|
|
||||||
onRightSwipe: pagination.prevPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={gridEl}
|
|
||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
|
||||||
{...elementProps}
|
|
||||||
>
|
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
|
||||||
{tracks.length > layout.maxTiles && (
|
|
||||||
<>
|
|
||||||
<PaginationIndicator
|
|
||||||
totalPageCount={pagination.totalPageCount}
|
|
||||||
currentPage={pagination.currentPage}
|
|
||||||
/>
|
|
||||||
<PaginationControl pagesContainer={gridEl} {...pagination} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { permissionsStore } from '@/stores/permissions'
|
||||||
|
|
||||||
|
export const useCannotUseDevice = (kind: MediaDeviceKind) => {
|
||||||
|
const {
|
||||||
|
isLoading,
|
||||||
|
isMicrophoneDenied,
|
||||||
|
isMicrophonePrompted,
|
||||||
|
isCameraDenied,
|
||||||
|
isCameraPrompted,
|
||||||
|
} = useSnapshot(permissionsStore)
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (isLoading) return true
|
||||||
|
|
||||||
|
switch (kind) {
|
||||||
|
case 'audioinput':
|
||||||
|
case 'audiooutput': // audiooutput uses microphone permissions
|
||||||
|
return isMicrophoneDenied || isMicrophonePrompted
|
||||||
|
case 'videoinput':
|
||||||
|
return isCameraDenied || isCameraPrompted
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
kind,
|
||||||
|
isLoading,
|
||||||
|
isMicrophoneDenied,
|
||||||
|
isMicrophonePrompted,
|
||||||
|
isCameraDenied,
|
||||||
|
isCameraPrompted,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { useTelephony } from './useTelephony'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { formatPinCode } from '@/features/rooms/utils/telephony'
|
||||||
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { getRouteUrl } from '@/navigation/getRouteUrl'
|
||||||
|
|
||||||
|
const COPY_SUCCESS_TIMEOUT = 3000
|
||||||
|
|
||||||
|
export const useCopyRoomToClipboard = (room: ApiRoom | undefined) => {
|
||||||
|
const telephony = useTelephony()
|
||||||
|
const { t } = useTranslation('global', { keyPrefix: 'clipboardContent' })
|
||||||
|
|
||||||
|
const [isCopied, setIsCopied] = useState(false)
|
||||||
|
const [isRoomUrlCopied, setIsRoomUrlCopied] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCopied) {
|
||||||
|
const timeout = setTimeout(() => setIsCopied(false), COPY_SUCCESS_TIMEOUT)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [isCopied])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isRoomUrlCopied) {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => setIsRoomUrlCopied(false),
|
||||||
|
COPY_SUCCESS_TIMEOUT
|
||||||
|
)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [isRoomUrlCopied])
|
||||||
|
|
||||||
|
const roomUrl = useMemo(() => {
|
||||||
|
return room?.slug ? getRouteUrl('room', room.slug) : ''
|
||||||
|
}, [room?.slug])
|
||||||
|
|
||||||
|
const hasTelephonyInfo = useMemo(() => {
|
||||||
|
return telephony.enabled && room?.pin_code
|
||||||
|
}, [telephony.enabled, room?.pin_code])
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (!roomUrl || !room) return ''
|
||||||
|
if (!hasTelephonyInfo) return roomUrl
|
||||||
|
|
||||||
|
return [
|
||||||
|
t('url', { roomUrl }),
|
||||||
|
t('numberAndPin', {
|
||||||
|
phoneNumber: telephony?.internationalPhoneNumber,
|
||||||
|
pinCode: formatPinCode(room.pin_code),
|
||||||
|
}),
|
||||||
|
].join('\n')
|
||||||
|
}, [roomUrl, hasTelephonyInfo, telephony, room, t])
|
||||||
|
|
||||||
|
const copyRoomToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content)
|
||||||
|
setIsCopied(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyRoomUrlToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(roomUrl)
|
||||||
|
setIsRoomUrlCopied(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCopied,
|
||||||
|
copyRoomToClipboard,
|
||||||
|
isRoomUrlCopied,
|
||||||
|
copyRoomUrlToClipboard,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
RemixiconComponentType,
|
||||||
|
RiMicLine,
|
||||||
|
RiMicOffLine,
|
||||||
|
RiVideoOffLine,
|
||||||
|
RiVideoOnLine,
|
||||||
|
RiVolumeDownLine,
|
||||||
|
RiVolumeMuteLine,
|
||||||
|
} from '@remixicon/react'
|
||||||
|
|
||||||
|
export interface DeviceIcons {
|
||||||
|
toggleOn: RemixiconComponentType
|
||||||
|
toggleOff: RemixiconComponentType
|
||||||
|
select: RemixiconComponentType
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<MediaDeviceKind | 'default', DeviceIcons> = {
|
||||||
|
audioinput: {
|
||||||
|
toggleOn: RiMicLine,
|
||||||
|
toggleOff: RiMicOffLine,
|
||||||
|
select: RiMicLine,
|
||||||
|
},
|
||||||
|
videoinput: {
|
||||||
|
toggleOn: RiVideoOnLine,
|
||||||
|
toggleOff: RiVideoOffLine,
|
||||||
|
select: RiVideoOnLine,
|
||||||
|
},
|
||||||
|
audiooutput: {
|
||||||
|
toggleOn: RiVolumeDownLine,
|
||||||
|
toggleOff: RiVolumeMuteLine,
|
||||||
|
select: RiVolumeDownLine,
|
||||||
|
},
|
||||||
|
default: { toggleOn: RiMicLine, toggleOff: RiMicOffLine, select: RiMicLine },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useDeviceIcons = (kind: MediaDeviceKind): DeviceIcons =>
|
||||||
|
ICONS[kind] ?? ICONS.default
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { Shortcut } from '@/features/shortcuts/types'
|
||||||
|
|
||||||
|
export const useDeviceShortcut = (kind: MediaDeviceKind) => {
|
||||||
|
return useMemo<Shortcut | undefined>(() => {
|
||||||
|
switch (kind) {
|
||||||
|
case 'audioinput':
|
||||||
|
return {
|
||||||
|
key: 'e',
|
||||||
|
ctrlKey: true,
|
||||||
|
}
|
||||||
|
case 'videoinput':
|
||||||
|
return {
|
||||||
|
key: 'd',
|
||||||
|
ctrlKey: true,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}, [kind])
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { GRID_LAYOUTS, selectGridLayout } from '@livekit/components-core'
|
|
||||||
import type {
|
|
||||||
GridLayoutDefinition,
|
|
||||||
GridLayoutInfo,
|
|
||||||
} from '@livekit/components-core'
|
|
||||||
import * as React from 'react'
|
|
||||||
import useSize from '@react-hook/size'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The `useGridLayout` hook tries to select the best layout to fit all tiles.
|
|
||||||
* If the available screen space is not enough, it will reduce the number of maximum visible
|
|
||||||
* tiles and select a layout that still works visually within the given limitations.
|
|
||||||
* As the order of tiles changes over time, the hook tries to keep visual updates to a minimum
|
|
||||||
* while trying to display important tiles such as speaking participants or screen shares.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { layout } = useGridLayout(gridElement, trackCount);
|
|
||||||
* ```
|
|
||||||
* @public
|
|
||||||
*/
|
|
||||||
export function useGridLayout(
|
|
||||||
/** HTML element that contains the grid. */
|
|
||||||
gridElement: React.RefObject<HTMLDivElement>,
|
|
||||||
/** Count of tracks that should get layed out */
|
|
||||||
trackCount: number,
|
|
||||||
options: {
|
|
||||||
gridLayouts?: GridLayoutDefinition[]
|
|
||||||
} = {}
|
|
||||||
): { layout: GridLayoutInfo; containerWidth: number; containerHeight: number } {
|
|
||||||
const gridLayouts = options.gridLayouts ?? GRID_LAYOUTS
|
|
||||||
const [width, height] = useSize(gridElement)
|
|
||||||
const layout = selectGridLayout(gridLayouts, trackCount, width, height)
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (gridElement.current && layout) {
|
|
||||||
gridElement.current.style.setProperty(
|
|
||||||
'--lk-col-count',
|
|
||||||
layout?.columns.toString()
|
|
||||||
)
|
|
||||||
gridElement.current.style.setProperty(
|
|
||||||
'--lk-row-count',
|
|
||||||
layout?.rows.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [gridElement, layout])
|
|
||||||
|
|
||||||
return {
|
|
||||||
layout,
|
|
||||||
containerWidth: width,
|
|
||||||
containerHeight: height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalytics
|
|||||||
import { isMobileBrowser } from '@livekit/components-core'
|
import { isMobileBrowser } from '@livekit/components-core'
|
||||||
|
|
||||||
export const useNoiseReductionAvailable = () => {
|
export const useNoiseReductionAvailable = () => {
|
||||||
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.faceLandmarks)
|
const featureEnabled = useFeatureFlagEnabled(FeatureFlags.noiseReduction)
|
||||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||||
|
|
||||||
const isMobile = isMobileBrowser()
|
const isMobile = isMobileBrowser()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
import { userChoicesStore } from '@/stores/userChoices'
|
||||||
|
import type { VideoResolution } from '@/stores/userChoices'
|
||||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
||||||
|
import type { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
export function usePersistentUserChoices() {
|
export function usePersistentUserChoices() {
|
||||||
const userChoicesSnap = useSnapshot(userChoicesStore)
|
const userChoicesSnap = useSnapshot(userChoicesStore)
|
||||||
@@ -16,9 +18,18 @@ export function usePersistentUserChoices() {
|
|||||||
saveAudioInputDeviceId: (deviceId: string) => {
|
saveAudioInputDeviceId: (deviceId: string) => {
|
||||||
userChoicesStore.audioDeviceId = deviceId
|
userChoicesStore.audioDeviceId = deviceId
|
||||||
},
|
},
|
||||||
|
saveAudioOutputDeviceId: (deviceId: string) => {
|
||||||
|
userChoicesStore.audioOutputDeviceId = deviceId
|
||||||
|
},
|
||||||
saveVideoInputDeviceId: (deviceId: string) => {
|
saveVideoInputDeviceId: (deviceId: string) => {
|
||||||
userChoicesStore.videoDeviceId = deviceId
|
userChoicesStore.videoDeviceId = deviceId
|
||||||
},
|
},
|
||||||
|
saveVideoPublishResolution: (resolution: VideoResolution) => {
|
||||||
|
userChoicesStore.videoPublishResolution = resolution
|
||||||
|
},
|
||||||
|
saveVideoSubscribeQuality: (quality: VideoQuality) => {
|
||||||
|
userChoicesStore.videoSubscribeQuality = quality
|
||||||
|
},
|
||||||
saveUsername: (username: string) => {
|
saveUsername: (username: string) => {
|
||||||
userChoicesStore.username = username
|
userChoicesStore.username = username
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,23 +1,58 @@
|
|||||||
import { LocalParticipant, Participant } from 'livekit-client'
|
import { LocalParticipant, Participant } from 'livekit-client'
|
||||||
import { useParticipantInfo } from '@livekit/components-react'
|
import {
|
||||||
|
useParticipantAttribute,
|
||||||
|
useParticipants,
|
||||||
|
} from '@livekit/components-react'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
type useRaisedHandProps = {
|
type useRaisedHandProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
export function useRaisedHandPosition({ participant }: useRaisedHandProps) {
|
||||||
// fixme - refactor this part to rely on attributes
|
const { isHandRaised } = useRaisedHand({ participant })
|
||||||
const { metadata } = useParticipantInfo({ participant })
|
|
||||||
const parsedMetadata = JSON.parse(metadata || '{}')
|
|
||||||
|
|
||||||
const toggleRaisedHand = () => {
|
const participants = useParticipants()
|
||||||
if (isLocal(participant)) {
|
|
||||||
parsedMetadata.raised = !parsedMetadata.raised
|
const positionInQueue = useMemo(() => {
|
||||||
const localParticipant = participant as LocalParticipant
|
if (!isHandRaised) return
|
||||||
localParticipant.setMetadata(JSON.stringify(parsedMetadata))
|
|
||||||
|
return (
|
||||||
|
participants
|
||||||
|
.filter((p) => !!p.attributes.handRaisedAt)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = new Date(a.attributes.handRaisedAt)
|
||||||
|
const dateB = new Date(b.attributes.handRaisedAt)
|
||||||
|
return dateA.getTime() - dateB.getTime()
|
||||||
|
})
|
||||||
|
.findIndex((p) => p.identity === participant.identity) + 1
|
||||||
|
)
|
||||||
|
}, [participants, participant, isHandRaised])
|
||||||
|
|
||||||
|
return {
|
||||||
|
positionInQueue,
|
||||||
|
firstInQueue: positionInQueue == 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRaisedHand({ participant }: useRaisedHandProps) {
|
||||||
|
const handRaisedAtAttribute = useParticipantAttribute('handRaisedAt', {
|
||||||
|
participant,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHandRaised = !!handRaisedAtAttribute
|
||||||
|
|
||||||
|
const toggleRaisedHand = async () => {
|
||||||
|
if (!isLocal(participant)) return
|
||||||
|
const localParticipant = participant as LocalParticipant
|
||||||
|
|
||||||
|
const attributes: Record<string, string> = {
|
||||||
|
handRaisedAt: !isHandRaised ? new Date().toISOString() : '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await localParticipant.setAttributes(attributes)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { isHandRaised: parsedMetadata.raised ?? false, toggleRaisedHand }
|
return { isHandRaised, toggleRaisedHand }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
const useLatest = <T>(current: T) => {
|
||||||
|
const storedValue = React.useRef(current)
|
||||||
|
React.useEffect(() => {
|
||||||
|
storedValue.current = current
|
||||||
|
})
|
||||||
|
return storedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A React hook that fires a callback whenever ResizeObserver detects a change to its size
|
||||||
|
* code extracted from https://github.com/jaredLunde/react-hook/blob/master/packages/resize-observer/src/index.tsx in order to not include the polyfill for resize-observer
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function useResizeObserver<T extends HTMLElement>(
|
||||||
|
target: React.RefObject<T>,
|
||||||
|
callback: UseResizeObserverCallback
|
||||||
|
) {
|
||||||
|
const resizeObserver = getResizeObserver()
|
||||||
|
const storedCallback = useLatest(callback)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
let didUnsubscribe = false
|
||||||
|
|
||||||
|
const targetEl = target.current
|
||||||
|
if (!targetEl) return
|
||||||
|
|
||||||
|
function cb(entry: ResizeObserverEntry, observer: ResizeObserver) {
|
||||||
|
if (didUnsubscribe) return
|
||||||
|
storedCallback.current(entry, observer)
|
||||||
|
}
|
||||||
|
|
||||||
|
resizeObserver?.subscribe(targetEl as HTMLElement, cb)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
didUnsubscribe = true
|
||||||
|
resizeObserver?.unsubscribe(targetEl as HTMLElement, cb)
|
||||||
|
}
|
||||||
|
}, [target.current, resizeObserver, storedCallback])
|
||||||
|
|
||||||
|
return resizeObserver?.observer
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResizeObserver() {
|
||||||
|
let ticking = false
|
||||||
|
let allEntries: ResizeObserverEntry[] = []
|
||||||
|
|
||||||
|
const callbacks: Map<unknown, Array<UseResizeObserverCallback>> = new Map()
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(
|
||||||
|
(entries: ResizeObserverEntry[], obs: ResizeObserver) => {
|
||||||
|
allEntries = allEntries.concat(entries)
|
||||||
|
if (!ticking) {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const triggered = new Set<Element>()
|
||||||
|
for (let i = 0; i < allEntries.length; i++) {
|
||||||
|
if (triggered.has(allEntries[i].target)) continue
|
||||||
|
triggered.add(allEntries[i].target)
|
||||||
|
const cbs = callbacks.get(allEntries[i].target)
|
||||||
|
cbs?.forEach((cb) => cb(allEntries[i], obs))
|
||||||
|
}
|
||||||
|
allEntries = []
|
||||||
|
ticking = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ticking = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
observer,
|
||||||
|
subscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||||
|
observer.observe(target)
|
||||||
|
const cbs = callbacks.get(target) ?? []
|
||||||
|
cbs.push(callback)
|
||||||
|
callbacks.set(target, cbs)
|
||||||
|
},
|
||||||
|
unsubscribe(target: HTMLElement, callback: UseResizeObserverCallback) {
|
||||||
|
const cbs = callbacks.get(target) ?? []
|
||||||
|
if (cbs.length === 1) {
|
||||||
|
observer.unobserve(target)
|
||||||
|
callbacks.delete(target)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cbIndex = cbs.indexOf(callback)
|
||||||
|
if (cbIndex !== -1) cbs.splice(cbIndex, 1)
|
||||||
|
callbacks.set(target, cbs)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let _resizeObserver: ReturnType<typeof createResizeObserver>
|
||||||
|
|
||||||
|
const getResizeObserver = () =>
|
||||||
|
!_resizeObserver
|
||||||
|
? (_resizeObserver = createResizeObserver())
|
||||||
|
: _resizeObserver
|
||||||
|
|
||||||
|
export type UseResizeObserverCallback = (
|
||||||
|
entry: ResizeObserverEntry,
|
||||||
|
observer: ResizeObserver
|
||||||
|
) => unknown
|
||||||
|
|
||||||
|
export const useSize = (target: React.RefObject<HTMLDivElement>) => {
|
||||||
|
const [size, setSize] = React.useState({ width: 0, height: 0 })
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (target.current) {
|
||||||
|
const { width, height } = target.current.getBoundingClientRect()
|
||||||
|
setSize({ width, height })
|
||||||
|
}
|
||||||
|
}, [target.current])
|
||||||
|
|
||||||
|
const resizeCallback = React.useCallback(
|
||||||
|
(entry: ResizeObserverEntry) => setSize(entry.contentRect),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
// Where the magic happens
|
||||||
|
useResizeObserver(target, resizeCallback)
|
||||||
|
return size
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export const useResolveInitiallyDefaultDeviceId = <
|
||||||
|
T extends { getDeviceId(): Promise<string | undefined> },
|
||||||
|
>(
|
||||||
|
currentId: string,
|
||||||
|
track: T | undefined,
|
||||||
|
save: (id: string) => void
|
||||||
|
) => {
|
||||||
|
const isInitiated = useRef(false)
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentId !== 'default' || !track || isInitiated.current) return
|
||||||
|
const resolveDefaultDeviceId = async () => {
|
||||||
|
const actualDeviceId = await track.getDeviceId()
|
||||||
|
if (actualDeviceId && actualDeviceId !== 'default') {
|
||||||
|
isInitiated.current = true
|
||||||
|
save(actualDeviceId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolveDefaultDeviceId()
|
||||||
|
}, [currentId, track, save])
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { usePersistentUserChoices } from './usePersistentUserChoices'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import {
|
||||||
|
RemoteParticipant,
|
||||||
|
RemoteTrackPublication,
|
||||||
|
RoomEvent,
|
||||||
|
Track,
|
||||||
|
VideoQuality,
|
||||||
|
} from 'livekit-client'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook sets initial video quality for new participants as they join.
|
||||||
|
* LiveKit doesn't allow handling video quality preferences at the room level.
|
||||||
|
*/
|
||||||
|
export const useVideoResolutionSubscription = () => {
|
||||||
|
const {
|
||||||
|
userChoices: { videoSubscribeQuality },
|
||||||
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!room) return
|
||||||
|
const handleTrackPublished = (
|
||||||
|
publication: RemoteTrackPublication,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_participant: RemoteParticipant
|
||||||
|
) => {
|
||||||
|
// By default, the maximum quality is set to high
|
||||||
|
if (
|
||||||
|
videoSubscribeQuality === undefined ||
|
||||||
|
videoSubscribeQuality === VideoQuality.HIGH
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (
|
||||||
|
publication.kind === Track.Kind.Video &&
|
||||||
|
publication.source !== Track.Source.ScreenShare
|
||||||
|
) {
|
||||||
|
publication.setVideoQuality(videoSubscribeQuality)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
room.on(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.TrackPublished, handleTrackPublished)
|
||||||
|
}
|
||||||
|
}, [room, videoSubscribeQuality])
|
||||||
|
}
|
||||||
@@ -3,98 +3,23 @@ import * as React from 'react'
|
|||||||
|
|
||||||
import { MobileControlBar } from './MobileControlBar'
|
import { MobileControlBar } from './MobileControlBar'
|
||||||
import { DesktopControlBar } from './DesktopControlBar'
|
import { DesktopControlBar } from './DesktopControlBar'
|
||||||
import { SettingsDialogProvider } from '../../components/controls/SettingsDialogContext'
|
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export type ControlBarControls = {
|
|
||||||
microphone?: boolean
|
|
||||||
camera?: boolean
|
|
||||||
chat?: boolean
|
|
||||||
screenShare?: boolean
|
|
||||||
leave?: boolean
|
|
||||||
settings?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
|
||||||
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||||
variation?: 'minimal' | 'verbose' | 'textOnly'
|
|
||||||
controls?: ControlBarControls
|
|
||||||
/**
|
|
||||||
* If `true`, the user's device choices will be persisted.
|
|
||||||
* This will enable the user to have the same device choices when they rejoin the room.
|
|
||||||
* @defaultValue true
|
|
||||||
* @alpha
|
|
||||||
*/
|
|
||||||
saveUserChoices?: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `ControlBar` prefab gives the user the basic user interface to control their
|
* The `ControlBar` prefab gives the user the basic user interface to control their
|
||||||
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
|
* media devices (camera, microphone and screen share), open the `Chat` and leave the room.
|
||||||
*
|
|
||||||
* @remarks
|
|
||||||
* This component is build with other LiveKit components like `TrackToggle`,
|
|
||||||
* `DeviceSelectorButton`, `DisconnectButton` and `StartAudio`.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <LiveKitRoom>
|
|
||||||
* <ControlBar />
|
|
||||||
* </LiveKitRoom>
|
|
||||||
* ```
|
|
||||||
* @public
|
|
||||||
*/
|
*/
|
||||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||||
const {
|
|
||||||
saveAudioInputEnabled,
|
|
||||||
saveVideoInputEnabled,
|
|
||||||
saveAudioInputDeviceId,
|
|
||||||
saveVideoInputDeviceId,
|
|
||||||
} = usePersistentUserChoices()
|
|
||||||
|
|
||||||
const microphoneOnChange = React.useCallback(
|
|
||||||
(enabled: boolean, isUserInitiated: boolean) =>
|
|
||||||
isUserInitiated ? saveAudioInputEnabled(enabled) : null,
|
|
||||||
[saveAudioInputEnabled]
|
|
||||||
)
|
|
||||||
|
|
||||||
const cameraOnChange = React.useCallback(
|
|
||||||
(enabled: boolean, isUserInitiated: boolean) =>
|
|
||||||
isUserInitiated ? saveVideoInputEnabled(enabled) : null,
|
|
||||||
[saveVideoInputEnabled]
|
|
||||||
)
|
|
||||||
|
|
||||||
const barProps = {
|
|
||||||
onDeviceError,
|
|
||||||
microphoneOnChange,
|
|
||||||
cameraOnChange,
|
|
||||||
saveAudioInputDeviceId,
|
|
||||||
saveVideoInputDeviceId,
|
|
||||||
}
|
|
||||||
|
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
return (
|
if (isMobile) {
|
||||||
<SettingsDialogProvider>
|
return <MobileControlBar onDeviceError={onDeviceError} />
|
||||||
{isMobile ? (
|
}
|
||||||
<MobileControlBar {...barProps} />
|
return <DesktopControlBar onDeviceError={onDeviceError} />
|
||||||
) : (
|
|
||||||
<DesktopControlBar {...barProps} />
|
|
||||||
)}
|
|
||||||
</SettingsDialogProvider>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlBarAuxProps {
|
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||||
onDeviceError: ControlBarProps['onDeviceError']
|
|
||||||
microphoneOnChange: (
|
|
||||||
enabled: boolean,
|
|
||||||
isUserInitiated: boolean
|
|
||||||
) => void | null
|
|
||||||
cameraOnChange: (enabled: boolean, isUserInitiated: boolean) => void | null
|
|
||||||
saveAudioInputDeviceId: (deviceId: string) => void
|
|
||||||
saveVideoInputDeviceId: (deviceId: string) => void
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { supportsScreenSharing } from '@livekit/components-core'
|
|||||||
import { ControlBarAuxProps } from './ControlBar'
|
import { ControlBarAuxProps } from './ControlBar'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
|
||||||
import { HandToggle } from '../../components/controls/HandToggle'
|
import { HandToggle } from '../../components/controls/HandToggle'
|
||||||
@@ -11,14 +10,12 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
|||||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||||
import { MoreOptions } from './MoreOptions'
|
import { MoreOptions } from './MoreOptions'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
|
|
||||||
export function DesktopControlBar({
|
export function DesktopControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
microphoneOnChange,
|
}: Readonly<ControlBarAuxProps>) {
|
||||||
cameraOnChange,
|
|
||||||
saveAudioInputDeviceId,
|
|
||||||
saveVideoInputDeviceId,
|
|
||||||
}: ControlBarAuxProps) {
|
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||||
return (
|
return (
|
||||||
@@ -53,27 +50,15 @@ export function DesktopControlBar({
|
|||||||
gap: '0.65rem',
|
gap: '0.65rem',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<SelectToggleDevice
|
<AudioDevicesControl
|
||||||
source={Track.Source.Microphone}
|
|
||||||
onChange={microphoneOnChange}
|
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||||
}
|
}
|
||||||
onActiveDeviceChange={(deviceId) =>
|
|
||||||
saveAudioInputDeviceId(deviceId ?? '')
|
|
||||||
}
|
|
||||||
menuVariant="dark"
|
|
||||||
/>
|
/>
|
||||||
<SelectToggleDevice
|
<VideoDeviceControl
|
||||||
source={Track.Source.Camera}
|
|
||||||
onChange={cameraOnChange}
|
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||||
}
|
}
|
||||||
onActiveDeviceChange={(deviceId) =>
|
|
||||||
saveVideoInputDeviceId(deviceId ?? '')
|
|
||||||
}
|
|
||||||
menuVariant="dark"
|
|
||||||
/>
|
/>
|
||||||
<ReactionsToggle />
|
<ReactionsToggle />
|
||||||
{browserSupportsScreenSharing && (
|
{browserSupportsScreenSharing && (
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { ControlBarAuxProps } from './ControlBar'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||||
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
|
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { HandToggle } from '../../components/controls/HandToggle'
|
import { HandToggle } from '../../components/controls/HandToggle'
|
||||||
import { Button } from '@/primitives/Button'
|
import { Button } from '@/primitives/Button'
|
||||||
@@ -19,24 +18,22 @@ import { ChatToggle } from '../../components/controls/ChatToggle'
|
|||||||
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
import { ParticipantsToggle } from '../../components/controls/Participants/ParticipantsToggle'
|
||||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||||
import { LinkButton } from '@/primitives'
|
import { LinkButton } from '@/primitives'
|
||||||
import { useSettingsDialog } from '../../components/controls/SettingsDialogContext'
|
|
||||||
import { ResponsiveMenu } from './ResponsiveMenu'
|
import { ResponsiveMenu } from './ResponsiveMenu'
|
||||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||||
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
import { CameraSwitchButton } from '../../components/controls/CameraSwitchButton'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||||
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
|
import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||||
|
|
||||||
export function MobileControlBar({
|
export function MobileControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
microphoneOnChange,
|
}: Readonly<ControlBarAuxProps>) {
|
||||||
cameraOnChange,
|
|
||||||
saveAudioInputDeviceId,
|
|
||||||
saveVideoInputDeviceId,
|
|
||||||
}: ControlBarAuxProps) {
|
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
const [isMenuOpened, setIsMenuOpened] = React.useState(false)
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
const { toggleEffects } = useSidePanel()
|
const { toggleEffects } = useSidePanel()
|
||||||
const { setDialogOpen } = useSettingsDialog()
|
const { openSettingsDialog } = useSettingsDialog()
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
|
||||||
@@ -62,27 +59,15 @@ export function MobileControlBar({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LeaveButton />
|
<LeaveButton />
|
||||||
<SelectToggleDevice
|
<AudioDevicesControl
|
||||||
source={Track.Source.Microphone}
|
|
||||||
onChange={microphoneOnChange}
|
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||||
}
|
}
|
||||||
onActiveDeviceChange={(deviceId) =>
|
|
||||||
saveAudioInputDeviceId(deviceId ?? '')
|
|
||||||
}
|
|
||||||
hideMenu={true}
|
|
||||||
/>
|
/>
|
||||||
<SelectToggleDevice
|
<VideoDeviceControl
|
||||||
source={Track.Source.Camera}
|
|
||||||
onChange={cameraOnChange}
|
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||||
}
|
}
|
||||||
onActiveDeviceChange={(deviceId) =>
|
|
||||||
saveVideoInputDeviceId(deviceId ?? '')
|
|
||||||
}
|
|
||||||
hideMenu={true}
|
|
||||||
/>
|
/>
|
||||||
<HandToggle />
|
<HandToggle />
|
||||||
<Button
|
<Button
|
||||||
@@ -167,7 +152,7 @@ export function MobileControlBar({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setDialogOpen(true)
|
openSettingsDialog()
|
||||||
setIsMenuOpened(false)
|
setIsMenuOpened(false)
|
||||||
}}
|
}}
|
||||||
variant="primaryTextDark"
|
variant="primaryTextDark"
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import { ParticipantsToggle } from '../../components/controls/Participants/Parti
|
|||||||
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
import { ToolsToggle } from '../../components/controls/ToolsToggle'
|
||||||
import { InfoToggle } from '../../components/controls/InfoToggle'
|
import { InfoToggle } from '../../components/controls/InfoToggle'
|
||||||
import { AdminToggle } from '../../components/AdminToggle'
|
import { AdminToggle } from '../../components/AdminToggle'
|
||||||
|
import { useSize } from '../../hooks/useResizeObserver'
|
||||||
import { useState, RefObject } from 'react'
|
import { useState, RefObject } from 'react'
|
||||||
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
import { Dialog, DialogTrigger, Popover } from 'react-aria-components'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import useSize from '@react-hook/size'
|
|
||||||
|
|
||||||
const CONTROL_BAR_BREAKPOINT = 1100
|
const CONTROL_BAR_BREAKPOINT = 1100
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export const MoreOptions = ({
|
|||||||
}: {
|
}: {
|
||||||
parentElement: RefObject<HTMLDivElement>
|
parentElement: RefObject<HTMLDivElement>
|
||||||
}) => {
|
}) => {
|
||||||
const parentWidth = useSize(parentElement)[0]
|
const { width: parentWidth } = useSize(parentElement)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { RoomEvent, Track } from 'livekit-client'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
|
CarouselLayout,
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
FocusLayoutContainer,
|
FocusLayoutContainer,
|
||||||
GridLayout,
|
GridLayout,
|
||||||
@@ -31,7 +32,8 @@ import { RecordingStateToast } from '@/features/recording'
|
|||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
import { CarouselLayout } from '../components/layout/CarouselLayout'
|
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||||
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
|
|
||||||
const LayoutWrapper = styled(
|
const LayoutWrapper = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -77,6 +79,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
|
|
||||||
useConnectionObserver()
|
useConnectionObserver()
|
||||||
|
useVideoResolutionSubscription()
|
||||||
|
|
||||||
const tracks = useTracks(
|
const tracks = useTracks(
|
||||||
[
|
[
|
||||||
@@ -238,6 +241,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<RoomAudioRenderer />
|
<RoomAudioRenderer />
|
||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
<RecordingStateToast />
|
<RecordingStateToast />
|
||||||
|
<SettingsDialogProvider />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { ReactNode, useEffect, useState } from 'react'
|
||||||
import { usePersistentUserChoices } from '@livekit/components-react'
|
|
||||||
import { useLocation, useParams } from 'wouter'
|
import { useLocation, useParams } from 'wouter'
|
||||||
import { ErrorScreen } from '@/components/ErrorScreen'
|
import { ErrorScreen } from '@/components/ErrorScreen'
|
||||||
import { useUser, UserAware } from '@/features/auth'
|
import { useUser, UserAware } from '@/features/auth'
|
||||||
import { Conference } from '../components/Conference'
|
import { Conference } from '../components/Conference'
|
||||||
import { Join } from '../components/Join'
|
import { Join } from '../components/Join'
|
||||||
|
import { Permissions } from '../components/Permissions'
|
||||||
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
import { useKeyboardShortcuts } from '@/features/shortcuts/useKeyboardShortcuts'
|
||||||
import {
|
import {
|
||||||
isRoomValid,
|
isRoomValid,
|
||||||
normalizeRoomId,
|
normalizeRoomId,
|
||||||
} from '@/features/rooms/utils/isRoomValid'
|
} from '@/features/rooms/utils/isRoomValid'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
|
||||||
|
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<UserAware>
|
||||||
|
<Permissions />
|
||||||
|
{children}
|
||||||
|
</UserAware>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Room = () => {
|
export const Room = () => {
|
||||||
const { isLoggedIn } = useUser()
|
const { isLoggedIn } = useUser()
|
||||||
const { userChoices: existingUserChoices } = usePersistentUserChoices()
|
const [hasSubmittedEntry, setHasSubmittedEntry] = useState(false)
|
||||||
const [userConfig, setUserConfig] = useState<LocalUserChoices | null>(null)
|
|
||||||
|
|
||||||
const { roomId } = useParams()
|
const { roomId } = useParams()
|
||||||
const [location, setLocation] = useLocation()
|
const [location, setLocation] = useLocation()
|
||||||
@@ -48,25 +55,21 @@ export const Room = () => {
|
|||||||
return <ErrorScreen />
|
return <ErrorScreen />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userConfig && !skipJoinScreen) {
|
if (!hasSubmittedEntry && !skipJoinScreen) {
|
||||||
return (
|
return (
|
||||||
<UserAware>
|
<BaseRoom>
|
||||||
<Join onSubmit={setUserConfig} roomId={roomId} />
|
<Join enterRoom={() => setHasSubmittedEntry(true)} roomId={roomId} />
|
||||||
</UserAware>
|
</BaseRoom>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserAware>
|
<BaseRoom>
|
||||||
<Conference
|
<Conference
|
||||||
initialRoomData={initialRoomData}
|
initialRoomData={initialRoomData}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
userConfig={{
|
|
||||||
...existingUserChoices,
|
|
||||||
...userConfig,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</UserAware>
|
</BaseRoom>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
export const safeParseMetadata = (
|
|
||||||
metadataStr: string | null | undefined
|
|
||||||
): Record<string, unknown> => {
|
|
||||||
if (!metadataStr) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(metadataStr)
|
|
||||||
|
|
||||||
// Ensure the result is an object
|
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
||||||
console.warn('Metadata parsed to non-object value:', parsed)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed as Record<string, unknown>
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to parse metadata:', error)
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,5 +19,5 @@ export const parseConfigPhoneNumber = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatPinCode(pinCode?: string) {
|
export function formatPinCode(pinCode?: string) {
|
||||||
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')} #`
|
return pinCode && `${pinCode.replace(/(\d{3})(\d{3})(\d{4})/, '$1 $2 $3')}#`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,13 +9,16 @@ import {
|
|||||||
RiNotification3Line,
|
RiNotification3Line,
|
||||||
RiSettings3Line,
|
RiSettings3Line,
|
||||||
RiSpeakerLine,
|
RiSpeakerLine,
|
||||||
|
RiVideoOnLine,
|
||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { AccountTab } from './tabs/AccountTab'
|
import { AccountTab } from './tabs/AccountTab'
|
||||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||||
import { GeneralTab } from './tabs/GeneralTab'
|
import { GeneralTab } from './tabs/GeneralTab'
|
||||||
import { AudioTab } from './tabs/AudioTab'
|
import { AudioTab } from './tabs/AudioTab'
|
||||||
|
import { VideoTab } from './tabs/VideoTab'
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
|
||||||
const tabsStyle = css({
|
const tabsStyle = css({
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||||
@@ -44,7 +47,9 @@ const tabPanelContainerStyle = css({
|
|||||||
export type SettingsDialogExtended = Pick<
|
export type SettingsDialogExtended = Pick<
|
||||||
DialogProps,
|
DialogProps,
|
||||||
'isOpen' | 'onOpenChange'
|
'isOpen' | 'onOpenChange'
|
||||||
>
|
> & {
|
||||||
|
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||||
|
}
|
||||||
|
|
||||||
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||||
// display only icon on small screen
|
// display only icon on small screen
|
||||||
@@ -55,7 +60,11 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||||
<Tabs orientation="vertical" className={tabsStyle}>
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
className={tabsStyle}
|
||||||
|
defaultSelectedKey={props.defaultSelectedTab}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
className={tabListContainerStyle}
|
className={tabListContainerStyle}
|
||||||
style={{
|
style={{
|
||||||
@@ -69,30 +78,39 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
|||||||
{t('dialog.heading')}
|
{t('dialog.heading')}
|
||||||
</Heading>
|
</Heading>
|
||||||
)}
|
)}
|
||||||
<TabList border={false} aria-label="Chat log orientation example">
|
<TabList border={false}>
|
||||||
<Tab icon highlight id="1">
|
<Tab icon highlight id={SettingsDialogExtendedKey.ACCOUNT}>
|
||||||
<RiAccountCircleLine />
|
<RiAccountCircleLine />
|
||||||
{isWideScreen && t('tabs.account')}
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.ACCOUNT}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab icon highlight id="2">
|
<Tab icon highlight id={SettingsDialogExtendedKey.AUDIO}>
|
||||||
<RiSpeakerLine />
|
<RiSpeakerLine />
|
||||||
{isWideScreen && t('tabs.audio')}
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.AUDIO}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab icon highlight id="3">
|
<Tab icon highlight id={SettingsDialogExtendedKey.VIDEO}>
|
||||||
|
<RiVideoOnLine />
|
||||||
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.VIDEO}`)}
|
||||||
|
</Tab>
|
||||||
|
<Tab icon highlight id={SettingsDialogExtendedKey.GENERAL}>
|
||||||
<RiSettings3Line />
|
<RiSettings3Line />
|
||||||
{isWideScreen && t('tabs.general')}
|
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.GENERAL}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab icon highlight id="4">
|
<Tab icon highlight id={SettingsDialogExtendedKey.NOTIFICATIONS}>
|
||||||
<RiNotification3Line />
|
<RiNotification3Line />
|
||||||
{isWideScreen && t('tabs.notifications')}
|
{isWideScreen &&
|
||||||
|
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||||
</Tab>
|
</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
</div>
|
</div>
|
||||||
<div className={tabPanelContainerStyle}>
|
<div className={tabPanelContainerStyle}>
|
||||||
<AccountTab id="1" onOpenChange={props.onOpenChange} />
|
<AccountTab
|
||||||
<AudioTab id="2" />
|
id={SettingsDialogExtendedKey.ACCOUNT}
|
||||||
<GeneralTab id="3" />
|
onOpenChange={props.onOpenChange}
|
||||||
<NotificationsTab id="4" />
|
/>
|
||||||
|
<AudioTab id={SettingsDialogExtendedKey.AUDIO} />
|
||||||
|
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||||
|
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||||
|
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { SettingsDialogExtended } from './SettingsDialogExtended'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { settingsStore } from '@/stores/settings'
|
||||||
|
|
||||||
|
export const SettingsDialogProvider = () => {
|
||||||
|
const { areSettingsOpen, defaultSelectedTab } = useSnapshot(settingsStore)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsDialogExtended
|
||||||
|
isOpen={areSettingsOpen}
|
||||||
|
defaultSelectedTab={defaultSelectedTab}
|
||||||
|
onOpenChange={(v) => {
|
||||||
|
if (!v && settingsStore.defaultSelectedTab) {
|
||||||
|
settingsStore.defaultSelectedTab = undefined
|
||||||
|
}
|
||||||
|
settingsStore.areSettingsOpen = v
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { DialogProps, Field, H, Switch, Text } from '@/primitives'
|
import { DialogProps, Field, Switch, Text } from '@/primitives'
|
||||||
|
|
||||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
import {
|
import {
|
||||||
@@ -9,78 +9,11 @@ import {
|
|||||||
import { isSafari } from '@/utils/livekit'
|
import { isSafari } from '@/utils/livekit'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SoundTester } from '@/components/SoundTester'
|
import { SoundTester } from '@/components/SoundTester'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
|
||||||
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
import { ActiveSpeaker } from '@/features/rooms/components/ActiveSpeaker'
|
||||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||||
import { ReactNode } from 'react'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import posthog from 'posthog-js'
|
|
||||||
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
|
import { useNoiseReductionAvailable } from '@/features/rooms/livekit/hooks/useNoiseReductionAvailable'
|
||||||
|
import posthog from 'posthog-js'
|
||||||
type RowWrapperProps = {
|
import { RowWrapper } from './layout/RowWrapper'
|
||||||
heading: string
|
|
||||||
children: ReactNode[]
|
|
||||||
beta?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const BetaBadge = () => (
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
content: '"Beta"',
|
|
||||||
display: 'block',
|
|
||||||
letterSpacing: '-0.02rem',
|
|
||||||
padding: '0 0.25rem',
|
|
||||||
backgroundColor: '#E8EDFF',
|
|
||||||
color: '#0063CB',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
margin: '0 0 0.9375rem 0.3125rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
borderRadius: '4px',
|
|
||||||
width: 'fit-content',
|
|
||||||
height: 'fit-content',
|
|
||||||
marginTop: { base: '10px', sm: '5px' },
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
|
|
||||||
const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<HStack>
|
|
||||||
<H lvl={2}>{heading}</H>
|
|
||||||
{beta && <BetaBadge />}
|
|
||||||
</HStack>
|
|
||||||
<HStack
|
|
||||||
gap={0}
|
|
||||||
style={{
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: '1 1 215px',
|
|
||||||
minWidth: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children[0]}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: '10rem',
|
|
||||||
justifyContent: 'center',
|
|
||||||
display: 'flex',
|
|
||||||
paddingLeft: '1.5rem',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children[1]}
|
|
||||||
</div>
|
|
||||||
</HStack>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
export type AudioTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
Pick<TabPanelProps, 'id'>
|
Pick<TabPanelProps, 'id'>
|
||||||
@@ -92,24 +25,19 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
const { localParticipant } = useRoomContext()
|
const { localParticipant } = useRoomContext()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
userChoices: { noiseReductionEnabled },
|
userChoices: { noiseReductionEnabled, audioDeviceId, audioOutputDeviceId },
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveNoiseReductionEnabled,
|
saveNoiseReductionEnabled,
|
||||||
|
saveAudioOutputDeviceId,
|
||||||
} = usePersistentUserChoices()
|
} = usePersistentUserChoices()
|
||||||
|
|
||||||
const isSpeaking = useIsSpeaking(localParticipant)
|
const isSpeaking = useIsSpeaking(localParticipant)
|
||||||
|
|
||||||
const {
|
const { devices: devicesOut, setActiveMediaDevice: setActiveMediaDeviceOut } =
|
||||||
devices: devicesOut,
|
useMediaDeviceSelect({ kind: 'audiooutput' })
|
||||||
activeDeviceId: activeDeviceIdOut,
|
|
||||||
setActiveMediaDevice: setActiveMediaDeviceOut,
|
|
||||||
} = useMediaDeviceSelect({ kind: 'audiooutput' })
|
|
||||||
|
|
||||||
const {
|
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
|
||||||
devices: devicesIn,
|
useMediaDeviceSelect({ kind: 'audioinput' })
|
||||||
activeDeviceId: activeDeviceIdIn,
|
|
||||||
setActiveMediaDevice: setActiveMediaDeviceIn,
|
|
||||||
} = useMediaDeviceSelect({ kind: 'audioinput' })
|
|
||||||
|
|
||||||
const itemsOut: DeviceItems = devicesOut.map((d) => ({
|
const itemsOut: DeviceItems = devicesOut.map((d) => ({
|
||||||
value: d.deviceId,
|
value: d.deviceId,
|
||||||
@@ -134,15 +62,6 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
defaultSelectedKey: undefined,
|
defaultSelectedKey: undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
// No API to directly query the default audio device; this function heuristically finds it.
|
|
||||||
// Returns the item with value 'default' if present; otherwise, returns the first item in the list.
|
|
||||||
const getDefaultSelectedKey = (items: DeviceItems) => {
|
|
||||||
if (!items || items.length === 0) return
|
|
||||||
const defaultItem =
|
|
||||||
items.find((item) => item.value === 'default') || items[0]
|
|
||||||
return defaultItem.value
|
|
||||||
}
|
|
||||||
|
|
||||||
const noiseReductionAvailable = useNoiseReductionAvailable()
|
const noiseReductionAvailable = useNoiseReductionAvailable()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -152,11 +71,9 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
type="select"
|
type="select"
|
||||||
label={t('audio.microphone.label')}
|
label={t('audio.microphone.label')}
|
||||||
items={itemsIn}
|
items={itemsIn}
|
||||||
defaultSelectedKey={
|
selectedKey={audioDeviceId}
|
||||||
activeDeviceIdIn || getDefaultSelectedKey(itemsIn)
|
onSelectionChange={async (key) => {
|
||||||
}
|
await setActiveMediaDeviceIn(key as string)
|
||||||
onSelectionChange={(key) => {
|
|
||||||
setActiveMediaDeviceIn(key as string)
|
|
||||||
saveAudioInputDeviceId(key as string)
|
saveAudioInputDeviceId(key as string)
|
||||||
}}
|
}}
|
||||||
{...disabledProps}
|
{...disabledProps}
|
||||||
@@ -180,12 +97,11 @@ export const AudioTab = ({ id }: AudioTabProps) => {
|
|||||||
type="select"
|
type="select"
|
||||||
label={t('audio.speakers.label')}
|
label={t('audio.speakers.label')}
|
||||||
items={itemsOut}
|
items={itemsOut}
|
||||||
defaultSelectedKey={
|
selectedKey={audioOutputDeviceId}
|
||||||
activeDeviceIdOut || getDefaultSelectedKey(itemsOut)
|
onSelectionChange={async (key) => {
|
||||||
}
|
await setActiveMediaDeviceOut(key as string)
|
||||||
onSelectionChange={async (key) =>
|
saveAudioOutputDeviceId(key as string)
|
||||||
setActiveMediaDeviceOut(key as string)
|
}}
|
||||||
}
|
|
||||||
{...disabledProps}
|
{...disabledProps}
|
||||||
style={{
|
style={{
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { DialogProps, Field } from '@/primitives'
|
||||||
|
|
||||||
|
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||||
|
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import {
|
||||||
|
createLocalVideoTrack,
|
||||||
|
LocalVideoTrack,
|
||||||
|
Track,
|
||||||
|
VideoPresets,
|
||||||
|
VideoQuality,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { BackgroundProcessorFactory } from '@/features/rooms/livekit/components/blur'
|
||||||
|
import { VideoResolution } from '@/stores/userChoices'
|
||||||
|
import { RowWrapper } from './layout/RowWrapper'
|
||||||
|
|
||||||
|
export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||||
|
Pick<TabPanelProps, 'id'>
|
||||||
|
|
||||||
|
type DeviceItems = Array<{ value: string; label: string }>
|
||||||
|
|
||||||
|
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||||
|
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||||
|
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||||
|
|
||||||
|
const {
|
||||||
|
userChoices: {
|
||||||
|
videoDeviceId,
|
||||||
|
processorSerialized,
|
||||||
|
videoPublishResolution,
|
||||||
|
videoSubscribeQuality,
|
||||||
|
},
|
||||||
|
saveVideoInputDeviceId,
|
||||||
|
saveVideoPublishResolution,
|
||||||
|
saveVideoSubscribeQuality,
|
||||||
|
} = usePersistentUserChoices()
|
||||||
|
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
const videoCallbackRef = useCallback((element: HTMLVideoElement | null) => {
|
||||||
|
setVideoElement(element)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { devices: devicesIn, setActiveMediaDevice: setActiveMediaDeviceIn } =
|
||||||
|
useMediaDeviceSelect({ kind: 'videoinput' })
|
||||||
|
|
||||||
|
const itemsIn: DeviceItems = devicesIn.map((d) => ({
|
||||||
|
value: d.deviceId,
|
||||||
|
label: d.label,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// The Permissions API is not fully supported in Firefox and Safari, and attempting to use it for camera permissions
|
||||||
|
// may raise an error. As a workaround, we infer camera permission status by checking if the list of camera input
|
||||||
|
// devices (devicesIn) is non-empty. If the list has one or more devices, we assume the user has granted camera access.
|
||||||
|
const isCamEnabled = devicesIn?.length > 0
|
||||||
|
|
||||||
|
const disabledProps = isCamEnabled
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
placeholder: t('permissionsRequired'),
|
||||||
|
isDisabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVideoResolutionChange = async (key: 'h720' | 'h360' | 'h180') => {
|
||||||
|
const videoPublication = localParticipant.getTrackPublication(
|
||||||
|
Track.Source.Camera
|
||||||
|
)
|
||||||
|
const videoTrack = videoPublication?.track
|
||||||
|
if (videoTrack) {
|
||||||
|
saveVideoPublishResolution(key)
|
||||||
|
await videoTrack.restartTrack({
|
||||||
|
resolution: VideoPresets[key].resolution,
|
||||||
|
deviceId: { exact: videoDeviceId },
|
||||||
|
processor:
|
||||||
|
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates video quality for all existing remote video tracks when user preference changes.
|
||||||
|
* LiveKit doesn't support setting video quality preferences at the room level for remote participants,
|
||||||
|
* so this function applies the selected quality to all existing remote video tracks.
|
||||||
|
* Hook useVideoResolutionSubscription updates quality preferences of new participants joining.
|
||||||
|
*/
|
||||||
|
const updateExistingRemoteVideoQuality = (selectedQuality: VideoQuality) => {
|
||||||
|
remoteParticipants.forEach((participant) => {
|
||||||
|
participant.videoTrackPublications.forEach((publication) => {
|
||||||
|
if (publication.videoQuality !== selectedQuality) {
|
||||||
|
publication.setVideoQuality(selectedQuality)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let videoTrack: LocalVideoTrack | null = null
|
||||||
|
|
||||||
|
const setUpVideoTrack = async () => {
|
||||||
|
if (videoElement) {
|
||||||
|
videoTrack = await createLocalVideoTrack({ deviceId: videoDeviceId })
|
||||||
|
videoTrack.attach(videoElement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpVideoTrack()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (videoElement && videoTrack) {
|
||||||
|
videoTrack.detach()
|
||||||
|
videoTrack.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [videoDeviceId, videoElement])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
|
<RowWrapper heading={t('camera.heading')}>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('camera.label')}
|
||||||
|
items={itemsIn}
|
||||||
|
selectedKey={videoDeviceId}
|
||||||
|
onSelectionChange={async (key) => {
|
||||||
|
await setActiveMediaDeviceIn(key as string)
|
||||||
|
saveVideoInputDeviceId(key as string)
|
||||||
|
}}
|
||||||
|
{...disabledProps}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={t(
|
||||||
|
`camera.previewAriaLabel.${localParticipant.isCameraEnabled ? 'enabled' : 'disabled'}`
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{localParticipant.isCameraEnabled ? (
|
||||||
|
<>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||||
|
<video
|
||||||
|
ref={videoCallbackRef}
|
||||||
|
width="160px"
|
||||||
|
height="56px"
|
||||||
|
style={{
|
||||||
|
display: !localParticipant.isCameraEnabled
|
||||||
|
? 'none'
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
className={css({
|
||||||
|
transform: 'rotateY(180deg)',
|
||||||
|
height: '69px',
|
||||||
|
width: '160px',
|
||||||
|
})}
|
||||||
|
disablePictureInPicture
|
||||||
|
disableRemotePlayback
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
textAlign: 'center',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{t('camera.disabled')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</RowWrapper>
|
||||||
|
<RowWrapper heading={t('resolution.heading')}>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('resolution.publish.label')}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
value: 'h720',
|
||||||
|
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'h360',
|
||||||
|
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'h180',
|
||||||
|
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
selectedKey={videoPublishResolution}
|
||||||
|
onSelectionChange={async (key) => {
|
||||||
|
await handleVideoResolutionChange(key as VideoResolution)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<></>
|
||||||
|
</RowWrapper>
|
||||||
|
<RowWrapper>
|
||||||
|
<Field
|
||||||
|
type="select"
|
||||||
|
label={t('resolution.subscribe.label')}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
value: VideoQuality.HIGH.toString(),
|
||||||
|
label: t('resolution.subscribe.items.high'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VideoQuality.MEDIUM.toString(),
|
||||||
|
label: t('resolution.subscribe.items.medium'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: VideoQuality.LOW.toString(),
|
||||||
|
label: t('resolution.subscribe.items.low'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
selectedKey={videoSubscribeQuality?.toString()}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
if (key == undefined) return
|
||||||
|
const selectedQuality = Number(String(key))
|
||||||
|
saveVideoSubscribeQuality(selectedQuality)
|
||||||
|
updateExistingRemoteVideoQuality(selectedQuality)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<></>
|
||||||
|
</RowWrapper>
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { H } from '@/primitives'
|
||||||
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
|
export type RowWrapperProps = {
|
||||||
|
heading?: string
|
||||||
|
children: ReactNode[]
|
||||||
|
beta?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const BetaBadge = () => (
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
content: '"Beta"',
|
||||||
|
display: 'block',
|
||||||
|
letterSpacing: '-0.02rem',
|
||||||
|
padding: '0 0.25rem',
|
||||||
|
backgroundColor: '#E8EDFF',
|
||||||
|
color: '#0063CB',
|
||||||
|
fontSize: '12px',
|
||||||
|
fontWeight: 500,
|
||||||
|
margin: '0 0 0.9375rem 0.3125rem',
|
||||||
|
lineHeight: '1rem',
|
||||||
|
borderRadius: '4px',
|
||||||
|
width: 'fit-content',
|
||||||
|
height: 'fit-content',
|
||||||
|
marginTop: { base: '10px', sm: '5px' },
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
Beta
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const RowWrapper = ({ heading, children, beta }: RowWrapperProps) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{heading && (
|
||||||
|
<HStack>
|
||||||
|
<H lvl={2}>{heading}</H>
|
||||||
|
{beta && <BetaBadge />}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
<HStack
|
||||||
|
gap={0}
|
||||||
|
style={{
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: '1 1 215px',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children[0]}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '10rem',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
paddingLeft: '1.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children[1]}
|
||||||
|
</div>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useSnapshot } from 'valtio/index'
|
||||||
|
import { settingsStore } from '@/stores/settings'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
|
||||||
|
export const useSettingsDialog = () => {
|
||||||
|
const { areSettingsOpen } = useSnapshot(settingsStore)
|
||||||
|
|
||||||
|
const openSettingsDialog = (
|
||||||
|
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||||
|
) => {
|
||||||
|
if (areSettingsOpen) return
|
||||||
|
if (defaultSelectedTab)
|
||||||
|
settingsStore.defaultSelectedTab = defaultSelectedTab
|
||||||
|
settingsStore.areSettingsOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openSettingsDialog,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export enum SettingsDialogExtendedKey {
|
||||||
|
ACCOUNT = 'account',
|
||||||
|
AUDIO = 'audio',
|
||||||
|
VIDEO = 'video',
|
||||||
|
GENERAL = 'general',
|
||||||
|
NOTIFICATIONS = 'notifications',
|
||||||
|
}
|
||||||
@@ -10,8 +10,9 @@ export const useKeyboardShortcuts = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// This approach handles basic shortcuts but isn't comprehensive.
|
// This approach handles basic shortcuts but isn't comprehensive.
|
||||||
// Issues might occur. First draft.
|
// Issues might occur. First draft.
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = async (e: KeyboardEvent) => {
|
||||||
const { key, metaKey, ctrlKey } = e
|
const { key, metaKey, ctrlKey } = e
|
||||||
|
if (!key) return
|
||||||
const shortcutKey = formatShortcutKey({
|
const shortcutKey = formatShortcutKey({
|
||||||
key,
|
key,
|
||||||
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
ctrlKey: ctrlKey || (isMacintosh() && metaKey),
|
||||||
@@ -19,7 +20,7 @@ export const useKeyboardShortcuts = () => {
|
|||||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||||
if (!shortcut) return
|
if (!shortcut) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
shortcut()
|
await shortcut()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown)
|
window.addEventListener('keydown', onKeyDown)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type useLongPressProps = {
|
|||||||
onKeyDown: () => void
|
onKeyDown: () => void
|
||||||
onKeyUp: () => void
|
onKeyUp: () => void
|
||||||
longPressThreshold?: number
|
longPressThreshold?: number
|
||||||
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLongPress = ({
|
export const useLongPress = ({
|
||||||
@@ -12,10 +13,18 @@ export const useLongPress = ({
|
|||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
longPressThreshold = 300,
|
longPressThreshold = 300,
|
||||||
|
isDisabled = false,
|
||||||
}: useLongPressProps) => {
|
}: useLongPressProps) => {
|
||||||
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const timeoutIdRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isDisabled) {
|
||||||
|
if (timeoutIdRef.current) {
|
||||||
|
clearTimeout(timeoutIdRef.current)
|
||||||
|
timeoutIdRef.current = null
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.code != keyCode || timeoutIdRef.current) return
|
if (event.code != keyCode || timeoutIdRef.current) return
|
||||||
timeoutIdRef.current = setTimeout(() => {
|
timeoutIdRef.current = setTimeout(() => {
|
||||||
@@ -38,8 +47,12 @@ export const useLongPress = ({
|
|||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown)
|
window.removeEventListener('keydown', handleKeyDown)
|
||||||
window.removeEventListener('keyup', handleKeyUp)
|
window.removeEventListener('keyup', handleKeyUp)
|
||||||
|
if (timeoutIdRef.current) {
|
||||||
|
clearTimeout(timeoutIdRef.current)
|
||||||
|
timeoutIdRef.current = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold])
|
}, [keyCode, onKeyDown, onKeyUp, longPressThreshold, isDisabled])
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,22 @@ import { Shortcut } from '@/features/shortcuts/types'
|
|||||||
|
|
||||||
export type useRegisterKeyboardShortcutProps = {
|
export type useRegisterKeyboardShortcutProps = {
|
||||||
shortcut?: Shortcut
|
shortcut?: Shortcut
|
||||||
handler: () => void
|
handler: () => Promise<void | boolean | undefined> | void
|
||||||
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useRegisterKeyboardShortcut = ({
|
export const useRegisterKeyboardShortcut = ({
|
||||||
shortcut,
|
shortcut,
|
||||||
handler,
|
handler,
|
||||||
|
isDisabled = false,
|
||||||
}: useRegisterKeyboardShortcutProps) => {
|
}: useRegisterKeyboardShortcutProps) => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!shortcut) return
|
if (!shortcut) return
|
||||||
keyboardShortcutsStore.shortcuts.set(formatShortcutKey(shortcut), handler)
|
const formattedKey = formatShortcutKey(shortcut)
|
||||||
}, [handler, shortcut])
|
if (isDisabled) {
|
||||||
|
keyboardShortcutsStore.shortcuts.delete(formattedKey)
|
||||||
|
} else {
|
||||||
|
keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
|
||||||
|
}
|
||||||
|
}, [handler, shortcut, isDisabled])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,9 @@
|
|||||||
"ariaLabel": "Hinweis schließen",
|
"ariaLabel": "Hinweis schließen",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clipboardContent": {
|
||||||
|
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
|
||||||
|
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,15 @@
|
|||||||
},
|
},
|
||||||
"laterMeetingDialog": {
|
"laterMeetingDialog": {
|
||||||
"heading": "Ihre Zugangsdaten",
|
"heading": "Ihre Zugangsdaten",
|
||||||
"description": "Senden Sie diesen Link an die Personen, die Sie zum Meeting einladen möchten. Sie können ohne ProConnect teilnehmen.",
|
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
|
||||||
"copy": "Meeting-Link kopieren",
|
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
|
||||||
"copied": "Link in die Zwischenablage kopiert",
|
"copy": "Informationen kopieren",
|
||||||
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten."
|
"copied": "Informationen kopiert",
|
||||||
|
"copyUrl": "Meeting-Link kopieren",
|
||||||
|
"phone": {
|
||||||
|
"call": "Rufen Sie an:",
|
||||||
|
"pinCode": "Code:"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
|
|||||||
@@ -7,9 +7,18 @@
|
|||||||
"home": "Zur Startseite zurückkehren",
|
"home": "Zur Startseite zurückkehren",
|
||||||
"back": "Dem Meeting erneut beitreten"
|
"back": "Dem Meeting erneut beitreten"
|
||||||
},
|
},
|
||||||
"join": {
|
"selectDevice": {
|
||||||
|
"loading": "Laden…",
|
||||||
|
"select": "Wählen Sie einen Wert",
|
||||||
|
"permissionsNeeded": "Genehmigung erforderlich",
|
||||||
|
"settings": {
|
||||||
|
"audio": "Audioeinstellungen",
|
||||||
|
"video": "Videoeinstellungen"
|
||||||
|
},
|
||||||
|
"effects": "Effekte anwenden",
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
"choose": "Kamera auswählen",
|
"choose": "Kamera auswählen",
|
||||||
|
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
||||||
"disable": "Kamera deaktivieren",
|
"disable": "Kamera deaktivieren",
|
||||||
"enable": "Kamera aktivieren",
|
"enable": "Kamera aktivieren",
|
||||||
"label": "Kamera",
|
"label": "Kamera",
|
||||||
@@ -17,10 +26,17 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Mikrofon auswählen",
|
"choose": "Mikrofon auswählen",
|
||||||
|
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
|
||||||
"disable": "Mikrofon deaktivieren",
|
"disable": "Mikrofon deaktivieren",
|
||||||
"enable": "Mikrofon aktivieren",
|
"enable": "Mikrofon aktivieren",
|
||||||
"label": "Mikrofon"
|
"label": "Mikrofon"
|
||||||
},
|
},
|
||||||
|
"audiooutput": {
|
||||||
|
"choose": "Lautsprecher auswählen",
|
||||||
|
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
"effects": {
|
"effects": {
|
||||||
"description": "Effekte anwenden",
|
"description": "Effekte anwenden",
|
||||||
"title": "Effekte",
|
"title": "Effekte",
|
||||||
@@ -37,7 +53,17 @@
|
|||||||
"usernameEmpty": "Ihr Name darf nicht leer sein"
|
"usernameEmpty": "Ihr Name darf nicht leer sein"
|
||||||
},
|
},
|
||||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||||
"cameraStarting": "Kamera wird gestartet.",
|
"cameraStarting": "Kamera wird gestartet…",
|
||||||
|
"cameraNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen können?",
|
||||||
|
"cameraAndMicNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen und hören können?",
|
||||||
|
"permissionsButton": {
|
||||||
|
"cameraAndMicNotGranted": "Zugriff auf Kamera und Mikrofon erlauben",
|
||||||
|
"cameraNotGranted": "Zugriff auf Kamera erlauben"
|
||||||
|
},
|
||||||
|
"videoPreview": {
|
||||||
|
"enabled": "Videovorschau aktiviert",
|
||||||
|
"disabled": "Videovorschau deaktiviert"
|
||||||
|
},
|
||||||
"waiting": {
|
"waiting": {
|
||||||
"title": "Beitrittsanfrage wird gesendet...",
|
"title": "Beitrittsanfrage wird gesendet...",
|
||||||
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
|
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
|
||||||
@@ -53,12 +79,16 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
|
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copy": "Meeting-Link kopieren",
|
"copy": "Informationen kopieren",
|
||||||
"copyButton": "Link kopieren",
|
"copyUrl": "Link kopieren",
|
||||||
"copied": "Link in die Zwischenablage kopiert",
|
"copied": "Informationen kopiert",
|
||||||
"heading": "Ihr Meeting ist bereit",
|
"heading": "Ihr Meeting ist bereit",
|
||||||
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
||||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten."
|
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
|
||||||
|
"phone": {
|
||||||
|
"call": "Rufen Sie an:",
|
||||||
|
"pinCode": "Code:"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mediaErrorDialog": {
|
"mediaErrorDialog": {
|
||||||
"DeviceInUse": {
|
"DeviceInUse": {
|
||||||
@@ -73,6 +103,30 @@
|
|||||||
},
|
},
|
||||||
"close": "OK"
|
"close": "OK"
|
||||||
},
|
},
|
||||||
|
"permissionErrorDialog": {
|
||||||
|
"heading": {
|
||||||
|
"camera": "{{appTitle}} darf Ihre Kamera nicht verwenden",
|
||||||
|
"microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden",
|
||||||
|
"cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden",
|
||||||
|
"default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"openMenu": {
|
||||||
|
"others": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers",
|
||||||
|
"safari": "Klicken Sie auf das Menü 'Safari' und öffnen Sie 'Einstellungen für {{appDomain}}'."
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"camera": "Zugriff auf die Kamera erlauben",
|
||||||
|
"microphone": "Zugriff auf das Mikrofon erlauben",
|
||||||
|
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben",
|
||||||
|
"default": "Aktivieren Sie die erforderlichen Berechtigungen."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"tooltip": "Mehr Infos",
|
||||||
|
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentifizierung erforderlich",
|
"heading": "Authentifizierung erforderlich",
|
||||||
@@ -226,9 +280,9 @@
|
|||||||
"roomInformation": {
|
"roomInformation": {
|
||||||
"title": "Verbindungsinformationen",
|
"title": "Verbindungsinformationen",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Kopiere deine Meeting-Adresse",
|
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
|
||||||
"copy": "Adresse kopieren",
|
"copy": "Informationen kopieren",
|
||||||
"copied": "Adresse kopiert"
|
"copied": "Informationen kopiert"
|
||||||
},
|
},
|
||||||
"phone": {
|
"phone": {
|
||||||
"call": "Rufen Sie an:",
|
"call": "Rufen Sie an:",
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": "Berechtigungen erforderlich"
|
"permissionsRequired": "Berechtigungen erforderlich"
|
||||||
},
|
},
|
||||||
|
"video": {
|
||||||
|
"camera": {
|
||||||
|
"heading": "Kamera",
|
||||||
|
"label": "Wählen Sie Ihre Videoeingabe aus",
|
||||||
|
"disabled": "Kamera deaktiviert",
|
||||||
|
"previewAriaLabel": {
|
||||||
|
"enabled": "Videovorschau aktiviert",
|
||||||
|
"disabled": "Videovorschau deaktiviert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"heading": "Auflösung",
|
||||||
|
"publish": {
|
||||||
|
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "Hohe Auflösung",
|
||||||
|
"medium": "Standardauflösung",
|
||||||
|
"low": "Niedrige Auflösung"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "Hohe Auflösung (automatisch)",
|
||||||
|
"medium": "Standardauflösung",
|
||||||
|
"low": "Niedrige Auflösung"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsRequired": "Berechtigungen erforderlich"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Tonbenachrichtigungen",
|
"heading": "Tonbenachrichtigungen",
|
||||||
"label": "Tonbenachrichtigungen für",
|
"label": "Tonbenachrichtigungen für",
|
||||||
@@ -54,6 +85,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Profil",
|
"account": "Profil",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
"video": "Video",
|
||||||
"general": "Allgemein",
|
"general": "Allgemein",
|
||||||
"notifications": "Benachrichtigungen"
|
"notifications": "Benachrichtigungen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,9 @@
|
|||||||
"ariaLabel": "Close the suggestion",
|
"ariaLabel": "Close the suggestion",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clipboardContent": {
|
||||||
|
"url": "To join the video conference, click on this link: {{roomUrl}}",
|
||||||
|
"numberAndPin": "To join by phone, dial {{phoneNumber}} and enter this code: {{pinCode}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,15 @@
|
|||||||
},
|
},
|
||||||
"laterMeetingDialog": {
|
"laterMeetingDialog": {
|
||||||
"heading": "Your connection details",
|
"heading": "Your connection details",
|
||||||
"description": "Send this link to the people you want to invite to the meeting. They will be able to join without ProConnect.",
|
"description": "Share this information with the guests. They will be able to join the meeting without needing to sign in. This meeting is permanent and can be reused.",
|
||||||
"copy": "Copy the meeting link",
|
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||||
"copied": "Link copied to clipboard",
|
"copy": "Copy information",
|
||||||
"permissions": "People with this link do not need your permission to join this meeting."
|
"copied": "Information copied to clipboard",
|
||||||
|
"copyUrl": "Copy the meeting link",
|
||||||
|
"phone": {
|
||||||
|
"call": "Call:",
|
||||||
|
"pinCode": "Code:"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
|
|||||||
@@ -7,9 +7,18 @@
|
|||||||
"home": "Return to home",
|
"home": "Return to home",
|
||||||
"back": "Rejoin the meeting"
|
"back": "Rejoin the meeting"
|
||||||
},
|
},
|
||||||
"join": {
|
"selectDevice": {
|
||||||
|
"loading": "Loading…",
|
||||||
|
"select": "Select a value",
|
||||||
|
"permissionsNeeded": "Permission needed",
|
||||||
|
"settings": {
|
||||||
|
"audio": "Audio settings",
|
||||||
|
"video": "Video settings"
|
||||||
|
},
|
||||||
|
"effects": "Effects",
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
"choose": "Select camera",
|
"choose": "Select camera",
|
||||||
|
"permissionsNeeded": "Select camera - permission needed",
|
||||||
"disable": "Disable camera",
|
"disable": "Disable camera",
|
||||||
"enable": "Enable camera",
|
"enable": "Enable camera",
|
||||||
"label": "Camera",
|
"label": "Camera",
|
||||||
@@ -17,10 +26,17 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Select microphone",
|
"choose": "Select microphone",
|
||||||
|
"permissionsNeeded": "Select microphone - permission needed",
|
||||||
"disable": "Disable microphone",
|
"disable": "Disable microphone",
|
||||||
"enable": "Enable microphone",
|
"enable": "Enable microphone",
|
||||||
"label": "Microphone"
|
"label": "Microphone"
|
||||||
},
|
},
|
||||||
|
"audiooutput": {
|
||||||
|
"choose": "Select speaker",
|
||||||
|
"permissionsNeeded": "Select speaker - permission needed"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
"effects": {
|
"effects": {
|
||||||
"description": "Apply effects",
|
"description": "Apply effects",
|
||||||
"title": "Effects",
|
"title": "Effects",
|
||||||
@@ -37,7 +53,17 @@
|
|||||||
"usernameEmpty": "Your name cannot be empty"
|
"usernameEmpty": "Your name cannot be empty"
|
||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is disabled.",
|
"cameraDisabled": "Camera is disabled.",
|
||||||
"cameraStarting": "Camera is starting.",
|
"cameraStarting": "Camera is starting…",
|
||||||
|
"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?",
|
||||||
|
"permissionsButton": {
|
||||||
|
"cameraAndMicNotGranted": "Allow access to camera and microphone",
|
||||||
|
"cameraNotGranted": "Allow access to camera"
|
||||||
|
},
|
||||||
|
"videoPreview": {
|
||||||
|
"enabled": "Video preview enabled",
|
||||||
|
"disabled": "Video preview disabled"
|
||||||
|
},
|
||||||
"waiting": {
|
"waiting": {
|
||||||
"title": "Requesting to join...",
|
"title": "Requesting to join...",
|
||||||
"body": "You will be able to join this call when someone authorizes you"
|
"body": "You will be able to join this call when someone authorizes you"
|
||||||
@@ -53,12 +79,16 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copy": "Copy the meeting link",
|
"copy": "Copy information",
|
||||||
"copyButton": "Copy link",
|
"copyUrl": "Copy link",
|
||||||
"copied": "Link copied to clipboard",
|
"copied": "Information copied to clipboard",
|
||||||
"heading": "Your meeting is ready",
|
"heading": "Your meeting is ready",
|
||||||
"description": "Share this link with people you want to invite to the meeting.",
|
"description": "Share this link with people you want to invite to the meeting.",
|
||||||
"permissions": "People with this link do not need your permission to join this meeting."
|
"permissions": "People with this link do not need your permission to join this meeting.",
|
||||||
|
"phone": {
|
||||||
|
"call": "Call:",
|
||||||
|
"pinCode": "Code:"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mediaErrorDialog": {
|
"mediaErrorDialog": {
|
||||||
"DeviceInUse": {
|
"DeviceInUse": {
|
||||||
@@ -73,6 +103,30 @@
|
|||||||
},
|
},
|
||||||
"close": "OK"
|
"close": "OK"
|
||||||
},
|
},
|
||||||
|
"permissionErrorDialog": {
|
||||||
|
"heading": {
|
||||||
|
"camera": "{{appTitle}} is not allowed to use your camera",
|
||||||
|
"microphone": "{{appTitle}} is not allowed to use your microphone",
|
||||||
|
"cameraAndMicrophone": "{{appTitle}} is not allowed to use your microphone or camera",
|
||||||
|
"default": "{{appTitle}} is not allowed to use certain permissions"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"openMenu": {
|
||||||
|
"others": "Click on the settings icon ICON_PLACEHOLDER in your browser’s address bar",
|
||||||
|
"safari": "Click the 'Safari' menu, and open 'Settings for {{appDomain}}'."
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"camera": "Allow access to the camera",
|
||||||
|
"microphone": "Allow access to the microphone",
|
||||||
|
"cameraAndMicrophone": "Allow access to the camera and microphone",
|
||||||
|
"default": "Enable the necessary permissions."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"tooltip": "More info",
|
||||||
|
"ariaLabel": "Permissions issue. Show more info"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentication Required",
|
"heading": "Authentication Required",
|
||||||
@@ -226,9 +280,9 @@
|
|||||||
"roomInformation": {
|
"roomInformation": {
|
||||||
"title": "Connection Information",
|
"title": "Connection Information",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Copy your meeting address",
|
"ariaLabel": "Copy the information from your meeting",
|
||||||
"copy": "Copy address",
|
"copy": "Copy information",
|
||||||
"copied": "Address copied"
|
"copied": "Information copied"
|
||||||
},
|
},
|
||||||
"phone": {
|
"phone": {
|
||||||
"call": "Call:",
|
"call": "Call:",
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": "Permissions required"
|
"permissionsRequired": "Permissions required"
|
||||||
},
|
},
|
||||||
|
"video": {
|
||||||
|
"camera": {
|
||||||
|
"heading": "Camera",
|
||||||
|
"label": "Select your video input",
|
||||||
|
"disabled": "Camera disabled",
|
||||||
|
"previewAriaLabel": {
|
||||||
|
"enabled": "Video preview enabled",
|
||||||
|
"disabled": "Video preview disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"heading": "Resolution",
|
||||||
|
"publish": {
|
||||||
|
"label": "Select your sending resolution (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "High definition",
|
||||||
|
"medium": "Standard definition",
|
||||||
|
"low": "Low definition"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"label": "Select your reception resolution (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "High definition (automatic)",
|
||||||
|
"medium": "Standard definition",
|
||||||
|
"low": "Low definition"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsRequired": "Permissions required"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Sound notifications",
|
"heading": "Sound notifications",
|
||||||
"label": "sound notifications for",
|
"label": "sound notifications for",
|
||||||
@@ -54,6 +85,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Profile",
|
"account": "Profile",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
"video": "Video",
|
||||||
"general": "General",
|
"general": "General",
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,9 @@
|
|||||||
"ariaLabel": "Fermer la suggestion",
|
"ariaLabel": "Fermer la suggestion",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clipboardContent": {
|
||||||
|
"url": "Pour participer à la visioconférence, cliquez sur ce lien : {{roomUrl}}",
|
||||||
|
"numberAndPin": "Pour participer par téléphone, composez le {{phoneNumber}} et saisissez ce code : {{pinCode}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,15 @@
|
|||||||
},
|
},
|
||||||
"laterMeetingDialog": {
|
"laterMeetingDialog": {
|
||||||
"heading": "Vos informations de connexion",
|
"heading": "Vos informations de connexion",
|
||||||
"description": "Envoyez ce lien aux personnes que vous souhaitez inviter à la réunion. Ils pourront la rejoindre sans ProConnect.",
|
"description": "Partagez ces informations avec les invités. Ils pourront rejoindre la réunion sans avoir besoin de se connecter. Cette réunion est permanente et peut être réutilisée.",
|
||||||
"copy": "Copier le lien de la réunion",
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
|
||||||
"copied": "Lien copié dans le presse-papiers",
|
"copy": "Copier les informations",
|
||||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
"copied": "Copiées dans le presse-papiers",
|
||||||
|
"copyUrl": "Copier le lien de la réunion",
|
||||||
|
"phone": {
|
||||||
|
"call": "Appelez le :",
|
||||||
|
"pinCode": "Code :"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
|
|||||||
@@ -7,9 +7,18 @@
|
|||||||
"home": "Retourner à l'accueil",
|
"home": "Retourner à l'accueil",
|
||||||
"back": "Réintégrer la réunion"
|
"back": "Réintégrer la réunion"
|
||||||
},
|
},
|
||||||
"join": {
|
"selectDevice": {
|
||||||
|
"loading": "Chargement…",
|
||||||
|
"select": "Sélectionnez une valeur",
|
||||||
|
"permissionsNeeded": "Autorisations nécessaires",
|
||||||
|
"settings": {
|
||||||
|
"audio": "Paramètres audio",
|
||||||
|
"video": "Paramètres video"
|
||||||
|
},
|
||||||
|
"effects": "Effets d'arrière-plan",
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
"choose": "Choisir la webcam",
|
"choose": "Choisir la webcam",
|
||||||
|
"permissionsNeeded": "Choisir la webcam - autorisations nécessaires",
|
||||||
"disable": "Désactiver la webcam",
|
"disable": "Désactiver la webcam",
|
||||||
"enable": "Activer la webcam",
|
"enable": "Activer la webcam",
|
||||||
"label": "Webcam",
|
"label": "Webcam",
|
||||||
@@ -17,13 +26,20 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Choisir le micro",
|
"choose": "Choisir le micro",
|
||||||
|
"permissionsNeeded": "Choisir le micro - autorisations nécessaires",
|
||||||
"disable": "Désactiver le micro",
|
"disable": "Désactiver le micro",
|
||||||
"enable": "Activer le micro",
|
"enable": "Activer le micro",
|
||||||
"label": "Microphone"
|
"label": "Microphone"
|
||||||
},
|
},
|
||||||
|
"audiooutput": {
|
||||||
|
"choose": "Choisir le haut-parleur",
|
||||||
|
"permissionsNeeded": "Choisir le haut-parleur - autorisations nécessaires"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
"heading": "Rejoindre la réunion ?",
|
"heading": "Rejoindre la réunion ?",
|
||||||
"effects": {
|
"effects": {
|
||||||
"description": "Effets d'arrière plan",
|
"description": "Effets d'arrière-plan",
|
||||||
"title": "Effets",
|
"title": "Effets",
|
||||||
"subTitle": "Paramétrez les effets de votre caméra."
|
"subTitle": "Paramétrez les effets de votre caméra."
|
||||||
},
|
},
|
||||||
@@ -37,7 +53,17 @@
|
|||||||
"usernameEmpty": "Votre nom ne peut pas être vide"
|
"usernameEmpty": "Votre nom ne peut pas être vide"
|
||||||
},
|
},
|
||||||
"cameraDisabled": "La caméra est désactivée.",
|
"cameraDisabled": "La caméra est désactivée.",
|
||||||
"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 ?",
|
||||||
|
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||||
|
"videoPreview": {
|
||||||
|
"enabled": "Apercu vidéo activé",
|
||||||
|
"disabled": "Apercu vidéo désactivé"
|
||||||
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"cameraAndMicNotGranted": "Autorisez l'accès à la caméra et au micro",
|
||||||
|
"cameraNotGranted": "Autorisez l'accès à la caméra"
|
||||||
|
},
|
||||||
"waiting": {
|
"waiting": {
|
||||||
"title": "Demande de participation…",
|
"title": "Demande de participation…",
|
||||||
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
|
"body": "Vous pourrez participer à cet appel lorsque quelqu'un vous y autorisera"
|
||||||
@@ -53,12 +79,16 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copy": "Copier le lien de la réunion",
|
"copy": "Copier les informations",
|
||||||
"copyButton": "Copier le lien",
|
"copyUrl": "Copier le lien",
|
||||||
"copied": "Lien copié dans le presse-papiers",
|
"copied": "Copiées dans le presse-papiers",
|
||||||
"heading": "Votre réunion est prête",
|
"heading": "Votre réunion est prête",
|
||||||
"description": "Partagez ce lien avec les personnes que vous souhaitez inviter à la réunion.",
|
"description": "Partagez ces informations avec les personnes que vous souhaitez inviter à la réunion.",
|
||||||
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion."
|
"permissions": "Les personnes disposant de ce lien n'ont pas besoin de votre autorisation pour rejoindre cette réunion.",
|
||||||
|
"phone": {
|
||||||
|
"call": "Appelez le :",
|
||||||
|
"pinCode": "Code :"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mediaErrorDialog": {
|
"mediaErrorDialog": {
|
||||||
"DeviceInUse": {
|
"DeviceInUse": {
|
||||||
@@ -73,6 +103,30 @@
|
|||||||
},
|
},
|
||||||
"close": "OK"
|
"close": "OK"
|
||||||
},
|
},
|
||||||
|
"permissionErrorDialog": {
|
||||||
|
"heading": {
|
||||||
|
"camera": "{{appTitle}} n'est pas autorisé à utiliser votre caméra",
|
||||||
|
"microphone": "{{appTitle}} n'est pas autorisé à utiliser votre micro",
|
||||||
|
"cameraAndMicrophone": "{{appTitle}} n'est pas autorisé à utiliser votre micro ni votre caméra",
|
||||||
|
"default": "{{appTitle}} n'est pas autorisé à utiliser certaines fonctionnalités nécessaires."
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"openMenu": {
|
||||||
|
"others": "Cliquez sur l'icône des paramètres ICON_PLACEHOLDER dans la barre d'adresse de votre navigateur",
|
||||||
|
"safari": "Cliquez sur le menu \"Safari\", et ouvrez \"Paramètres pour {{appDomain}}\"."
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"camera": "Autorisez l'accès à la caméra",
|
||||||
|
"microphone": "Autorisez l'accès au microphone",
|
||||||
|
"cameraAndMicrophone": "Autorisez l'accès à la caméra et au microphone",
|
||||||
|
"default": "Activez les autorisations nécessaires."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"tooltip": "Plus d'infos",
|
||||||
|
"ariaLabel": "Problème de permissions. Afficher plus d'infos"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Authentification requise",
|
"heading": "Authentification requise",
|
||||||
@@ -144,7 +198,7 @@
|
|||||||
"feedback": "Partager votre avis",
|
"feedback": "Partager votre avis",
|
||||||
"settings": "Paramètres",
|
"settings": "Paramètres",
|
||||||
"username": "Choisir votre nom",
|
"username": "Choisir votre nom",
|
||||||
"effects": "Effets d'arrière plan",
|
"effects": "Effets d'arrière-plan",
|
||||||
"switchCamera": "Changer de caméra",
|
"switchCamera": "Changer de caméra",
|
||||||
"fullscreen": {
|
"fullscreen": {
|
||||||
"enter": "Plein écran",
|
"enter": "Plein écran",
|
||||||
@@ -226,9 +280,9 @@
|
|||||||
"roomInformation": {
|
"roomInformation": {
|
||||||
"title": "Informations de connexions",
|
"title": "Informations de connexions",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Copier l'adresse de votre réunion",
|
"ariaLabel": "Copier les informations de votre réunion",
|
||||||
"copy": "Copier l'adresse",
|
"copy": "Copier les informations",
|
||||||
"copied": "Adresse copiée"
|
"copied": "Informations copiées"
|
||||||
},
|
},
|
||||||
"phone": {
|
"phone": {
|
||||||
"call": "Appelez le :",
|
"call": "Appelez le :",
|
||||||
@@ -408,7 +462,7 @@
|
|||||||
"enable": "Épingler",
|
"enable": "Épingler",
|
||||||
"disable": "Annuler l'épinglage"
|
"disable": "Annuler l'épinglage"
|
||||||
},
|
},
|
||||||
"effects": "Effets d'arrière plan",
|
"effects": "Effets d'arrière-plan",
|
||||||
"muteParticipant": "Couper le micro de {{name}}",
|
"muteParticipant": "Couper le micro de {{name}}",
|
||||||
"fullScreen": "Plein écran"
|
"fullScreen": "Plein écran"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": "Autorisations nécessaires"
|
"permissionsRequired": "Autorisations nécessaires"
|
||||||
},
|
},
|
||||||
|
"video": {
|
||||||
|
"camera": {
|
||||||
|
"heading": "Caméra",
|
||||||
|
"label": "Sélectionner votre entrée vidéo",
|
||||||
|
"disabled": "Caméra désactivée",
|
||||||
|
"previewAriaLabel": {
|
||||||
|
"enabled": "Aperçu vidéo activé",
|
||||||
|
"disabled": "Aperçu vidéo désactivé"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"heading": "Résolution",
|
||||||
|
"publish": {
|
||||||
|
"label": "Sélectionner votre résolution d'envoi (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "Haute définition",
|
||||||
|
"medium": "Définition standard",
|
||||||
|
"low": "Basse définition"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"label": "Sélectionner votre résolution de réception (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "Haute définition (automatique)",
|
||||||
|
"medium": "Définition standard",
|
||||||
|
"low": "Basse définition"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsRequired": "Autorisations nécessaires"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Notifications sonores",
|
"heading": "Notifications sonores",
|
||||||
"label": "la notification sonore pour",
|
"label": "la notification sonore pour",
|
||||||
@@ -54,6 +85,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Profil",
|
"account": "Profil",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
"video": "Vidéo",
|
||||||
"general": "Général",
|
"general": "Général",
|
||||||
"notifications": "Notifications"
|
"notifications": "Notifications"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,5 +50,9 @@
|
|||||||
"ariaLabel": "Sluit de suggestie",
|
"ariaLabel": "Sluit de suggestie",
|
||||||
"label": "OK"
|
"label": "OK"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"clipboardContent": {
|
||||||
|
"url": "Klik op deze link om deel te nemen aan de videoconferentie: {{roomUrl}}",
|
||||||
|
"numberAndPin": "Bel {{phoneNumber}} en voer deze code in om telefonisch deel te nemen: {{pinCode}}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,15 @@
|
|||||||
},
|
},
|
||||||
"laterMeetingDialog": {
|
"laterMeetingDialog": {
|
||||||
"heading": "Uw verbindingsgegevens",
|
"heading": "Uw verbindingsgegevens",
|
||||||
"description": "Stuur deze link naar de mensen die u wilt uitnodigen voor de vergadering. Zij kunnen deelnemen zonder ProConnect.",
|
"description": "Deel deze informatie met de genodigden. Zij kunnen deelnemen aan de vergadering zonder zich aan te melden. Deze vergadering is permanent en kan hergebruikt worden.",
|
||||||
"copy": "Kopieer de vergaderlink",
|
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
|
||||||
"copied": "Link gekopieerd naar klembord",
|
"copy": "Informatie kopiëren",
|
||||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
|
"copied": "Informatie gekopieerd",
|
||||||
|
"copyUrl": "Kopieer de vergaderlink",
|
||||||
|
"phone": {
|
||||||
|
"call": "Bel:",
|
||||||
|
"pinCode": "Code:"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"introSlider": {
|
"introSlider": {
|
||||||
"previous": {
|
"previous": {
|
||||||
|
|||||||
@@ -7,9 +7,18 @@
|
|||||||
"home": "Keer terug naar het hoofdscherm",
|
"home": "Keer terug naar het hoofdscherm",
|
||||||
"back": "Sluit weer bij de vergadering aan"
|
"back": "Sluit weer bij de vergadering aan"
|
||||||
},
|
},
|
||||||
"join": {
|
"selectDevice": {
|
||||||
|
"loading": "Bezig met laden…",
|
||||||
|
"select": "Selecteer een waarde",
|
||||||
|
"permissionsNeeded": "Toestemming vereist",
|
||||||
|
"settings": {
|
||||||
|
"audio": "Audio-instellingen",
|
||||||
|
"video": "Video-instellingen"
|
||||||
|
},
|
||||||
|
"effects": "Pas effecten toe",
|
||||||
"videoinput": {
|
"videoinput": {
|
||||||
"choose": "Selecteer camera",
|
"choose": "Selecteer camera",
|
||||||
|
"permissionsNeeded": "Selecteer camera - Toestemming vereist",
|
||||||
"disable": "Camera uitschakelen",
|
"disable": "Camera uitschakelen",
|
||||||
"enable": "Camera inschakelen",
|
"enable": "Camera inschakelen",
|
||||||
"label": "Camera",
|
"label": "Camera",
|
||||||
@@ -17,10 +26,17 @@
|
|||||||
},
|
},
|
||||||
"audioinput": {
|
"audioinput": {
|
||||||
"choose": "Selecteer microfoon",
|
"choose": "Selecteer microfoon",
|
||||||
|
"permissionsNeeded": "Selecteer microfoon - Toestemming vereist",
|
||||||
"disable": "Microfoon dempen",
|
"disable": "Microfoon dempen",
|
||||||
"enable": "Microfoon dempen opheffen",
|
"enable": "Microfoon dempen opheffen",
|
||||||
"label": "Microfoon"
|
"label": "Microfoon"
|
||||||
},
|
},
|
||||||
|
"audiooutput": {
|
||||||
|
"choose": "Selecteer luidspreker",
|
||||||
|
"permissionsNeeded": "Selecteer luidspreker - Toestemming vereist"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"join": {
|
||||||
"effects": {
|
"effects": {
|
||||||
"description": "Pas effecten toe",
|
"description": "Pas effecten toe",
|
||||||
"title": "Effecten",
|
"title": "Effecten",
|
||||||
@@ -37,7 +53,17 @@
|
|||||||
"usernameEmpty": "Uw naam kan niet leeg zijn"
|
"usernameEmpty": "Uw naam kan niet leeg zijn"
|
||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||||
"cameraStarting": "Camera wordt ingeschakeld.",
|
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||||
|
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||||
|
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||||
|
"permissionsButton": {
|
||||||
|
"cameraAndMicNotGranted": "Toegang tot camera en microfoon toestaan",
|
||||||
|
"cameraNotGranted": "Toegang tot camera toestaan"
|
||||||
|
},
|
||||||
|
"videoPreview": {
|
||||||
|
"enabled": "Videovoorbeeld ingeschakeld",
|
||||||
|
"disabled": "Videovoorbeeld uitgeschakeld"
|
||||||
|
},
|
||||||
"waiting": {
|
"waiting": {
|
||||||
"title": "Verzoek tot deelname...",
|
"title": "Verzoek tot deelname...",
|
||||||
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
|
"body": "U kunt deelnemen aan dit gesprek wanneer iemand u toestemming geeft"
|
||||||
@@ -53,12 +79,16 @@
|
|||||||
},
|
},
|
||||||
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
|
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
|
||||||
"shareDialog": {
|
"shareDialog": {
|
||||||
"copy": "Kopieer de vergaderlink",
|
"copy": "Informatie kopiëren",
|
||||||
"copyButton": "Kopieerlink",
|
"copyUrl": "Kopieerlink",
|
||||||
"copied": "Link gekopieerd naar het klembord",
|
"copied": "Informatie gekopieerd",
|
||||||
"heading": "Uw vergadering is klaar",
|
"heading": "Uw vergadering is klaar",
|
||||||
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
|
"description": "Deel deze link met mensen die u wilt uitnodigen voor de vergadering.",
|
||||||
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering."
|
"permissions": "Mensen met deze link hebben uw toestemming niet nodig om deel te nemen aan deze vergadering.",
|
||||||
|
"phone": {
|
||||||
|
"call": "Bel:",
|
||||||
|
"pinCode": "Code:"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"mediaErrorDialog": {
|
"mediaErrorDialog": {
|
||||||
"DeviceInUse": {
|
"DeviceInUse": {
|
||||||
@@ -73,6 +103,30 @@
|
|||||||
},
|
},
|
||||||
"close": "OK"
|
"close": "OK"
|
||||||
},
|
},
|
||||||
|
"permissionErrorDialog": {
|
||||||
|
"heading": {
|
||||||
|
"camera": "{{appTitle}} mag uw camera niet gebruiken",
|
||||||
|
"microphone": "{{appTitle}} mag uw microfoon niet gebruiken",
|
||||||
|
"cameraAndMicrophone": "{{appTitle}} mag uw microfoon en camera niet gebruiken",
|
||||||
|
"default": "{{appTitle}} heeft geen toestemming voor bepaalde rechten"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"openMenu": {
|
||||||
|
"others": "Klik op het instellingenpictogram ICON_PLACEHOLDER in de adresbalk van uw browser",
|
||||||
|
"safari": "Klik op het menu 'Safari' en open 'Instellingen voor {{appDomain}}'."
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"camera": "Toegang tot de camera toestaan",
|
||||||
|
"microphone": "Toegang tot de microfoon toestaan",
|
||||||
|
"cameraAndMicrophone": "Toegang tot camera en microfoon toestaan",
|
||||||
|
"default": "Schakel de vereiste machtigingen in."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsButton": {
|
||||||
|
"tooltip": "Meer info",
|
||||||
|
"ariaLabel": "Probleem met machtigingen. Meer info weergeven"
|
||||||
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"createRoom": {
|
"createRoom": {
|
||||||
"heading": "Verificatie vereist",
|
"heading": "Verificatie vereist",
|
||||||
@@ -226,9 +280,9 @@
|
|||||||
"roomInformation": {
|
"roomInformation": {
|
||||||
"title": "Verbindingsinformatie",
|
"title": "Verbindingsinformatie",
|
||||||
"button": {
|
"button": {
|
||||||
"ariaLabel": "Kopieer je vergaderadres",
|
"ariaLabel": "Kopieer de informatie van uw vergadering",
|
||||||
"copy": "Adres kopiëren",
|
"copy": "Informatie kopiëren",
|
||||||
"copied": "Adres gekopieerd"
|
"copied": "Informatie gekopieerd"
|
||||||
},
|
},
|
||||||
"phone": {
|
"phone": {
|
||||||
"call": "Bel:",
|
"call": "Bel:",
|
||||||
|
|||||||
@@ -30,6 +30,37 @@
|
|||||||
},
|
},
|
||||||
"permissionsRequired": "Machtigingen vereist"
|
"permissionsRequired": "Machtigingen vereist"
|
||||||
},
|
},
|
||||||
|
"video": {
|
||||||
|
"camera": {
|
||||||
|
"heading": "Camera",
|
||||||
|
"label": "Selecteer uw video-ingang",
|
||||||
|
"disabled": "Camera uitgeschakeld",
|
||||||
|
"previewAriaLabel": {
|
||||||
|
"enabled": "Videovoorbeeld ingeschakeld",
|
||||||
|
"disabled": "Videovoorbeeld uitgeschakeld"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"resolution": {
|
||||||
|
"heading": "Resolutie",
|
||||||
|
"publish": {
|
||||||
|
"label": "Selecteer uw verzendresolutie (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "Hoge definitie",
|
||||||
|
"medium": "Standaarddefinitie",
|
||||||
|
"low": "Lage definitie"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"label": "Selecteer uw ontvangstresolutie (max.)",
|
||||||
|
"items": {
|
||||||
|
"high": "Hoge definitie (automatisch)",
|
||||||
|
"medium": "Standaarddefinitie",
|
||||||
|
"low": "Lage definitie"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"permissionsRequired": "Machtigingen vereist"
|
||||||
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"heading": "Geluidsmeldingen",
|
"heading": "Geluidsmeldingen",
|
||||||
"label": "Geluidsmeldingen voor",
|
"label": "Geluidsmeldingen voor",
|
||||||
@@ -54,6 +85,7 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"account": "Profiel",
|
"account": "Profiel",
|
||||||
"audio": "Audio",
|
"audio": "Audio",
|
||||||
|
"video": "Video",
|
||||||
"general": "Algemeen",
|
"general": "Algemeen",
|
||||||
"notifications": "Meldingen"
|
"notifications": "Meldingen"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,18 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
|||||||
transform: 'rotate(180deg) translateY(-1px)',
|
transform: 'rotate(180deg) translateY(-1px)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
light: {},
|
||||||
|
dark: {
|
||||||
|
fill: 'primaryDark.50',
|
||||||
|
stroke: 'primaryDark.50',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'light',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +68,8 @@ const StyledOverlayArrow = styled(OverlayArrow, {
|
|||||||
*/
|
*/
|
||||||
export const Popover = ({
|
export const Popover = ({
|
||||||
children,
|
children,
|
||||||
|
variant = 'light',
|
||||||
|
withArrow = true,
|
||||||
...dialogProps
|
...dialogProps
|
||||||
}: {
|
}: {
|
||||||
children: [
|
children: [
|
||||||
@@ -64,20 +78,24 @@ export const Popover = ({
|
|||||||
| (({ close }: { close: () => void }) => ReactNode)
|
| (({ close }: { close: () => void }) => ReactNode)
|
||||||
| ReactNode,
|
| ReactNode,
|
||||||
]
|
]
|
||||||
} & DialogProps) => {
|
variant?: 'dark' | 'light'
|
||||||
|
withArrow?: boolean
|
||||||
|
} & Omit<DialogProps, 'children'>) => {
|
||||||
const [trigger, popoverContent] = children
|
const [trigger, popoverContent] = children
|
||||||
return (
|
return (
|
||||||
<DialogTrigger>
|
<DialogTrigger>
|
||||||
{trigger}
|
{trigger}
|
||||||
<StyledPopover>
|
<StyledPopover>
|
||||||
<StyledOverlayArrow>
|
{withArrow && (
|
||||||
<svg width={12} height={12} viewBox="0 0 12 12">
|
<StyledOverlayArrow variant={variant}>
|
||||||
<path d="M0 0 L6 6 L12 0" />
|
<svg width={12} height={12} viewBox="0 0 12 12">
|
||||||
</svg>
|
<path d="M0 0 L6 6 L12 0" />
|
||||||
</StyledOverlayArrow>
|
</svg>
|
||||||
|
</StyledOverlayArrow>
|
||||||
|
)}
|
||||||
<Dialog {...dialogProps}>
|
<Dialog {...dialogProps}>
|
||||||
{({ close }) => (
|
{({ close }) => (
|
||||||
<Box size="sm" type="popover">
|
<Box size="sm" type="popover" variant={variant}>
|
||||||
{typeof popoverContent === 'function'
|
{typeof popoverContent === 'function'
|
||||||
? popoverContent({ close })
|
? popoverContent({ close })
|
||||||
: popoverContent}
|
: popoverContent}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
import { type ReactNode } from 'react'
|
import { type ReactNode } from 'react'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { RiArrowDropDownLine } from '@remixicon/react'
|
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
ListBox,
|
ListBox,
|
||||||
ListBoxItem,
|
ListBoxItem,
|
||||||
Select as RACSelect,
|
Select as RACSelect,
|
||||||
SelectProps,
|
SelectProps as RACSelectProps,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from 'react-aria-components'
|
} from 'react-aria-components'
|
||||||
import { Box } from './Box'
|
import { Box } from './Box'
|
||||||
import { StyledPopover } from './Popover'
|
import { StyledPopover } from './Popover'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import type { Placement } from '@react-types/overlays'
|
||||||
|
|
||||||
const StyledButton = styled(Button, {
|
const StyledButton = styled(Button, {
|
||||||
base: {
|
base: {
|
||||||
width: 'full',
|
width: 'full',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
paddingY: 0.125,
|
paddingY: 0.125,
|
||||||
paddingX: 0.25,
|
paddingX: 0.25,
|
||||||
border: '1px solid',
|
border: '1px solid',
|
||||||
@@ -39,6 +42,31 @@ const StyledButton = styled(Button, {
|
|||||||
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
|
boxShadow: '0 1px 2px rgba(0 0 0 / 0.02)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
light: {},
|
||||||
|
dark: {
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
fontWeight: 'medium !important',
|
||||||
|
color: 'white',
|
||||||
|
'&[data-pressed]': {
|
||||||
|
backgroundColor: 'primaryDark.900',
|
||||||
|
color: 'primaryDark.100',
|
||||||
|
},
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'primaryDark.300',
|
||||||
|
color: 'white',
|
||||||
|
},
|
||||||
|
'&[data-selected]': {
|
||||||
|
backgroundColor: 'primaryDark.700 !important',
|
||||||
|
color: 'primaryDark.100 !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'light',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const StyledSelectValue = styled(SelectValue, {
|
const StyledSelectValue = styled(SelectValue, {
|
||||||
@@ -53,30 +81,60 @@ const StyledSelectValue = styled(SelectValue, {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export const Select = <T extends string | number>({
|
const StyledIcon = styled('div', {
|
||||||
label,
|
base: {
|
||||||
items,
|
marginRight: '0.35rem',
|
||||||
errors,
|
flexShrink: 0,
|
||||||
...props
|
},
|
||||||
}: Omit<SelectProps<object>, 'items' | 'label' | 'errors'> & {
|
})
|
||||||
|
|
||||||
|
export type SelectProps<T> = Omit<
|
||||||
|
RACSelectProps<object>,
|
||||||
|
'items' | 'label' | 'errors'
|
||||||
|
> & {
|
||||||
|
iconComponent?: RemixiconComponentType
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
items: Array<{ value: T; label: ReactNode }>
|
items: Array<{ value: T; label: ReactNode }>
|
||||||
errors?: ReactNode
|
errors?: ReactNode
|
||||||
}) => {
|
placement?: Placement
|
||||||
|
variant?: 'light' | 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = <T extends string | number>({
|
||||||
|
label,
|
||||||
|
iconComponent,
|
||||||
|
items,
|
||||||
|
errors,
|
||||||
|
placement,
|
||||||
|
variant = 'light',
|
||||||
|
...props
|
||||||
|
}: SelectProps<T>) => {
|
||||||
|
const IconComponent = iconComponent
|
||||||
return (
|
return (
|
||||||
<RACSelect {...props}>
|
<RACSelect {...props}>
|
||||||
{label}
|
{label}
|
||||||
<StyledButton>
|
<StyledButton variant={variant}>
|
||||||
|
{!!IconComponent && (
|
||||||
|
<StyledIcon>
|
||||||
|
<IconComponent size={18} />
|
||||||
|
</StyledIcon>
|
||||||
|
)}
|
||||||
<StyledSelectValue />
|
<StyledSelectValue />
|
||||||
<RiArrowDropDownLine aria-hidden="true" />
|
<RiArrowDropDownLine
|
||||||
|
aria-hidden="true"
|
||||||
|
className={css({ flexShrink: 0 })}
|
||||||
|
/>
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
<StyledPopover>
|
<StyledPopover placement={placement}>
|
||||||
<Box size="sm" type="popover" variant="control">
|
<Box size="sm" type="popover" variant={variant}>
|
||||||
<ListBox>
|
<ListBox>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<ListBoxItem
|
<ListBoxItem
|
||||||
className={
|
className={
|
||||||
menuRecipe({ extraPadding: true, variant: 'light' }).item
|
menuRecipe({
|
||||||
|
extraPadding: true,
|
||||||
|
variant: variant,
|
||||||
|
}).item
|
||||||
}
|
}
|
||||||
id={item.value}
|
id={item.value}
|
||||||
key={item.value}
|
key={item.value}
|
||||||
|
|||||||
@@ -22,16 +22,15 @@ export const StyledSwitch = styled(RACSwitch, {
|
|||||||
borderRadius: '1.143rem',
|
borderRadius: '1.143rem',
|
||||||
transition: 'all 200ms, outline 200ms',
|
transition: 'all 200ms, outline 200ms',
|
||||||
_before: {
|
_before: {
|
||||||
|
willChange: 'transform',
|
||||||
content: '""',
|
content: '""',
|
||||||
display: 'block',
|
display: 'block',
|
||||||
margin: '0.125rem',
|
margin: '0.125rem',
|
||||||
width: '1.063rem',
|
width: '1.063rem',
|
||||||
height: '1.063rem',
|
height: '1.063rem',
|
||||||
borderRadius: '1.063rem',
|
borderRadius: '1.063rem',
|
||||||
border: '2px solid',
|
background: 'primary.800',
|
||||||
borderColor: 'primary.800',
|
transition: 'transform 200ms, background-color 200ms',
|
||||||
background: 'white',
|
|
||||||
transition: 'opacity 10ms',
|
|
||||||
transitionDelay: '0ms',
|
transitionDelay: '0ms',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -44,14 +43,29 @@ export const StyledSwitch = styled(RACSwitch, {
|
|||||||
color: 'primary.800',
|
color: 'primary.800',
|
||||||
fontSize: '0.75rem',
|
fontSize: '0.75rem',
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
|
pointerEvents: 'none',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
|
'& .cross': {
|
||||||
|
position: 'absolute',
|
||||||
|
display: 'block',
|
||||||
|
top: '50%',
|
||||||
|
left: '0.375rem',
|
||||||
|
transform: 'translateY(-50%)',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: '0.70rem',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
opacity: 1,
|
||||||
|
transition: 'opacity 200ms',
|
||||||
|
transitionDelay: '0ms',
|
||||||
|
},
|
||||||
'&[data-selected] .indicator': {
|
'&[data-selected] .indicator': {
|
||||||
borderColor: 'primary.800',
|
borderColor: 'primary.800',
|
||||||
background: 'primary.800',
|
background: 'primary.800',
|
||||||
_before: {
|
_before: {
|
||||||
border: 'none',
|
|
||||||
background: 'white',
|
background: 'white',
|
||||||
transform: 'translateX(100%)',
|
transform: 'translateX(100%)',
|
||||||
},
|
},
|
||||||
@@ -61,6 +75,11 @@ export const StyledSwitch = styled(RACSwitch, {
|
|||||||
transition: 'opacity 30ms',
|
transition: 'opacity 30ms',
|
||||||
transitionDelay: '150ms',
|
transitionDelay: '150ms',
|
||||||
},
|
},
|
||||||
|
'&[data-selected] .cross': {
|
||||||
|
opacity: 0,
|
||||||
|
transition: 'opacity 10ms',
|
||||||
|
transitionDelay: '0ms',
|
||||||
|
},
|
||||||
'&[data-disabled] .indicator': {
|
'&[data-disabled] .indicator': {
|
||||||
borderColor: 'primary.200',
|
borderColor: 'primary.200',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
@@ -68,6 +87,9 @@ export const StyledSwitch = styled(RACSwitch, {
|
|||||||
background: 'primary.200',
|
background: 'primary.200',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'&[data-disabled] .cross': {
|
||||||
|
color: 'primary.500',
|
||||||
|
},
|
||||||
'&[data-focus-visible] .indicator': {
|
'&[data-focus-visible] .indicator': {
|
||||||
outline: '2px solid!',
|
outline: '2px solid!',
|
||||||
outlineColor: 'focusRing!',
|
outlineColor: 'focusRing!',
|
||||||
@@ -86,7 +108,12 @@ export type SwitchProps = StyledVariantProps<typeof StyledSwitch> &
|
|||||||
export const Switch = ({ children, ...props }: SwitchProps) => (
|
export const Switch = ({ children, ...props }: SwitchProps) => (
|
||||||
<StyledSwitch {...props}>
|
<StyledSwitch {...props}>
|
||||||
<div className="indicator">
|
<div className="indicator">
|
||||||
<span className="checkmark">✓</span>
|
<span className="checkmark" aria-hidden="true">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<span className="cross" aria-hidden="true">
|
||||||
|
✕
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</StyledSwitch>
|
</StyledSwitch>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const buttonRecipe = cva({
|
|||||||
},
|
},
|
||||||
secondaryText: {
|
secondaryText: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
|
fontWeight: 'medium !important',
|
||||||
color: 'primary.800',
|
color: 'primary.800',
|
||||||
'&[data-hovered]': {
|
'&[data-hovered]': {
|
||||||
backgroundColor: 'greyscale.100',
|
backgroundColor: 'greyscale.100',
|
||||||
@@ -250,6 +251,20 @@ export const buttonRecipe = cva({
|
|||||||
color: 'error.300',
|
color: 'error.300',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
errorCircle: {
|
||||||
|
backgroundColor: 'error.500',
|
||||||
|
width: '56px',
|
||||||
|
height: '56px',
|
||||||
|
borderRadius: '100%',
|
||||||
|
color: 'white',
|
||||||
|
'&[data-hovered]': {
|
||||||
|
backgroundColor: 'error.600',
|
||||||
|
},
|
||||||
|
'&[data-pressed]': {
|
||||||
|
backgroundColor: 'error.700',
|
||||||
|
color: 'error.200',
|
||||||
|
},
|
||||||
|
},
|
||||||
// @TODO: better handling of colors… this is a mess
|
// @TODO: better handling of colors… this is a mess
|
||||||
success: {
|
success: {
|
||||||
colorPalette: 'success',
|
colorPalette: 'success',
|
||||||
@@ -269,6 +284,20 @@ export const buttonRecipe = cva({
|
|||||||
color: 'primary !important',
|
color: 'primary !important',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
permission: {
|
||||||
|
position: 'relative',
|
||||||
|
// background: 'None !important',
|
||||||
|
borderRadius: '100%',
|
||||||
|
// border: 'none !important',
|
||||||
|
color: 'amber.500',
|
||||||
|
width: 'fit-content',
|
||||||
|
height: 'fit-content',
|
||||||
|
padding: '0 !important',
|
||||||
|
margin: '0 !important',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
invisible: {
|
invisible: {
|
||||||
true: {
|
true: {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
import { derive } from 'derive-valtio'
|
||||||
|
|
||||||
|
type PermissionState =
|
||||||
|
| undefined
|
||||||
|
| 'granted'
|
||||||
|
| 'prompt'
|
||||||
|
| 'denied'
|
||||||
|
| 'unavailable'
|
||||||
|
|
||||||
|
type BaseState = {
|
||||||
|
cameraPermission: PermissionState
|
||||||
|
microphonePermission: PermissionState
|
||||||
|
isLoading: boolean
|
||||||
|
isPermissionDialogOpen: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type DerivedState = {
|
||||||
|
isCameraGranted: boolean
|
||||||
|
isMicrophoneGranted: boolean
|
||||||
|
isCameraDenied: boolean
|
||||||
|
isMicrophoneDenied: boolean
|
||||||
|
isCameraPrompted: boolean
|
||||||
|
isMicrophonePrompted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = BaseState & DerivedState
|
||||||
|
|
||||||
|
export const permissionsStore = proxy<BaseState>({
|
||||||
|
cameraPermission: undefined,
|
||||||
|
microphonePermission: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isPermissionDialogOpen: false,
|
||||||
|
}) as State
|
||||||
|
|
||||||
|
derive(
|
||||||
|
{
|
||||||
|
isCameraGranted: (get) =>
|
||||||
|
get(permissionsStore).cameraPermission == 'granted',
|
||||||
|
isMicrophoneGranted: (get) =>
|
||||||
|
get(permissionsStore).microphonePermission == 'granted',
|
||||||
|
isCameraDenied: (get) => get(permissionsStore).cameraPermission == 'denied',
|
||||||
|
isMicrophoneDenied: (get) =>
|
||||||
|
get(permissionsStore).microphonePermission == 'denied',
|
||||||
|
isCameraPrompted: (get) =>
|
||||||
|
get(permissionsStore).cameraPermission == 'prompt',
|
||||||
|
isMicrophonePrompted: (get) =>
|
||||||
|
get(permissionsStore).microphonePermission == 'prompt',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
proxy: permissionsStore,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export const openPermissionsDialog = () => {
|
||||||
|
permissionsStore.isPermissionDialogOpen = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export const closePermissionsDialog = () => {
|
||||||
|
permissionsStore.isPermissionDialogOpen = false
|
||||||
|
}
|
||||||
+1
-1
@@ -4,6 +4,6 @@ type State = {
|
|||||||
enabled: boolean
|
enabled: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ScreenSharePreferenceStore = proxy<State>({
|
export const screenSharePreferenceStore = proxy<State>({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { proxy } from 'valtio'
|
||||||
|
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
areSettingsOpen: boolean
|
||||||
|
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsStore = proxy<State>({
|
||||||
|
areSettingsOpen: false,
|
||||||
|
defaultSelectedTab: undefined,
|
||||||
|
})
|
||||||
@@ -5,15 +5,24 @@ import {
|
|||||||
saveUserChoices,
|
saveUserChoices,
|
||||||
LocalUserChoices as LocalUserChoicesLK,
|
LocalUserChoices as LocalUserChoicesLK,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
|
import { VideoQuality } from 'livekit-client'
|
||||||
|
|
||||||
|
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||||
|
|
||||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||||
processorSerialized?: ProcessorSerialized
|
processorSerialized?: ProcessorSerialized
|
||||||
noiseReductionEnabled?: boolean
|
noiseReductionEnabled?: boolean
|
||||||
|
audioOutputDeviceId?: string
|
||||||
|
videoPublishResolution?: VideoResolution
|
||||||
|
videoSubscribeQuality?: VideoQuality
|
||||||
}
|
}
|
||||||
|
|
||||||
function getUserChoicesState(): LocalUserChoices {
|
function getUserChoicesState(): LocalUserChoices {
|
||||||
return {
|
return {
|
||||||
noiseReductionEnabled: false,
|
noiseReductionEnabled: false,
|
||||||
|
audioOutputDeviceId: 'default', // Use 'default' to match LiveKit's standard device selection behavior
|
||||||
|
videoPublishResolution: 'h720',
|
||||||
|
videoSubscribeQuality: VideoQuality.HIGH,
|
||||||
...loadUserChoices(),
|
...loadUserChoices(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* FRAGILE: Splits translated text on placeholder to inject icons inline.
|
||||||
|
*
|
||||||
|
* Fragile because:
|
||||||
|
* - Relies on exact string matching - typos break it silently
|
||||||
|
* - Translators may accidentally modify/remove placeholders
|
||||||
|
* - No validation or error handling
|
||||||
|
*/
|
||||||
|
export const injectIconIntoTranslation = (
|
||||||
|
translation: string,
|
||||||
|
placeholder: string = 'ICON_PLACEHOLDER'
|
||||||
|
) => {
|
||||||
|
return translation.split(placeholder)
|
||||||
|
}
|
||||||
@@ -13,8 +13,9 @@ egress:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
redis:
|
redis:
|
||||||
address: redis-master:6379
|
address: dev-backend-redis:6379
|
||||||
password: pass
|
password: pass
|
||||||
|
username: user
|
||||||
s3:
|
s3:
|
||||||
access_key: meet
|
access_key: meet
|
||||||
secret: password
|
secret: password
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ livekit:
|
|||||||
port_range_end: 60000
|
port_range_end: 60000
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
redis:
|
redis:
|
||||||
address: redis-master:6379
|
address: dev-backend-redis:6379
|
||||||
password: pass
|
password: pass
|
||||||
|
username: user
|
||||||
keys:
|
keys:
|
||||||
turn:
|
turn:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -35,15 +35,21 @@ backend:
|
|||||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||||
DB_HOST: postgres-postgresql
|
DB_HOST: dev-backend-postgres
|
||||||
DB_NAME: meet
|
DB_NAME:
|
||||||
DB_USER: dinum
|
secretKeyRef:
|
||||||
DB_PASSWORD: pass
|
name: dev-backend-postgres
|
||||||
|
key: database
|
||||||
|
DB_USER:
|
||||||
|
secretKeyRef:
|
||||||
|
name: dev-backend-postgres
|
||||||
|
key: username
|
||||||
|
DB_PASSWORD:
|
||||||
|
secretKeyRef:
|
||||||
|
name: dev-backend-postgres
|
||||||
|
key: password
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: meet
|
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
{{- with .Values.livekit.keys }}
|
{{- with .Values.livekit.keys }}
|
||||||
{{- range $key, $value := . }}
|
{{- range $key, $value := . }}
|
||||||
@@ -61,8 +67,8 @@ backend:
|
|||||||
FRONTEND_USE_FRENCH_GOV_FOOTER: True
|
FRONTEND_USE_FRENCH_GOV_FOOTER: True
|
||||||
FRONTEND_USE_PROCONNECT_BUTTON: True
|
FRONTEND_USE_PROCONNECT_BUTTON: True
|
||||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
AWS_S3_ENDPOINT_URL: http://dev-backend-minio.meet.svc.cluster.local:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: dinum
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||||
AWS_S3_REGION_NAME: local
|
AWS_S3_REGION_NAME: local
|
||||||
@@ -81,8 +87,7 @@ backend:
|
|||||||
- "/bin/sh"
|
- "/bin/sh"
|
||||||
- "-c"
|
- "-c"
|
||||||
- |
|
- |
|
||||||
python manage.py migrate --no-input &&
|
python manage.py migrate --no-input
|
||||||
python manage.py create_demo --force
|
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
command:
|
command:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
djangoSecretKey: u!vbjDW71aru&OZA%NZQi0x
|
djangoSecretKey: 7K9mQ2xR8pL3vN6tY1sW4jH5cE0zF9bM2qA7uI3oP6rT1wErt12te12
|
||||||
livekit:
|
livekit:
|
||||||
keys:
|
keys:
|
||||||
devkey: secret
|
devkey: secret
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ egress:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
redis:
|
redis:
|
||||||
address: redis-master:6379
|
address: dev-backend-redis:6379
|
||||||
password: pass
|
password: pass
|
||||||
|
username: user
|
||||||
s3:
|
s3:
|
||||||
access_key: meet
|
access_key: meet
|
||||||
secret: password
|
secret: password
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ livekit:
|
|||||||
port_range_end: 60000
|
port_range_end: 60000
|
||||||
tcp_port: 7881
|
tcp_port: 7881
|
||||||
redis:
|
redis:
|
||||||
address: redis-master:6379
|
address: dev-backend-redis:6379
|
||||||
password: pass
|
password: pass
|
||||||
|
username: user
|
||||||
keys:
|
keys:
|
||||||
turn:
|
turn:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
@@ -35,15 +35,21 @@ backend:
|
|||||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||||
DB_HOST: postgres-postgresql
|
DB_HOST: dev-backend-postgres
|
||||||
DB_NAME: meet
|
DB_NAME:
|
||||||
DB_USER: dinum
|
secretKeyRef:
|
||||||
DB_PASSWORD: pass
|
name: dev-backend-postgres
|
||||||
|
key: database
|
||||||
|
DB_USER:
|
||||||
|
secretKeyRef:
|
||||||
|
name: dev-backend-postgres
|
||||||
|
key: username
|
||||||
|
DB_PASSWORD:
|
||||||
|
secretKeyRef:
|
||||||
|
name: dev-backend-postgres
|
||||||
|
key: password
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
POSTGRES_DB: meet
|
REDIS_URL: redis://user:pass@dev-backend-redis:6379/1
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
{{- with .Values.livekit.keys }}
|
{{- with .Values.livekit.keys }}
|
||||||
{{- range $key, $value := . }}
|
{{- range $key, $value := . }}
|
||||||
@@ -52,14 +58,16 @@ backend:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||||
|
LIVEKIT_FORCE_WSS_PROTOCOL: True
|
||||||
|
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
|
||||||
ALLOW_UNREGISTERED_ROOMS: False
|
ALLOW_UNREGISTERED_ROOMS: False
|
||||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
||||||
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
|
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
|
||||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
AWS_S3_ENDPOINT_URL: http://dev-backend-minio.meet.svc.cluster.local:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: dinum
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||||
AWS_S3_REGION_NAME: local
|
AWS_S3_REGION_NAME: local
|
||||||
@@ -70,6 +78,8 @@ backend:
|
|||||||
SUMMARY_SERVICE_API_TOKEN: password
|
SUMMARY_SERVICE_API_TOKEN: password
|
||||||
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
|
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
|
||||||
ROOM_TELEPHONY_ENABLED: True
|
ROOM_TELEPHONY_ENABLED: True
|
||||||
|
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
|
||||||
|
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
|
||||||
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||||
|
|
||||||
|
|
||||||
@@ -78,15 +88,7 @@ backend:
|
|||||||
- "/bin/sh"
|
- "/bin/sh"
|
||||||
- "-c"
|
- "-c"
|
||||||
- |
|
- |
|
||||||
while ! python manage.py check --database default > /dev/null 2>&1
|
python manage.py migrate --no-input
|
||||||
do
|
|
||||||
echo "Database not ready"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Database is ready"
|
|
||||||
|
|
||||||
python manage.py migrate --no-input &&
|
|
||||||
python manage.py create_demo --force
|
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
command:
|
command:
|
||||||
@@ -101,13 +103,6 @@ backend:
|
|||||||
- "/bin/sh"
|
- "/bin/sh"
|
||||||
- "-c"
|
- "-c"
|
||||||
- |
|
- |
|
||||||
while ! python manage.py check --database default > /dev/null 2>&1
|
|
||||||
do
|
|
||||||
echo "Database not ready"
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
echo "Database is ready"
|
|
||||||
|
|
||||||
python manage.py createsuperuser --email admin@example.com --password admin
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user