mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-10 23:34:38 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3705af768 |
+1
-1
@@ -10,8 +10,8 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) let users pick the recording encoding
|
||||
- ✨(backend) introduce a token exchange endpoint for iframe embeds
|
||||
- ✨(all) allow any authenticated user to start a recording
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
|
||||
|
||||
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
The feature allows users to record their room sessions. When a recording is complete, the user who started it and the room's owners and administrators receive a notification with a link to download the recorded file. The transcript summary is sent to the user who started the recording. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
|
||||
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -89,6 +89,7 @@ sequenceDiagram
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||
| **RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED**| Boolean | `True` | When enabled, any authenticated participant currently connected to a room with the `trusted` or `public` access level can start and stop recordings. When disabled, only room admins and owners can. On `restricted` rooms, only admins and owners can record. |
|
||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
|
||||
@@ -408,6 +408,7 @@ These are the environmental options available on meet backend.
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED | Let any authenticated participant connected to a trusted or public room start/stop recordings. Set to false to reserve recording to room admins/owners | true |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
|
||||
@@ -44,17 +44,9 @@ def get_frontend_configuration(request):
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
"encoding": {
|
||||
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
|
||||
"available_resolutions": list(
|
||||
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
|
||||
),
|
||||
"available_profiles": list(
|
||||
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
|
||||
),
|
||||
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
|
||||
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
|
||||
},
|
||||
"authenticated_participants_enabled": (
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED
|
||||
),
|
||||
},
|
||||
"background_image": {
|
||||
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
|
||||
|
||||
@@ -187,29 +187,29 @@ class IsPresentInMeeting(permissions.BasePermission):
|
||||
return False
|
||||
|
||||
|
||||
class CanManageLobby(permissions.BasePermission):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
class ParticipantCapability(permissions.BasePermission):
|
||||
"""Base permission for in-meeting capabilities opened up to participants.
|
||||
|
||||
- Room admins/owners can always manage the lobby.
|
||||
- When the room access level is TRUSTED, any authenticated user who is
|
||||
currently connected to the meeting can manage the lobby. Presence is
|
||||
verified cache-first (Redis), falling back to the LiveKit API.
|
||||
- Room admins/owners always hold the capability.
|
||||
- When the room access level is TRUSTED or PUBLIC, any
|
||||
session-authenticated user who is currently connected to the meeting
|
||||
holds it too.
|
||||
|
||||
Access level is always read fresh from the DB; only presence is cached,
|
||||
so changing the room to RESTRICTED takes effect immediately.
|
||||
so changing the room access level takes effect immediately.
|
||||
"""
|
||||
|
||||
message = "You are not allowed to manage this room's lobby."
|
||||
message = "You are not allowed to perform this action."
|
||||
enabled_setting = None
|
||||
|
||||
# pylint: disable=too-many-return-statements
|
||||
def has_object_permission(self, request, view, obj): # noqa: PLR0911
|
||||
"""Check privileges first, then the trusted-room presence path."""
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check privileges first, then the participant presence path."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
# Product choice: lobby management is reserved for session-authenticated
|
||||
# Product choice: these capabilities are reserved for session-authenticated
|
||||
# users with a real account, not holders of a LiveKit room token.
|
||||
if request.auth and hasattr(request.auth, "video"):
|
||||
return False
|
||||
@@ -217,16 +217,35 @@ class CanManageLobby(permissions.BasePermission):
|
||||
if obj.is_administrator_or_owner(user):
|
||||
return True
|
||||
|
||||
if obj.access_level != RoomAccessLevel.TRUSTED:
|
||||
is_open_room = obj.access_level in (
|
||||
RoomAccessLevel.TRUSTED,
|
||||
RoomAccessLevel.PUBLIC,
|
||||
)
|
||||
is_enabled = not self.enabled_setting or getattr(settings, self.enabled_setting)
|
||||
if not (is_open_room and is_enabled):
|
||||
return False
|
||||
|
||||
self.message = "You must be connected to the meeting to manage its lobby."
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting_cached(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
return False
|
||||
|
||||
|
||||
class CanManageLobby(ParticipantCapability):
|
||||
"""Grant lobby management (list/accept/deny waiting participants).
|
||||
|
||||
Public rooms have no lobby: the endpoints short-circuit there, so opening
|
||||
them to participants exposes nothing.
|
||||
"""
|
||||
|
||||
|
||||
class CanManageRecording(ParticipantCapability):
|
||||
"""Grant starting/stopping a room recording (screen recording or transcript).
|
||||
|
||||
Self-hosters can keep it admin/owner only with
|
||||
`RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED`.
|
||||
"""
|
||||
|
||||
enabled_setting = "RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED"
|
||||
|
||||
@@ -404,7 +404,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.CanManageRecording,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
@@ -437,11 +437,7 @@ class RoomViewSet(
|
||||
mode=mode,
|
||||
options=options_data,
|
||||
)
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
recording.grant_initial_accesses(starter=self.request.user)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -485,7 +481,7 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.CanManageRecording,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
|
||||
@@ -690,6 +690,29 @@ class Recording(BaseModel):
|
||||
"update": False,
|
||||
}
|
||||
|
||||
def grant_initial_accesses(self, starter):
|
||||
"""Make the starter and the room's owners/administrators OWNER of the recording.
|
||||
|
||||
The starter's access is created first, so it is the oldest one: the
|
||||
transcript summary, which goes to a single owner, is sent to them.
|
||||
|
||||
Room roles are copied when the recording starts: later changes to the
|
||||
room's accesses do not affect existing recordings.
|
||||
"""
|
||||
RecordingAccess.objects.create(
|
||||
user=starter, role=RoleChoices.OWNER, recording=self
|
||||
)
|
||||
|
||||
room_privileged_user_ids = (
|
||||
self.room.accesses.filter(role__in=[RoleChoices.OWNER, RoleChoices.ADMIN])
|
||||
.exclude(user=starter)
|
||||
.values_list("user_id", flat=True)
|
||||
)
|
||||
RecordingAccess.objects.bulk_create(
|
||||
RecordingAccess(user_id=user_id, role=RoleChoices.OWNER, recording=self)
|
||||
for user_id in room_privileged_user_ids
|
||||
)
|
||||
|
||||
def is_savable(self) -> bool:
|
||||
"""Determine if the recording can be saved based on its current status."""
|
||||
|
||||
|
||||
@@ -222,6 +222,23 @@ class NotificationService:
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_summary_recipient(recording: models.Recording):
|
||||
"""Return the single user who receives the transcript summary.
|
||||
|
||||
A recording can have several owners (its starter and the room's
|
||||
owners/administrators), but the summary is generated once. It goes to
|
||||
the oldest owner, which is the starter: their access is created first
|
||||
(see `Recording.grant_initial_accesses`).
|
||||
"""
|
||||
oldest_owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(role=models.RoleChoices.OWNER, recording_id=recording.id)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
return oldest_owner_access.user if oldest_owner_access else None
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -233,14 +250,7 @@ class NotificationService:
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
recipient = NotificationService._get_summary_recipient(recording)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
@@ -250,7 +260,7 @@ class NotificationService:
|
||||
else:
|
||||
metadata_filename = None
|
||||
|
||||
if not owner_access:
|
||||
if not recipient:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
@@ -259,16 +269,16 @@ class NotificationService:
|
||||
)(recording.worker_id)
|
||||
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"owner_id": str(recipient.id),
|
||||
"recording_filename": recording.key,
|
||||
"metadata_filename": metadata_filename,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"email": recipient.email,
|
||||
"sub": recipient.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"owner_timezone": str(owner_access.user.timezone),
|
||||
"owner_timezone": str(recipient.timezone),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": owner_access.user.language,
|
||||
"context_language": recipient.language,
|
||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||
}
|
||||
@@ -308,14 +318,7 @@ class NotificationService:
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
recipient = NotificationService._get_summary_recipient(recording)
|
||||
metadata_filename: None | str = None
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
@@ -323,7 +326,7 @@ class NotificationService:
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
|
||||
if not owner_access:
|
||||
if not recipient:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
@@ -350,8 +353,8 @@ class NotificationService:
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
"user_sub": recipient.sub,
|
||||
"user_email": recipient.email,
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
@@ -360,20 +363,20 @@ class NotificationService:
|
||||
"language": recording.options.get(
|
||||
"language", get_language().split("-")[0].lower()
|
||||
),
|
||||
"context_language": owner_access.user.language,
|
||||
"context_language": recipient.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner_access.user.email,
|
||||
"user_email": recipient.email,
|
||||
"title": NotificationService._generate_title(
|
||||
locale=owner_access.user.language
|
||||
locale=recipient.language
|
||||
or recording.options.get("language", get_language()),
|
||||
room=recording.room.name,
|
||||
recording_datetime=started_at,
|
||||
owner_timezone=str(owner_access.user.timezone),
|
||||
owner_timezone=str(recipient.timezone),
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
recipient, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
|
||||
@@ -479,3 +479,24 @@ def test_notify_summary_service_v2_payload_json_serializable_without_timestamps(
|
||||
assert isinstance(title, str)
|
||||
# ...so the payload serializes exactly the way ``requests`` serializes it.
|
||||
json.dumps(payload)
|
||||
|
||||
|
||||
def test_summary_recipient_is_oldest_owner():
|
||||
"""With several owners, the summary goes to the oldest one."""
|
||||
recording = factories.RecordingFactory()
|
||||
oldest = factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
for _ in range(2):
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) == oldest.user
|
||||
|
||||
|
||||
def test_summary_recipient_none_without_owner():
|
||||
"""No owner: nobody to send the summary to."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
assert NotificationService._get_summary_recipient(recording) is None
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
"""Trusted and public rooms: any authenticated participant present in the meeting can record."""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from core.models import (
|
||||
Recording,
|
||||
RecordingStatusChoices,
|
||||
RoleChoices,
|
||||
RoomAccessLevel,
|
||||
)
|
||||
from core.recording.event.notification import NotificationService
|
||||
from core.services.presence import PresenceCache
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager():
|
||||
"""Mock the worker service factory and mediator."""
|
||||
with (
|
||||
mock.patch("core.api.viewsets.get_worker_service"),
|
||||
mock.patch("core.api.viewsets.WorkerServiceMediator") as mediator_class,
|
||||
):
|
||||
mediator = mock.Mock()
|
||||
mediator_class.return_value = mediator
|
||||
yield mediator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_recording(settings):
|
||||
"""Recording must be enabled for the endpoints to be reachable."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_open_room_present_user_can_start_recording(
|
||||
mock_check, access_level, mock_worker_manager
|
||||
):
|
||||
"""Authenticated + present in a trusted/public room -> 201, starter owns it."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=access_level)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_check.assert_called_once_with(room_name=str(room.id), identity=str(user.sub))
|
||||
recording = Recording.objects.get()
|
||||
mock_worker_manager.start.assert_called_once_with(recording)
|
||||
access = recording.accesses.get()
|
||||
assert access.user == user
|
||||
assert access.role == RoleChoices.OWNER
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_presence_is_cached_until_participant_leaves(mock_check, mock_worker_manager):
|
||||
"""Presence is memoized like for the lobby; clearing it forces a LiveKit re-check."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = f"/api/v1.0/rooms/{room.id}/stop-recording/"
|
||||
|
||||
assert client.post(url).status_code == 200
|
||||
assert client.post(url).status_code == 200
|
||||
assert mock_check.call_count == 1
|
||||
|
||||
# participant_left webhook
|
||||
PresenceCache().clear(room.id, str(user.sub))
|
||||
mock_check.return_value = False
|
||||
|
||||
assert client.post(url).status_code == 403
|
||||
assert mock_check.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=False,
|
||||
)
|
||||
def test_open_room_absent_user_forbidden(mock_check, access_level, mock_worker_manager):
|
||||
"""Authenticated but not connected to the meeting -> 403."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "You are not allowed to perform this action."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_restricted_room_present_user_forbidden(mock_check, mock_worker_manager):
|
||||
"""Presence is not enough on restricted rooms; LiveKit is not even asked."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_open_room_present_user_can_stop_recording(
|
||||
mock_check, access_level, mock_worker_manager
|
||||
):
|
||||
"""Any present authenticated user can stop the active recording."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_admin_does_not_need_presence(mock_check, mock_worker_manager):
|
||||
"""Admins/owners keep recording rights on any room, without a LiveKit check."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room.accesses.create(user=user, role=RoleChoices.ADMIN)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
def test_open_room_anonymous_forbidden(access_level):
|
||||
"""Anonymous users never record, even on public rooms."""
|
||||
room = RoomFactory(access_level=access_level)
|
||||
response = APIClient().post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"access_level", [RoomAccessLevel.TRUSTED, RoomAccessLevel.PUBLIC]
|
||||
)
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_path_disabled_by_setting(
|
||||
mock_check, access_level, settings, mock_worker_manager
|
||||
):
|
||||
"""Self-hosters can opt out: participants then get 403, LiveKit not asked."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
room = RoomFactory(access_level=access_level)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
start = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
stop = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert start.status_code == 403
|
||||
assert stop.status_code == 403
|
||||
mock_check.assert_not_called()
|
||||
mock_worker_manager.start.assert_not_called()
|
||||
mock_worker_manager.stop.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_participant_path_disabled_admin_still_records(
|
||||
mock_check, settings, mock_worker_manager
|
||||
):
|
||||
"""The setting only removes the participant path, never admin rights."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
user = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=user, role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
mock_check.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_lobby_not_affected_by_recording_setting(mock_check, settings):
|
||||
"""The recording opt-out must not leak into the lobby capability."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = False
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_public_room_lobby_is_open_but_empty(mock_check):
|
||||
"""Participants pass the lobby permission on public rooms, which have no lobby."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
listing = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
entry = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert listing.json() == {"participants": []}
|
||||
assert entry.status_code == 404
|
||||
assert entry.json() == {"message": "Room has no lobby system."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_config_exposes_authenticated_participants_setting(settings, enabled):
|
||||
"""The frontend reads the setting from /config/ to gate the UI."""
|
||||
settings.RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = enabled
|
||||
|
||||
response = APIClient().get("/api/v1.0/config/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["recording"]["authenticated_participants_enabled"] is enabled
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_room_owners_and_admins_get_access_to_participant_recording(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""Starter and room owners/admins all own the recording; members get nothing."""
|
||||
starter = UserFactory()
|
||||
room_owner = UserFactory()
|
||||
room_admin = UserFactory()
|
||||
room_member = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
room.accesses.create(user=room_owner, role=RoleChoices.OWNER)
|
||||
room.accesses.create(user=room_admin, role=RoleChoices.ADMIN)
|
||||
room.accesses.create(user=room_member, role=RoleChoices.MEMBER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||
starter.id: RoleChoices.OWNER,
|
||||
room_owner.id: RoleChoices.OWNER,
|
||||
room_admin.id: RoleChoices.OWNER,
|
||||
}
|
||||
|
||||
# Room admins can see and download it; plain members cannot.
|
||||
client.force_login(room_admin)
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 200
|
||||
client.force_login(room_member)
|
||||
assert client.get(f"/api/v1.0/recordings/{recording.id}/").status_code == 404
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting"
|
||||
)
|
||||
def test_room_admin_starter_is_not_duplicated(mock_check, mock_worker_manager):
|
||||
"""A room admin who starts the recording gets a single access."""
|
||||
starter = UserFactory()
|
||||
co_owner = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room.accesses.create(user=starter, role=RoleChoices.ADMIN)
|
||||
room.accesses.create(user=co_owner, role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "screen_recording"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert dict(recording.accesses.values_list("user_id", "role")) == {
|
||||
starter.id: RoleChoices.OWNER,
|
||||
co_owner.id: RoleChoices.OWNER,
|
||||
}
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_participant_can_stop_recording_started_by_someone_else(
|
||||
mock_check, mock_worker_manager
|
||||
):
|
||||
"""Stopping is room-wide on trusted rooms, not tied to who started it."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
recording = RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
recording.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_worker_manager.stop.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.participants_management.ParticipantsManagement.check_if_in_meeting",
|
||||
return_value=True,
|
||||
)
|
||||
def test_summary_goes_to_starter_among_several_owners(mock_check, mock_worker_manager):
|
||||
"""The starter's access is created first, so the summary goes to them."""
|
||||
starter = UserFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
for _ in range(2):
|
||||
room.accesses.create(user=UserFactory(), role=RoleChoices.OWNER)
|
||||
client = APIClient()
|
||||
client.force_login(starter)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/", {"mode": "transcript"}
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get()
|
||||
assert recording.accesses.count() == 3
|
||||
assert NotificationService._get_summary_recipient(recording) == starter
|
||||
@@ -11,7 +11,7 @@ from livekit import api as livekit_api
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
from ...models import Recording, RoomAccessLevel
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -42,8 +42,13 @@ def mock_worker_manager(mock_worker_service):
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
def test_start_recording_anonymous():
|
||||
@pytest.mark.parametrize(
|
||||
"recording_enabled, expected_status",
|
||||
[(True, 401), (False, 404)],
|
||||
)
|
||||
def test_start_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
"""Anonymous users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = recording_enabled
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -52,14 +57,14 @@ def test_start_recording_anonymous():
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == expected_status
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -10,7 +10,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...models import Recording, RecordingStatusChoices, RoomAccessLevel
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -41,15 +41,20 @@ def mock_worker_manager(mock_worker_service):
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
def test_stop_recording_anonymous():
|
||||
@pytest.mark.parametrize(
|
||||
"recording_enabled, expected_status",
|
||||
[(True, 401), (False, 404)],
|
||||
)
|
||||
def test_stop_recording_anonymous(settings, recording_enabled, expected_status):
|
||||
"""Anonymous users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = recording_enabled
|
||||
room = RoomFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.status_code == expected_status
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
@@ -57,7 +62,7 @@ def test_stop_recording_anonymous():
|
||||
def test_stop_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
@@ -793,6 +793,14 @@ class Base(Configuration):
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
False, environ_name="RECORDING_ENABLE", environ_prefix=None
|
||||
)
|
||||
# Let any authenticated participant connected to a trusted or public room
|
||||
# start/stop recordings. Disable to keep recording reserved to room
|
||||
# admins/owners.
|
||||
RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED = values.BooleanValue(
|
||||
True,
|
||||
environ_name="RECORDING_AUTHENTICATED_PARTICIPANTS_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
RECORDING_OUTPUT_FOLDER = values.Value(
|
||||
"recordings", environ_name="RECORDING_OUTPUT_FOLDER", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -6,5 +6,4 @@ export const keys = {
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
files: 'files',
|
||||
startRecording: 'startRecording',
|
||||
}
|
||||
|
||||
@@ -36,13 +36,7 @@ export interface ApiConfig {
|
||||
available_modes?: RecordingMode[]
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
encoding?: {
|
||||
is_custom_enabled: boolean
|
||||
available_resolutions: string[]
|
||||
available_profiles: string[]
|
||||
default_resolution?: string | null
|
||||
default_profile?: string | null
|
||||
}
|
||||
authenticated_participants_enabled?: boolean
|
||||
}
|
||||
background_image: {
|
||||
upload_is_enabled: boolean
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59456 12.8115C8.53273 12.5511 8.5 12.2794 8.5 12C8.5 11.7207 8.53272 11.449 8.59454 11.1886L7.60291 10.6161L8.60291 8.88402L9.59523 9.45694C9.98834 9.08508 10.4676 8.80338 11 8.64494V7.5H13V8.64494C13.5324 8.80337 14.0116 9.08506 14.4047 9.4569L15.3971 8.88393L16.3972 10.616L15.4054 11.1885C15.4673 11.449 15.5 11.7207 15.5 12C15.5 12.2793 15.4673 12.551 15.4055 12.8114L16.3972 13.3839L15.3972 15.116L14.4048 14.543C14.0117 14.9149 13.5325 15.1966 13.0001 15.355V16.5H11.0001V15.3551C10.4677 15.1967 9.98844 14.915 9.59532 14.5431L8.60297 15.1161L7.60291 13.384L8.59456 12.8115ZM12 13.5C12.8284 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 994 B |
@@ -2,22 +2,12 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { RecordingMode } from '../types'
|
||||
import type { RecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
|
||||
export interface StartRecordingOptions {
|
||||
language?: string
|
||||
transcribe?: boolean
|
||||
collect_metadata?: boolean
|
||||
original_mode?: RecordingMode
|
||||
encoding?: RecordingEncoding
|
||||
}
|
||||
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
options?: StartRecordingOptions
|
||||
options?: Record<string, string | boolean>
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
@@ -38,7 +28,6 @@ export function useStartRecording(
|
||||
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
|
||||
) {
|
||||
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
|
||||
mutationKey: [keys.startRecording],
|
||||
mutationFn: startRecording,
|
||||
onSuccess: options?.onSuccess,
|
||||
onError: options?.onError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Button, Div, H, Text } from '@/primitives'
|
||||
import { Div, H, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -22,15 +22,14 @@ import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox'
|
||||
import {
|
||||
SettingsDialogExtendedKey,
|
||||
useTranscriptionLanguage,
|
||||
} from '@/features/settings'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import {
|
||||
useCanRecord,
|
||||
useIsRecordingOpenToParticipants,
|
||||
} from '../hooks/useCanRecord'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { LimitDescription } from './LimitDescription'
|
||||
import { captureEvent, reportError } from '@/features/analytics/telemetry'
|
||||
@@ -44,6 +43,8 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canRecord = useCanRecord()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
@@ -58,8 +59,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { encoding, isCustomEncodingEnabled } = useRecordingEncoding()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
@@ -100,7 +99,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
language: selectedLanguageKey,
|
||||
}),
|
||||
...(includeTranscript && hasTranscriptAccess && { transcribe: true }),
|
||||
...(encoding && { encoding }),
|
||||
}
|
||||
|
||||
await startRecording({
|
||||
@@ -115,8 +113,6 @@ export const ScreenRecordingSidePanel = () => {
|
||||
captureEvent('screen-recording-started', {
|
||||
includeTranscript: includeTranscript,
|
||||
language: selectedLanguageKey,
|
||||
resolution: encoding?.resolution,
|
||||
profile: encoding?.profile,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -126,11 +122,13 @@ export const ScreenRecordingSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (!isAdminOrOwner) {
|
||||
if (!canRecord) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey="notAdminOrOwner"
|
||||
i18nKey={
|
||||
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
|
||||
}
|
||||
helpArticle={data?.support?.help_article_recording}
|
||||
imagePath="/assets/intro-slider/4.png"
|
||||
handleRequest={handleRequestScreenRecording}
|
||||
@@ -193,41 +191,9 @@ export const ScreenRecordingSidePanel = () => {
|
||||
<RowWrapper iconName="cloud_download" position="first">
|
||||
<Text variant="sm">{t('details.destination')}</Text>
|
||||
</RowWrapper>
|
||||
<RowWrapper
|
||||
iconName="mail"
|
||||
position={isCustomEncodingEnabled ? 'middle' : 'last'}
|
||||
>
|
||||
<RowWrapper iconName="mail" position="last">
|
||||
<Text variant="sm">{t('details.receiver')}</Text>
|
||||
</RowWrapper>
|
||||
{isCustomEncodingEnabled && (
|
||||
<RowWrapper iconName="file_settings_line" position="last">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
minWidth: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
})}
|
||||
>
|
||||
{t('details.quality')}
|
||||
</Text>
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({ flexShrink: 0, whiteSpace: 'nowrap' })}
|
||||
>
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onPress={() =>
|
||||
openSettingsDialog(SettingsDialogExtendedKey.RECORDING)
|
||||
}
|
||||
>
|
||||
{t('details.qualityEdit')}
|
||||
</Button>
|
||||
</Text>
|
||||
</RowWrapper>
|
||||
)}
|
||||
{hasTranscriptAccess && (
|
||||
<>
|
||||
<div className={css({ height: '15px' })} />
|
||||
|
||||
@@ -6,7 +6,8 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useHasFeatureWithoutRecordingRights,
|
||||
useIsRecordingOpenToParticipants,
|
||||
useRecordingStatuses,
|
||||
} from '../index'
|
||||
import { useState } from 'react'
|
||||
@@ -29,7 +30,6 @@ import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
|
||||
import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnabled'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
@@ -58,17 +58,16 @@ export const TranscriptSidePanel = () => {
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
const hasFeatureWithoutRecordingRights = useHasFeatureWithoutRecordingRights(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
|
||||
|
||||
const { encoding } = useRecordingEncoding()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
@@ -117,7 +116,6 @@ export const TranscriptSidePanel = () => {
|
||||
...(withScreenRecording && {
|
||||
transcribe: true,
|
||||
original_mode: RecordingMode.Transcript,
|
||||
...(encoding && { encoding }),
|
||||
}),
|
||||
collect_metadata: isMetadataCollectorEnabled,
|
||||
}
|
||||
@@ -143,11 +141,13 @@ export const TranscriptSidePanel = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
if (hasFeatureWithoutRecordingRights) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey="notAdminOrOwner"
|
||||
i18nKey={
|
||||
isRecordingOpenToParticipants ? 'notLoggedIn' : 'notAdminOrOwner'
|
||||
}
|
||||
helpArticle={data?.support?.help_article_transcript}
|
||||
imagePath="/assets/intro-slider/3.png"
|
||||
handleRequest={handleRequestTranscription}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
// Room access levels on which authenticated participants can record.
|
||||
const OPEN_ACCESS_LEVELS = [ApiAccessLevel.TRUSTED, ApiAccessLevel.PUBLIC]
|
||||
|
||||
export const useIsRecordingOpenToParticipants = () => {
|
||||
const roomData = useRoomData()
|
||||
const { data: config } = useConfig()
|
||||
|
||||
return (
|
||||
config?.recording?.authenticated_participants_enabled === true &&
|
||||
!!roomData?.id &&
|
||||
!!roomData?.access_level &&
|
||||
OPEN_ACCESS_LEVELS.includes(roomData.access_level)
|
||||
)
|
||||
}
|
||||
|
||||
export const useCanRecord = () => {
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const { isLoggedIn } = useUser()
|
||||
const isRecordingOpenToParticipants = useIsRecordingOpenToParticipants()
|
||||
|
||||
return (
|
||||
isAdminOrOwner || (isLoggedIn === true && isRecordingOpenToParticipants)
|
||||
)
|
||||
}
|
||||
+4
-4
@@ -2,21 +2,21 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasFeatureWithoutAdminRights = (
|
||||
export const useHasFeatureWithoutRecordingRights = (
|
||||
mode: RecordingMode,
|
||||
featureFlag: FeatureFlags
|
||||
) => {
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canRecord = useCanRecord()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isRecordingModeEnabled &&
|
||||
!isAdminOrOwner
|
||||
!canRecord
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
import type { RecordingMode } from '../types'
|
||||
import { useIsRecordingModeEnabled } from './useIsRecordingModeEnabled'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useCanRecord } from './useCanRecord'
|
||||
import type { FeatureFlags } from '@/features/analytics/enums'
|
||||
|
||||
export const useHasRecordingAccess = (
|
||||
@@ -12,11 +12,11 @@ export const useHasRecordingAccess = (
|
||||
const featureEnabled = useFeatureFlagEnabled(featureFlag)
|
||||
const isAnalyticsEnabled = useIsAnalyticsEnabled()
|
||||
const isRecordingModeEnabled = useIsRecordingModeEnabled(mode)
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canRecord = useCanRecord()
|
||||
|
||||
return (
|
||||
(featureEnabled || !isAnalyticsEnabled) &&
|
||||
isAdminOrOwner &&
|
||||
canRecord &&
|
||||
isRecordingModeEnabled
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { useIsMutating } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { RecordingMode } from '../types'
|
||||
import { useRecordingStatuses } from './useRecordingStatuses'
|
||||
|
||||
/**
|
||||
* True while a recording is being requested, starting, started or saving.
|
||||
* Recording settings must not change during that window, as they were (or
|
||||
* are about to be) sent to the backend for the current recording.
|
||||
*/
|
||||
export const useIsRecordingLocked = () => {
|
||||
const screenRecording = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
const transcript = useRecordingStatuses(RecordingMode.Transcript)
|
||||
const pendingStarts = useIsMutating({ mutationKey: [keys.startRecording] })
|
||||
|
||||
return pendingStarts > 0 || screenRecording.isActive || transcript.isActive
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import {
|
||||
recordingEncodingStore,
|
||||
setRecordingEncodingPreference,
|
||||
} from '@/stores/recordingEncoding'
|
||||
|
||||
// Fallback storage slot when no authenticated user is available.
|
||||
const ANONYMOUS_USER_KEY = 'anonymous'
|
||||
|
||||
export type RecordingEncoding = {
|
||||
resolution: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the stored value if the backend still offers it, otherwise the
|
||||
* backend default, otherwise the first available value.
|
||||
*/
|
||||
const resolveValue = (
|
||||
available: string[],
|
||||
stored?: string,
|
||||
fallback?: string | null
|
||||
): string | undefined => {
|
||||
if (stored && available.includes(stored)) return stored
|
||||
if (fallback && available.includes(fallback)) return fallback
|
||||
return available[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-user recording encoding preferences (resolution + profile).
|
||||
*
|
||||
* Values are persisted in localStorage, scoped by user id, and always
|
||||
* reconciled against what the backend currently exposes, so a stale value
|
||||
* (e.g. a profile removed by the operator) never reaches the API.
|
||||
*/
|
||||
export const useRecordingEncoding = () => {
|
||||
const { data } = useConfig()
|
||||
const { user } = useUser()
|
||||
const snap = useSnapshot(recordingEncodingStore)
|
||||
|
||||
const encodingConfig = data?.recording?.encoding
|
||||
const availableResolutions = useMemo(
|
||||
() => encodingConfig?.available_resolutions ?? [],
|
||||
[encodingConfig]
|
||||
)
|
||||
const availableProfiles = useMemo(
|
||||
() => encodingConfig?.available_profiles ?? [],
|
||||
[encodingConfig]
|
||||
)
|
||||
|
||||
const isCustomEncodingEnabled =
|
||||
!!encodingConfig?.is_custom_enabled && availableResolutions.length > 0
|
||||
|
||||
const userKey = user?.id ?? ANONYMOUS_USER_KEY
|
||||
const stored = snap.byUser[userKey]
|
||||
|
||||
const resolution = resolveValue(
|
||||
availableResolutions,
|
||||
stored?.resolution,
|
||||
encodingConfig?.default_resolution
|
||||
)
|
||||
const profile = resolveValue(
|
||||
availableProfiles,
|
||||
stored?.profile,
|
||||
encodingConfig?.default_profile
|
||||
)
|
||||
|
||||
const setResolution = useCallback(
|
||||
(value: string) => {
|
||||
if (!availableResolutions.includes(value)) return
|
||||
setRecordingEncodingPreference(userKey, { resolution: value })
|
||||
},
|
||||
[availableResolutions, userKey]
|
||||
)
|
||||
|
||||
const setProfile = useCallback(
|
||||
(value: string) => {
|
||||
if (!availableProfiles.includes(value)) return
|
||||
setRecordingEncodingPreference(userKey, { profile: value })
|
||||
},
|
||||
[availableProfiles, userKey]
|
||||
)
|
||||
|
||||
// Payload for the start-recording API. Undefined when per-recording
|
||||
// encoding is disabled, as the backend would reject it.
|
||||
const encoding: RecordingEncoding | undefined = useMemo(() => {
|
||||
if (!isCustomEncodingEnabled || !resolution) return undefined
|
||||
return {
|
||||
resolution,
|
||||
...(profile && { profile }),
|
||||
}
|
||||
}, [isCustomEncodingEnabled, resolution, profile])
|
||||
|
||||
return {
|
||||
isCustomEncodingEnabled,
|
||||
availableResolutions,
|
||||
availableProfiles,
|
||||
resolution,
|
||||
profile,
|
||||
setResolution,
|
||||
setProfile,
|
||||
encoding,
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
export { useHasFeatureWithoutRecordingRights } from './hooks/useHasFeatureWithoutRecordingRights'
|
||||
export {
|
||||
useCanRecord,
|
||||
useIsRecordingOpenToParticipants,
|
||||
} from './hooks/useCanRecord'
|
||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||
export { useRecordingEncoding } from './hooks/useRecordingEncoding'
|
||||
export type { RecordingEncoding } from './hooks/useRecordingEncoding'
|
||||
export { useIsRecordingLocked } from './hooks/useIsRecordingLocked'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
RiVideoOnLine,
|
||||
RiEyeLine,
|
||||
RiKeyboardBoxLine,
|
||||
RiRecordCircleLine,
|
||||
} from '@remixicon/react'
|
||||
import { AccountTab } from './tabs/AccountTab'
|
||||
import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
@@ -27,12 +26,6 @@ import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { AccessibilityTab } from './tabs/AccessibilityTab'
|
||||
import { RecordingTab } from './tabs/RecordingTab'
|
||||
import {
|
||||
RecordingMode,
|
||||
useIsRecordingModeEnabled,
|
||||
useRecordingEncoding,
|
||||
} from '@/features/recording'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -74,12 +67,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const { isCustomEncodingEnabled } = useRecordingEncoding()
|
||||
const showRecordingTab =
|
||||
isAdminOrOwner && !!isScreenRecordingEnabled && isCustomEncodingEnabled
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
@@ -134,13 +121,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
{showRecordingTab && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
|
||||
<RiRecordCircleLine />
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
|
||||
</Tab>
|
||||
)}
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
|
||||
<RiEyeLine />
|
||||
{isWideScreen &&
|
||||
@@ -160,8 +140,6 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
{/* Recording tab won't be accessible if the tab is not active in the tab list */}
|
||||
<RecordingTab id={SettingsDialogExtendedKey.RECORDING} />
|
||||
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||
import { Field, H, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
useIsRecordingLocked,
|
||||
useRecordingEncoding,
|
||||
} from '@/features/recording'
|
||||
|
||||
export type RecordingTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const RecordingTab = ({ id }: RecordingTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'recording' })
|
||||
|
||||
const {
|
||||
availableResolutions,
|
||||
availableProfiles,
|
||||
resolution,
|
||||
profile,
|
||||
setResolution,
|
||||
setProfile,
|
||||
} = useRecordingEncoding()
|
||||
|
||||
const isLocked = useIsRecordingLocked()
|
||||
|
||||
// Resolutions and profiles are operator-defined: fall back to the raw key
|
||||
// when no translation exists for a custom value.
|
||||
const resolutionItems = useMemo(
|
||||
() =>
|
||||
availableResolutions.map((key) => ({
|
||||
value: key,
|
||||
label: t(`resolution.options.${key}`, { defaultValue: key }),
|
||||
})),
|
||||
[availableResolutions, t]
|
||||
)
|
||||
|
||||
const profileItems = useMemo(
|
||||
() =>
|
||||
availableProfiles.map((key) => ({
|
||||
value: key,
|
||||
label: t(`profile.options.${key}.label`, { defaultValue: key }),
|
||||
})),
|
||||
[availableProfiles, t]
|
||||
)
|
||||
|
||||
const profileDescription = profile
|
||||
? t(`profile.options.${profile}.description`, { defaultValue: '' })
|
||||
: ''
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('heading')}</H>
|
||||
{isLocked && (
|
||||
<Text variant="note" role="status" margin="sm">
|
||||
{t('locked')}
|
||||
</Text>
|
||||
)}
|
||||
<Field
|
||||
type="select"
|
||||
label={t('profile.label')}
|
||||
description={profileDescription || undefined}
|
||||
items={profileItems}
|
||||
selectedKey={profile ?? null}
|
||||
isDisabled={isLocked}
|
||||
onSelectionChange={(key) => {
|
||||
if (isLocked || key === null) return
|
||||
setProfile(String(key))
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.label')}
|
||||
description={t('resolution.description')}
|
||||
items={resolutionItems}
|
||||
selectedKey={resolution ?? null}
|
||||
isDisabled={isLocked}
|
||||
onSelectionChange={(key) => {
|
||||
if (isLocked || key === null) return
|
||||
setResolution(String(key))
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
@@ -7,5 +7,4 @@ export enum SettingsDialogExtendedKey {
|
||||
TRANSCRIPTION = 'transcription',
|
||||
SHORTCUTS = 'shortcuts',
|
||||
ACCESSIBILITY = 'accessibility',
|
||||
RECORDING = 'recording',
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@
|
||||
"buttonLabel": "Anfragen"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Zum Transkribieren anmelden",
|
||||
"body": "In diesem Meeting kann jede angemeldete Person eine Transkription (Beta) starten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Transkriptionszugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Transkription zu starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Transkription anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced-Funktion",
|
||||
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
|
||||
@@ -535,9 +551,7 @@
|
||||
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
|
||||
"loading": "Aufzeichnung wird gestartet",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"transcription": "Diese Aufzeichnung transkribieren",
|
||||
"quality": "Videoqualität anpassbar.",
|
||||
"qualityEdit": "Ändern"
|
||||
"transcription": "Diese Aufzeichnung transkribieren"
|
||||
},
|
||||
"button": {
|
||||
"start": "Meeting-Aufzeichnung starten",
|
||||
@@ -562,6 +576,22 @@
|
||||
"buttonLabel": "Anfrage senden"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Zum Aufnehmen anmelden",
|
||||
"body": "In diesem Meeting kann jede angemeldete Person eine Videoaufnahme (Beta) starten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Aufzeichnungszugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Melden Sie sich an und kehren Sie dann zu diesem Bereich zurück, um die Aufnahme zu starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Aufnahme anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Aufnahme starten.",
|
||||
"buttonLabel": "Anfrage senden"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced-Funktion",
|
||||
"body": "Diese Funktion ist für Sie nicht verfügbar. Bitte wenden Sie sich an den Support, um weitere Informationen zu erhalten.",
|
||||
|
||||
@@ -184,45 +184,10 @@
|
||||
"accessibility": "Barrierefreiheit",
|
||||
"transcription": "Transkription",
|
||||
"shortcuts": "Tastenkürzel",
|
||||
"rooms": "Räume",
|
||||
"recording": "Aufzeichnung"
|
||||
"rooms": "Räume"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Standardeinstellungen für Räume",
|
||||
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Aufzeichnungsqualität",
|
||||
"locked": "Diese Einstellungen können nicht geändert werden, während eine Aufzeichnung startet oder läuft.",
|
||||
"profile": {
|
||||
"label": "Inhaltstyp",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Sprechende Personen",
|
||||
"description": "Optimiert für Personen vor der Kamera mit gelegentlichen Folien. Kleinste Dateien."
|
||||
},
|
||||
"text": {
|
||||
"label": "Präsentationen und Text",
|
||||
"description": "Hält Text beim Teilen von Folien, Dokumenten oder Code scharf."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Gemischte Inhalte",
|
||||
"description": "Ausgewogen für Kameras, Bildschirmfreigabe und moderate Bewegung."
|
||||
},
|
||||
"full": {
|
||||
"label": "Maximale Qualität",
|
||||
"description": "Höchste Wiedergabetreue und flüssigste Bewegung. Größte Dateien."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Auflösung",
|
||||
"description": "Höhere Auflösungen erzeugen größere Dateien.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in to transcribe",
|
||||
"body": "In this meeting, any logged-in participant can start a transcription (beta).",
|
||||
"linkMore": "Open documentation",
|
||||
"linkAriaLabel": "Open documentation about transcription access - opens in new window",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "Login Required",
|
||||
"body": "Log in, then come back to this panel to start the transcription."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request Transcription",
|
||||
"body": "The host will be notified and can enable transcription for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced feature",
|
||||
"body": "This feature is not available to you. Please contact support for more information.",
|
||||
@@ -535,9 +551,7 @@
|
||||
"destination": "This recording will be temporarily stored on our servers",
|
||||
"loading": "Starting recording",
|
||||
"linkMore": "Learn more",
|
||||
"transcription": "Transcribe this recording",
|
||||
"quality": "Adjustable video quality.",
|
||||
"qualityEdit": "Change"
|
||||
"transcription": "Transcribe this recording"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start recording the meeting",
|
||||
@@ -562,6 +576,22 @@
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in to record",
|
||||
"body": "In this meeting, any logged-in participant can start a recording (beta).",
|
||||
"linkMore": "Open documentation",
|
||||
"linkAriaLabel": "Open documentation about recording access - opens in new window",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "Login Required",
|
||||
"body": "Log in, then come back to this panel to start the recording."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request Recording",
|
||||
"body": "The host will be notified and can enable recording for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Advanced feature",
|
||||
"body": "This feature is not available to you. Please contact support for more information.",
|
||||
|
||||
@@ -184,45 +184,10 @@
|
||||
"accessibility": "Accessibility",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Shortcuts",
|
||||
"rooms": "Rooms",
|
||||
"recording": "Recording"
|
||||
"rooms": "Rooms"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Default room settings",
|
||||
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Recording quality",
|
||||
"locked": "These settings can't be changed while a recording is starting or in progress.",
|
||||
"profile": {
|
||||
"label": "Content type",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Talking heads",
|
||||
"description": "Optimized for people speaking on camera with occasional slides. Smallest files."
|
||||
},
|
||||
"text": {
|
||||
"label": "Presentations and text",
|
||||
"description": "Keeps text sharp when sharing slides, documents or code."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Mixed content",
|
||||
"description": "Balanced for a mix of cameras, screen sharing and moderate motion."
|
||||
},
|
||||
"full": {
|
||||
"label": "Maximum quality",
|
||||
"description": "Highest fidelity and smoothest motion. Largest files."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolution",
|
||||
"description": "Higher resolutions produce larger files.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +506,22 @@
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Inicia sesión para transcribir",
|
||||
"body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una transcripción (beta).",
|
||||
"linkMore": "Abrir la documentación",
|
||||
"linkAriaLabel": "Abrir la documentación sobre el acceso a la transcripción - se abre en una nueva ventana",
|
||||
"dividerLabel": "O",
|
||||
"login": {
|
||||
"heading": "Inicio de sesión necesario",
|
||||
"body": "Inicia sesión y vuelve a este panel para iniciar la transcripción."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Pedírselo al organizador",
|
||||
"body": "El anfitrión recibirá una notificación y podrá iniciar la transcripción por ti.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Función avanzada",
|
||||
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
|
||||
@@ -534,9 +550,7 @@
|
||||
"destination": "Esta grabación se conservará temporalmente en nuestros servidores",
|
||||
"loading": "Iniciando la grabación",
|
||||
"linkMore": "Más información",
|
||||
"transcription": "Transcribir esta grabación",
|
||||
"quality": "Calidad de vídeo ajustable.",
|
||||
"qualityEdit": "Cambiar"
|
||||
"transcription": "Transcribir esta grabación"
|
||||
},
|
||||
"button": {
|
||||
"start": "Empezar a grabar la reunión",
|
||||
@@ -561,6 +575,22 @@
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Inicia sesión para grabar",
|
||||
"body": "En esta reunión, cualquier participante con sesión iniciada puede iniciar una grabación (beta).",
|
||||
"linkMore": "Abrir la documentación",
|
||||
"linkAriaLabel": "Abrir la documentación sobre el acceso a la grabación - se abre en una nueva ventana",
|
||||
"dividerLabel": "O",
|
||||
"login": {
|
||||
"heading": "Inicio de sesión necesario",
|
||||
"body": "Inicia sesión y vuelve a este panel para iniciar la grabación."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Pedírselo al organizador",
|
||||
"body": "El anfitrión recibirá una notificación y podrá iniciar la grabación por ti.",
|
||||
"buttonLabel": "Solicitar"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Función avanzada",
|
||||
"body": "Esta función no está disponible para ti. Ponte en contacto con el soporte para obtener más información.",
|
||||
|
||||
@@ -184,45 +184,10 @@
|
||||
"accessibility": "Accesibilidad",
|
||||
"transcription": "Transcripción",
|
||||
"shortcuts": "Atajos",
|
||||
"rooms": "Reuniones",
|
||||
"recording": "Grabación"
|
||||
"rooms": "Reuniones"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Ajustes predeterminados de las reuniones",
|
||||
"description": "Elige la configuración que se aplica de forma predeterminada a las nuevas reuniones que crees. Siempre podrás modificarla para cada reunión desde el menu de configuración."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Calidad de la grabación",
|
||||
"locked": "Estos ajustes no se pueden modificar mientras una grabación se está iniciando o está en curso.",
|
||||
"profile": {
|
||||
"label": "Tipo de contenido",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Personas hablando",
|
||||
"description": "Optimizado para personas ante la cámara con diapositivas ocasionales. Archivos más ligeros."
|
||||
},
|
||||
"text": {
|
||||
"label": "Presentaciones y texto",
|
||||
"description": "Mantiene el texto nítido al compartir diapositivas, documentos o código."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Contenido mixto",
|
||||
"description": "Equilibrado entre cámaras, pantalla compartida y movimiento moderado."
|
||||
},
|
||||
"full": {
|
||||
"label": "Calidad máxima",
|
||||
"description": "Máxima fidelidad y movimiento más fluido. Archivos más pesados."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolución",
|
||||
"description": "Las resoluciones más altas generan archivos más pesados.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Connectez-vous pour transcrire",
|
||||
"body": "Dans cette réunion, tout participant connecté peut lancer une transcription (beta).",
|
||||
"linkMore": "Ouvrir la documentation",
|
||||
"linkAriaLabel": "Ouvrir la documentation sur l'accès à la transcription - ouvre dans une nouvelle fenêtre",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Connexion requise",
|
||||
"body": "Connectez-vous, puis revenez sur ce panneau pour lancer la transcription."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Fonctionnalité avancée",
|
||||
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
|
||||
@@ -535,9 +551,7 @@
|
||||
"destination": "Cet enregistrement sera conservé temporairement sur nos serveurs",
|
||||
"loading": "Démarrage de l'enregistrement",
|
||||
"linkMore": "En savoir plus",
|
||||
"transcription": "Transcrire cet enregistrement",
|
||||
"quality": "Qualité vidéo ajustable.",
|
||||
"qualityEdit": "Modifier"
|
||||
"transcription": "Transcrire cet enregistrement"
|
||||
},
|
||||
"button": {
|
||||
"start": "Commencer à enregistrer la réunion",
|
||||
@@ -562,6 +576,22 @@
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Connectez-vous pour enregistrer",
|
||||
"body": "Dans cette réunion, tout participant connecté peut lancer un enregistrement (beta).",
|
||||
"linkMore": "Ouvrir la documentation",
|
||||
"linkAriaLabel": "Ouvrir la documentation sur l'accès à l'enregistrement - ouvre dans une nouvelle fenêtre",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Connexion requise",
|
||||
"body": "Connectez-vous, puis revenez sur ce panneau pour lancer l'enregistrement."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Fonctionnalité avancée",
|
||||
"body": "Cette fonctionnalité ne vous est pas ouverte. Contactez le support pour obtenir plus d'informations.",
|
||||
|
||||
@@ -184,45 +184,10 @@
|
||||
"accessibility": "Accessibilité",
|
||||
"transcription": "Transcription",
|
||||
"shortcuts": "Raccourcis",
|
||||
"rooms": "Réunions",
|
||||
"recording": "Enregistrement"
|
||||
"rooms": "Réunions"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Paramètres par défaut des réunions",
|
||||
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Qualité de l'enregistrement",
|
||||
"locked": "Ces paramètres ne peuvent pas être modifiés pendant le démarrage ou le déroulement d'un enregistrement.",
|
||||
"profile": {
|
||||
"label": "Type de contenu",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Visioconférence",
|
||||
"description": "Optimisé pour des personnes à la caméra avec quelques diapositives. Fichiers les plus légers."
|
||||
},
|
||||
"text": {
|
||||
"label": "Présentations et texte",
|
||||
"description": "Garde le texte net lors du partage de diapositives, de documents ou de code."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Contenu mixte",
|
||||
"description": "Équilibré entre caméras, partage d'écran et mouvements modérés."
|
||||
},
|
||||
"full": {
|
||||
"label": "Qualité maximale",
|
||||
"description": "Meilleure fidélité et mouvements les plus fluides. Fichiers les plus lourds."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Résolution",
|
||||
"description": "Une résolution plus élevée produit des fichiers plus lourds.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -507,6 +507,22 @@
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in om te transcriberen",
|
||||
"body": "In deze vergadering kan elke ingelogde deelnemer een transcriptie starten (beta).",
|
||||
"linkMore": "Documentatie openen",
|
||||
"linkAriaLabel": "Documentatie over transcriptietoegang openen - opent in nieuw venster",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Log in en kom daarna terug naar dit paneel om de transcriptie te starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Transcriptie aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Geavanceerde functie",
|
||||
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
|
||||
@@ -535,9 +551,7 @@
|
||||
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
|
||||
"loading": "Opname wordt gestart",
|
||||
"linkMore": "Meer informatie",
|
||||
"transcription": "Transcribeer deze opname",
|
||||
"quality": "Videokwaliteit aanpasbaar.",
|
||||
"qualityEdit": "Wijzigen"
|
||||
"transcription": "Transcribeer deze opname"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start met opnemen van de vergadering",
|
||||
@@ -562,6 +576,22 @@
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"notLoggedIn": {
|
||||
"heading": "Log in om op te nemen",
|
||||
"body": "In deze vergadering kan elke ingelogde deelnemer een opname starten (beta).",
|
||||
"linkMore": "Documentatie openen",
|
||||
"linkAriaLabel": "Documentatie over opnametoegang openen - opent in nieuw venster",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Log in en kom daarna terug naar dit paneel om de opname te starten."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Opname aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de opname voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Geavanceerde functie",
|
||||
"body": "Deze functie is niet voor u beschikbaar. Neem contact op met de ondersteuning voor meer informatie.",
|
||||
|
||||
@@ -184,45 +184,10 @@
|
||||
"accessibility": "Toegankelijkheid",
|
||||
"transcription": "Transcriptie",
|
||||
"shortcuts": "Sneltoetsen",
|
||||
"rooms": "Vergaderingen",
|
||||
"recording": "Opname"
|
||||
"rooms": "Vergaderingen"
|
||||
},
|
||||
"roomDefaults": {
|
||||
"heading": "Standaardinstellingen voor vergaderingen",
|
||||
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
|
||||
},
|
||||
"recording": {
|
||||
"heading": "Opnamekwaliteit",
|
||||
"locked": "Deze instellingen kunnen niet worden gewijzigd terwijl een opname start of bezig is.",
|
||||
"profile": {
|
||||
"label": "Type inhoud",
|
||||
"options": {
|
||||
"talking_heads": {
|
||||
"label": "Sprekende personen",
|
||||
"description": "Geoptimaliseerd voor mensen voor de camera met af en toe dia's. Kleinste bestanden."
|
||||
},
|
||||
"text": {
|
||||
"label": "Presentaties en tekst",
|
||||
"description": "Houdt tekst scherp bij het delen van dia's, documenten of code."
|
||||
},
|
||||
"mixed": {
|
||||
"label": "Gemengde inhoud",
|
||||
"description": "Gebalanceerd voor camera's, schermdeling en matige beweging."
|
||||
},
|
||||
"full": {
|
||||
"label": "Maximale kwaliteit",
|
||||
"description": "Hoogste getrouwheid en vloeiendste beweging. Grootste bestanden."
|
||||
}
|
||||
}
|
||||
},
|
||||
"resolution": {
|
||||
"label": "Resolutie",
|
||||
"description": "Hogere resoluties leveren grotere bestanden op.",
|
||||
"options": {
|
||||
"540p": "540p (qHD)",
|
||||
"720p": "720p (HD)",
|
||||
"1080p": "1080p (Full HD)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
export type RecordingEncodingPreference = {
|
||||
resolution?: string
|
||||
profile?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Recording encoding preferences, keyed by user id so that several accounts
|
||||
* sharing the same browser don't overwrite each other's choices.
|
||||
*/
|
||||
type State = {
|
||||
byUser: Record<string, RecordingEncodingPreference>
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: State = {
|
||||
byUser: {},
|
||||
}
|
||||
|
||||
function getRecordingEncodingState(): State {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.RECORDING_ENCODING)
|
||||
if (!stored) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(stored)
|
||||
if (!parsed || typeof parsed.byUser !== 'object') return DEFAULT_STATE
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
reportError('generic_failure', error, {
|
||||
context: '[RecordingEncodingStore] Failed to parse stored settings:',
|
||||
})
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export const recordingEncodingStore = proxy<State>(getRecordingEncodingState())
|
||||
|
||||
subscribe(recordingEncodingStore, () => {
|
||||
localStorage.setItem(
|
||||
STORAGE_KEYS.RECORDING_ENCODING,
|
||||
JSON.stringify(recordingEncodingStore)
|
||||
)
|
||||
})
|
||||
|
||||
export const setRecordingEncodingPreference = (
|
||||
userId: string,
|
||||
preference: RecordingEncodingPreference
|
||||
) => {
|
||||
recordingEncodingStore.byUser[userId] = {
|
||||
...recordingEncodingStore.byUser[userId],
|
||||
...preference,
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,4 @@ export const STORAGE_KEYS = {
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
USER: 'app_user',
|
||||
ACCESSIBILITY: 'app_accessibility_settings',
|
||||
RECORDING_ENCODING: 'app_recording_encoding',
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user