mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-02 13:48:26 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ed6091331 |
+1
-14
@@ -10,32 +10,19 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- 📈(frontend) track errors when starting or stopping a recording
|
- ✨(backend) add OpenShift-compatible recording download endpoint
|
||||||
- 🚸(frontend) explain camera-in-use failures on the join screen
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ✨(backend) accept form-urlencoded on the user token endpoint
|
- ✨(backend) accept form-urlencoded on the user token endpoint
|
||||||
- ✨(summary) configurable s3 region
|
|
||||||
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
- ⬆️(frontend) upgrade i18next and react-i18next patch versions
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
- ⬆️(frontend) upgrade posthog-js from 1.395.0 to 1.404.1
|
||||||
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
- ⬆️(frontend) upgrade livekit-client and @livekit/components-react
|
||||||
- 💄(frontend) increase the blur intensity
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- 📝(docs) fix minor typos in comments and docstrings
|
- 📝(docs) fix minor typos in comments and docstrings
|
||||||
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
|
- ⬆️(backend) bump sqlparse from 0.5.5 to 0.6.0
|
||||||
- ⬆️(mail) bump @html-to/text-cli from 0.6.0 to 0.6.1
|
|
||||||
- 🐛(frontend) treat client-initiated connect aborts as events
|
|
||||||
- 🐛(frontend) use state instead of a ref for MoreControls container
|
|
||||||
- 🐛(frontend) stop init_virtual_background from firing on blur updates
|
|
||||||
- 🐛(frontend) hoist mute confirmation dialog to VideoConference level
|
|
||||||
- 🐛(frontend) fix joined notification tile no longer rendering properly
|
|
||||||
- 🐛(frontend) handle device-in-use errors on Chrome / Windows 10
|
|
||||||
- 🐛(frontend) handle Firefox/Windows AbortError on device start
|
|
||||||
- 🐛(frontend) treat "Timeout starting source" AbortError as device-in-use
|
|
||||||
- 🔇(frontend) suppress leaked WebSocket error events from livekit-client
|
|
||||||
|
|
||||||
## [1.27.0] - 2026-08-14
|
## [1.27.0] - 2026-08-14
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@ services:
|
|||||||
working_dir: /app
|
working_dir: /app
|
||||||
|
|
||||||
node:
|
node:
|
||||||
image: node:22
|
image: node:18
|
||||||
user: "${DOCKER_USER:-1000}"
|
user: "${DOCKER_USER:-1000}"
|
||||||
environment:
|
environment:
|
||||||
HOME: /tmp
|
HOME: /tmp
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ from django.core.exceptions import ValidationError as DjangoValidationError
|
|||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.db import IntegrityError, transaction
|
from django.db import IntegrityError, transaction
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.http import Http404, StreamingHttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from botocore.exceptions import BotoCoreError, ClientError
|
||||||
from django_filters import rest_framework as django_filters
|
from django_filters import rest_framework as django_filters
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
decorators,
|
decorators,
|
||||||
@@ -113,6 +114,14 @@ from .feature_flag import FeatureFlag
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StorageUnavailable(drf_exceptions.APIException):
|
||||||
|
"""Exception raised when recording storage cannot be reached."""
|
||||||
|
|
||||||
|
status_code = drf_status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
default_detail = "The recording storage is temporarily unavailable."
|
||||||
|
default_code = "storage_unavailable"
|
||||||
|
|
||||||
|
|
||||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||||
"""
|
"""
|
||||||
A generic Viewset aims to be used in a nested route context.
|
A generic Viewset aims to be used in a nested route context.
|
||||||
@@ -1220,6 +1229,87 @@ class RecordingViewSet(
|
|||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||||
|
|
||||||
|
@decorators.action(detail=False, methods=["get"], url_path="download")
|
||||||
|
def media_download(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Stream a recording file directly from S3 to the browser.
|
||||||
|
|
||||||
|
This endpoint is registered at /media/recordings/<uuid>.<ext> in core/urls.py
|
||||||
|
and handles the download flow on OpenShift where the Nginx auth_request
|
||||||
|
mechanism is not available.
|
||||||
|
|
||||||
|
Django acts as a streaming proxy: bytes flow from S3 (internal cluster URL)
|
||||||
|
directly to the client response. This avoids any page navigation or redirect,
|
||||||
|
so the browser triggers a native download without leaving the current page.
|
||||||
|
|
||||||
|
Access control mirrors media_auth: the user must have the "retrieve" ability
|
||||||
|
on the recording and the recording must be in a saved state.
|
||||||
|
"""
|
||||||
|
recording_id = self.kwargs.get("recording_id")
|
||||||
|
extension = self.kwargs.get("extension")
|
||||||
|
|
||||||
|
if extension not in [file.value for file in FileExtension]:
|
||||||
|
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||||
|
|
||||||
|
try:
|
||||||
|
recording = models.Recording.objects.get(id=recording_id)
|
||||||
|
except models.Recording.DoesNotExist as e:
|
||||||
|
raise drf_exceptions.NotFound("No recording found for this id.") from e
|
||||||
|
|
||||||
|
if extension != recording.extension:
|
||||||
|
raise drf_exceptions.NotFound("No recording found with this extension.")
|
||||||
|
|
||||||
|
abilities = recording.get_abilities(request.user)
|
||||||
|
|
||||||
|
if not abilities["retrieve"]:
|
||||||
|
logger.debug(
|
||||||
|
"User '%s' lacks permission for recording download", request.user.id
|
||||||
|
)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
if not recording.is_saved:
|
||||||
|
logger.debug("Recording '%s' has not been saved", recording)
|
||||||
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
try:
|
||||||
|
s3_object = s3_client.get_object(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=recording.key,
|
||||||
|
)
|
||||||
|
except ClientError as error:
|
||||||
|
error_code = error.response.get("Error", {}).get("Code")
|
||||||
|
if error_code in {"404", "NoSuchKey", "NoSuchObject"}:
|
||||||
|
raise drf_exceptions.NotFound(
|
||||||
|
"No recording file found in storage."
|
||||||
|
) from error
|
||||||
|
logger.exception("Unable to retrieve recording from storage")
|
||||||
|
raise StorageUnavailable() from error
|
||||||
|
except BotoCoreError as error:
|
||||||
|
logger.exception("Unable to retrieve recording from storage")
|
||||||
|
raise StorageUnavailable() from error
|
||||||
|
|
||||||
|
content_type = s3_object.get("ContentType") or "video/mp4"
|
||||||
|
content_length = s3_object.get("ContentLength")
|
||||||
|
filename = f"{recording_id}.{extension}"
|
||||||
|
body = s3_object["Body"]
|
||||||
|
|
||||||
|
def stream():
|
||||||
|
try:
|
||||||
|
yield from body.iter_chunks(chunk_size=65536)
|
||||||
|
finally:
|
||||||
|
body.close()
|
||||||
|
|
||||||
|
streaming_response = StreamingHttpResponse(
|
||||||
|
stream(),
|
||||||
|
content_type=content_type,
|
||||||
|
)
|
||||||
|
streaming_response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
if content_length:
|
||||||
|
streaming_response["Content-Length"] = content_length
|
||||||
|
|
||||||
|
return streaming_response
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=too-many-public-methods
|
# pylint: disable=too-many-public-methods
|
||||||
class FileViewSet(
|
class FileViewSet(
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ Test recordings API endpoints in the Meet core app: retrieve.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from botocore.exceptions import ClientError, EndpointConnectionError
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
@@ -14,6 +18,11 @@ from ...models import RecordingStatusChoices
|
|||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def media_recording_url(recording):
|
||||||
|
"""Return the direct media URL for a recording."""
|
||||||
|
return f"/media/recordings/{recording.id!s}.{recording.extension}"
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_anonymous():
|
def test_api_recording_retrieve_anonymous():
|
||||||
"""Anonymous users should not be able to retrieve recordings."""
|
"""Anonymous users should not be able to retrieve recordings."""
|
||||||
recording = RecordingFactory()
|
recording = RecordingFactory()
|
||||||
@@ -25,6 +34,9 @@ def test_api_recording_retrieve_anonymous():
|
|||||||
"detail": "Authentication credentials were not provided."
|
"detail": "Authentication credentials were not provided."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_authenticated():
|
def test_api_recording_retrieve_authenticated():
|
||||||
"""Authenticated users without access receive 404 when requesting recordings.
|
"""Authenticated users without access receive 404 when requesting recordings.
|
||||||
@@ -44,6 +56,9 @@ def test_api_recording_retrieve_authenticated():
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
assert response.json() == {"detail": "No Recording matches the given query."}
|
assert response.json() == {"detail": "No Recording matches the given query."}
|
||||||
|
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_members():
|
def test_api_recording_retrieve_members():
|
||||||
"""
|
"""
|
||||||
@@ -63,6 +78,11 @@ def test_api_recording_retrieve_members():
|
|||||||
"detail": "You do not have permission to perform this action."
|
"detail": "You do not have permission to perform this action."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
recording.status = RecordingStatusChoices.SAVED
|
||||||
|
recording.save(update_fields=["status"])
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_api_recording_retrieve_administrators(settings):
|
def test_api_recording_retrieve_administrators(settings):
|
||||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||||
@@ -137,6 +157,78 @@ def test_api_recording_retrieve_owners(settings):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("role", ["owner", "administrator"])
|
||||||
|
def test_api_recording_media_download_authorized(role):
|
||||||
|
"""Owners and administrators can download a saved recording."""
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
UserRecordingAccessFactory(recording=recording, user=user, role=role)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
body = mock.Mock(iter_chunks=mock.Mock(return_value=[b"recording content"]))
|
||||||
|
s3_object = {
|
||||||
|
"Body": body,
|
||||||
|
"ContentType": "video/mp4",
|
||||||
|
"ContentLength": len(b"recording content"),
|
||||||
|
}
|
||||||
|
with mock.patch.object(
|
||||||
|
default_storage.connection.meta.client,
|
||||||
|
"get_object",
|
||||||
|
return_value=s3_object,
|
||||||
|
) as get_object:
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
get_object.assert_called_once_with(
|
||||||
|
Bucket=default_storage.bucket_name,
|
||||||
|
Key=recording.key,
|
||||||
|
)
|
||||||
|
assert response["Content-Type"] == "video/mp4"
|
||||||
|
assert response["Content-Disposition"] == (
|
||||||
|
f'attachment; filename="{recording.id}.{recording.extension}"'
|
||||||
|
)
|
||||||
|
assert b"".join(response.streaming_content) == b"recording content"
|
||||||
|
body.close.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recording_media_download_missing_object():
|
||||||
|
"""A missing recording object should return 404."""
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
error = ClientError(
|
||||||
|
{"Error": {"Code": "NoSuchKey", "Message": "The object does not exist."}},
|
||||||
|
"GetObject",
|
||||||
|
)
|
||||||
|
with mock.patch.object(
|
||||||
|
default_storage.connection.meta.client, "get_object", side_effect=error
|
||||||
|
):
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_recording_media_download_storage_unavailable():
|
||||||
|
"""A storage connection failure should return 503."""
|
||||||
|
user = UserFactory()
|
||||||
|
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||||
|
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.force_login(user)
|
||||||
|
error = EndpointConnectionError(endpoint_url="https://storage.test")
|
||||||
|
with mock.patch.object(
|
||||||
|
default_storage.connection.meta.client, "get_object", side_effect=error
|
||||||
|
):
|
||||||
|
response = client.get(media_recording_url(recording))
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2023-01-15 12:00:00")
|
@freeze_time("2023-01-15 12:00:00")
|
||||||
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||||
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
||||||
|
|||||||
@@ -60,6 +60,13 @@ urlpatterns = [
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
# The request is handled by RecordingViewSet.media_download which validates
|
||||||
|
# access rights and streams the recording file directly from MinIO to the browser.
|
||||||
|
path(
|
||||||
|
"media/recordings/<uuid:recording_id>.<slug:extension>",
|
||||||
|
viewsets.RecordingViewSet.as_view({"get": "media_download"}),
|
||||||
|
name="recording_media_download",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.EXTERNAL_API_ENABLED:
|
if settings.EXTERNAL_API_ENABLED:
|
||||||
|
|||||||
@@ -2,12 +2,6 @@ import type { CaptureResult } from 'posthog-js'
|
|||||||
|
|
||||||
const IGNORED_EXCEPTION_PATTERNS = [
|
const IGNORED_EXCEPTION_PATTERNS = [
|
||||||
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
/ResizeObserver loop (completed with undelivered notifications|limit exceeded)/,
|
||||||
// livekit-client leaks the raw WebSocket error Event as an unhandled
|
|
||||||
// rejection when the signal ws errors after connect (Firefox-heavy,
|
|
||||||
// coincides with signal reconnects). Carries zero diagnostic content —
|
|
||||||
// the close reason is already logged by the SDK.
|
|
||||||
// See: https://github.com/livekit/client-sdk-js/issues/2062
|
|
||||||
/^Event captured as exception with keys: isTrusted$/,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const shouldIgnoreException = (value: unknown): boolean =>
|
const shouldIgnoreException = (value: unknown): boolean =>
|
||||||
|
|||||||
@@ -137,7 +137,6 @@ export const captureMediaEvent = async (
|
|||||||
| 'media-device-topology'
|
| 'media-device-topology'
|
||||||
| 'media-device-success'
|
| 'media-device-success'
|
||||||
| 'device-not-found'
|
| 'device-not-found'
|
||||||
| 'device-in-use'
|
|
||||||
| 'permissions-denied'
|
| 'permissions-denied'
|
||||||
| 'screen-share-permission-denied'
|
| 'screen-share-permission-denied'
|
||||||
| 'silent-mic-detected'
|
| 'silent-mic-detected'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useToast } from 'react-aria'
|
import { useToast } from 'react-aria'
|
||||||
import { useMemo, useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Button as RACButton } from 'react-aria-components'
|
import { Button as RACButton } from 'react-aria-components'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
@@ -11,7 +11,6 @@ import { Div } from '@/primitives'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { StyledToastContainer } from './StyledToastContainer'
|
import { StyledToastContainer } from './StyledToastContainer'
|
||||||
import { setPinnedTrack } from '@/stores/layout'
|
import { setPinnedTrack } from '@/stores/layout'
|
||||||
import { useParticipantTracks } from '@livekit/components-react'
|
|
||||||
|
|
||||||
const ClickableToast = styled(RACButton, {
|
const ClickableToast = styled(RACButton, {
|
||||||
base: {
|
base: {
|
||||||
@@ -31,18 +30,14 @@ export function ToastJoined({ state, ...props }: Readonly<ToastProps>) {
|
|||||||
)
|
)
|
||||||
const participant = props.toast.content.participant
|
const participant = props.toast.content.participant
|
||||||
|
|
||||||
const [cameraTrack] = useParticipantTracks(
|
|
||||||
[Source.Camera],
|
|
||||||
participant?.identity
|
|
||||||
)
|
|
||||||
|
|
||||||
const trackReference = useMemo(
|
|
||||||
() => cameraTrack ?? { participant, source: Source.Camera },
|
|
||||||
[cameraTrack, participant]
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!participant) return
|
if (!participant) return
|
||||||
|
|
||||||
|
const trackReference = {
|
||||||
|
participant,
|
||||||
|
publication: participant.getTrackPublication(Source.Camera),
|
||||||
|
source: Source.Camera,
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledToastContainer {...toastProps} ref={ref}>
|
<StyledToastContainer {...toastProps} ref={ref}>
|
||||||
<ClickableToast
|
<ClickableToast
|
||||||
|
|||||||
+17
-2
@@ -1,9 +1,11 @@
|
|||||||
import { Participant, Track } from 'livekit-client'
|
import { Participant, Track } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
|
import { useState } from 'react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||||
import { openMuteDialog } from '@/stores/muteDialog'
|
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||||
|
|
||||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
@@ -13,18 +15,31 @@ export const MuteButton = ({ participant }: { participant: Participant }) => {
|
|||||||
source: Track.Source.Microphone,
|
source: Track.Source.Microphone,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
|
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isMuted}
|
isDisabled={isMuted}
|
||||||
size={'sm'}
|
size={'sm'}
|
||||||
variant={'primaryTextDark'}
|
variant={'primaryTextDark'}
|
||||||
square
|
square
|
||||||
onPress={() => openMuteDialog(participant)}
|
onPress={() => setIsAlertOpen(true)}
|
||||||
tooltip={t('muteParticipant', { name })}
|
tooltip={t('muteParticipant', { name })}
|
||||||
>
|
>
|
||||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||||
</Button>
|
</Button>
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onSubmit={() =>
|
||||||
|
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||||
|
}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import {
|
|||||||
import Source = Track.Source
|
import Source = Track.Source
|
||||||
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
import { RiMicFill, RiMicOffFill } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||||
import { openMuteDialog } from '@/stores/muteDialog'
|
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||||
import { ParticipantName } from './ParticipantName'
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
@@ -32,6 +34,7 @@ type MicIndicatorProps = {
|
|||||||
|
|
||||||
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
|
const { muteParticipant } = useMuteParticipant()
|
||||||
const { isMuted } = useTrackMutedIndicator({
|
const { isMuted } = useTrackMutedIndicator({
|
||||||
participant: participant,
|
participant: participant,
|
||||||
source: Source.Microphone,
|
source: Source.Microphone,
|
||||||
@@ -39,6 +42,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
|
|
||||||
const canMute = useCanMute(participant)
|
const canMute = useCanMute(participant)
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
const isSpeaking = useIsSpeaking(participant)
|
||||||
|
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
const label = isLocal(participant)
|
const label = isLocal(participant)
|
||||||
@@ -48,6 +52,7 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
square
|
square
|
||||||
variant="greyscale"
|
variant="greyscale"
|
||||||
@@ -57,8 +62,10 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
isDisabled={isMuted || !canMute}
|
isDisabled={isMuted || !canMute}
|
||||||
onPress={async () =>
|
onPress={async () =>
|
||||||
!isMuted && isLocal(participant)
|
!isMuted && isLocal(participant)
|
||||||
? await (participant as LocalParticipant)?.setMicrophoneEnabled(false)
|
? await (participant as LocalParticipant)?.setMicrophoneEnabled(
|
||||||
: openMuteDialog(participant)
|
false
|
||||||
|
)
|
||||||
|
: setIsAlertOpen(true)
|
||||||
}
|
}
|
||||||
data-attr="participants-mute"
|
data-attr="participants-mute"
|
||||||
>
|
>
|
||||||
@@ -76,6 +83,15 @@ const MicIndicator = ({ participant }: MicIndicatorProps) => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
<MuteAlertDialog
|
||||||
|
isOpen={isAlertOpen}
|
||||||
|
onSubmit={() =>
|
||||||
|
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||||
|
}
|
||||||
|
onClose={() => setIsAlertOpen(false)}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||||
import { recordingStore } from '@/stores/recording'
|
import { recordingStore } from '@/stores/recording'
|
||||||
import { captureEvent } from '@/features/analytics/telemetry'
|
|
||||||
|
|
||||||
export const useMutateRecording = () => {
|
export const useMutateRecording = () => {
|
||||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||||
useStartRecording({
|
useStartRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'start'
|
recordingStore.isErrorDialogOpen = 'start'
|
||||||
captureEvent('error-starting-recording')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||||
useStopRecording({
|
useStopRecording({
|
||||||
onError: () => {
|
onError: () => {
|
||||||
recordingStore.isErrorDialogOpen = 'stop'
|
recordingStore.isErrorDialogOpen = 'stop'
|
||||||
captureEvent('error-stopping-recording')
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,15 +6,12 @@ import {
|
|||||||
usePersistentUserChoices,
|
usePersistentUserChoices,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import {
|
import {
|
||||||
ConnectionError,
|
|
||||||
ConnectionErrorReason,
|
|
||||||
DisconnectReason,
|
DisconnectReason,
|
||||||
MediaDeviceFailure,
|
MediaDeviceFailure,
|
||||||
Room,
|
Room,
|
||||||
type RoomOptions,
|
type RoomOptions,
|
||||||
VideoPresets,
|
VideoPresets,
|
||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { getMediaDeviceFailure } from '@/features/rooms/livekit/utils/mediaPermissions'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
import { keys } from '@/api/queryKeys'
|
||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { Screen } from '@/layout/Screen'
|
import { Screen } from '@/layout/Screen'
|
||||||
@@ -28,11 +25,7 @@ import { VideoConference } from '../livekit/prefabs/VideoConference'
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
import { BackgroundProcessorFactory } from '../livekit/components/blur'
|
||||||
import { LocalUserChoices } from '@/stores/userChoices'
|
import { LocalUserChoices } from '@/stores/userChoices'
|
||||||
import {
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
captureEvent,
|
|
||||||
captureMediaEvent,
|
|
||||||
reportError,
|
|
||||||
} from '@/features/analytics/telemetry'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
import { isFireFox } from '@/utils/livekit'
|
import { isFireFox } from '@/utils/livekit'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
import { useIsMobile } from '@/utils/useIsMobile'
|
||||||
@@ -232,18 +225,8 @@ export const Conference = ({
|
|||||||
backgroundColor: 'primaryDark.50 !important',
|
backgroundColor: 'primaryDark.50 !important',
|
||||||
})}
|
})}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
const failure = getMediaDeviceFailure(e)
|
const failure = MediaDeviceFailure.getFailure(e)
|
||||||
if (failure && failure !== MediaDeviceFailure.Other) return
|
if (failure && failure !== MediaDeviceFailure.Other) return
|
||||||
|
|
||||||
// connect() was aborted by a disconnect() before the join completed
|
|
||||||
if (
|
|
||||||
e instanceof ConnectionError &&
|
|
||||||
e.reason === ConnectionErrorReason.Cancelled
|
|
||||||
) {
|
|
||||||
void captureEvent('connection-cancelled')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportError('livekit_room_error', e, {
|
reportError('livekit_room_error', e, {
|
||||||
path: 'connect_publish',
|
path: 'connect_publish',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ import {
|
|||||||
userChoicesStore,
|
userChoicesStore,
|
||||||
} from '@/stores/userChoices'
|
} from '@/stores/userChoices'
|
||||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||||
import { useDeviceInUse } from '../livekit/hooks/useDeviceInUse'
|
|
||||||
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../livekit/hooks/useDeviceMissing'
|
||||||
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
import { useJoinTracks } from '../livekit/hooks/useJoinTracks'
|
||||||
import { SilentMicDetector } from './SilentMicDetector'
|
import { SilentMicDetector } from './SilentMicDetector'
|
||||||
@@ -219,14 +218,12 @@ const switchTrackDevice =
|
|||||||
function getPreviewMessages({
|
function getPreviewMessages({
|
||||||
cameraFound,
|
cameraFound,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
cameraInUse,
|
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
}: {
|
}: {
|
||||||
cameraFound: boolean
|
cameraFound: boolean
|
||||||
cameraDenied: boolean
|
cameraDenied: boolean
|
||||||
cameraInUse: boolean
|
|
||||||
micDenied: boolean
|
micDenied: boolean
|
||||||
videoEnabled: boolean
|
videoEnabled: boolean
|
||||||
videoStarted: boolean
|
videoStarted: boolean
|
||||||
@@ -238,9 +235,6 @@ function getPreviewMessages({
|
|||||||
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
const key = micDenied ? 'cameraAndMicNotGranted' : 'cameraNotGranted'
|
||||||
return { hint: key, permissionsButtonLabel: key }
|
return { hint: key, permissionsButtonLabel: key }
|
||||||
}
|
}
|
||||||
if (cameraInUse) {
|
|
||||||
return { hint: 'cameraInUse', permissionsButtonLabel: null }
|
|
||||||
}
|
|
||||||
if (!videoEnabled) {
|
if (!videoEnabled) {
|
||||||
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
return { hint: 'cameraDisabled', permissionsButtonLabel: null }
|
||||||
}
|
}
|
||||||
@@ -334,20 +328,18 @@ const VideoPreview = ({
|
|||||||
const cameraDenied = useCannotUseDevice('videoinput')
|
const cameraDenied = useCannotUseDevice('videoinput')
|
||||||
const micDenied = useCannotUseDevice('audioinput')
|
const micDenied = useCannotUseDevice('audioinput')
|
||||||
const cameraMissing = useDeviceMissing('videoinput')
|
const cameraMissing = useDeviceMissing('videoinput')
|
||||||
const cameraInUse = useDeviceInUse('videoinput')
|
|
||||||
|
|
||||||
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
const { videoEl, videoStarted } = useAttachedVideo(videoTrack, videoEnabled)
|
||||||
|
|
||||||
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
const { hint, permissionsButtonLabel } = getPreviewMessages({
|
||||||
cameraFound: !cameraMissing,
|
cameraFound: !cameraMissing,
|
||||||
cameraDenied,
|
cameraDenied,
|
||||||
cameraInUse,
|
|
||||||
micDenied,
|
micDenied,
|
||||||
videoEnabled,
|
videoEnabled,
|
||||||
videoStarted,
|
videoStarted,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isError = cameraMissing || cameraDenied || cameraInUse
|
const isError = cameraMissing || cameraDenied
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.previewFrame}>
|
<div className={styles.previewFrame}>
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { useEffect } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import {
|
|
||||||
clearDeviceInUseForOtherDevice,
|
|
||||||
deviceAvailabilityStore,
|
|
||||||
} from '@/stores/deviceAvailability'
|
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
|
||||||
import { probeDeviceReleased } from '../livekit/utils/mediaPermissions'
|
|
||||||
import type { PermissionKind } from '@/stores/permissions'
|
|
||||||
|
|
||||||
const RETRY_INTERVAL_MS = 30_000
|
|
||||||
|
|
||||||
/**
|
|
||||||
* There is no browser event for "another app released the device", so
|
|
||||||
* while a device is flagged in use, re-probe it periodically. The probe
|
|
||||||
* targets the currently selected device (the flag is about what the app
|
|
||||||
* would acquire, not about any device of the kind). Only clears the flag
|
|
||||||
* (the toggle becomes usable again); it never re-enables the device on
|
|
||||||
* the user's behalf.
|
|
||||||
*/
|
|
||||||
export function useWatchDeviceReleased() {
|
|
||||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
|
||||||
const { audioDeviceId, videoDeviceId } = useSnapshot(userChoicesStore)
|
|
||||||
useWatchKind('camera', cameraInUse, videoDeviceId)
|
|
||||||
useWatchKind('microphone', microphoneInUse, audioDeviceId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useWatchKind(
|
|
||||||
kind: PermissionKind,
|
|
||||||
inUse: boolean,
|
|
||||||
selectedDeviceId?: string
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (inUse) clearDeviceInUseForOtherDevice(kind, selectedDeviceId)
|
|
||||||
}, [kind, inUse, selectedDeviceId])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!inUse) return
|
|
||||||
let stopped = false
|
|
||||||
let timer: number | undefined
|
|
||||||
const tick = async () => {
|
|
||||||
await probeDeviceReleased(kind, selectedDeviceId || undefined)
|
|
||||||
if (!stopped) {
|
|
||||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
timer = window.setTimeout(tick, RETRY_INTERVAL_MS)
|
|
||||||
return () => {
|
|
||||||
stopped = true
|
|
||||||
window.clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [kind, inUse, selectedDeviceId])
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useRef } from 'react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|
||||||
import { closeMuteDialog, muteDialogStore } from '@/stores/muteDialog'
|
|
||||||
import { MuteAlertDialog } from './MuteAlertDialog'
|
|
||||||
|
|
||||||
export const MuteAlertDialogProvider = () => {
|
|
||||||
const { participant } = useSnapshot(muteDialogStore)
|
|
||||||
const { muteParticipant } = useMuteParticipant()
|
|
||||||
|
|
||||||
const lastNameRef = useRef('')
|
|
||||||
if (participant) {
|
|
||||||
lastNameRef.current = participant.name || participant.identity
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MuteAlertDialog
|
|
||||||
isOpen={!!participant}
|
|
||||||
name={lastNameRef.current}
|
|
||||||
onClose={closeMuteDialog}
|
|
||||||
onSubmit={() => {
|
|
||||||
const target = muteDialogStore.participant
|
|
||||||
if (!target) return
|
|
||||||
muteParticipant(target).then(closeMuteDialog)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+3
-1
@@ -105,7 +105,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
|||||||
|
|
||||||
_initVirtualBackgroundImage() {
|
_initVirtualBackgroundImage() {
|
||||||
if (this.options.type !== 'virtual') {
|
if (this.options.type !== 'virtual') {
|
||||||
return
|
throw new Error(
|
||||||
|
'Virtual background is only supported for virtual background'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const needsUpdate =
|
const needsUpdate =
|
||||||
|
|||||||
+6
-12
@@ -5,18 +5,16 @@ import { css } from '@/styled-system/css'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
type PermissionNeededButtonProps = {
|
type PermissionNeededButtonProps = {
|
||||||
tooltip?: string | null
|
tooltip?: string
|
||||||
onPress?: (() => void) | null
|
onPress?: () => void
|
||||||
ariaLabel?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PermissionNeededButton = ({
|
export const PermissionNeededButton = ({
|
||||||
tooltip,
|
tooltip,
|
||||||
onPress,
|
onPress,
|
||||||
ariaLabel,
|
|
||||||
}: PermissionNeededButtonProps) => {
|
}: PermissionNeededButtonProps) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'permissionsButton' })
|
||||||
const label = tooltip === undefined ? t('tooltip') : tooltip
|
const label = tooltip ?? t('tooltip')
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
@@ -28,13 +26,9 @@ export const PermissionNeededButton = ({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
aria-label={ariaLabel ?? label ?? t('ariaLabel')}
|
aria-label={tooltip ? label : t('ariaLabel')}
|
||||||
tooltip={label ?? undefined}
|
tooltip={label}
|
||||||
onPress={
|
onPress={onPress ?? (() => openPermissionsDialog())}
|
||||||
onPress === undefined
|
|
||||||
? () => openPermissionsDialog()
|
|
||||||
: (onPress ?? undefined)
|
|
||||||
}
|
|
||||||
variant="permission"
|
variant="permission"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+19
-49
@@ -20,10 +20,8 @@ import { openPermissionsDialog } from '@/stores/permissions'
|
|||||||
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
import { openSilentMicDialog, silentMicStore } from '@/stores/silentMic'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
import { useCannotUseDevice } from '../../../hooks/useCannotUseDevice'
|
||||||
import { useDeviceInUse } from '../../../hooks/useDeviceInUse'
|
|
||||||
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
import { useDeviceMissing } from '../../../hooks/useDeviceMissing'
|
||||||
import { requestDevicePermission } from '../../../utils/mediaPermissions'
|
import { requestDevicePermission } from '../../../hooks/useJoinTracks'
|
||||||
import { userChoicesStore } from '@/stores/userChoices'
|
|
||||||
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
import { useDeviceIcons } from '../../../hooks/useDeviceIcons'
|
||||||
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
import { useDeviceShortcut } from '../../../hooks/useDeviceShortcut'
|
||||||
import type {
|
import type {
|
||||||
@@ -99,51 +97,38 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
const deviceIcons = useDeviceIcons(kind)
|
const deviceIcons = useDeviceIcons(kind)
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
const cannotUseDevice = useCannotUseDevice(kind)
|
||||||
const deviceMissing = useDeviceMissing(kind)
|
const deviceMissing = useDeviceMissing(kind)
|
||||||
const deviceInUse = useDeviceInUse(kind)
|
|
||||||
const explainDeviceInUse = deviceInUse && context === 'room'
|
|
||||||
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
const { status: silentMicStatus } = useSnapshot(silentMicStore)
|
||||||
const silentMicWarning =
|
const silentMicWarning =
|
||||||
kind === 'audioinput' &&
|
kind === 'audioinput' &&
|
||||||
silentMicStatus === 'silent' &&
|
silentMicStatus === 'silent' &&
|
||||||
!cannotUseDevice &&
|
!cannotUseDevice &&
|
||||||
!deviceMissing &&
|
!deviceMissing
|
||||||
!deviceInUse
|
|
||||||
const deviceShortcut = useDeviceShortcut(kind)
|
const deviceShortcut = useDeviceShortcut(kind)
|
||||||
const announce = useScreenReaderAnnounce()
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
const isRequestingPermission = useRef(false)
|
const isRequestingPermission = useRef(false)
|
||||||
const [alertError, setAlertError] = useState<MediaDeviceFailure | null>(null)
|
const [showDeviceNotFound, setShowDeviceNotFound] = useState(false)
|
||||||
|
|
||||||
const mediaPath = context === 'join' ? 'join_preview' : 'room'
|
|
||||||
|
|
||||||
const onPress = async () => {
|
const onPress = async () => {
|
||||||
if (!enabled && deviceMissing) {
|
if (!enabled && deviceMissing) {
|
||||||
setAlertError(MediaDeviceFailure.NotFound)
|
setShowDeviceNotFound(true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!cannotUseDevice && !deviceInUse) {
|
if (!cannotUseDevice) {
|
||||||
toggle()
|
toggle()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isRequestingPermission.current) return
|
if (isRequestingPermission.current) return
|
||||||
isRequestingPermission.current = true
|
isRequestingPermission.current = true
|
||||||
try {
|
try {
|
||||||
const selectedDeviceId = cannotUseDevice
|
const granted = await requestDevicePermission(
|
||||||
? undefined
|
|
||||||
: kind === 'videoinput'
|
|
||||||
? userChoicesStore.videoDeviceId
|
|
||||||
: userChoicesStore.audioDeviceId
|
|
||||||
const acquired = await requestDevicePermission(
|
|
||||||
kind,
|
kind,
|
||||||
mediaPath,
|
context === 'join' ? 'join_preview' : 'room'
|
||||||
selectedDeviceId || undefined
|
|
||||||
)
|
)
|
||||||
if (acquired) {
|
if (granted) {
|
||||||
toggle()
|
toggle()
|
||||||
} else if (cannotUseDevice) {
|
} else {
|
||||||
openPermissionsDialog(kind)
|
openPermissionsDialog(kind)
|
||||||
} else if (explainDeviceInUse) {
|
|
||||||
setAlertError(MediaDeviceFailure.DeviceInUse)
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingPermission.current = false
|
isRequestingPermission.current = false
|
||||||
@@ -194,34 +179,13 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
return <ActiveSpeakerWrapper />
|
return <ActiveSpeakerWrapper />
|
||||||
}
|
}
|
||||||
|
|
||||||
const getToggleTooltip = () => {
|
|
||||||
if (deviceMissing) return t(`deviceNotFound.${kind}`)
|
|
||||||
if (explainDeviceInUse) return t(`deviceInUse.${kind}`)
|
|
||||||
if (cannotUseDevice) return t('tooltip', { keyPrefix: 'permissionsButton' })
|
|
||||||
return toggleLabel
|
|
||||||
}
|
|
||||||
const toggleTooltip = getToggleTooltip()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
{(cannotUseDevice || deviceMissing) && (
|
{(cannotUseDevice || deviceMissing) && (
|
||||||
<PermissionNeededButton
|
<PermissionNeededButton
|
||||||
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
tooltip={deviceMissing ? t(`deviceNotFound.${kind}`) : undefined}
|
||||||
onPress={
|
onPress={
|
||||||
deviceMissing
|
deviceMissing ? () => setShowDeviceNotFound(true) : undefined
|
||||||
? () => setAlertError(MediaDeviceFailure.NotFound)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{deviceInUse && (
|
|
||||||
<PermissionNeededButton
|
|
||||||
tooltip={explainDeviceInUse ? t(`deviceInUse.${kind}`) : null}
|
|
||||||
ariaLabel={t(`deviceInUse.${kind}`)}
|
|
||||||
onPress={
|
|
||||||
explainDeviceInUse
|
|
||||||
? () => setAlertError(MediaDeviceFailure.DeviceInUse)
|
|
||||||
: null
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -240,16 +204,22 @@ export const ToggleDevice = <T extends ToggleSource>({
|
|||||||
shySelected
|
shySelected
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
aria-label={toggleLabel}
|
aria-label={toggleLabel}
|
||||||
tooltip={toggleTooltip}
|
tooltip={
|
||||||
|
deviceMissing
|
||||||
|
? t(`deviceNotFound.${kind}`)
|
||||||
|
: cannotUseDevice
|
||||||
|
? t('tooltip', { keyPrefix: 'permissionsButton' })
|
||||||
|
: toggleLabel
|
||||||
|
}
|
||||||
{...computedToggleButtonProps}
|
{...computedToggleButtonProps}
|
||||||
{...overrideToggleButtonProps}
|
{...overrideToggleButtonProps}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
<MediaDeviceErrorAlert
|
<MediaDeviceErrorAlert
|
||||||
error={alertError}
|
error={showDeviceNotFound ? MediaDeviceFailure.NotFound : null}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
onClose={() => setAlertError(null)}
|
onClose={() => setShowDeviceNotFound(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ import { reportError } from '@/features/analytics/telemetry'
|
|||||||
|
|
||||||
enum BlurRadius {
|
enum BlurRadius {
|
||||||
NONE = 0,
|
NONE = 0,
|
||||||
LIGHT = 10,
|
LIGHT = 5,
|
||||||
NORMAL = 20,
|
NORMAL = 10,
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSupported = BackgroundProcessorFactory.isSupported()
|
const isSupported = BackgroundProcessorFactory.isSupported()
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import { useSnapshot } from 'valtio'
|
|
||||||
import { deviceAvailabilityStore } from '@/stores/deviceAvailability'
|
|
||||||
import { useCannotUseDevice } from './useCannotUseDevice'
|
|
||||||
import { useDeviceMissing } from './useDeviceMissing'
|
|
||||||
|
|
||||||
export const useDeviceInUse = (kind: MediaDeviceKind): boolean => {
|
|
||||||
const { cameraInUse, microphoneInUse } = useSnapshot(deviceAvailabilityStore)
|
|
||||||
const cannotUseDevice = useCannotUseDevice(kind)
|
|
||||||
const deviceMissing = useDeviceMissing(kind)
|
|
||||||
|
|
||||||
if (cannotUseDevice || deviceMissing) return false
|
|
||||||
|
|
||||||
switch (kind) {
|
|
||||||
case 'videoinput':
|
|
||||||
return cameraInUse
|
|
||||||
case 'audioinput':
|
|
||||||
return microphoneInUse
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,16 @@ import {
|
|||||||
} from 'livekit-client'
|
} from 'livekit-client'
|
||||||
import { BackgroundProcessorFactory } from '../components/blur'
|
import { BackgroundProcessorFactory } from '../components/blur'
|
||||||
import {
|
import {
|
||||||
|
classifyPermissionError,
|
||||||
|
isLikelySystemNotFound,
|
||||||
isSystemPermissionError,
|
isSystemPermissionError,
|
||||||
|
noteGumSuccess,
|
||||||
|
notePermissionDeniedFromGum,
|
||||||
|
noteSystemPermissionDenied,
|
||||||
type PermissionKind,
|
type PermissionKind,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
import {
|
import { getOS } from '@/utils/os'
|
||||||
getMediaDeviceFailure,
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
noteDeviceReady,
|
|
||||||
onMediaPermissionError,
|
|
||||||
} from '../utils/mediaPermissions'
|
|
||||||
import {
|
import {
|
||||||
saveAudioInputDeviceId,
|
saveAudioInputDeviceId,
|
||||||
saveAudioInputEnabled,
|
saveAudioInputEnabled,
|
||||||
@@ -37,6 +39,64 @@ const VOICE_AUDIO_CONSTRAINTS = {
|
|||||||
sampleSize: 16,
|
sampleSize: 16,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
|
const PERMISSION_KIND: Record<'audioinput' | 'videoinput', PermissionKind> = {
|
||||||
|
audioinput: 'microphone',
|
||||||
|
videoinput: 'camera',
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaPath = 'join_preview' | 'room'
|
||||||
|
|
||||||
|
const onMediaPermissionError = (
|
||||||
|
e: Error,
|
||||||
|
kind?: PermissionKind,
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.PermissionDenied
|
||||||
|
) {
|
||||||
|
void classifyPermissionError(e, kind).then((scope) => {
|
||||||
|
if (scope === 'system') {
|
||||||
|
noteSystemPermissionDenied(kind)
|
||||||
|
} else {
|
||||||
|
notePermissionDeniedFromGum(kind)
|
||||||
|
}
|
||||||
|
captureMediaEvent('permissions-denied', {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
denied_scope: scope,
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MediaDeviceFailure.getFailure(e) === MediaDeviceFailure.NotFound) {
|
||||||
|
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
||||||
|
// settings, missing Android app permissions).
|
||||||
|
void isLikelySystemNotFound(e, kind).then((system) => {
|
||||||
|
if (system) {
|
||||||
|
noteSystemPermissionDenied(kind)
|
||||||
|
captureMediaEvent('permissions-denied', {
|
||||||
|
path,
|
||||||
|
kind,
|
||||||
|
denied_scope: 'system',
|
||||||
|
os: getOS(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
captureMediaEvent('device-not-found', { path, kind })
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Other" and "Device in use" are still reported as errors, as they are not handled on the join screen.
|
||||||
|
reportError(
|
||||||
|
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
||||||
|
e,
|
||||||
|
{ path, kind }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Module-level: effect dependencies, must be referentially stable.
|
// Module-level: effect dependencies, must be referentially stable.
|
||||||
const disableAudio = () => saveAudioInputEnabled(false)
|
const disableAudio = () => saveAudioInputEnabled(false)
|
||||||
const disableVideo = () => saveVideoInputEnabled(false)
|
const disableVideo = () => saveVideoInputEnabled(false)
|
||||||
@@ -44,6 +104,24 @@ const disableVideo = () => saveVideoInputEnabled(false)
|
|||||||
const stopAll = (stream: MediaStream) =>
|
const stopAll = (stream: MediaStream) =>
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
stream.getTracks().forEach((track) => track.stop())
|
||||||
|
|
||||||
|
export const requestDevicePermission = async (
|
||||||
|
kind: 'audioinput' | 'videoinput',
|
||||||
|
path: MediaPath = 'join_preview'
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const track =
|
||||||
|
kind === 'audioinput'
|
||||||
|
? await createLocalAudioTrack()
|
||||||
|
: await createLocalVideoTrack()
|
||||||
|
track.stop()
|
||||||
|
noteGumSuccess(PERMISSION_KIND[kind])
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
onMediaPermissionError(error as Error, PERMISSION_KIND[kind], path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type WarmupState = {
|
type WarmupState = {
|
||||||
audioReady: boolean
|
audioReady: boolean
|
||||||
videoReady: boolean
|
videoReady: boolean
|
||||||
@@ -80,11 +158,11 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
video: true,
|
video: true,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
noteDeviceReady()
|
noteGumSuccess()
|
||||||
bothReady()
|
bothReady()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
getMediaDeviceFailure(error as Error) ===
|
MediaDeviceFailure.getFailure(error as Error) ===
|
||||||
MediaDeviceFailure.PermissionDenied &&
|
MediaDeviceFailure.PermissionDenied &&
|
||||||
!isSystemPermissionError(error)
|
!isSystemPermissionError(error)
|
||||||
) {
|
) {
|
||||||
@@ -102,7 +180,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ audio: true })
|
.getUserMedia({ audio: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteDeviceReady('microphone')
|
noteGumSuccess('microphone')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
.catch((e) => onMediaPermissionError(e as Error, 'microphone'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -112,7 +190,7 @@ function useWarmupPermissions(): WarmupState {
|
|||||||
.getUserMedia({ video: true })
|
.getUserMedia({ video: true })
|
||||||
.then((stream) => {
|
.then((stream) => {
|
||||||
stopAll(stream)
|
stopAll(stream)
|
||||||
noteDeviceReady('camera')
|
noteGumSuccess('camera')
|
||||||
})
|
})
|
||||||
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
.catch((e) => onMediaPermissionError(e as Error, 'camera'))
|
||||||
.finally(() =>
|
.finally(() =>
|
||||||
@@ -131,14 +209,12 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
|||||||
enabled,
|
enabled,
|
||||||
create,
|
create,
|
||||||
permissionKind,
|
permissionKind,
|
||||||
deviceId,
|
|
||||||
onFailure,
|
onFailure,
|
||||||
}: {
|
}: {
|
||||||
ready: boolean
|
ready: boolean
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
create: () => Promise<T>
|
create: () => Promise<T>
|
||||||
permissionKind: PermissionKind
|
permissionKind: PermissionKind
|
||||||
deviceId?: string
|
|
||||||
onFailure: () => void
|
onFailure: () => void
|
||||||
}): T | null {
|
}): T | null {
|
||||||
const [track, setTrack] = useState<T | null>(null)
|
const [track, setTrack] = useState<T | null>(null)
|
||||||
@@ -151,27 +227,21 @@ function useLocalTrack<T extends LocalAudioTrack | LocalVideoTrack>({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
create()
|
create()
|
||||||
.then((newTrack) => {
|
.then((newTrack) => {
|
||||||
|
noteGumSuccess(permissionKind)
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
newTrack.stop()
|
newTrack.stop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
noteDeviceReady(permissionKind)
|
|
||||||
setTrack(newTrack)
|
setTrack(newTrack)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
if (cancelled) return
|
onMediaPermissionError(error as Error, permissionKind)
|
||||||
onMediaPermissionError(
|
|
||||||
error as Error,
|
|
||||||
permissionKind,
|
|
||||||
'join_preview',
|
|
||||||
deviceId
|
|
||||||
)
|
|
||||||
onFailure()
|
onFailure()
|
||||||
})
|
})
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [ready, enabled, track, create, permissionKind, deviceId, onFailure])
|
}, [ready, enabled, track, create, permissionKind, onFailure])
|
||||||
|
|
||||||
// Release on toggle-off so the LED turns off.
|
// Release on toggle-off so the LED turns off.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -245,7 +315,6 @@ export function useJoinTracks(): {
|
|||||||
enabled: audioEnabled,
|
enabled: audioEnabled,
|
||||||
create: createAudio,
|
create: createAudio,
|
||||||
permissionKind: 'microphone',
|
permissionKind: 'microphone',
|
||||||
deviceId: audioDeviceId,
|
|
||||||
onFailure: disableAudio,
|
onFailure: disableAudio,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -254,7 +323,6 @@ export function useJoinTracks(): {
|
|||||||
enabled: videoEnabled,
|
enabled: videoEnabled,
|
||||||
create: createVideo,
|
create: createVideo,
|
||||||
permissionKind: 'camera',
|
permissionKind: 'camera',
|
||||||
deviceId: videoDeviceId,
|
|
||||||
onFailure: disableVideo,
|
onFailure: disableVideo,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import {
|
import { MediaDeviceFailure, RoomEvent } from 'livekit-client'
|
||||||
type LocalTrackPublication,
|
|
||||||
MediaDeviceFailure,
|
|
||||||
RoomEvent,
|
|
||||||
Track,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import {
|
import {
|
||||||
PERMISSION_BY_DEVICE_KIND,
|
PERMISSION_BY_DEVICE_KIND,
|
||||||
type PermissionDeniedScope,
|
type PermissionDeniedScope,
|
||||||
@@ -15,13 +10,8 @@ import {
|
|||||||
notePermissionDeniedFromGum,
|
notePermissionDeniedFromGum,
|
||||||
noteSystemPermissionDenied,
|
noteSystemPermissionDenied,
|
||||||
} from '@/stores/permissions'
|
} from '@/stores/permissions'
|
||||||
import {
|
import { syncDeviceAvailability } from '@/stores/deviceAvailability'
|
||||||
clearDeviceInUse,
|
|
||||||
noteDeviceInUse,
|
|
||||||
syncDeviceAvailability,
|
|
||||||
} from '@/stores/deviceAvailability'
|
|
||||||
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
import { captureMediaEvent } from '@/features/analytics/telemetry'
|
||||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
|
||||||
import { getOS } from '@/utils/os'
|
import { getOS } from '@/utils/os'
|
||||||
|
|
||||||
type MediaDeviceAlert = {
|
type MediaDeviceAlert = {
|
||||||
@@ -31,11 +21,6 @@ type MediaDeviceAlert = {
|
|||||||
|
|
||||||
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
const NO_ALERT: MediaDeviceAlert = { error: null, kind: null }
|
||||||
|
|
||||||
const PERMISSION_BY_SOURCE: Partial<Record<Track.Source, PermissionKind>> = {
|
|
||||||
[Track.Source.Camera]: 'camera',
|
|
||||||
[Track.Source.Microphone]: 'microphone',
|
|
||||||
}
|
|
||||||
|
|
||||||
const capturePermissionsDenied = (
|
const capturePermissionsDenied = (
|
||||||
scope: PermissionDeniedScope,
|
scope: PermissionDeniedScope,
|
||||||
kind?: PermissionKind
|
kind?: PermissionKind
|
||||||
@@ -64,7 +49,7 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
const onDeviceError = (error: Error, kind?: MediaDeviceKind) => {
|
||||||
const failure = getMediaDeviceFailure(error)
|
const failure = MediaDeviceFailure.getFailure(error)
|
||||||
if (!failure) return
|
if (!failure) return
|
||||||
if (failure != MediaDeviceFailure.Other) {
|
if (failure != MediaDeviceFailure.Other) {
|
||||||
void captureMediaEvent('media-device-error', {
|
void captureMediaEvent('media-device-error', {
|
||||||
@@ -78,7 +63,6 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
const permissionKind = PERMISSION_BY_DEVICE_KIND[kind]
|
||||||
switch (failure) {
|
switch (failure) {
|
||||||
case MediaDeviceFailure.DeviceInUse:
|
case MediaDeviceFailure.DeviceInUse:
|
||||||
if (permissionKind) noteDeviceInUse(permissionKind)
|
|
||||||
setAlert({ error: failure, kind })
|
setAlert({ error: failure, kind })
|
||||||
break
|
break
|
||||||
case MediaDeviceFailure.NotFound:
|
case MediaDeviceFailure.NotFound:
|
||||||
@@ -108,16 +92,9 @@ export const useWatchMediaDeviceErrors = (): MediaDeviceAlert & {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onTrackPublished = (publication: LocalTrackPublication) => {
|
|
||||||
const permissionKind = PERMISSION_BY_SOURCE[publication.source]
|
|
||||||
if (permissionKind) clearDeviceInUse(permissionKind)
|
|
||||||
}
|
|
||||||
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
room.on(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
room.on(RoomEvent.LocalTrackPublished, onTrackPublished)
|
|
||||||
return () => {
|
return () => {
|
||||||
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
room.off(RoomEvent.MediaDevicesError, onDeviceError)
|
||||||
room.off(RoomEvent.LocalTrackPublished, onTrackPublished)
|
|
||||||
clearDeviceInUse()
|
|
||||||
}
|
}
|
||||||
}, [room])
|
}, [room])
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { SubtitlesToggle } from '../../components/controls/SubtitlesToggle'
|
|||||||
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||||
import { MoreOptions } from './MoreOptions'
|
import { MoreOptions } from './MoreOptions'
|
||||||
import { RefObject, useMemo, useState } from 'react'
|
import { useRef } from 'react'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
@@ -21,14 +21,7 @@ export function DesktopControlBar({
|
|||||||
onDeviceError,
|
onDeviceError,
|
||||||
}: Readonly<ControlBarAuxProps>) {
|
}: Readonly<ControlBarAuxProps>) {
|
||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
|
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||||
const [controlBarElement, setControlBarElement] =
|
|
||||||
useState<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
const desktopControlBarEl = useMemo<RefObject<HTMLDivElement>>(
|
|
||||||
() => ({ current: controlBarElement }),
|
|
||||||
[controlBarElement]
|
|
||||||
)
|
|
||||||
|
|
||||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||||
|
|
||||||
@@ -52,7 +45,7 @@ export function DesktopControlBar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setControlBarElement}
|
ref={desktopControlBarEl}
|
||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { isWeb } from '@livekit/components-core'
|
import { isWeb } from '@livekit/components-core'
|
||||||
import { MediaDeviceFailure, Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { getMediaDeviceFailure } from '../utils/mediaPermissions'
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import {
|
import {
|
||||||
ConnectionStateToast,
|
ConnectionStateToast,
|
||||||
@@ -20,7 +19,6 @@ import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer
|
|||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
import { MuteAlertDialogProvider } from '@/features/rooms/livekit/components/MuteAlertDialogProvider'
|
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
import { ReactionPortals } from '@/features/reactions/components/ReactionPortals'
|
||||||
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
import { RoomContentArea } from '@/features/layout/components/RoomContentArea'
|
||||||
@@ -101,11 +99,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getMediaDeviceFailure(error) !== MediaDeviceFailure.Other) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
reportError('device_switch_failure', error, {
|
reportError('device_switch_failure', error, {
|
||||||
at: 'ControlBar.onDeviceError',
|
at: 'ControlBar.onDeviceError',
|
||||||
source,
|
source,
|
||||||
@@ -151,7 +144,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<ConnectionStateToast />
|
<ConnectionStateToast />
|
||||||
<RecordingProvider />
|
<RecordingProvider />
|
||||||
<SettingsDialogProvider />
|
<SettingsDialogProvider />
|
||||||
<MuteAlertDialogProvider />
|
|
||||||
<ReactionPortals />
|
<ReactionPortals />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
import {
|
|
||||||
createLocalAudioTrack,
|
|
||||||
createLocalVideoTrack,
|
|
||||||
MediaDeviceFailure,
|
|
||||||
} from 'livekit-client'
|
|
||||||
import {
|
|
||||||
classifyPermissionError,
|
|
||||||
isLikelySystemNotFound,
|
|
||||||
noteGumSuccess,
|
|
||||||
notePermissionDeniedFromGum,
|
|
||||||
noteSystemPermissionDenied,
|
|
||||||
type PermissionKind,
|
|
||||||
} from '@/stores/permissions'
|
|
||||||
import { clearDeviceInUse, noteDeviceInUse } from '@/stores/deviceAvailability'
|
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
|
||||||
import { getOS } from '@/utils/os'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared handling of getUserMedia() outcomes, used by both:
|
|
||||||
* - the join preview (`useJoinTracks`: warmup + local track acquisition)
|
|
||||||
* - the in-room device toggle (`ToggleDevice`, when permission is missing)
|
|
||||||
*/
|
|
||||||
export type MediaPath = 'join_preview' | 'room'
|
|
||||||
|
|
||||||
const DEVICE_START_FAILURE =
|
|
||||||
/^(Starting (video|audio)input failed|Timeout starting (video|audio) source)/i
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LiveKit only maps NotReadableError/TrackStartError to DeviceInUse.
|
|
||||||
* Firefox reports a device held by another app as
|
|
||||||
* `AbortError: Starting videoinput failed` (or audioinput), which LiveKit
|
|
||||||
* classifies as Other. Normalise it here; every classification in the app
|
|
||||||
* should go through this instead of MediaDeviceFailure.getFailure.
|
|
||||||
*/
|
|
||||||
export const getMediaDeviceFailure = (
|
|
||||||
error: Error
|
|
||||||
): MediaDeviceFailure | undefined => {
|
|
||||||
const failure = MediaDeviceFailure.getFailure(error)
|
|
||||||
if (
|
|
||||||
failure === MediaDeviceFailure.Other &&
|
|
||||||
error.name === 'AbortError' &&
|
|
||||||
DEVICE_START_FAILURE.test(error.message)
|
|
||||||
) {
|
|
||||||
return MediaDeviceFailure.DeviceInUse
|
|
||||||
}
|
|
||||||
return failure
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PERMISSION_KIND: Record<
|
|
||||||
'audioinput' | 'videoinput',
|
|
||||||
PermissionKind
|
|
||||||
> = {
|
|
||||||
audioinput: 'microphone',
|
|
||||||
videoinput: 'camera',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const noteDeviceReady = (kind?: PermissionKind) => {
|
|
||||||
noteGumSuccess(kind)
|
|
||||||
clearDeviceInUse(kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const onMediaPermissionError = (
|
|
||||||
e: Error,
|
|
||||||
kind?: PermissionKind,
|
|
||||||
path: MediaPath = 'join_preview',
|
|
||||||
deviceId?: string
|
|
||||||
) => {
|
|
||||||
const failure = getMediaDeviceFailure(e)
|
|
||||||
|
|
||||||
if (failure === MediaDeviceFailure.PermissionDenied) {
|
|
||||||
void classifyPermissionError(e, kind).then((scope) => {
|
|
||||||
if (scope === 'system') {
|
|
||||||
noteSystemPermissionDenied(kind)
|
|
||||||
} else {
|
|
||||||
notePermissionDeniedFromGum(kind)
|
|
||||||
}
|
|
||||||
captureMediaEvent('permissions-denied', {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
denied_scope: scope,
|
|
||||||
os: getOS(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failure === MediaDeviceFailure.NotFound) {
|
|
||||||
// Firefox reports OS-level blocks as NotFoundError (macOS privacy
|
|
||||||
// settings, missing Android app permissions).
|
|
||||||
void isLikelySystemNotFound(e, kind).then((system) => {
|
|
||||||
if (system) {
|
|
||||||
noteSystemPermissionDenied(kind)
|
|
||||||
captureMediaEvent('permissions-denied', {
|
|
||||||
path,
|
|
||||||
kind,
|
|
||||||
denied_scope: 'system',
|
|
||||||
os: getOS(),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
captureMediaEvent('device-not-found', { path, kind })
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failure === MediaDeviceFailure.DeviceInUse) {
|
|
||||||
noteDeviceInUse(kind, deviceId)
|
|
||||||
void captureMediaEvent('device-in-use', { path, kind, os: getOS() })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Other" is still reported as an error.
|
|
||||||
reportError(
|
|
||||||
path === 'room' ? 'room_media_failure' : 'join_preview_failure',
|
|
||||||
e,
|
|
||||||
{ path, kind }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Silent availability check for a device that was reported "in use":
|
|
||||||
* acquires and releases it without any error reporting, so it can be
|
|
||||||
* polled. Clears the in-use flag on success.
|
|
||||||
*/
|
|
||||||
export const probeDeviceReleased = async (
|
|
||||||
kind: PermissionKind,
|
|
||||||
deviceId?: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const constraint = deviceId ? { deviceId: { exact: deviceId } } : true
|
|
||||||
const stream = await navigator.mediaDevices.getUserMedia(
|
|
||||||
kind === 'camera' ? { video: constraint } : { audio: constraint }
|
|
||||||
)
|
|
||||||
stream.getTracks().forEach((track) => track.stop())
|
|
||||||
noteDeviceReady(kind)
|
|
||||||
return true
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Triggers the browser permission prompt for one device kind by acquiring
|
|
||||||
* and immediately releasing a track. Resolves to whether access was granted.
|
|
||||||
*/
|
|
||||||
export const requestDevicePermission = async (
|
|
||||||
kind: 'audioinput' | 'videoinput',
|
|
||||||
path: MediaPath = 'join_preview',
|
|
||||||
deviceId?: string
|
|
||||||
): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const track =
|
|
||||||
kind === 'audioinput'
|
|
||||||
? await createLocalAudioTrack({ deviceId })
|
|
||||||
: await createLocalVideoTrack({ deviceId })
|
|
||||||
track.stop()
|
|
||||||
noteDeviceReady(PERMISSION_KIND[kind])
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
onMediaPermissionError(
|
|
||||||
error as Error,
|
|
||||||
PERMISSION_KIND[kind],
|
|
||||||
path,
|
|
||||||
deviceId
|
|
||||||
)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
import { useConfig } from '@/api/useConfig.ts'
|
import { useConfig } from '@/api/useConfig.ts'
|
||||||
import { LogLevel, setLogLevel } from 'livekit-client'
|
import { LogLevel, setLogLevel } from 'livekit-client'
|
||||||
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
import { useWatchDeviceAvailability } from '@/features/rooms/hooks/useWatchDeviceAvailability'
|
||||||
import { useWatchDeviceReleased } from '@/features/rooms/hooks/useWatchDeviceReleased'
|
|
||||||
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '@/features/rooms/livekit/hooks/useRoomPageTitle'
|
||||||
|
|
||||||
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
const BaseRoom = ({ children }: { children: ReactNode }) => {
|
||||||
@@ -50,7 +49,6 @@ const Room = () => {
|
|||||||
|
|
||||||
useKeyboardShortcuts()
|
useKeyboardShortcuts()
|
||||||
useWatchDeviceAvailability()
|
useWatchDeviceAvailability()
|
||||||
useWatchDeviceReleased()
|
|
||||||
|
|
||||||
const clearRouterState = () => {
|
const clearRouterState = () => {
|
||||||
if (window?.history?.state) {
|
if (window?.history?.state) {
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"videoinput": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
"audioinput": "Kein Mikrofon erkannt. Prüfe, ob es richtig angeschlossen ist."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Kamera nicht verfügbar: Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
|
||||||
"audioinput": "Mikrofon nicht verfügbar: Es wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audioeinstellungen",
|
"audio": "Audioeinstellungen",
|
||||||
"video": "Videoeinstellungen"
|
"video": "Videoeinstellungen"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||||
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
"cameraNotFound": "Keine Kamera erkannt. Prüfe, ob sie richtig angeschlossen ist.",
|
||||||
"cameraInUse": "Deine Kamera ist nicht verfügbar. Sie wird wahrscheinlich von einer anderen App oder einem anderen Tab verwendet.",
|
|
||||||
"cameraStarting": "Kamera wird gestartet…",
|
"cameraStarting": "Kamera wird gestartet…",
|
||||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "No camera detected. Check that it is properly wired.",
|
"videoinput": "No camera detected. Check that it is properly wired.",
|
||||||
"audioinput": "No microphone detected. Check that it is properly wired."
|
"audioinput": "No microphone detected. Check that it is properly wired."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Camera unavailable: it is probably in use by another application or browser tab.",
|
|
||||||
"audioinput": "Microphone unavailable: it is probably in use by another application or browser tab."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio settings",
|
"audio": "Audio settings",
|
||||||
"video": "Video settings"
|
"video": "Video settings"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is disabled.",
|
"cameraDisabled": "Camera is disabled.",
|
||||||
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
"cameraNotFound": "No camera detected. Check that it is properly plugged in.",
|
||||||
"cameraInUse": "Your camera is unavailable. It is probably being used by another application or browser tab.",
|
|
||||||
"cameraStarting": "Camera is starting…",
|
"cameraStarting": "Camera is starting…",
|
||||||
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
"cameraNotGranted": "Would you like others to be able to see you during the meeting?",
|
||||||
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
"cameraAndMicNotGranted": "Would you like others to be able to see and hear you during the meeting?",
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"videoinput": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
"audioinput": "Aucun microphone détecté. Vérifiez qu'il est bien branché."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Caméra indisponible : elle est probablement utilisée par une autre application ou un autre onglet.",
|
|
||||||
"audioinput": "Microphone indisponible : il est probablement utilisé par une autre application ou un autre onglet."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Paramètres audio",
|
"audio": "Paramètres audio",
|
||||||
"video": "Paramètres video"
|
"video": "Paramètres video"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "La caméra est désactivée.",
|
"cameraDisabled": "La caméra est désactivée.",
|
||||||
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
"cameraNotFound": "Aucune caméra détectée. Vérifiez qu'elle est bien branchée.",
|
||||||
"cameraInUse": "Votre caméra n'est pas disponible. Elle est probablement utilisée par une autre application ou un autre onglet.",
|
|
||||||
"cameraStarting": "La caméra va démarrer…",
|
"cameraStarting": "La caméra va démarrer…",
|
||||||
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
"cameraNotGranted": "Souhaitez-vous que les autres puissent vous voir pendant la réunion ?",
|
||||||
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
"cameraAndMicNotGranted": "Souhaitez-vous que les autres puissent vous voir et vous entendre pendant la réunion ?",
|
||||||
|
|||||||
@@ -16,10 +16,6 @@
|
|||||||
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"videoinput": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
"audioinput": "Geen microfoon gedetecteerd. Controleer of deze goed is aangesloten."
|
||||||
},
|
},
|
||||||
"deviceInUse": {
|
|
||||||
"videoinput": "Camera niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
|
||||||
"audioinput": "Microfoon niet beschikbaar: deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad."
|
|
||||||
},
|
|
||||||
"settings": {
|
"settings": {
|
||||||
"audio": "Audio-instellingen",
|
"audio": "Audio-instellingen",
|
||||||
"video": "Video-instellingen"
|
"video": "Video-instellingen"
|
||||||
@@ -71,7 +67,6 @@
|
|||||||
},
|
},
|
||||||
"cameraDisabled": "Camera is uitgeschakeld.",
|
"cameraDisabled": "Camera is uitgeschakeld.",
|
||||||
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
"cameraNotFound": "Geen camera gedetecteerd. Controleer of deze goed is aangesloten.",
|
||||||
"cameraInUse": "Je camera is niet beschikbaar. Deze wordt waarschijnlijk gebruikt door een andere toepassing of een ander tabblad.",
|
|
||||||
"cameraStarting": "Camera wordt ingeschakeld…",
|
"cameraStarting": "Camera wordt ingeschakeld…",
|
||||||
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
"cameraNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien?",
|
||||||
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
"cameraAndMicNotGranted": "Wilt u dat anderen u tijdens de vergadering kunnen zien en horen?",
|
||||||
|
|||||||
@@ -1,68 +1,14 @@
|
|||||||
import { proxy } from 'valtio'
|
import { proxy } from 'valtio'
|
||||||
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
import { captureMediaEvent, reportError } from '@/features/analytics/telemetry'
|
||||||
import type { PermissionKind } from './permissions'
|
|
||||||
|
|
||||||
// Device availability (not permission):
|
// Device presence (not permission): enumerateDevices() exposes kinds
|
||||||
// - presence: enumerateDevices() exposes kinds before any grant.
|
// before any grant. Optimistic defaults until the first sync.
|
||||||
// Optimistic defaults until the first sync.
|
export const deviceAvailabilityStore = proxy({
|
||||||
// - in use: the device exists and is allowed, but getUserMedia() failed
|
|
||||||
// because another application or tab is holding it. The id of the
|
|
||||||
// affected device is kept (when known) so that a selection change can
|
|
||||||
// invalidate a flag that no longer concerns the selected device.
|
|
||||||
export const deviceAvailabilityStore = proxy<{
|
|
||||||
hasCamera: boolean
|
|
||||||
hasMicrophone: boolean
|
|
||||||
cameraInUse: boolean
|
|
||||||
microphoneInUse: boolean
|
|
||||||
cameraInUseDeviceId?: string
|
|
||||||
microphoneInUseDeviceId?: string
|
|
||||||
synced: boolean
|
|
||||||
}>({
|
|
||||||
hasCamera: true,
|
hasCamera: true,
|
||||||
hasMicrophone: true,
|
hasMicrophone: true,
|
||||||
cameraInUse: false,
|
|
||||||
microphoneInUse: false,
|
|
||||||
synced: false,
|
synced: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const IN_USE_KEY: Record<PermissionKind, 'cameraInUse' | 'microphoneInUse'> = {
|
|
||||||
camera: 'cameraInUse',
|
|
||||||
microphone: 'microphoneInUse',
|
|
||||||
}
|
|
||||||
|
|
||||||
const IN_USE_DEVICE_KEY: Record<
|
|
||||||
PermissionKind,
|
|
||||||
'cameraInUseDeviceId' | 'microphoneInUseDeviceId'
|
|
||||||
> = {
|
|
||||||
camera: 'cameraInUseDeviceId',
|
|
||||||
microphone: 'microphoneInUseDeviceId',
|
|
||||||
}
|
|
||||||
|
|
||||||
const ALL_KINDS: PermissionKind[] = ['camera', 'microphone']
|
|
||||||
|
|
||||||
export const noteDeviceInUse = (kind?: PermissionKind, deviceId?: string) => {
|
|
||||||
for (const k of kind ? [kind] : ALL_KINDS) {
|
|
||||||
deviceAvailabilityStore[IN_USE_KEY[k]] = true
|
|
||||||
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = deviceId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearDeviceInUse = (kind?: PermissionKind) => {
|
|
||||||
for (const k of kind ? [kind] : ALL_KINDS) {
|
|
||||||
deviceAvailabilityStore[IN_USE_KEY[k]] = false
|
|
||||||
deviceAvailabilityStore[IN_USE_DEVICE_KEY[k]] = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const clearDeviceInUseForOtherDevice = (
|
|
||||||
kind: PermissionKind,
|
|
||||||
selectedDeviceId?: string
|
|
||||||
) => {
|
|
||||||
const affected = deviceAvailabilityStore[IN_USE_DEVICE_KEY[kind]]
|
|
||||||
if (!affected || !selectedDeviceId || affected === selectedDeviceId) return
|
|
||||||
clearDeviceInUse(kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const syncDeviceAvailability = async (): Promise<void> => {
|
export const syncDeviceAvailability = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const devices = await navigator.mediaDevices.enumerateDevices()
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { proxy, ref } from 'valtio'
|
|
||||||
import type { Participant } from 'livekit-client'
|
|
||||||
|
|
||||||
type State = {
|
|
||||||
participant: Participant | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const muteDialogStore = proxy<State>({
|
|
||||||
participant: null,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const openMuteDialog = (participant: Participant) => {
|
|
||||||
muteDialogStore.participant = ref(participant)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const closeMuteDialog = () => {
|
|
||||||
muteDialogStore.participant = null
|
|
||||||
}
|
|
||||||
@@ -5,25 +5,15 @@ import { visualizer } from 'rollup-plugin-visualizer'
|
|||||||
import svgr from 'vite-plugin-svgr'
|
import svgr from 'vite-plugin-svgr'
|
||||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||||
|
|
||||||
const readPackageJson = (path: string) =>
|
const mediapipeVersion: string = JSON.parse(
|
||||||
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf-8'))
|
readFileSync(
|
||||||
|
new URL(
|
||||||
const mediapipeVersion: string = readPackageJson(
|
'./node_modules/@mediapipe/tasks-vision/package.json',
|
||||||
'./node_modules/@mediapipe/tasks-vision/package.json'
|
import.meta.url
|
||||||
).version
|
),
|
||||||
|
'utf-8'
|
||||||
const livekitMediapipeVersion: string = readPackageJson(
|
|
||||||
'./node_modules/@livekit/track-processors/package.json'
|
|
||||||
).dependencies['@mediapipe/tasks-vision']
|
|
||||||
|
|
||||||
if (mediapipeVersion !== livekitMediapipeVersion) {
|
|
||||||
throw new Error(
|
|
||||||
`@mediapipe/tasks-vision@${mediapipeVersion} is installed, but ` +
|
|
||||||
`@livekit/track-processors declares "${livekitMediapipeVersion}". ` +
|
|
||||||
`The two must stay in sync: pin "@mediapipe/tasks-vision" to ` +
|
|
||||||
`"${livekitMediapipeVersion}" in package.json.`
|
|
||||||
)
|
)
|
||||||
}
|
).version
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(({ mode }) => {
|
export default defineConfig(({ mode }) => {
|
||||||
|
|||||||
Generated
+8
-18
@@ -9,7 +9,7 @@
|
|||||||
"version": "1.27.0",
|
"version": "1.27.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.1",
|
"@html-to/text-cli": "0.6.0",
|
||||||
"mjml": "5.4.0"
|
"mjml": "5.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -52,14 +52,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@html-to/text-cli": {
|
"node_modules/@html-to/text-cli": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@html-to/text-cli/-/text-cli-0.6.0.tgz",
|
||||||
"integrity": "sha512-fnbLS8bra4BkGinXWzUKfalqLPYmz8E2ABT+TgHUZ4inhAUYF2rBEEctOKqZ6FgaJF1iZ//KBBLKVz6nm3RbhA==",
|
"integrity": "sha512-JwlrCBccUM/QkUpc37P+qG+hpkXRbWOVcHd9vM+5D+O82Ak2p8anGRxisr33//aJzlkYNKNg2I8LDh3Bx2tBPg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@selderee/plugin-htmlparser2": "~0.12.0",
|
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||||
"aspargvs": "~0.7.0",
|
"aspargvs": "~0.7.0",
|
||||||
"deepmerge-ts": "^8.0.1",
|
"deepmerge-ts": "^7.1.5",
|
||||||
"htmlparser2": "^10.1.0",
|
"htmlparser2": "^10.1.0",
|
||||||
"selderee": "~0.12.0"
|
"selderee": "~0.12.0"
|
||||||
},
|
},
|
||||||
@@ -656,19 +656,9 @@
|
|||||||
"license": "CC0-1.0"
|
"license": "CC0-1.0"
|
||||||
},
|
},
|
||||||
"node_modules/deepmerge-ts": {
|
"node_modules/deepmerge-ts": {
|
||||||
"version": "8.0.1",
|
"version": "7.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "ko-fi",
|
|
||||||
"url": "https://ko-fi.com/rebeccastevens"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.1",
|
"@html-to/text-cli": "0.6.0",
|
||||||
"mjml": "5.4.0"
|
"mjml": "5.4.0"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@@ -83,7 +83,6 @@ class Settings(BaseSettings):
|
|||||||
aws_s3_access_key_id: str
|
aws_s3_access_key_id: str
|
||||||
aws_s3_secret_access_key: SecretStr
|
aws_s3_secret_access_key: SecretStr
|
||||||
aws_s3_secure_access: bool = True
|
aws_s3_secure_access: bool = True
|
||||||
aws_s3_region_name: str | None = None
|
|
||||||
aws_transcript_path: str = "transcripts"
|
aws_transcript_path: str = "transcripts"
|
||||||
aws_summary_path: str = "summaries"
|
aws_summary_path: str = "summaries"
|
||||||
|
|
||||||
|
|||||||
@@ -282,7 +282,6 @@ class FileService:
|
|||||||
access_key=settings.aws_s3_access_key_id,
|
access_key=settings.aws_s3_access_key_id,
|
||||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||||
secure=settings.aws_s3_secure_access,
|
secure=settings.aws_s3_secure_access,
|
||||||
region=settings.aws_s3_region_name,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self._bucket_name = settings.aws_storage_bucket_name
|
self._bucket_name = settings.aws_storage_bucket_name
|
||||||
|
|||||||
Reference in New Issue
Block a user