Compare commits
52 Commits
v1.1.0
...
update-cgus
| Author | SHA1 | Date | |
|---|---|---|---|
| c1be443a46 | |||
| 47cd3eff74 | |||
| 5769203705 | |||
| cadc186c62 | |||
| 5be7595533 | |||
| 0fe8d9b681 | |||
| 83654cf7c0 | |||
| f6cdb1125b | |||
| 2863aa832d | |||
| 48af2e3a5f | |||
| 8a0dfd1478 | |||
| 37a2f3985a | |||
| 39271544d7 | |||
| f7b45622bc | |||
| f3e2bbf701 | |||
| 6e1ad7fca5 | |||
| d9dbededee | |||
| 70403ad0d8 | |||
| 9d69fe4f4f | |||
| 08f281e778 | |||
| da3dfedcbc | |||
| 16badde82d | |||
| 57a7523cc4 | |||
| 398ef1ae8a | |||
| f7d463f380 | |||
| 5e1705d259 | |||
| 236245740f | |||
| 9ebf2f277b | |||
| 049a9079c4 | |||
| 19f8c96e9d | |||
| 857b4bd1f1 | |||
| 309c532811 | |||
| 4e5032a7a4 | |||
| 4cb6320b83 | |||
| 587a5bc574 | |||
| 0d8c76cd03 | |||
| b19ac7f82b | |||
| d3e6af6f82 | |||
| 2fbb476b02 | |||
| 1b2139a9ff | |||
| 54e47e33a9 | |||
| 20b99cf2ad | |||
| db75b0eae9 | |||
| 5163f849e4 | |||
| 4345711771 | |||
| 7c690c369e | |||
| ef09629566 | |||
| cff1dbf39e | |||
| b466515306 | |||
| c678e9420e | |||
| 3af115dafb | |||
| 0daa6d0432 |
@@ -8,6 +8,25 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(agent) support Kyutai client for subtitle
|
||||
- ✨(all) support starting transcription and recording simultaneously
|
||||
- ✨(backend) persist options on a recording
|
||||
- ✨(all) support choosing the transcription language
|
||||
- ✨(summary) add a download link to the audio/video file
|
||||
- ✨(frontend) allow unprivileged users to request a recording
|
||||
|
||||
### Changed
|
||||
|
||||
- 🚸(frontend) remove the beta badge
|
||||
- ♻️(summary) extract file handling in a robust service
|
||||
- ♻️(all) manage recording state on the backend side
|
||||
|
||||
## [1.1.0] - 2025-12-22
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) enable user creation via email for external integrations
|
||||
|
||||
+1
-1
@@ -235,7 +235,7 @@ services:
|
||||
- livekit-egress
|
||||
|
||||
livekit-egress:
|
||||
image: livekit/egress
|
||||
image: livekit/egress:v1.11.0
|
||||
environment:
|
||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||
volumes:
|
||||
|
||||
@@ -277,7 +277,6 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
|
||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
|
||||
@@ -63,7 +63,7 @@ RECORDING_STORAGE_EVENT_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from lasuite.plugins import kyutai
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
@@ -13,14 +14,15 @@ from livekit.agents import (
|
||||
JobContext,
|
||||
JobProcess,
|
||||
JobRequest,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.agents import (
|
||||
room_io as lk_room_io,
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
load_dotenv()
|
||||
@@ -28,60 +30,25 @@ load_dotenv()
|
||||
logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
|
||||
# Default Deepgram STT configuration
|
||||
DEEPGRAM_STT_DEFAULTS = {
|
||||
"model": "nova-3",
|
||||
"language": "multi",
|
||||
}
|
||||
|
||||
# Supported parameters for LiveKit's deepgram.STT() in streaming mode
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
# Use language="multi" instead for automatic multilingual support
|
||||
DEEPGRAM_STT_SUPPORTED_PARAMS = {
|
||||
"model",
|
||||
"language",
|
||||
}
|
||||
STT_PROVIDER = os.getenv("STT_PROVIDER", "deepgram")
|
||||
|
||||
|
||||
def _build_deepgram_stt_kwargs():
|
||||
"""Build Deepgram STT kwargs from DEEPGRAM_STT_* environment variables.
|
||||
def create_stt_provider():
|
||||
"""Create STT provider based on environment configuration."""
|
||||
if STT_PROVIDER == "deepgram":
|
||||
# Note: Not all Deepgram API parameters are supported by the LiveKit plugin
|
||||
# detect_language is NOT supported for real-time streaming
|
||||
# Use language="multi" instead for automatic multilingual support
|
||||
_stt_instance = deepgram.STT(
|
||||
model=os.getenv("DEEPGRAM_STT_MODEL", "nova-3"),
|
||||
language=os.getenv("DEEPGRAM_STT_LANGUAGE", "multi"),
|
||||
)
|
||||
elif STT_PROVIDER == "kyutai":
|
||||
_stt_instance = kyutai.STT(base_url=os.getenv("KYUTAI_STT_BASE_URL"))
|
||||
else:
|
||||
raise ValueError(f"Unknown STT_PROVIDER: {STT_PROVIDER}")
|
||||
|
||||
Only parameters supported by LiveKit's deepgram.STT() are included.
|
||||
Unsupported parameters are logged as warnings.
|
||||
"""
|
||||
stt_kwargs = DEEPGRAM_STT_DEFAULTS.copy()
|
||||
|
||||
# Scan environment variables for DEEPGRAM_STT_* pattern
|
||||
for key, value in os.environ.items():
|
||||
if key.startswith("DEEPGRAM_STT_"):
|
||||
# Extract parameter name and convert to lowercase
|
||||
param_name = key.replace("DEEPGRAM_STT_", "", 1).lower()
|
||||
|
||||
# Check if parameter is supported by LiveKit plugin
|
||||
if param_name not in DEEPGRAM_STT_SUPPORTED_PARAMS:
|
||||
supported = ", ".join(sorted(DEEPGRAM_STT_SUPPORTED_PARAMS))
|
||||
logger.warning(
|
||||
f"Ignoring unsupported Deepgram STT parameter: {param_name}. "
|
||||
f"Supported parameters: {supported}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Parse value type
|
||||
value_lower = value.lower()
|
||||
if value_lower in ("true", "false"):
|
||||
# Boolean values
|
||||
stt_kwargs[param_name] = value_lower == "true"
|
||||
elif value.isdigit():
|
||||
# Integer values
|
||||
stt_kwargs[param_name] = int(value)
|
||||
else:
|
||||
# String values
|
||||
stt_kwargs[param_name] = value
|
||||
|
||||
logger.info(f"Deepgram STT configuration: {stt_kwargs}")
|
||||
return stt_kwargs
|
||||
return _stt_instance
|
||||
|
||||
|
||||
class Transcriber(Agent):
|
||||
@@ -89,12 +56,11 @@ class Transcriber(Agent):
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
"""Init transcription agent."""
|
||||
# Build STT configuration from environment variables
|
||||
stt_kwargs = _build_deepgram_stt_kwargs()
|
||||
stt = create_stt_provider()
|
||||
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
stt=deepgram.STT(**stt_kwargs),
|
||||
stt=stt,
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
|
||||
@@ -163,12 +129,8 @@ class MultiUserTranscriber:
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
transcription_enabled=True,
|
||||
audio_enabled=False,
|
||||
options=lk_room_io.RoomOptions(
|
||||
text_input=False, audio_output=False, text_output=True
|
||||
),
|
||||
)
|
||||
await room_io.start()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.2.18",
|
||||
"livekit-plugins-deepgram==1.2.18",
|
||||
"livekit-plugins-silero==1.2.18",
|
||||
"livekit-agents==1.3.10",
|
||||
"livekit-plugins-deepgram==1.3.10",
|
||||
"livekit-plugins-silero==1.3.10",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.1"
|
||||
]
|
||||
|
||||
|
||||
@@ -181,6 +181,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"status",
|
||||
"mode",
|
||||
"options",
|
||||
"key",
|
||||
"is_expired",
|
||||
"expired_at",
|
||||
@@ -212,6 +213,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
options = serializers.JSONField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=dict,
|
||||
)
|
||||
|
||||
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
|
||||
@@ -308,10 +308,13 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data["options"]
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(room=room, mode=mode)
|
||||
recording = models.Recording.objects.create(
|
||||
room=room, mode=mode, options=options
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-29 15:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0015_application_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recording',
|
||||
name='options',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Recording options', verbose_name='Recording options'),
|
||||
),
|
||||
]
|
||||
@@ -577,6 +577,12 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Recording mode"),
|
||||
help_text=_("Defines the mode of recording being called."),
|
||||
)
|
||||
options = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name=_("Recording options"),
|
||||
help_text=_("Recording options"),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
|
||||
@@ -16,6 +16,23 @@ from core import models
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_recording_download_base_url() -> str:
|
||||
"""Get the recording download base URL with backward compatibility."""
|
||||
new_setting = settings.RECORDING_DOWNLOAD_BASE_URL
|
||||
old_setting = settings.SCREEN_RECORDING_BASE_URL
|
||||
|
||||
if old_setting:
|
||||
logger.warning(
|
||||
"SCREEN_RECORDING_BASE_URL is deprecated and will be removed in a future version. "
|
||||
"Please use RECORDING_DOWNLOAD_BASE_URL instead."
|
||||
)
|
||||
|
||||
if new_setting:
|
||||
return new_setting
|
||||
|
||||
return old_setting
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
@@ -26,7 +43,12 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
return self._notify_user_by_email(recording)
|
||||
summary_success = True
|
||||
if recording.options.get("transcribe", False):
|
||||
summary_success = self._notify_summary_service(recording)
|
||||
|
||||
email_success = self._notify_user_by_email(recording)
|
||||
return email_success and summary_success
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -64,7 +86,7 @@ class NotificationService:
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
||||
"link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
}
|
||||
|
||||
has_failures = False
|
||||
@@ -137,12 +159,14 @@ class NotificationService:
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%H:%M"),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Recording-related LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -14,6 +18,27 @@ class RecordingEventsError(Exception):
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related Livekit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
|
||||
status_mapping = {
|
||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||
}
|
||||
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
|
||||
from .exceptions import (
|
||||
@@ -60,6 +61,15 @@ class WorkerServiceMediator:
|
||||
finally:
|
||||
recording.save()
|
||||
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
logger.info(
|
||||
"Worker started for room %s (worker ID: %s)",
|
||||
recording.room,
|
||||
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core import models, utils
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
@@ -118,8 +118,10 @@ class LiveKitEventsService:
|
||||
except Exception as e:
|
||||
raise InvalidPayloadError("Invalid webhook payload") from e
|
||||
|
||||
if self._filter_regex and not self._filter_regex.search(data.room.name):
|
||||
logger.info("Filtered webhook event for room '%s'", data.room.name)
|
||||
room_name = data.room.name or data.egress_info.room_name
|
||||
|
||||
if self._filter_regex and not self._filter_regex.search(room_name):
|
||||
logger.info("Filtered webhook event for room '%s'", room_name)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -138,6 +140,20 @@ class LiveKitEventsService:
|
||||
# pylint: disable=not-callable
|
||||
handler(data)
|
||||
|
||||
def _handle_egress_updated(self, data):
|
||||
"""Handle 'egress_updated' event."""
|
||||
|
||||
egress_id = data.egress_info.egress_id
|
||||
try:
|
||||
recording = models.Recording.objects.get(worker_id=egress_id)
|
||||
except models.Recording.DoesNotExist as err:
|
||||
raise ActionFailedError(
|
||||
f"Recording with worker ID {egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
egress_status = data.egress_info.status
|
||||
self.recording_events.handle_update(recording, egress_status)
|
||||
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event."""
|
||||
|
||||
@@ -150,6 +166,14 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
utils.update_room_metadata(
|
||||
room_name, {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
|
||||
@@ -60,6 +60,26 @@ def test_notify_external_services_screen_recording_mode(mock_notify_email):
|
||||
mock_notify_email.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
|
||||
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
|
||||
def test_notify_external_services_screen_recording_mode_with_transcribe(
|
||||
mock_notify_email, mock_notify_summary
|
||||
):
|
||||
"""Test notification routing for screen recording mode with transcribe option."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING, options={"transcribe": True}
|
||||
)
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_email.assert_called_once_with(recording)
|
||||
mock_notify_summary.assert_called_once_with(recording)
|
||||
|
||||
|
||||
def test_notify_external_services_unknown_mode(caplog):
|
||||
"""Test notification for unknown recording mode."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
@@ -82,6 +82,7 @@ def test_api_recordings_list_authenticated_direct(role, settings):
|
||||
"key": recording.key,
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"mode": recording.mode,
|
||||
"options": {},
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
|
||||
@@ -95,6 +95,7 @@ def test_api_recording_retrieve_administrators(settings):
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"options": {},
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
@@ -130,6 +131,7 @@ def test_api_recording_retrieve_owners(settings):
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"options": {},
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
@@ -169,6 +171,7 @@ def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"options": {},
|
||||
"expired_at": "2023-01-16T12:00:00Z",
|
||||
"is_expired": False, # Ensure the recording is still valid and hasn't expired
|
||||
}
|
||||
@@ -209,6 +212,7 @@ def test_api_recording_retrieve_expired(settings):
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"options": {},
|
||||
"expired_at": "2023-01-17T12:00:00Z",
|
||||
"is_expired": True, # Ensure the recording has expired
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -33,14 +34,18 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
def test_start_recording_success(mediator, mock_worker_service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, mediator, mock_worker_service
|
||||
):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
mock_worker_service.start.return_value = worker_id
|
||||
|
||||
mock_recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.INITIATED, worker_id=None
|
||||
status=RecordingStatusChoices.INITIATED,
|
||||
worker_id=None,
|
||||
)
|
||||
mediator.start(mock_recording)
|
||||
|
||||
@@ -55,12 +60,18 @@ def test_start_recording_success(mediator, mock_worker_service):
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(mock_recording.room.id),
|
||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mediator, mock_worker_service, error_class
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -78,6 +89,8 @@ def test_mediator_start_recording_worker_errors(
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
@@ -90,8 +103,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mediator, mock_worker_service, status
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -105,6 +119,8 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
"""Test successful recording stop"""
|
||||
|
||||
@@ -21,7 +21,7 @@ from core.services.livekit_events import (
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -70,7 +70,10 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
@@ -83,13 +86,98 @@ def test_handle_egress_ended_success(mock_notify, mode, notification_type, servi
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "status"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_updated_success(
|
||||
mock_update_room_metadata, egress_status, status, service
|
||||
):
|
||||
"""Should successfully update room's metadata."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="initiated")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": status}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_room_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="initiated")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_notification_fails(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording when metadata's update fails."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
@@ -108,9 +196,16 @@ def test_handle_egress_ended_notification_fails(mock_notify, service):
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_recording_not_found(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
@@ -124,13 +219,17 @@ def test_handle_egress_ended_recording_not_found(mock_notify, service):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_recording_not_active(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||
@@ -141,13 +240,19 @@ def test_handle_egress_ended_recording_not_active(mock_notify, service):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "failed_to_stop"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||
@@ -158,6 +263,9 @@ def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -244,6 +245,57 @@ async def notify_participants(room_name: str, notification_data: dict):
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
class MetadataUpdateException(Exception):
|
||||
"""Room's metadata update fails."""
|
||||
|
||||
|
||||
@async_to_sync
|
||||
async def update_room_metadata(
|
||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
||||
):
|
||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
||||
|
||||
Args:
|
||||
room_name: Name of the room to update
|
||||
metadata: Dictionary of metadata key-values to add/update
|
||||
remove_keys: Optional list of keys to remove from existing metadata.
|
||||
"""
|
||||
|
||||
lkapi = create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[room_name],
|
||||
)
|
||||
)
|
||||
|
||||
if not response.rooms:
|
||||
return
|
||||
|
||||
room = response.rooms[0]
|
||||
|
||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
||||
|
||||
if remove_keys:
|
||||
for key in remove_keys:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **metadata}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
raise MetadataUpdateException(
|
||||
f"Failed to update metadata for room {room_name}: {e}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -315,92 +315,96 @@ msgstr "Aufzeichnungsmodus"
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:694
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:735
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:738
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:733
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:747
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:763
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:792
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:796
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -309,95 +309,99 @@ msgstr "Recording mode"
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:694
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:735
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:738
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:733
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:747
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:763
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:792
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:796
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -315,93 +315,97 @@ msgstr "Mode d'enregistrement"
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:694
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:735
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:738
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:733
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:747
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:763
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:792
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:796
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-17 15:12+0000\n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -311,92 +311,96 @@ msgstr "Opnamemodus"
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:583 core/models.py:584
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:590
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:591
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:693
|
||||
#: core/models.py:699
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:694
|
||||
#: core/models.py:700
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:706
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:712
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:718
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:729
|
||||
#: core/models.py:735
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:730
|
||||
#: core/models.py:736
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:731
|
||||
#: core/models.py:737
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:732
|
||||
#: core/models.py:738
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:733
|
||||
#: core/models.py:739
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:752
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:747
|
||||
#: core/models.py:753
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:763
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:774
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:775
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:792
|
||||
#: core/models.py:798
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:795
|
||||
#: core/models.py:801
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:796
|
||||
#: core/models.py:802
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:814
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:815
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
|
||||
@@ -342,12 +342,12 @@ class Base(Configuration):
|
||||
"use_proconnect_button": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
|
||||
),
|
||||
"transcript": values.DictValue(
|
||||
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
|
||||
),
|
||||
"manifest_link": values.Value(
|
||||
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
|
||||
),
|
||||
"transcription_destination": values.Value(
|
||||
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
@@ -634,6 +634,9 @@ class Base(Configuration):
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||
)
|
||||
RECORDING_DOWNLOAD_BASE_URL = values.Value(
|
||||
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.0.1"
|
||||
version = "1.2.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+28
-25
@@ -1,13 +1,15 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
"@livekit/track-processors": "0.6.1",
|
||||
@@ -104,7 +106,6 @@
|
||||
"integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
@@ -1019,6 +1020,24 @@
|
||||
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fontsource-variable/material-symbols-outlined": {
|
||||
"version": "5.2.30",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource-variable/material-symbols-outlined/-/material-symbols-outlined-5.2.30.tgz",
|
||||
"integrity": "sha512-BjSx7nqvISJs2Pjd8sBH583AnD4k6dD4Em7AVISoLXzbX3PIFWAE2GPm13LlCys8u8idkyUd62L8yn6ts6DdbA==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/material-icons-outlined": {
|
||||
"version": "5.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/material-icons-outlined/-/material-icons-outlined-5.2.6.tgz",
|
||||
"integrity": "sha512-99XKAkwnCg0s0/ywax+o3m01HSNM5gGzSBw+WnlWG2+WY3wOjcN+wXMfm4zP37Yme7Yze2DvKxF78tHWOrlwFw==",
|
||||
"license": "OFL-1.1",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ayuhito"
|
||||
}
|
||||
},
|
||||
"node_modules/@formatjs/ecma402-abstract": {
|
||||
"version": "2.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.4.tgz",
|
||||
@@ -3870,7 +3889,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz",
|
||||
"integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.81.5"
|
||||
},
|
||||
@@ -3962,7 +3980,8 @@
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-transform": {
|
||||
"version": "0.1.11",
|
||||
@@ -3978,7 +3997,8 @@
|
||||
"version": "0.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.15.tgz",
|
||||
"integrity": "sha512-omOlCPvTWyPm4ZE5bZUhlSvnHM2ZWM2U+1cPiYFL/e8aV5O9MouELp+L4dMKNTON0nTeHqEg+KWDfFQMY5Wkaw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
@@ -4005,7 +4025,6 @@
|
||||
"integrity": "sha512-B2egV9wALML1JCpv3VQoQ+yesQKAmNMBIAY7OteVrikcOcAkWm+dGL6qpeCktPjAv6N1JLnhbNiqS35UpFyBsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -4023,7 +4042,6 @@
|
||||
"integrity": "sha512-D2wOSq/d6Agt28q7rSI3jhU7G6aiuzljDGZ2hTZHIkrTLUI+AF3WMeKkEZ9nN2fkBAlcktT6vcZjDFiIhMYEQw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/prop-types": "*",
|
||||
"csstype": "^3.0.2"
|
||||
@@ -4090,7 +4108,6 @@
|
||||
"integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.1",
|
||||
"@typescript-eslint/types": "8.35.1",
|
||||
@@ -4382,7 +4399,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz",
|
||||
"integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -4797,7 +4813,6 @@
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001646",
|
||||
"electron-to-chromium": "^1.5.4",
|
||||
@@ -5607,7 +5622,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
|
||||
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
@@ -6575,7 +6589,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.27.6"
|
||||
},
|
||||
@@ -7460,7 +7473,6 @@
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.15.7.tgz",
|
||||
"integrity": "sha512-19m8Q1cvRl5PslRawDUgWXeP8vL8584tX8kiZEJaPZo83U/L6VPS/O7pP06phfJaBWeeV8sAOVtEPlQiZEHtpg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.39.3",
|
||||
@@ -8120,7 +8132,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8234,7 +8245,6 @@
|
||||
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cssesc": "^3.0.0",
|
||||
"util-deprecate": "^1.0.2"
|
||||
@@ -8388,7 +8398,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0"
|
||||
},
|
||||
@@ -8495,7 +8504,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.1.0",
|
||||
"scheduler": "^0.23.2"
|
||||
@@ -8745,6 +8753,7 @@
|
||||
"integrity": "sha512-GBg5pV8LHOTbeVmH2VHLEFR0mc2QpQMzAvcoxEGfPNWgWHc8UvKCyq7pqN1vA+fDZ+yXXbixeO0kB2pzVvFCBw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"postcss": "^8.4.38"
|
||||
}
|
||||
@@ -9248,7 +9257,6 @@
|
||||
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -9367,8 +9375,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -9480,7 +9487,6 @@
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9641,7 +9647,6 @@
|
||||
"resolved": "https://registry.npmjs.org/valtio/-/valtio-2.1.5.tgz",
|
||||
"integrity": "sha512-vsh1Ixu5mT0pJFZm+Jspvhga5GzHUTYv0/+Th203pLfh3/wbHwxhu/Z2OkZDXIgHfjnjBns7SN9HNcbDvPmaGw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"proxy-compare": "^3.0.1"
|
||||
},
|
||||
@@ -9742,7 +9747,6 @@
|
||||
"integrity": "sha512-cJBdq0/u+8rgstg9t7UkBilf8ipLmeXJO30NxD5HAHOivnj10ocV8YtR/XBvd2wQpN3TmcaxNKaHX3tN7o5F5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.4.6",
|
||||
@@ -9877,7 +9881,6 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.0.1",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,6 +13,8 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.30",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
"@livekit/components-react": "2.9.13",
|
||||
"@livekit/components-styles": "1.1.6",
|
||||
"@livekit/track-processors": "0.6.1",
|
||||
|
||||
@@ -17,9 +17,6 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
transcript: {
|
||||
form_beta_users: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
custom_css_url?: string
|
||||
@@ -47,6 +44,7 @@ export interface ApiConfig {
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
default_sources: string[]
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button, LinkButton } from '@/primitives'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const Heading = styled('h2', {
|
||||
base: {
|
||||
@@ -165,14 +164,7 @@ export const IntroSlider = () => {
|
||||
const [slideIndex, setSlideIndex] = useState(0)
|
||||
const { t } = useTranslation('home', { keyPrefix: 'introSlider' })
|
||||
|
||||
const { data } = useConfig()
|
||||
|
||||
const filteredSlides = useMemo(
|
||||
() => (data?.transcript?.form_beta_users ? SLIDES : SLIDES.slice(0, 2)),
|
||||
[data]
|
||||
)
|
||||
|
||||
const NUMBER_SLIDES = filteredSlides.length
|
||||
const NUMBER_SLIDES = SLIDES.length
|
||||
|
||||
return (
|
||||
<Container>
|
||||
@@ -198,24 +190,12 @@ export const IntroSlider = () => {
|
||||
</ButtonVerticalCenter>
|
||||
</ButtonContainer>
|
||||
<SlideContainer>
|
||||
{filteredSlides.map((slide, index) => (
|
||||
{SLIDES.map((slide, index) => (
|
||||
<Slide visible={index == slideIndex} key={index}>
|
||||
<Image src={slide.src} alt={t(`${slide.key}.imgAlt`)} />
|
||||
<TextAnimation visible={index == slideIndex}>
|
||||
<Heading>{t(`${slide.key}.title`)}</Heading>
|
||||
<Body>{t(`${slide.key}.body`)}</Body>
|
||||
{slide.isAvailableInBeta && (
|
||||
<LinkButton
|
||||
href={data?.transcript.form_beta_users}
|
||||
target="_blank"
|
||||
tooltip={t('beta.tooltip')}
|
||||
variant={'primary'}
|
||||
size={'sm'}
|
||||
style={{ marginTop: '1rem', width: 'fit-content' }}
|
||||
>
|
||||
{t('beta.text')}
|
||||
</LinkButton>
|
||||
)}
|
||||
</TextAnimation>
|
||||
</Slide>
|
||||
))}
|
||||
@@ -241,7 +221,7 @@ export const IntroSlider = () => {
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
{filteredSlides.map((_, index) => (
|
||||
{SLIDES.map((_, index) => (
|
||||
<Dot key={index} selected={index == slideIndex} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -111,15 +111,13 @@ export const TermsOfServiceRoute = () => {
|
||||
))}
|
||||
|
||||
{/* Article 7 */}
|
||||
<H lvl={2} margin={false}>
|
||||
{t('articles.article7.title')}
|
||||
</H>
|
||||
<H lvl={2}>{t('articles.article7.title')}</H>
|
||||
<P>{t('articles.article7.content')}</P>
|
||||
|
||||
{/* Section 7.1 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section1.title')}
|
||||
</H>
|
||||
<P>{t('articles.article7.sections.section1.content')}</P>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section1.paragraphs', {
|
||||
returnObjects: true,
|
||||
@@ -132,16 +130,51 @@ export const TermsOfServiceRoute = () => {
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section2.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section2.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.3 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section3.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section3.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.4 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section4.title')}
|
||||
</H>
|
||||
{ensureArray(
|
||||
t('articles.article7.sections.section4.paragraphs', {
|
||||
returnObjects: true,
|
||||
})
|
||||
).map((paragraph, index) => (
|
||||
<P key={index}>{paragraph}</P>
|
||||
))}
|
||||
|
||||
{/* Section 7.5 */}
|
||||
<H lvl={3} bold>
|
||||
{t('articles.article7.sections.section5.title')}
|
||||
</H>
|
||||
<P>
|
||||
{t('articles.article7.sections.section2.content')
|
||||
{t('articles.article7.sections.section5.content')
|
||||
.split('https://github.com/suitenumerique/meet')[0]
|
||||
.replace('https://github.com/suitenumerique/meet', '')}{' '}
|
||||
<A href="https://github.com/suitenumerique/meet" color="primary">
|
||||
https://github.com/suitenumerique/meet
|
||||
</A>
|
||||
{'. '}
|
||||
{
|
||||
t('articles.article7.sections.section2.content').split(
|
||||
t('articles.article7.sections.section5.content').split(
|
||||
'https://github.com/suitenumerique/meet'
|
||||
)[1]
|
||||
}
|
||||
|
||||
@@ -104,6 +104,16 @@ export const MainNotificationToast = () => {
|
||||
{ timeout: NotificationDuration.ALERT }
|
||||
)
|
||||
break
|
||||
case NotificationType.TranscriptionRequested:
|
||||
case NotificationType.ScreenRecordingRequested:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: notification.type,
|
||||
},
|
||||
{ timeout: NotificationDuration.RECORDING_REQUESTED }
|
||||
)
|
||||
break
|
||||
case NotificationType.PermissionsRemoved: {
|
||||
const removedSources = notification?.data?.removedSources
|
||||
if (!removedSources?.length) break
|
||||
|
||||
@@ -13,4 +13,5 @@ export const NotificationDuration = {
|
||||
LOWER_HAND: ToastDuration.EXTRA_LONG,
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
@@ -9,8 +9,10 @@ export enum NotificationType {
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||
TranscriptionRequested = 'transcriptionRequested',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useToast } from '@react-aria/toast'
|
||||
import { useMemo, useRef } from 'react'
|
||||
|
||||
import { StyledToastContainer, ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { NotificationType } from '../NotificationType'
|
||||
import { Button } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export function ToastRecordingRequest({
|
||||
state,
|
||||
...props
|
||||
}: Readonly<ToastProps>) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const participant = props.toast.content.participant
|
||||
const type = props.toast.content.type
|
||||
|
||||
const {
|
||||
isTranscriptOpen,
|
||||
openTranscript,
|
||||
isScreenRecordingOpen,
|
||||
openScreenRecording,
|
||||
} = useSidePanel()
|
||||
|
||||
const options = useMemo(() => {
|
||||
switch (type) {
|
||||
case NotificationType.TranscriptionRequested:
|
||||
return {
|
||||
key: 'transcript.requested',
|
||||
isMenuOpen: isTranscriptOpen,
|
||||
openMenu: openTranscript,
|
||||
}
|
||||
case NotificationType.ScreenRecordingRequested:
|
||||
return {
|
||||
key: 'screenRecording.requested',
|
||||
isMenuOpen: isScreenRecordingOpen,
|
||||
openMenu: openScreenRecording,
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}, [
|
||||
type,
|
||||
isTranscriptOpen,
|
||||
isScreenRecordingOpen,
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
])
|
||||
|
||||
if (!options) return
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t(options.key, {
|
||||
name: participant?.name,
|
||||
})}
|
||||
{!options.isMenuOpen && (
|
||||
<div
|
||||
className={css({
|
||||
marginLeft: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="text"
|
||||
className={css({
|
||||
color: 'primary.300',
|
||||
})}
|
||||
onPress={options.openMenu}
|
||||
>
|
||||
{t('openMenu')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ToastLowerHand } from './ToastLowerHand'
|
||||
import { ToastAnyRecording } from './ToastAnyRecording'
|
||||
import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -52,6 +53,12 @@ const renderToast = (
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.TranscriptionRequested:
|
||||
case NotificationType.ScreenRecordingRequested:
|
||||
return (
|
||||
<ToastRecordingRequest key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.RecordingSaving:
|
||||
return (
|
||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||
|
||||
@@ -7,16 +7,19 @@ import { RecordingMode } from '../types'
|
||||
export interface StartRecordingParams {
|
||||
id: string
|
||||
mode?: RecordingMode
|
||||
options?: Record<string, string | boolean>
|
||||
}
|
||||
|
||||
const startRecording = ({
|
||||
id,
|
||||
mode = RecordingMode.Transcript,
|
||||
options,
|
||||
}: StartRecordingParams): Promise<ApiRoom> => {
|
||||
return fetchApi(`rooms/${id}/start-recording/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
mode: mode,
|
||||
options: options,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatuses } from '../hooks/useRecordingStatuses'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { ConnectionState } from 'livekit-client'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { parseLineBreaks } from '@/utils/parseLineBreaks'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => (
|
||||
<div
|
||||
className={css({
|
||||
marginBottom: '80px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
interface ControlsButtonProps {
|
||||
i18nKeyPrefix: string
|
||||
statuses: RecordingStatuses
|
||||
handle: () => void
|
||||
isPendingToStart: boolean
|
||||
isPendingToStop: boolean
|
||||
openSidePanel: () => void
|
||||
}
|
||||
|
||||
const MIN_SPINNER_DISPLAY_TIME = 2000
|
||||
|
||||
export const ControlsButton = ({
|
||||
i18nKeyPrefix,
|
||||
statuses,
|
||||
handle,
|
||||
isPendingToStart,
|
||||
isPendingToStop,
|
||||
openSidePanel,
|
||||
}: ControlsButtonProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
|
||||
const [showSaving, setShowSaving] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
const isSaving = statuses.isSaving || isPendingToStop
|
||||
const isDisabled = !isRoomConnected || statuses.isAnotherModeStarted
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaving) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
setShowSaving(true)
|
||||
} else if (showSaving) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setShowSaving(false)
|
||||
}, MIN_SPINNER_DISPLAY_TIME)
|
||||
}
|
||||
|
||||
return () => clearTimeout(timeoutRef.current)
|
||||
}, [isSaving, showSaving])
|
||||
|
||||
// Saving state
|
||||
if (showSaving) {
|
||||
return (
|
||||
<Layout>
|
||||
<HStack width="100%" height="46px" justify="center">
|
||||
<Spinner size={30} />
|
||||
<Text variant="body">{t('button.saving')}</Text>
|
||||
</HStack>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
// Starting state
|
||||
if (statuses.isStarting || isPendingToStart) {
|
||||
return (
|
||||
<Layout>
|
||||
<HStack width="100%" height="46px" justify="center">
|
||||
<Spinner size={30} />
|
||||
{t('button.starting')}
|
||||
</HStack>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
// Active state (Stop button)
|
||||
if (statuses.isStarted) {
|
||||
return (
|
||||
<Layout>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{t('button.stop')}
|
||||
</Button>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
// Inactive state (Start button)
|
||||
return (
|
||||
<Layout>
|
||||
{statuses.isAnotherModeStarted && (
|
||||
<RACButton
|
||||
className={css({
|
||||
backgroundColor: 'primary.50',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.200',
|
||||
borderRadius: '6px',
|
||||
padding: '0.75rem',
|
||||
marginBottom: '0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'left',
|
||||
textAlign: 'left',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
backgroundColor: 'primary.100',
|
||||
borderColor: 'primary.400',
|
||||
},
|
||||
})}
|
||||
onPress={() => openSidePanel()}
|
||||
>
|
||||
<span
|
||||
className={cx(
|
||||
'material-icons',
|
||||
css({
|
||||
color: 'primary.500',
|
||||
marginRight: '1rem',
|
||||
})
|
||||
)}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
<Text variant={'smNote'}>
|
||||
{parseLineBreaks(t('button.anotherModeStarted'))}
|
||||
</Text>
|
||||
<span
|
||||
className={cx(
|
||||
'material-icons',
|
||||
css({
|
||||
color: 'primary.500',
|
||||
marginLeft: 'auto',
|
||||
})
|
||||
)}
|
||||
>
|
||||
chevron_right
|
||||
</span>
|
||||
</RACButton>
|
||||
)}
|
||||
<Button
|
||||
variant={isDisabled ? 'primary' : 'tertiary'}
|
||||
fullWidth
|
||||
onPress={handle}
|
||||
isDisabled={isDisabled}
|
||||
size="compact"
|
||||
>
|
||||
{t('button.start')}
|
||||
</Button>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Button, Dialog, P } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { recordingStore } from '@/stores/recording'
|
||||
|
||||
export const ErrorAlertDialog = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'errorRecordingAlertDialog',
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={!!recordingSnap.isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
title={t('title')}
|
||||
aria-label={t('title')}
|
||||
>
|
||||
<P>{t(`body.${recordingSnap.isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => (recordingStore.isErrorDialogOpen = '')}
|
||||
>
|
||||
{t('button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Dialog, P } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import { useHumanizeRecordingMaxDuration } from '@/features/recording'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { NotificationType } from '@/features/notifications'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
|
||||
export const LimitReachedAlertDialog = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation('rooms', {
|
||||
export const LimitReachedAlertDialog = () => {
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingStateToast.limitReachedAlert',
|
||||
})
|
||||
const { data } = useConfig()
|
||||
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const maxDuration = useHumanizeRecordingMaxDuration()
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (payload: Uint8Array) => {
|
||||
if (!isAdminOrOwner) return
|
||||
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
|
||||
if (
|
||||
notification?.type === NotificationType.TranscriptionLimitReached ||
|
||||
notification?.type === NotificationType.ScreenRecordingLimitReached
|
||||
) {
|
||||
setIsAlertOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
}
|
||||
}, [room, isAdminOrOwner])
|
||||
|
||||
if (!isAdminOrOwner) return null
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} role="alertdialog" title={t('title')}>
|
||||
<Dialog isOpen={isAlertOpen} role="alertdialog" title={t('title')}>
|
||||
<P>
|
||||
{t('description', {
|
||||
duration_message: data?.recording?.max_duration
|
||||
duration_message: maxDuration
|
||||
? t('durationMessage', {
|
||||
duration: humanizeDuration(data?.recording?.max_duration, {
|
||||
language: i18n.language,
|
||||
}),
|
||||
duration: maxDuration,
|
||||
})
|
||||
: '',
|
||||
})}
|
||||
</P>
|
||||
<HStack gap={1}>
|
||||
<Button variant="text" size="sm" onPress={onClose}>
|
||||
<Button variant="text" size="sm" onPress={() => setIsAlertOpen(false)}>
|
||||
{t('button')}
|
||||
</Button>
|
||||
</HStack>
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
interface LoginPromptProps {
|
||||
heading: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export const LoginPrompt = ({ heading, body }: LoginPromptProps) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.50',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid',
|
||||
borderColor: 'primary.200',
|
||||
paddingY: '1rem',
|
||||
paddingX: '1rem',
|
||||
marginTop: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||
<span className="material-symbols" aria-hidden={true}>
|
||||
login
|
||||
</span>
|
||||
<H lvl={3} margin={false} padding={false}>
|
||||
{heading}
|
||||
</H>
|
||||
</HStack>
|
||||
<Text variant="smNote" wrap="pretty">
|
||||
{body}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<LoginButton proConnectHint={false} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { A, Div, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LoginPrompt } from './LoginPrompt'
|
||||
import { RequestRecording } from './RequestRecording'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { HStack, VStack } from '@/styled-system/jsx'
|
||||
|
||||
const Divider = ({ label }: { label: string }) => (
|
||||
<HStack gap="1rem" alignItems="center" width="100%" marginY="1rem">
|
||||
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
|
||||
<Text variant="xsNote">{label}</Text>
|
||||
<div className={css({ flex: 1, height: '1px', bg: 'neutral.200' })} />
|
||||
</HStack>
|
||||
)
|
||||
|
||||
interface NoAccessViewProps {
|
||||
i18nKeyPrefix: string
|
||||
i18nKey: string
|
||||
helpArticle?: string
|
||||
imagePath: string
|
||||
handleRequest: () => Promise<void>
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const NoAccessView = ({
|
||||
i18nKeyPrefix,
|
||||
i18nKey,
|
||||
helpArticle,
|
||||
imagePath,
|
||||
handleRequest,
|
||||
isActive,
|
||||
}: NoAccessViewProps) => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: i18nKeyPrefix })
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
overflowY="scroll"
|
||||
padding="0 1.5rem"
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="center"
|
||||
>
|
||||
<img
|
||||
src={imagePath}
|
||||
alt=""
|
||||
className={css({
|
||||
minHeight: '250px',
|
||||
height: '250px',
|
||||
marginBottom: '1rem',
|
||||
marginTop: '-16px',
|
||||
'@media (max-height: 900px)': {
|
||||
height: 'auto',
|
||||
minHeight: 'auto',
|
||||
maxHeight: '25%',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
'@media (max-height: 770px)': {
|
||||
display: 'none',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<VStack gap={0} marginBottom={0}>
|
||||
<H lvl={1} margin={'sm'} fullWidth centered>
|
||||
{t(`${i18nKey}.heading`)}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
'@media (max-height: 700px)': {
|
||||
marginBottom: '1rem',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{t(`${i18nKey}.body`)}
|
||||
<br />
|
||||
{helpArticle && (
|
||||
<A href={helpArticle} target="_blank">
|
||||
{t(`${i18nKey}.linkMore`)}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
{!isLoggedIn && (
|
||||
<LoginPrompt
|
||||
heading={t(`${i18nKey}.login.heading`)}
|
||||
body={t(`${i18nKey}.login.body`)}
|
||||
/>
|
||||
)}
|
||||
{!isLoggedIn && !isActive && (
|
||||
<Divider label={t(`${i18nKey}.dividerLabel`)} />
|
||||
)}
|
||||
{!isActive && (
|
||||
<RequestRecording
|
||||
heading={t(`${i18nKey}.request.heading`)}
|
||||
body={t(`${i18nKey}.request.body`)}
|
||||
buttonLabel={t(`${i18nKey}.request.buttonLabel`)}
|
||||
handleRequest={handleRequest}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
import { RecordingStateToast } from './RecordingStateToast'
|
||||
import { ErrorAlertDialog } from './ErrorAlertDialog'
|
||||
|
||||
export const RecordingProvider = () => {
|
||||
return (
|
||||
<>
|
||||
<RecordingStateToast />
|
||||
<LimitReachedAlertDialog />
|
||||
<ErrorAlertDialog />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,143 +1,73 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { decodeNotificationDataReceived } from '@/features/notifications/utils'
|
||||
import { NotificationType } from '@/features/notifications/NotificationType'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { RiRecordCircleLine } from '@remixicon/react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useIsRecordingActive,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { LimitReachedAlertDialog } from './LimitReachedAlertDialog'
|
||||
import { useRoomMetadata } from '../hooks/useRoomMetadata'
|
||||
import { RecordingStatusIcon } from './RecordingStatusIcon'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
|
||||
export const RecordingStateToast = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'recordingStateToast',
|
||||
})
|
||||
const room = useRoomContext()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { openTranscript, openScreenRecording } = useSidePanel()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
FeatureFlags.Transcript
|
||||
)
|
||||
|
||||
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const isScreenRecordingActive = useIsRecordingActive(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
const {
|
||||
isStarted: isScreenRecordingStarted,
|
||||
isStarting: isScreenRecordingStarting,
|
||||
isActive: isScreenRecordingActive,
|
||||
} = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
|
||||
useEffect(() => {
|
||||
if (room.isRecording && recordingSnap.status == RecordingStatus.STOPPED) {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [room.isRecording])
|
||||
const {
|
||||
isStarted: isTranscriptStarted,
|
||||
isStarting: isTranscriptStarting,
|
||||
isActive: isTranscriptActive,
|
||||
} = useRecordingStatuses(RecordingMode.Transcript)
|
||||
|
||||
useEffect(() => {
|
||||
const handleDataReceived = (payload: Uint8Array) => {
|
||||
const notification = decodeNotificationDataReceived(payload)
|
||||
const isStarted = isScreenRecordingStarted || isTranscriptStarted
|
||||
const isStarting = isTranscriptStarting || isScreenRecordingStarting
|
||||
|
||||
if (!notification) return
|
||||
|
||||
switch (notification.type) {
|
||||
case NotificationType.TranscriptionStarted:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
break
|
||||
case NotificationType.TranscriptionStopped:
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
if (isAdminOrOwner) setIsAlertOpen(true)
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
break
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
if (isAdminOrOwner) setIsAlertOpen(true)
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const handleRecordingStatusChanged = (status: boolean) => {
|
||||
if (!status) {
|
||||
recordingStore.status = RecordingStatus.STOPPED
|
||||
} else if (recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING) {
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTED
|
||||
} else if (
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
) {
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTED
|
||||
} else {
|
||||
recordingStore.status = RecordingStatus.ANY_STARTED
|
||||
}
|
||||
}
|
||||
|
||||
room.on(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.DataReceived, handleDataReceived)
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room, recordingSnap, setIsAlertOpen, isAdminOrOwner])
|
||||
const metadata = useRoomMetadata()
|
||||
const isRecording = useIsRecording()
|
||||
|
||||
const key = useMemo(() => {
|
||||
switch (recordingSnap.status) {
|
||||
case RecordingStatus.TRANSCRIPT_STARTED:
|
||||
return 'transcript.started'
|
||||
case RecordingStatus.TRANSCRIPT_STARTING:
|
||||
return 'transcript.starting'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTED:
|
||||
return 'screenRecording.started'
|
||||
case RecordingStatus.SCREEN_RECORDING_STARTING:
|
||||
return 'screenRecording.starting'
|
||||
case RecordingStatus.ANY_STARTED:
|
||||
return 'any.started'
|
||||
default:
|
||||
return
|
||||
if (!metadata?.recording_status || !metadata?.recording_mode) {
|
||||
return undefined
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
if (!key)
|
||||
return isAdminOrOwner ? (
|
||||
<LimitReachedAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
aria-label="Recording limit exceeded"
|
||||
/>
|
||||
) : null
|
||||
if (!isStarting && !isStarted) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isStarted = key?.includes('started')
|
||||
let status = metadata.recording_status
|
||||
|
||||
if (isStarted && !isRecording) {
|
||||
status = 'starting'
|
||||
}
|
||||
|
||||
return `${metadata.recording_mode}.${status}`
|
||||
}, [metadata, isStarted, isStarting, isRecording])
|
||||
|
||||
if (!key) return null
|
||||
|
||||
const hasScreenRecordingAccessAndActive =
|
||||
isScreenRecordingActive && hasScreenRecordingAccess
|
||||
@@ -160,16 +90,10 @@ export const RecordingStateToast = () => {
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{isStarted ? (
|
||||
<RiRecordCircleLine
|
||||
size={20}
|
||||
className={css({
|
||||
animation: 'pulse_background 1s infinite',
|
||||
})}
|
||||
/>
|
||||
) : (
|
||||
<Spinner size={20} variant="dark" />
|
||||
)}
|
||||
<RecordingStatusIcon
|
||||
isStarted={isStarted}
|
||||
isTranscriptActive={isTranscriptActive}
|
||||
/>
|
||||
|
||||
{!hasScreenRecordingAccessAndActive && !hasTranscriptAccessAndActive && (
|
||||
<Text
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
|
||||
interface RecordingStatusIconProps {
|
||||
isStarted: boolean
|
||||
isTranscriptActive: boolean
|
||||
}
|
||||
|
||||
export const RecordingStatusIcon = ({
|
||||
isStarted,
|
||||
isTranscriptActive,
|
||||
}: RecordingStatusIconProps) => {
|
||||
if (!isStarted) {
|
||||
return <Spinner size={20} variant="dark" />
|
||||
}
|
||||
|
||||
if (isTranscriptActive) {
|
||||
return <span className="material-symbols">speech_to_text</span>
|
||||
}
|
||||
|
||||
return <span className="material-symbols">screen_record</span>
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Button, H, Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Spinner } from '@/primitives/Spinner.tsx'
|
||||
import { NotificationDuration } from '@/features/notifications/NotificationDuration'
|
||||
|
||||
interface RequestRecordingProps {
|
||||
heading: string
|
||||
body: string
|
||||
buttonLabel: string
|
||||
handleRequest: () => Promise<void>
|
||||
}
|
||||
|
||||
export const RequestRecording = ({
|
||||
heading,
|
||||
body,
|
||||
buttonLabel,
|
||||
handleRequest,
|
||||
}: RequestRecordingProps) => {
|
||||
const [isDisabled, setIsDisabled] = useState(false)
|
||||
const timeoutRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const onPress = async () => {
|
||||
setIsDisabled(true)
|
||||
|
||||
try {
|
||||
await handleRequest()
|
||||
} catch {
|
||||
setIsDisabled(false)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setIsDisabled(false)
|
||||
}, NotificationDuration.RECORDING_REQUESTED)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'neutral.50',
|
||||
borderRadius: '5px',
|
||||
border: '1px solid',
|
||||
borderColor: 'neutral.200',
|
||||
paddingY: '1rem',
|
||||
paddingX: '1rem',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
marginBottom: '1.5rem',
|
||||
})}
|
||||
>
|
||||
<HStack justify="start" alignItems="center" marginBottom="0.5rem">
|
||||
<span className="material-symbols">person_raised_hand</span>
|
||||
<H lvl={3} margin={false} padding={false}>
|
||||
{heading}
|
||||
</H>
|
||||
</HStack>
|
||||
<Text variant="smNote" wrap="pretty">
|
||||
{body}
|
||||
</Text>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1rem',
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
fullWidth
|
||||
onPress={onPress}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
{isDisabled && <Spinner size={24} />}
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
type RowPosition = 'first' | 'middle' | 'last' | 'single'
|
||||
|
||||
const BORDER_RADIUS_MAP: Record<RowPosition, string> = {
|
||||
first: '4px 4px 0 0',
|
||||
middle: '0',
|
||||
last: '0 0 4px 4px',
|
||||
single: '4px',
|
||||
} as const
|
||||
|
||||
interface RowWrapperProps {
|
||||
iconName: string
|
||||
children: ReactNode
|
||||
position?: RowPosition
|
||||
}
|
||||
|
||||
export const RowWrapper = ({
|
||||
iconName,
|
||||
children,
|
||||
position = 'middle',
|
||||
}: RowWrapperProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
borderRadius: BORDER_RADIUS_MAP[position],
|
||||
}}
|
||||
className={css({
|
||||
width: '100%',
|
||||
background: 'gray.100',
|
||||
paddingBlock: '0.5rem',
|
||||
paddingInline: '0',
|
||||
display: 'flex',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingInline: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{/* fixme - doesn't handle properly material-symbols */}
|
||||
<span className="material-icons">{iconName}</span>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
flex: 6,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
paddingInlineEnd: '8px',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,16 @@
|
||||
import { A, Button, Dialog, Div, H, P, Text } from '@/primitives'
|
||||
import { A, Div, H, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useHumanizeRecordingMaxDuration,
|
||||
useRecordingStatuses,
|
||||
} from '@/features/recording'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -20,59 +18,51 @@ import {
|
||||
useNotifyParticipants,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export const ScreenRecordingSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenRecording' })
|
||||
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
const keyPrefix = 'screenRecording'
|
||||
const { t } = useTranslation('rooms', { keyPrefix })
|
||||
|
||||
const [includeTranscript, setIncludeTranscript] = useState(false)
|
||||
|
||||
const hasFeatureWithoutAdminRights = useHasFeatureWithoutAdminRights(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const { selectedLanguageKey, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
useMutateRecording()
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
isAnotherModeStarted:
|
||||
recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
|
||||
isStarting:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
isStarted:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
isStopping:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
}
|
||||
}, [recordingSnap])
|
||||
const statuses = useRecordingStatuses(RecordingMode.ScreenRecording)
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
const { openTranscript } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
const handleRequestScreenRecording = async () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingRequested,
|
||||
})
|
||||
posthog.capture('screen-recording-requested', {})
|
||||
}
|
||||
|
||||
const handleScreenRecording = async () => {
|
||||
if (!roomId) {
|
||||
@@ -80,10 +70,10 @@ export const ScreenRecordingSidePanel = () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STOPPING
|
||||
if (statuses.isStarted || statuses.isStarting) {
|
||||
setIncludeTranscript(false)
|
||||
await stopRecording({ id: roomId })
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStopped,
|
||||
})
|
||||
@@ -92,30 +82,41 @@ export const ScreenRecordingSidePanel = () => {
|
||||
room.localParticipant
|
||||
)
|
||||
} else {
|
||||
await startRecordingRoom({
|
||||
const recordingOptions = {
|
||||
...(!isLanguageSetToAuto && {
|
||||
language: selectedLanguageKey,
|
||||
}),
|
||||
...(includeTranscript && { transcribe: true }),
|
||||
}
|
||||
|
||||
await startRecording({
|
||||
id: roomId,
|
||||
mode: RecordingMode.ScreenRecording,
|
||||
options: recordingOptions,
|
||||
})
|
||||
recordingStore.status = RecordingStatus.SCREEN_RECORDING_STARTING
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ScreenRecordingStarted,
|
||||
})
|
||||
posthog.capture('screen-recording-started', {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
console.error('Failed to handle recording:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey="notAdminOrOwner"
|
||||
helpArticle={data?.support?.help_article_recording}
|
||||
imagePath="/assets/intro-slider/4.png"
|
||||
handleRequest={handleRequestScreenRecording}
|
||||
isActive={statuses.isActive}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
@@ -128,130 +129,74 @@ export const ScreenRecordingSidePanel = () => {
|
||||
>
|
||||
<img
|
||||
src="/assets/intro-slider/4.png"
|
||||
alt={''}
|
||||
alt=""
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
minHeight: '250px',
|
||||
height: '250px',
|
||||
marginBottom: '1rem',
|
||||
marginTop: '-16px',
|
||||
'@media (max-height: 900px)': {
|
||||
height: 'auto',
|
||||
minHeight: 'auto',
|
||||
maxHeight: '25%',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
'@media (max-height: 770px)': {
|
||||
display: 'none',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
{statuses.isStarted ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stop.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="stop-screen-recording"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stopping.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('start.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('start.body', {
|
||||
duration_message: data?.recording?.max_duration
|
||||
? t('durationMessage', {
|
||||
max_duration: humanizeDuration(
|
||||
data?.recording?.max_duration,
|
||||
{
|
||||
language: i18n.language,
|
||||
}
|
||||
),
|
||||
})
|
||||
: '',
|
||||
})}{' '}
|
||||
{data?.support?.help_article_recording && (
|
||||
<A href={data.support.help_article_recording} target="_blank">
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleScreenRecording()}
|
||||
data-attr="start-screen-recording"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('start.button')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
<VStack gap={0} marginBottom={30}>
|
||||
<H lvl={1} margin={'sm'} fullWidth>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Text variant="body" fullWidth>
|
||||
{recordingMaxDuration
|
||||
? t('body', {
|
||||
max_duration: recordingMaxDuration,
|
||||
})
|
||||
: t('bodyWithoutMaxDuration')}{' '}
|
||||
{data?.support?.help_article_recording && (
|
||||
<A href={data.support.help_article_recording} target="_blank">
|
||||
{t('linkMore')}
|
||||
</A>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} marginBottom={40}>
|
||||
<RowWrapper iconName="cloud_download" position="first">
|
||||
<Text variant="sm">{t('details.destination')}</Text>
|
||||
</RowWrapper>
|
||||
<RowWrapper iconName="mail" position="last">
|
||||
<Text variant="sm">{t('details.receiver')}</Text>
|
||||
</RowWrapper>
|
||||
|
||||
<div className={css({ height: '15px' })} />
|
||||
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
marginLeft: '20px',
|
||||
})}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
isSelected={includeTranscript}
|
||||
onChange={setIncludeTranscript}
|
||||
isDisabled={statuses.isActive || isPendingToStart}
|
||||
>
|
||||
<Text variant="sm">{t('details.transcription')}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</VStack>
|
||||
<ControlsButton
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
handle={handleScreenRecording}
|
||||
statuses={statuses}
|
||||
isPendingToStart={isPendingToStart}
|
||||
isPendingToStop={isPendingToStop}
|
||||
openSidePanel={openTranscript}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { A, Button, Dialog, Div, H, LinkButton, P, Text } from '@/primitives'
|
||||
import { A, Button, Div, H, Text } from '@/primitives'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
|
||||
@@ -6,15 +6,12 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import {
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
useIsRecordingTransitioning,
|
||||
useStartRecording,
|
||||
useStopRecording,
|
||||
useHasFeatureWithoutAdminRights,
|
||||
useHumanizeRecordingMaxDuration,
|
||||
useRecordingStatuses,
|
||||
} from '../index'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ConnectionState, RoomEvent } from 'livekit-client'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -22,23 +19,35 @@ import {
|
||||
notifyRecordingSaveInProgress,
|
||||
} from '@/features/notifications'
|
||||
import posthog from 'posthog-js'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
import { Spinner } from '@/primitives/Spinner'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Checkbox } from '@/primitives/Checkbox.tsx'
|
||||
|
||||
import {
|
||||
useSettingsDialog,
|
||||
SettingsDialogExtendedKey,
|
||||
useTranscriptionLanguage,
|
||||
} from '@/features/settings'
|
||||
import { NoAccessView } from './NoAccessView'
|
||||
import { ControlsButton } from './ControlsButton'
|
||||
import { RowWrapper } from './RowWrapper'
|
||||
import { useMutateRecording } from '../hooks/useMutateRecording'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
export const TranscriptSidePanel = () => {
|
||||
const { data } = useConfig()
|
||||
const recordingMaxDuration = useHumanizeRecordingMaxDuration()
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'transcript' })
|
||||
const keyPrefix = 'transcript'
|
||||
const { t } = useTranslation('rooms', { keyPrefix })
|
||||
|
||||
const [isErrorDialogOpen, setIsErrorDialogOpen] = useState('')
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
const [includeScreenRecording, setIncludeScreenRecording] = useState(false)
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const { selectedLanguageKey, selectedLanguageLabel, isLanguageSetToAuto } =
|
||||
useTranscriptionLanguage()
|
||||
|
||||
const { openSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const hasTranscriptAccess = useHasRecordingAccess(
|
||||
RecordingMode.Transcript,
|
||||
@@ -52,40 +61,20 @@ export const TranscriptSidePanel = () => {
|
||||
|
||||
const roomId = useRoomId()
|
||||
|
||||
const { mutateAsync: startRecordingRoom, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => setIsErrorDialogOpen('start'),
|
||||
})
|
||||
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
|
||||
useMutateRecording()
|
||||
|
||||
const { mutateAsync: stopRecordingRoom, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => setIsErrorDialogOpen('stop'),
|
||||
})
|
||||
|
||||
const statuses = useMemo(() => {
|
||||
return {
|
||||
isAnotherModeStarted:
|
||||
recordingSnap.status == RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
isStarting: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTING,
|
||||
isStarted: recordingSnap.status == RecordingStatus.TRANSCRIPT_STARTED,
|
||||
isStopping: recordingSnap.status == RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
}
|
||||
}, [recordingSnap])
|
||||
|
||||
const isRecordingTransitioning = useIsRecordingTransitioning()
|
||||
const statuses = useRecordingStatuses(RecordingMode.Transcript)
|
||||
|
||||
const room = useRoomContext()
|
||||
const isRoomConnected = room.state == ConnectionState.Connected
|
||||
const { openScreenRecording } = useSidePanel()
|
||||
|
||||
useEffect(() => {
|
||||
const handleRecordingStatusChanged = () => {
|
||||
setIsLoading(false)
|
||||
}
|
||||
room.on(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.RecordingStatusChanged, handleRecordingStatusChanged)
|
||||
}
|
||||
}, [room])
|
||||
const handleRequestTranscription = async () => {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionRequested,
|
||||
})
|
||||
posthog.capture('transcript-requested', {})
|
||||
}
|
||||
|
||||
const handleTranscript = async () => {
|
||||
if (!roomId) {
|
||||
@@ -93,10 +82,10 @@ export const TranscriptSidePanel = () => {
|
||||
return
|
||||
}
|
||||
try {
|
||||
setIsLoading(true)
|
||||
if (room.isRecording) {
|
||||
await stopRecordingRoom({ id: roomId })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STOPPING
|
||||
if (statuses.isStarted || statuses.isStarting) {
|
||||
await stopRecording({ id: roomId })
|
||||
setIncludeScreenRecording(false)
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStopped,
|
||||
})
|
||||
@@ -105,8 +94,26 @@ export const TranscriptSidePanel = () => {
|
||||
room.localParticipant
|
||||
)
|
||||
} else {
|
||||
await startRecordingRoom({ id: roomId, mode: RecordingMode.Transcript })
|
||||
recordingStore.status = RecordingStatus.TRANSCRIPT_STARTING
|
||||
const recordingMode = includeScreenRecording
|
||||
? RecordingMode.ScreenRecording
|
||||
: RecordingMode.Transcript
|
||||
|
||||
const recordingOptions = {
|
||||
...(!isLanguageSetToAuto && {
|
||||
language: selectedLanguageKey,
|
||||
}),
|
||||
...(includeScreenRecording && {
|
||||
transcribe: true,
|
||||
original_mode: RecordingMode.Transcript,
|
||||
}),
|
||||
}
|
||||
|
||||
await startRecording({
|
||||
id: roomId,
|
||||
mode: recordingMode,
|
||||
options: recordingOptions,
|
||||
})
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.TranscriptionStarted,
|
||||
})
|
||||
@@ -114,18 +121,34 @@ export const TranscriptSidePanel = () => {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to handle transcript:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isDisabled = useMemo(
|
||||
() =>
|
||||
isLoading ||
|
||||
isRecordingTransitioning ||
|
||||
statuses.isAnotherModeStarted ||
|
||||
!isRoomConnected,
|
||||
[isLoading, isRecordingTransitioning, statuses, isRoomConnected]
|
||||
)
|
||||
if (hasFeatureWithoutAdminRights) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey="notAdminOrOwner"
|
||||
helpArticle={data?.support?.help_article_transcript}
|
||||
imagePath="/assets/intro-slider/3.png"
|
||||
handleRequest={handleRequestTranscription}
|
||||
isActive={statuses.isActive}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasTranscriptAccess) {
|
||||
return (
|
||||
<NoAccessView
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
i18nKey="premium"
|
||||
helpArticle={data?.support?.help_article_transcript}
|
||||
imagePath="/assets/intro-slider/3.png"
|
||||
handleRequest={handleRequestTranscription}
|
||||
isActive={statuses.isActive}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Div
|
||||
@@ -138,199 +161,101 @@ export const TranscriptSidePanel = () => {
|
||||
>
|
||||
<img
|
||||
src="/assets/intro-slider/3.png"
|
||||
alt={''}
|
||||
alt=""
|
||||
className={css({
|
||||
minHeight: '309px',
|
||||
minHeight: '250px',
|
||||
height: '250px',
|
||||
marginBottom: '1rem',
|
||||
marginTop: '-16px',
|
||||
'@media (max-height: 900px)': {
|
||||
height: 'auto',
|
||||
minHeight: 'auto',
|
||||
maxHeight: '25%',
|
||||
marginBottom: '0.75rem',
|
||||
},
|
||||
'@media (max-height: 770px)': {
|
||||
display: 'none',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{!hasTranscriptAccess ? (
|
||||
<>
|
||||
{hasFeatureWithoutAdminRights ? (
|
||||
<>
|
||||
<Text>{t('notAdminOrOwner.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('notAdminOrOwner.body')}
|
||||
<br />
|
||||
{data?.support?.help_article_transcript && (
|
||||
<A
|
||||
href={data.support.help_article_transcript}
|
||||
target="_blank"
|
||||
>
|
||||
{t('notAdminOrOwner.linkMore')}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text>{t('beta.heading')}</Text>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('beta.body')}{' '}
|
||||
{data?.support?.help_article_transcript && (
|
||||
<A
|
||||
href={data.support.help_article_transcript}
|
||||
target="_blank"
|
||||
>
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
{data?.transcript.form_beta_users && (
|
||||
<LinkButton
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
href={data?.transcript.form_beta_users}
|
||||
<VStack gap={0} marginBottom={30}>
|
||||
<H lvl={1} margin={'sm'}>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Text variant="body" fullWidth>
|
||||
{recordingMaxDuration
|
||||
? t('body', {
|
||||
max_duration: recordingMaxDuration,
|
||||
})
|
||||
: t('bodyWithoutMaxDuration')}{' '}
|
||||
{data?.support?.help_article_transcript && (
|
||||
<A href={data.support.help_article_transcript} target="_blank">
|
||||
{t('linkMore')}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
<VStack gap={0} marginBottom={40}>
|
||||
<RowWrapper iconName="article" position="first">
|
||||
<Text variant="sm">
|
||||
{data?.transcription_destination ? (
|
||||
<>
|
||||
{t('details.destination')}{' '}
|
||||
<A
|
||||
href={data.transcription_destination}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('beta.button')}
|
||||
</LinkButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStarted ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stop.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stop.body')}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="stop-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{t('stop.button')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statuses.isStopping || isPendingToStop ? (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('stopping.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap={'pretty'}
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('stopping.body')}
|
||||
</Text>
|
||||
<Spinner />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<H lvl={3} margin={false}>
|
||||
{t('start.heading')}
|
||||
</H>
|
||||
<Text
|
||||
variant="note"
|
||||
wrap="balance"
|
||||
centered
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
maxWidth: '90%',
|
||||
marginBottom: '2.5rem',
|
||||
marginTop: '0.25rem',
|
||||
})}
|
||||
>
|
||||
{t('start.body', {
|
||||
duration_message: data?.recording?.max_duration
|
||||
? t('durationMessage', {
|
||||
max_duration: humanizeDuration(
|
||||
data?.recording?.max_duration,
|
||||
{
|
||||
language: i18n.language,
|
||||
}
|
||||
),
|
||||
})
|
||||
: '',
|
||||
})}{' '}
|
||||
{data?.support?.help_article_transcript && (
|
||||
<A
|
||||
href={data.support.help_article_transcript}
|
||||
target="_blank"
|
||||
>
|
||||
{t('start.linkMore')}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => handleTranscript()}
|
||||
data-attr="start-transcript"
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
>
|
||||
{statuses.isStarting || isPendingToStart ? (
|
||||
<>
|
||||
<Spinner size={20} />
|
||||
{t('start.loading')}
|
||||
</>
|
||||
) : (
|
||||
t('start.button')
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog
|
||||
isOpen={!!isErrorDialogOpen}
|
||||
role="alertdialog"
|
||||
aria-label={t('alert.title')}
|
||||
>
|
||||
<P>{t(`alert.body.${isErrorDialogOpen}`)}</P>
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setIsErrorDialogOpen('')}
|
||||
{data.transcription_destination.replace('https://', '')}
|
||||
</A>
|
||||
</>
|
||||
) : (
|
||||
t('details.destinationUnknown')
|
||||
)}
|
||||
</Text>
|
||||
</RowWrapper>
|
||||
<RowWrapper iconName="mail">
|
||||
<Text variant="sm">{t('details.receiver')}</Text>
|
||||
</RowWrapper>
|
||||
<RowWrapper iconName="language" position="last">
|
||||
<Text variant="sm">{t('details.language')}</Text>
|
||||
<Text variant="sm">
|
||||
<Button
|
||||
variant="text"
|
||||
size="xs"
|
||||
onPress={() =>
|
||||
openSettingsDialog(SettingsDialogExtendedKey.TRANSCRIPTION)
|
||||
}
|
||||
>
|
||||
{selectedLanguageLabel}
|
||||
</Button>
|
||||
</Text>
|
||||
</RowWrapper>
|
||||
<div className={css({ height: '15px' })} />
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
marginLeft: '20px',
|
||||
})}
|
||||
>
|
||||
{t('alert.button')}
|
||||
</Button>
|
||||
</Dialog>
|
||||
<Checkbox
|
||||
size="sm"
|
||||
isSelected={includeScreenRecording}
|
||||
onChange={setIncludeScreenRecording}
|
||||
isDisabled={statuses.isActive || isPendingToStart}
|
||||
>
|
||||
<Text variant="sm">{t('details.recording')}</Text>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</VStack>
|
||||
<ControlsButton
|
||||
i18nKeyPrefix={keyPrefix}
|
||||
handle={handleTranscript}
|
||||
statuses={statuses}
|
||||
isPendingToStart={isPendingToStart}
|
||||
isPendingToStop={isPendingToStop}
|
||||
openSidePanel={openScreenRecording}
|
||||
/>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useMemo } from 'react'
|
||||
import humanizeDuration from 'humanize-duration'
|
||||
import i18n from 'i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const useHumanizeRecordingMaxDuration = () => {
|
||||
const { data } = useConfig()
|
||||
|
||||
return useMemo(() => {
|
||||
if (!data?.recording?.max_duration) return
|
||||
|
||||
return humanizeDuration(data?.recording?.max_duration, {
|
||||
language: i18n.language,
|
||||
})
|
||||
}, [data])
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export const useIsRecordingActive = (mode: RecordingMode) => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
switch (mode) {
|
||||
case RecordingMode.Transcript:
|
||||
return [
|
||||
RecordingStatus.TRANSCRIPT_STARTED,
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
].includes(recordingSnap.status)
|
||||
case RecordingMode.ScreenRecording:
|
||||
return [
|
||||
RecordingStatus.SCREEN_RECORDING_STARTED,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
].includes(recordingSnap.status)
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { RecordingStatus, recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useIsRecordingTransitioning = () => {
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const transitionalStates = [
|
||||
RecordingStatus.TRANSCRIPT_STARTING,
|
||||
RecordingStatus.TRANSCRIPT_STOPPING,
|
||||
RecordingStatus.SCREEN_RECORDING_STARTING,
|
||||
RecordingStatus.SCREEN_RECORDING_STOPPING,
|
||||
]
|
||||
|
||||
return transitionalStates.includes(recordingSnap.status)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useStartRecording, useStopRecording } from '@/features/recording'
|
||||
import { recordingStore } from '@/stores/recording'
|
||||
|
||||
export const useMutateRecording = () => {
|
||||
const { mutateAsync: startRecording, isPending: isPendingToStart } =
|
||||
useStartRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'start'
|
||||
},
|
||||
})
|
||||
const { mutateAsync: stopRecording, isPending: isPendingToStop } =
|
||||
useStopRecording({
|
||||
onError: () => {
|
||||
recordingStore.isErrorDialogOpen = 'stop'
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
startRecording,
|
||||
isPendingToStart,
|
||||
stopRecording,
|
||||
isPendingToStop,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { useRoomMetadata } from './useRoomMetadata'
|
||||
import { useMemo } from 'react'
|
||||
import { useIsRecording } from '@livekit/components-react'
|
||||
|
||||
export enum RecordingStatus {
|
||||
Starting = 'starting',
|
||||
Started = 'started',
|
||||
Saving = 'saving',
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = [
|
||||
RecordingStatus.Starting,
|
||||
RecordingStatus.Started,
|
||||
RecordingStatus.Saving,
|
||||
] as const
|
||||
|
||||
export interface RecordingStatuses {
|
||||
isAnotherModeStarted: boolean
|
||||
isStarting: boolean
|
||||
isStarted: boolean
|
||||
isSaving: boolean
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export const useRecordingStatuses = (
|
||||
mode: RecordingMode
|
||||
): RecordingStatuses => {
|
||||
const metadata = useRoomMetadata()
|
||||
const isRecording = useIsRecording()
|
||||
|
||||
return useMemo(() => {
|
||||
if (metadata && metadata?.recording_mode === mode) {
|
||||
return {
|
||||
isAnotherModeStarted: false,
|
||||
isStarting:
|
||||
metadata.recording_status === RecordingStatus.Starting ||
|
||||
(metadata.recording_status === RecordingStatus.Started &&
|
||||
!isRecording),
|
||||
isStarted:
|
||||
metadata.recording_status === RecordingStatus.Started && isRecording,
|
||||
isSaving: metadata.recording_status === RecordingStatus.Saving,
|
||||
isActive: ACTIVE_STATUSES.includes(
|
||||
metadata.recording_status as RecordingStatus
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const isAnotherModeStarted =
|
||||
!!metadata?.recording_mode &&
|
||||
metadata?.recording_mode !== mode &&
|
||||
ACTIVE_STATUSES.includes(metadata.recording_status as RecordingStatus)
|
||||
|
||||
return {
|
||||
isAnotherModeStarted,
|
||||
isStarting: false,
|
||||
isStarted: false,
|
||||
isSaving: false,
|
||||
isActive: false,
|
||||
}
|
||||
}, [mode, metadata, isRecording])
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useRoomInfo } from '@livekit/components-react'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export const useRoomMetadata = () => {
|
||||
const { metadata } = useRoomInfo()
|
||||
return useMemo(() => {
|
||||
if (metadata) {
|
||||
try {
|
||||
return JSON.parse(metadata)
|
||||
} catch (error) {
|
||||
console.error('Failed to parse room metadata:', error)
|
||||
return undefined
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}, [metadata])
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
// hooks
|
||||
export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
|
||||
export { useIsRecordingTransitioning } from './hooks/useIsRecordingTransitioning'
|
||||
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
|
||||
export { useIsRecordingActive } from './hooks/useIsRecordingActive'
|
||||
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
|
||||
export { useHumanizeRecordingMaxDuration } from './hooks/useHumanizeRecordingMaxDuration'
|
||||
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
|
||||
|
||||
// api
|
||||
export { useStartRecording } from './api/startRecording'
|
||||
@@ -11,7 +11,7 @@ export { useStopRecording } from './api/stopRecording'
|
||||
export { RecordingMode, RecordingStatus } from './types'
|
||||
|
||||
// components
|
||||
export { RecordingStateToast } from './components/RecordingStateToast'
|
||||
export { RecordingProvider } from './components/RecordingProvider'
|
||||
export { TranscriptSidePanel } from './components/TranscriptSidePanel'
|
||||
export { ScreenRecordingSidePanel } from './components/ScreenRecordingSidePanel'
|
||||
|
||||
|
||||
@@ -3,17 +3,13 @@ import { css } from '@/styled-system/css'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactNode } from 'react'
|
||||
import { RiFileTextFill, RiLiveFill } from '@remixicon/react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
useHasRecordingAccess,
|
||||
TranscriptSidePanel,
|
||||
ScreenRecordingSidePanel,
|
||||
useIsRecordingActive,
|
||||
} from '@/features/recording'
|
||||
import { FeatureFlags } from '@/features/analytics/enums'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export interface ToolsButtonProps {
|
||||
@@ -21,8 +17,6 @@ export interface ToolsButtonProps {
|
||||
title: string
|
||||
description: string
|
||||
onPress: () => void
|
||||
isBetaFeature?: boolean
|
||||
isActive?: boolean
|
||||
}
|
||||
|
||||
const ToolButton = ({
|
||||
@@ -30,8 +24,6 @@ const ToolButton = ({
|
||||
title,
|
||||
description,
|
||||
onPress,
|
||||
isBetaFeature = false,
|
||||
isActive = false,
|
||||
}: ToolsButtonProps) => {
|
||||
return (
|
||||
<RACButton
|
||||
@@ -42,9 +34,9 @@ const ToolButton = ({
|
||||
justifyContent: 'start',
|
||||
paddingY: '0.5rem',
|
||||
paddingX: '0.75rem 1.5rem',
|
||||
borderRadius: '5px',
|
||||
gap: '1.25rem',
|
||||
borderRadius: '30px',
|
||||
width: 'full',
|
||||
backgroundColor: 'gray.50',
|
||||
textAlign: 'start',
|
||||
'&[data-hovered]': {
|
||||
backgroundColor: 'primary.50',
|
||||
@@ -55,59 +47,47 @@ const ToolButton = ({
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
height: '50px',
|
||||
minWidth: '50px',
|
||||
height: '40px',
|
||||
minWidth: '40px',
|
||||
borderRadius: '25px',
|
||||
backgroundColor: 'primary.800',
|
||||
marginRight: '0.75rem',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
background: 'primary.800',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
{isBetaFeature && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
backgroundColor: 'primary.50',
|
||||
color: 'primary.800',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
borderRadius: '4px',
|
||||
paddingX: '4px',
|
||||
paddingBottom: '1px',
|
||||
bottom: -8,
|
||||
right: -8,
|
||||
})}
|
||||
>
|
||||
BETA
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Text
|
||||
margin={false}
|
||||
as="h3"
|
||||
className={css({ display: 'flex', gap: 0.25 })}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0.25,
|
||||
fontWeight: 'semibold',
|
||||
})}
|
||||
>
|
||||
{title}
|
||||
{isActive && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primary.500',
|
||||
height: '10px',
|
||||
width: '10px',
|
||||
marginTop: '5px',
|
||||
borderRadius: '100%',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Text>
|
||||
<Text as="p" variant="smNote" wrap="pretty">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginLeft: 'auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<span className="material-symbols">chevron_forward</span>
|
||||
</div>
|
||||
</RACButton>
|
||||
)
|
||||
}
|
||||
@@ -122,14 +102,7 @@ export const Tools = () => {
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
|
||||
const isTranscriptActive = useIsRecordingActive(RecordingMode.Transcript)
|
||||
|
||||
const hasScreenRecordingAccess = useHasRecordingAccess(
|
||||
RecordingMode.ScreenRecording,
|
||||
FeatureFlags.ScreenRecording
|
||||
)
|
||||
|
||||
const isScreenRecordingActive = useIsRecordingActive(
|
||||
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
|
||||
@@ -150,6 +123,7 @@ export const Tools = () => {
|
||||
flexGrow={1}
|
||||
flexDirection="column"
|
||||
alignItems="start"
|
||||
gap={0.5}
|
||||
>
|
||||
<Text
|
||||
variant="note"
|
||||
@@ -157,8 +131,8 @@ export const Tools = () => {
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
paddingX: '0.75rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
margin="md"
|
||||
>
|
||||
{t('body')}{' '}
|
||||
{data?.support?.help_article_more_tools && (
|
||||
@@ -172,22 +146,18 @@ export const Tools = () => {
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiFileTextFill size={24} color="white" />}
|
||||
icon={<span className="material-symbols">speech_to_text</span>}
|
||||
title={t('tools.transcript.title')}
|
||||
description={t('tools.transcript.body')}
|
||||
onPress={() => openTranscript()}
|
||||
isBetaFeature
|
||||
isActive={isTranscriptActive}
|
||||
/>
|
||||
)}
|
||||
{hasScreenRecordingAccess && (
|
||||
{isScreenRecordingEnabled && (
|
||||
<ToolButton
|
||||
icon={<RiLiveFill size={24} color="white" />}
|
||||
icon={<span className="material-symbols">mode_standby</span>}
|
||||
title={t('tools.screenRecording.title')}
|
||||
description={t('tools.screenRecording.body')}
|
||||
onPress={() => openScreenRecording()}
|
||||
isBetaFeature
|
||||
isActive={isScreenRecordingActive}
|
||||
/>
|
||||
)}
|
||||
</Div>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { FocusLayout } from '../components/FocusLayout'
|
||||
import { ParticipantTile } from '../components/ParticipantTile'
|
||||
import { SidePanel } from '../components/SidePanel'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RecordingStateToast } from '@/features/recording'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
@@ -261,7 +261,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
<ConnectionStateToast />
|
||||
<RecordingStateToast />
|
||||
<RecordingProvider />
|
||||
<SettingsDialogProvider />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -16,9 +16,11 @@ import { NotificationsTab } from './tabs/NotificationsTab'
|
||||
import { GeneralTab } from './tabs/GeneralTab'
|
||||
import { AudioTab } from './tabs/AudioTab'
|
||||
import { VideoTab } from './tabs/VideoTab'
|
||||
import { TranscriptionTab } from './tabs/TranscriptionTab'
|
||||
import { useRef } from 'react'
|
||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
const tabsStyle = css({
|
||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||
@@ -58,6 +60,8 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
const dialogEl = useRef<HTMLDivElement>(null)
|
||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
return (
|
||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||
<Tabs
|
||||
@@ -100,6 +104,13 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
|
||||
</Tab>
|
||||
{isAdminOrOwner && (
|
||||
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
|
||||
<span className="material-symbols">speech_to_text</span>
|
||||
{isWideScreen &&
|
||||
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
|
||||
</Tab>
|
||||
)}
|
||||
</TabList>
|
||||
</div>
|
||||
<div className={tabPanelContainerStyle}>
|
||||
@@ -111,6 +122,8 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
|
||||
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
|
||||
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
|
||||
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
|
||||
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
|
||||
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
|
||||
</div>
|
||||
</Tabs>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { Field, H } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RecordingLanguage, recordingStore } from '@/stores/recording'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useTranscriptionLanguage } from '@/features/settings'
|
||||
|
||||
export type TranscriptionTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
export const TranscriptionTab = ({ id }: TranscriptionTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'transcription' })
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const { languageOptions } = useTranscriptionLanguage()
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('heading')}</H>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('language.label')}
|
||||
items={languageOptions}
|
||||
selectedKey={recordingSnap.language}
|
||||
onSelectionChange={(lang) => {
|
||||
recordingStore.language = lang as RecordingLanguage
|
||||
}}
|
||||
/>
|
||||
</TabPanel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMemo } from 'react'
|
||||
import { RecordingLanguage, recordingStore } from '@/stores/recording'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio/index'
|
||||
|
||||
export const useTranscriptionLanguage = () => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'transcription' })
|
||||
|
||||
const recordingSnap = useSnapshot(recordingStore)
|
||||
|
||||
const languages = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: RecordingLanguage.FRENCH,
|
||||
label: t('language.options.french'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.ENGLISH,
|
||||
label: t('language.options.english'),
|
||||
},
|
||||
{
|
||||
key: RecordingLanguage.AUTOMATIC,
|
||||
label: t('language.options.auto'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
)
|
||||
|
||||
const languageOptions = useMemo(() => {
|
||||
return languages.map((i) => ({
|
||||
key: i.key,
|
||||
value: i.key,
|
||||
label: i.label,
|
||||
}))
|
||||
}, [languages])
|
||||
|
||||
const { selectedLanguageKey, selectedLanguageLabel } = useMemo(() => {
|
||||
const selectedLanguageLabel = languages.find(
|
||||
(option) => option.key === recordingSnap.language
|
||||
)?.label
|
||||
|
||||
const selectedLanguageKey = recordingSnap.language
|
||||
|
||||
return { selectedLanguageKey, selectedLanguageLabel }
|
||||
}, [recordingSnap.language, languages])
|
||||
|
||||
return {
|
||||
languageOptions,
|
||||
selectedLanguageKey,
|
||||
selectedLanguageLabel,
|
||||
isLanguageSetToAuto: selectedLanguageKey === RecordingLanguage.AUTOMATIC,
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,7 @@
|
||||
export { SettingsButton } from './components/SettingsButton'
|
||||
export { SettingsDialog } from './components/SettingsDialog'
|
||||
|
||||
export { useTranscriptionLanguage } from './hook/useTranscriptionLanguage'
|
||||
export { useSettingsDialog } from './hook/useSettingsDialog'
|
||||
|
||||
export { SettingsDialogExtendedKey } from './type.ts'
|
||||
|
||||
@@ -4,4 +4,5 @@ export enum SettingsDialogExtendedKey {
|
||||
VIDEO = 'video',
|
||||
GENERAL = 'general',
|
||||
NOTIFICATIONS = 'notifications',
|
||||
TRANSCRIPTION = 'transcription',
|
||||
}
|
||||
|
||||
@@ -13,29 +13,6 @@ import { LoginButton } from '@/components/LoginButton'
|
||||
|
||||
import { useLoginHint } from '@/hooks/useLoginHint'
|
||||
|
||||
const BetaBadge = () => (
|
||||
<span
|
||||
className={css({
|
||||
content: '"Beta"',
|
||||
display: 'block',
|
||||
letterSpacing: '-0.02rem',
|
||||
padding: '0 0.25rem',
|
||||
backgroundColor: '#E8EDFF',
|
||||
color: '#0063CB',
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
margin: '0 0 0.9375rem 0.3125rem',
|
||||
lineHeight: '1rem',
|
||||
borderRadius: '4px',
|
||||
width: 'fit-content',
|
||||
height: 'fit-content',
|
||||
marginTop: { base: '10px', sm: '5px' },
|
||||
})}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)
|
||||
|
||||
const Logo = () => (
|
||||
<img
|
||||
src="/assets/logo.svg"
|
||||
@@ -158,7 +135,6 @@ export const Header = () => {
|
||||
/>
|
||||
<HStack gap={0}>
|
||||
<Logo />
|
||||
<BetaBadge />
|
||||
</HStack>
|
||||
</Link>
|
||||
</Stack>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"heading": "Beim Laden der Seite ist ein Fehler aufgetreten"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
|
||||
"context": "Ihr Feedback prägt das Produkt!",
|
||||
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
"transcript": {
|
||||
"started": "{{name}} hat die Transkription des Treffens gestartet.",
|
||||
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
|
||||
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
|
||||
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"requested": "{{name}} will die Transkription starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
|
||||
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
|
||||
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert."
|
||||
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"requested": "{{name}} will die Aufnahme starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
@@ -46,5 +48,6 @@
|
||||
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald sie fertig ist.",
|
||||
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald sie fertig ist."
|
||||
}
|
||||
}
|
||||
},
|
||||
"openMenu": "Menü öffnen"
|
||||
}
|
||||
|
||||
@@ -260,8 +260,8 @@
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
"chat": "Nachrichten im Chat",
|
||||
"transcript": "Transkription",
|
||||
"screenRecording": "Aufzeichnung",
|
||||
"transcript": "Transkribieren",
|
||||
"screenRecording": "Aufzeichnen",
|
||||
"admin": "Admin-Einstellungen",
|
||||
"tools": "Weitere Tools",
|
||||
"info": "Meeting-Informationen"
|
||||
@@ -270,8 +270,8 @@
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
"chat": "Nachrichten",
|
||||
"transcript": "Transkription",
|
||||
"screenRecording": "Aufzeichnung",
|
||||
"transcript": "transkribieren",
|
||||
"screenRecording": "aufzeichnen",
|
||||
"admin": "Admin-Einstellungen",
|
||||
"tools": "Weitere Tools",
|
||||
"info": "Meeting-Informationen"
|
||||
@@ -286,12 +286,12 @@
|
||||
"moreLink": "Mehr erfahren",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transkription",
|
||||
"body": "Führen Sie ein schriftliches Protokoll Ihres Meetings."
|
||||
"title": "Transkribieren",
|
||||
"body": "Das Gespräch aufzeichnen."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Aufzeichnung",
|
||||
"body": "Zeichnen Sie Ihr Meeting auf, um es später erneut anzusehen."
|
||||
"title": "Aufzeichnen",
|
||||
"body": "Das Meeting aufzeichnen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,74 +310,99 @@
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "Dieses Gespräch transkribieren",
|
||||
"body": "Dieses Gespräch automatisch transkribieren {{duration_message}} und die Zusammenfassung in Docs erhalten.",
|
||||
"button": "Transkription starten",
|
||||
"loading": "Transkription wird gestartet",
|
||||
"linkMore": "Mehr erfahren"
|
||||
"heading": "Transkribieren Sie Ihr Meeting mit dem KI-Assistenten",
|
||||
"body": "Transkribieren Sie bis zu {{max_duration}} Meetingdauer.",
|
||||
"bodyWithoutMaxDuration": "Transkribieren Sie Ihr Meeting ohne Begrenzung.",
|
||||
"details": {
|
||||
"receiver": "Das Transkript wird an den Organisator und die Mitorganisatoren gesendet.",
|
||||
"destination": "Ein neues Dokument wird erstellt auf",
|
||||
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
||||
"language": "Meeting-Sprache:",
|
||||
"recording": "Auch eine Aufzeichnung starten"
|
||||
},
|
||||
"button": {
|
||||
"start": "Meeting transkribieren starten",
|
||||
"stop": "Meeting transkribieren beenden",
|
||||
"saving": "Speichern…",
|
||||
"starting": "Wird gestartet…",
|
||||
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
|
||||
},
|
||||
"linkMore": "Mehr erfahren",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Zugriff eingeschränkt",
|
||||
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Transkription (Beta) starten.",
|
||||
"linkMore": "Mehr erfahren"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Transkription läuft...",
|
||||
"body": "Die Transkription deines Meetings läuft. Du erhältst das Ergebnis per E-Mail, sobald das Meeting beendet ist.",
|
||||
"button": "Transkription stoppen"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Daten werden gespeichert…",
|
||||
"body": "Sie können das Meeting verlassen, wenn Sie möchten; die Aufzeichnung wird automatisch beendet."
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Werde Beta-Tester",
|
||||
"body": "Zeichne dein Meeting auf. Du erhältst eine Zusammenfassung per E-Mail nach dem Meeting.",
|
||||
"button": "Anmelden"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transkription fehlgeschlagen",
|
||||
"body": {
|
||||
"stop": "Die Transkription konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
|
||||
"start": "Die Transkription konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
|
||||
"linkMore": "Mehr erfahren",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
|
||||
},
|
||||
"button": "OK"
|
||||
"request": {
|
||||
"heading": "Anfrage an Moderator",
|
||||
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfordern"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(begrenzt auf {{max_duration}}) "
|
||||
"premium": {
|
||||
"heading": "Premium-Funktion",
|
||||
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn Ihre E-Mail-Adresse nicht autorisiert ist, kontaktieren Sie bitte den Support, um Zugriff zu erhalten.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Anfrage an Moderator",
|
||||
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfordern"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Dieses Gespräch aufzeichnen",
|
||||
"body": "Zeichne dieses Gespräch auf, um es später anzusehen {{duration_message}}. Du erhältst die Videoaufnahme per E-Mail.",
|
||||
"button": "Aufzeichnung starten",
|
||||
"heading": "Diesen Anruf für später aufzeichnen",
|
||||
"body": "Zeichnen Sie bis zu {{max_duration}} des Meetings auf.",
|
||||
"bodyWithoutMaxDuration": "Zeichnen Sie Ihr Meeting unbegrenzt auf.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"details": {
|
||||
"receiver": "Die Aufzeichnung wird an den Organisator und die Mitorganisatoren gesendet.",
|
||||
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
|
||||
"loading": "Aufzeichnung wird gestartet",
|
||||
"linkMore": "Mehr erfahren"
|
||||
"linkMore": "Mehr erfahren",
|
||||
"transcription": "Diese Aufzeichnung transkribieren"
|
||||
},
|
||||
"button": {
|
||||
"start": "Meeting-Aufzeichnung starten",
|
||||
"stop": "Meeting-Aufzeichnung beenden",
|
||||
"saving": "Wird gespeichert…",
|
||||
"starting": "Wird gestartet…",
|
||||
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Zugriff eingeschränkt",
|
||||
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine videoaufnahme (Beta) starten.",
|
||||
"linkMore": "Mehr erfahren"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Daten werden gespeichert…",
|
||||
"body": "Sie können das Meeting verlassen, wenn Sie möchten; die Aufzeichnung wird automatisch beendet."
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Aufzeichnung läuft…",
|
||||
"body": "Du erhältst das Ergebnis per E-Mail, sobald die Aufzeichnung abgeschlossen ist.",
|
||||
"button": "Aufzeichnung stoppen"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Aufzeichnung fehlgeschlagen",
|
||||
"body": {
|
||||
"stop": "Die Aufzeichnung konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
|
||||
"start": "Die Aufzeichnung konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
|
||||
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Videoaufnahme (Beta) starten.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Nur der Ersteller der Besprechung oder ein Administrator kann die Aufzeichnung starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
|
||||
},
|
||||
"button": "OK"
|
||||
"request": {
|
||||
"heading": "Aufnahme anfordern",
|
||||
"body": "Der Moderator wird benachrichtigt und kann die Aufnahme für Sie starten.",
|
||||
"buttonLabel": "Anfrage senden"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(begrenzt auf {{max_duration}})"
|
||||
},
|
||||
"errorRecordingAlertDialog": {
|
||||
"title": "Aufzeichnung fehlgeschlagen",
|
||||
"body": {
|
||||
"stop": "Die Aufzeichnung konnte nicht gestoppt werden. Bitte versuche es in einem Moment erneut.",
|
||||
"start": "Die Aufzeichnung konnte nicht gestartet werden. Bitte versuche es in einem Moment erneut."
|
||||
},
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Diese Einstellungen für Organisatoren ermöglichen dir die Kontrolle über dein Meeting. Nur Organisatoren haben Zugriff auf diese Optionen.",
|
||||
"access": {
|
||||
@@ -496,7 +521,7 @@
|
||||
"starting": "Transkription wird gestartet",
|
||||
"stopping": "Transkription wird gestoppt"
|
||||
},
|
||||
"screenRecording": {
|
||||
"screen_recording": {
|
||||
"started": "Aufzeichnung läuft",
|
||||
"starting": "Aufzeichnung wird gestartet",
|
||||
"stopping": "Aufzeichnung wird gestoppt"
|
||||
|
||||
@@ -68,6 +68,17 @@
|
||||
},
|
||||
"permissionsRequired": "Berechtigungen erforderlich"
|
||||
},
|
||||
"transcription": {
|
||||
"heading": "Transkription",
|
||||
"language": {
|
||||
"label": "Besprechungssprache",
|
||||
"options": {
|
||||
"french": "Französisch (fr)",
|
||||
"english": "Englisch (en)",
|
||||
"auto": "Automatisch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Tonbenachrichtigungen",
|
||||
"label": "Tonbenachrichtigungen für",
|
||||
@@ -100,6 +111,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Allgemein",
|
||||
"notifications": "Benachrichtigungen"
|
||||
"notifications": "Benachrichtigungen",
|
||||
"transcription": "Transkription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Verpflichtungen und Verantwortlichkeiten von DINUM",
|
||||
"content": "DINUM ist für die allgemeine Verwaltung des Dienstes verantwortlich. Sie stellt den Bediensteten alle erforderlichen Informationen zur Verfügung, um die Nutzung zu erleichtern. Sie bietet technische und funktionale Unterstützung, um den ordnungsgemäßen Betrieb des Dienstes sicherzustellen. Sie informiert die Nutzer auf jede zumutbare Weise über jede Schwierigkeit, die diesen ordnungsgemäßen Betrieb beeinträchtigen könnte.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Sicherheit und Plattformzugang",
|
||||
"content": "DINUM verpflichtet sich, Visio zu sichern, insbesondere durch alle notwendigen Maßnahmen zur Gewährleistung der Sicherheit und Vertraulichkeit der bereitgestellten Informationen.",
|
||||
"title": "7.1. Verfügbarkeitsniveau",
|
||||
"paragraphs": [
|
||||
"DINUM verpflichtet sich, die notwendigen und angemessenen Mittel bereitzustellen, um einen kontinuierlichen Zugang zu Visio zu gewährleisten.",
|
||||
"DINUM behält sich das Recht vor, den Dienst aus Wartungsgründen oder aus anderen als notwendig erachteten Gründen ohne Vorankündigung zu ändern, zu modifizieren oder auszusetzen.",
|
||||
"DINUM behält sich das Recht vor, ein Nutzerkonto des Dienstes zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger strafrechtlicher und zivilrechtlicher Maßnahmen, die gegen den Nutzer ergriffen werden könnten."
|
||||
"Der Dienst ist 24/7 verfügbar, vorbehaltlich Wartungszeiten.",
|
||||
"DINUM verfolgt ein jährliches Verfügbarkeitsziel von 99,5 % (ohne geplante Ausfälle). Bei einem Vorfall oder einer Wartung strebt sie eine Wiederherstellungszeit von 72 Stunden außerhalb der Geschäftszeiten an.",
|
||||
"Im Rahmen der Wartung der Ausführungsumgebung des Dienstes behält sich DINUM das Recht vor, den Betrieb des Dienstes vorübergehend auszusetzen. Sie informiert die Nutzer mindestens 48 Stunden im Voraus. In dringenden Fällen kann diese Aussetzung ohne Vorankündigung erfolgen.",
|
||||
"Diese außergewöhnlichen Stillstände können beispielsweise für Maßnahmen zur Datenverwaltung im Back Office, Produktionsbereitstellungen oder Architekturänderungen erforderlich sein.",
|
||||
"Eine Nichtverfügbarkeit des Dienstes begründet keinerlei Anspruch auf Entschädigung."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2. Open Source und Lizenzen",
|
||||
"content": "Der Quellcode von Visio ist frei und hier verfügbar: https://github.com/suitenumerique/meet\nDie von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
|
||||
"title": "7.2. Sicherheit des Dienstes",
|
||||
"paragraphs": [
|
||||
"DINUM ergreift alle erforderlichen Vorsichtsmaßnahmen, um die Sicherheit der Plattform und der im Rahmen des Dienstes eingesetzten Werkzeuge zu gewährleisten, insbesondere in Bezug auf den Zugang zum Dienst, die Verwaltung der Nutzerkonten und die Verarbeitung der erhobenen Daten."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Support-Management",
|
||||
"paragraphs": [
|
||||
"DINUM leistet First-Level-Support für die Nutzer, ausschließlich zu den technischen Teilen des Dienstes.",
|
||||
"Dieser Support ist über unser Online-Kontaktformular erreichbar.",
|
||||
"Die angestrebte Antwortzeit auf Anfragen beträgt zwei Werktage ab Absenden; dieser Zeitraum kann je nach Komplexität und Anzahl der zu bearbeitenden Anfragen variieren."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Kontrolle der Dienstenutzung",
|
||||
"paragraphs": [
|
||||
"DINUM behält sich das Recht vor, ein Agentenkonto zu sperren oder zu löschen, das gegen diese Nutzungsbedingungen verstoßen hat, unbeschadet etwaiger straf- und zivilrechtlicher Maßnahmen gegen den betreffenden Agenten.",
|
||||
"Die Sperrung oder Aufhebung eines oder mehrerer Konten begründet keinen Anspruch auf irgendeine Entschädigung."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source und Lizenzen",
|
||||
"content": "Der Quellcode des Dienstes ist frei und hier verfügbar: https://github.com/suitenumerique/meet. Die von DINUM angebotenen Inhalte stehen unter einer Open License, mit Ausnahme von Logos sowie ikonografischen und fotografischen Darstellungen, die möglicherweise eigenen Lizenzen unterliegen."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"heading": "An error occurred while loading the page"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Product under development — your input matters!",
|
||||
"context": "Your feedback shapes the product!",
|
||||
"cta": "Share your feedback - new tab"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
"transcript": {
|
||||
"started": "{{name}} started the meeting transcription.",
|
||||
"stopped": "{{name}} stopped the meeting transcription.",
|
||||
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved."
|
||||
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
|
||||
"requested": "{{name}} wants to start the meeting transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} started the meeting recording.",
|
||||
"stopped": "{{name}} stopped the meeting recording.",
|
||||
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved."
|
||||
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
|
||||
"requested": "{{name}} wants to start the meeting recording."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
@@ -46,5 +48,6 @@
|
||||
"message": "Your recording is being saved! We’ll send a notification to <strong>{{email}}</strong> as soon as it’s ready.",
|
||||
"default": "Your recording is being saved! We’ll send a notification to your email as soon as it’s ready."
|
||||
}
|
||||
}
|
||||
},
|
||||
"openMenu": "Open menu"
|
||||
}
|
||||
|
||||
@@ -260,8 +260,8 @@
|
||||
"participants": "Participants",
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat",
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Recording",
|
||||
"transcript": "Transcribe",
|
||||
"screenRecording": "Record",
|
||||
"admin": "Admin settings",
|
||||
"tools": "More tools",
|
||||
"info": "Meeting information"
|
||||
@@ -270,8 +270,8 @@
|
||||
"participants": "participants",
|
||||
"effects": "effects",
|
||||
"chat": "messages",
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "recording",
|
||||
"transcript": "transcribe",
|
||||
"screenRecording": "record",
|
||||
"admin": "admin settings",
|
||||
"tools": "more tools",
|
||||
"info": "meeting information"
|
||||
@@ -286,12 +286,12 @@
|
||||
"moreLink": "Learn more",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcription",
|
||||
"body": "Keep a written record of your meeting."
|
||||
"title": "Transcribe",
|
||||
"body": "Record the conversation."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Recording",
|
||||
"body": "Record your meeting to watch it again whenever you like."
|
||||
"title": "Record",
|
||||
"body": "Record the meeting."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,74 +310,99 @@
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "Transcribe this call",
|
||||
"body": "Automatically transcribe this call {{duration_message}} and receive the summary in Docs.",
|
||||
"button": "Start transcription",
|
||||
"loading": "Transcription starting",
|
||||
"linkMore": "Learn more"
|
||||
"heading": "Transcribe your meeting with the AI Assistant",
|
||||
"body": "Transcribe up to {{max_duration}} of meeting time.",
|
||||
"bodyWithoutMaxDuration": "Transcribe your meeting without limits.",
|
||||
"details": {
|
||||
"receiver": "The transcript will be sent to the host and co-hosts.",
|
||||
"destination": "A new document will be created on",
|
||||
"destinationUnknown": "A new document will be created",
|
||||
"language": "Meeting language:",
|
||||
"recording": "Also start a recording"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start transcribing the meeting",
|
||||
"stop": "Stop transcribing the meeting",
|
||||
"saving": "Saving…",
|
||||
"starting": "Starting…",
|
||||
"anotherModeStarted": "Screen recording is running. Switch panels and stop it."
|
||||
},
|
||||
"linkMore": "Learn more",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Restricted Access",
|
||||
"body": "For security reasons, only the meeting creator or an admin can start a transcription (beta).",
|
||||
"linkMore": "Learn more"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Transcription in progress...",
|
||||
"body": "The transcription of your meeting is in progress. You will receive the result by email once the meeting is finished.",
|
||||
"button": "Stop transcription"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Saving your data…",
|
||||
"body": "You can leave the meeting if you wish; the recording will finish automatically."
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Become a beta tester",
|
||||
"body": "Record your meeting for later. You will receive a summary by email once the meeting is finished.",
|
||||
"button": "Sign up"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transcription Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the transcription. Please try again in a moment.",
|
||||
"start": "We were unable to start the transcription. Please try again in a moment."
|
||||
"linkMore": "Learn more",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "Login Required",
|
||||
"body": "Only the meeting creator or an admin can start a transcription. Log in to verify your permissions."
|
||||
},
|
||||
"button": "OK"
|
||||
"request": {
|
||||
"heading": "Request Transcription",
|
||||
"body": "The host will be notified and can enable transcription for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(limited to {{max_duration}}) "
|
||||
"premium": {
|
||||
"heading": "Premium feature",
|
||||
"body": "This feature is reserved for public agents. If your email address is not authorized, please contact support to get access.",
|
||||
"linkMore": "Learn more",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "You are not logged in!",
|
||||
"body": "You must be logged in to use this feature. Please log in, then try again."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Request Transcription",
|
||||
"body": "The host will be notified and can enable transcription for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Record this call",
|
||||
"body": "Record this call to watch it later {{duration_message}} and receive the video recording by email.",
|
||||
"button": "Start recording",
|
||||
"loading": "Recording starting",
|
||||
"linkMore": "Learn more"
|
||||
"heading": "Record this call for later",
|
||||
"body": "Record up to {{max_duration}} of meeting.",
|
||||
"bodyWithoutMaxDuration": "Record your meeting without limit.",
|
||||
"linkMore": "Learn more",
|
||||
"details": {
|
||||
"receiver": "The recording will be sent to the host and co-hosts.",
|
||||
"destination": "This recording will be temporarily stored on our servers",
|
||||
"loading": "Starting recording",
|
||||
"linkMore": "Learn more",
|
||||
"transcription": "Transcribe this recording"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start recording the meeting",
|
||||
"stop": "Stop recording the meeting",
|
||||
"saving": "Saving…",
|
||||
"starting": "Starting…",
|
||||
"anotherModeStarted": "Transcription is running. <br/> Switch panels and stop it."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Restricted Access",
|
||||
"body": "For security reasons, only the meeting creator or an admin can start a recording (beta).",
|
||||
"linkMore": "Learn more"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Saving your data…",
|
||||
"body": "You can leave the meeting if you wish; the recording will finish automatically."
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Recording in progress…",
|
||||
"body": "You will receive the result by email once the recording is complete.",
|
||||
"button": "Stop recording"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Recording Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the recording. Please try again in a moment.",
|
||||
"start": "We were unable to start the recording. Please try again in a moment."
|
||||
"linkMore": "Learn more",
|
||||
"dividerLabel": "OR",
|
||||
"login": {
|
||||
"heading": "Login Required",
|
||||
"body": "Only the meeting creator or an admin can start screen recording. Log in to verify your permissions."
|
||||
},
|
||||
"button": "OK"
|
||||
"request": {
|
||||
"heading": "Request Recording",
|
||||
"body": "The host will be notified and can enable recording for you.",
|
||||
"buttonLabel": "Request"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(limited to {{max_duration}}) "
|
||||
},
|
||||
"errorRecordingAlertDialog": {
|
||||
"title": "Recording Failed",
|
||||
"body": {
|
||||
"stop": "We were unable to stop the recording. Please try again in a moment.",
|
||||
"start": "We were unable to start the recording. Please try again in a moment."
|
||||
},
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "These organizer settings allow you to maintain control of your meeting. Only organizers can access these controls.",
|
||||
"access": {
|
||||
@@ -496,7 +521,7 @@
|
||||
"starting": "Transcription starting",
|
||||
"stopping": "Transcription stopping"
|
||||
},
|
||||
"screenRecording": {
|
||||
"screen_recording": {
|
||||
"started": "Recording in progress",
|
||||
"starting": "Starting recording",
|
||||
"stopping": "Stopping recording"
|
||||
|
||||
@@ -68,6 +68,17 @@
|
||||
},
|
||||
"permissionsRequired": "Permissions required"
|
||||
},
|
||||
"transcription": {
|
||||
"heading": "Transcription",
|
||||
"language": {
|
||||
"label": "Meeting language",
|
||||
"options": {
|
||||
"french": "French (fr)",
|
||||
"english": "English (en)",
|
||||
"auto": "Automatic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Sound notifications",
|
||||
"label": "sound notifications for",
|
||||
@@ -100,6 +111,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "General",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. DINUM Commitments and Responsibilities",
|
||||
"content": "DINUM is responsible for the overall administration of the service. It provides agents with all necessary information to facilitate its use. It offers technical and functional assistance to ensure the proper operation of the service. It informs users by any reasonable means of any difficulty that may affect this proper operation.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Security and Platform Access",
|
||||
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
|
||||
"title": "7.1. Service Availability",
|
||||
"paragraphs": [
|
||||
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
|
||||
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
|
||||
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
|
||||
"The service is available 24/7, except during maintenance windows.",
|
||||
"DINUM targets an annual service availability of 99.5%, excluding planned outages. In case of an incident or maintenance, it aims for a recovery time of 72 hours outside business hours.",
|
||||
"As part of maintaining the service runtime environment, DINUM reserves the right to temporarily suspend the operation of the service. It informs users at least 48 hours in advance. In emergencies, this suspension may occur without notice.",
|
||||
"These exceptional stoppages may be necessary, for example, for back-office data management operations, production deployments, or architecture changes.",
|
||||
"Service unavailability does not give rise to any compensation of any kind."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2 Open Source and Licenses",
|
||||
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
|
||||
"title": "7.2. Service Security",
|
||||
"paragraphs": [
|
||||
"DINUM takes all necessary precautions to preserve the security of the platform and the tools used within the service, particularly regarding access to the service, management of user accounts, and processing of collected data."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Support Management",
|
||||
"paragraphs": [
|
||||
"DINUM provides first-level support to users, exclusively on the technical parts of the service.",
|
||||
"This support can be reached via our online contact form.",
|
||||
"The target response time to requests is two business days from submission; this timeframe may vary depending on the complexity and number of requests to be processed."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Monitoring Service Use",
|
||||
"paragraphs": [
|
||||
"DINUM reserves the right to suspend or delete an agent account that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the agent concerned.",
|
||||
"The suspension or revocation of one or more accounts does not entitle the user to any compensation of any kind."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source and Licenses",
|
||||
"content": "The service source code is free and available here: https://github.com/suitenumerique/meet. The content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"heading": "Une erreur est survenue lors du chargement de la page"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Produit en cours de développement — votre avis compte !",
|
||||
"context": "Vos retours sont essentiels !",
|
||||
"cta": "Partagez votre avis - nouvelle fenêtre"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
"transcript": {
|
||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||
"stopped": "{{name}} a arrêté la transcription de la réunion.",
|
||||
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée."
|
||||
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
|
||||
"requested": "{{name}} a demandé à démarrer la transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
|
||||
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé."
|
||||
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
|
||||
"requested": "{{name}} a demandé à démarrer l'enregistrement."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
@@ -46,5 +48,6 @@
|
||||
"message": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail à <strong>{{email}}</strong> dès qu’il sera prêt.",
|
||||
"default": "Nous finalisons votre enregistrement ! Vous recevrez un e-mail dès qu’il sera prêt."
|
||||
}
|
||||
}
|
||||
},
|
||||
"openMenu": "Ouvrir le menu"
|
||||
}
|
||||
|
||||
@@ -194,8 +194,8 @@
|
||||
"closed": "Afficher les participants"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Masquer plus d'outils",
|
||||
"closed": "Afficher plus d'outils"
|
||||
"open": "Masquer les outils de réunion",
|
||||
"closed": "Afficher les outils de réunion"
|
||||
},
|
||||
"admin": {
|
||||
"open": "Masquer l'admin",
|
||||
@@ -260,20 +260,20 @@
|
||||
"participants": "Participants",
|
||||
"effects": "Effets",
|
||||
"chat": "Messages dans l'appel",
|
||||
"transcript": "Transcription",
|
||||
"screenRecording": "Enregistrement",
|
||||
"transcript": "Transcrire",
|
||||
"screenRecording": "Enregistrer",
|
||||
"admin": "Commandes de l'organisateur",
|
||||
"tools": "Plus d'outils",
|
||||
"tools": "Outils de réunion",
|
||||
"info": "Informations sur la réunion"
|
||||
},
|
||||
"content": {
|
||||
"participants": "les participants",
|
||||
"effects": "les effets",
|
||||
"chat": "les messages",
|
||||
"transcript": "transcription",
|
||||
"screenRecording": "enregistrement",
|
||||
"transcript": "transcrire",
|
||||
"screenRecording": "enregistrer",
|
||||
"admin": "commandes de l'organisateur",
|
||||
"tools": "plus d'outils",
|
||||
"tools": "outils de réunion",
|
||||
"info": "informations sur la réunion"
|
||||
},
|
||||
"closeButton": "Masquer {{content}}"
|
||||
@@ -286,12 +286,12 @@
|
||||
"moreLink": "En savoir plus",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcription",
|
||||
"body": "Conservez une trace écrite de votre réunion."
|
||||
"title": "Transcrire",
|
||||
"body": "Enregistrer la conversation."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Enregistrement",
|
||||
"body": "Enregistrez votre réunion pour la revoir quand vous le souhaitez."
|
||||
"title": "Enregistrer",
|
||||
"body": "Enregistrer la réunion."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,74 +310,99 @@
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "Transcrire cet appel",
|
||||
"body": "Transcrivez cet appel automatiquement {{duration_message}} et recevez le compte rendu dans Docs.",
|
||||
"button": "Démarrer la transcription",
|
||||
"loading": "Démarrage de la transcription",
|
||||
"linkMore": "En savoir plus"
|
||||
"heading": "Transcrivez votre réunion avec l'Assistant IA",
|
||||
"body": "Transcrivez jusqu'à {{max_duration}} de réunion.",
|
||||
"bodyWithoutMaxDuration": "Transcrivez votre réunion sans limite.",
|
||||
"details": {
|
||||
"receiver": "La transcription sera envoyée à l'organisateur et aux coorganisateurs.",
|
||||
"destination": "Un nouveau document sera créé sur",
|
||||
"destinationUnknown": "Un nouveau document sera créé",
|
||||
"language": "Langue de la réunion :",
|
||||
"recording": "Démarrer aussi un enregistrement"
|
||||
},
|
||||
"button": {
|
||||
"start": "Commencer à transcrire la réunion",
|
||||
"stop": "Arrêter de transcrire la réunion",
|
||||
"saving": "Sauvegarde…",
|
||||
"starting": "Démarrage…",
|
||||
"anotherModeStarted": "Un enregistrement est en cours. <br/> Changez de menu et arrêtez le."
|
||||
},
|
||||
"linkMore": "En savoir plus",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Accès restreint",
|
||||
"body": "Pour des raisons de sécurité, seul le créateur ou un administrateur de la réunion peut lancer une transcription (beta).",
|
||||
"linkMore": "En savoir plus"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Transcription en cours …",
|
||||
"body": "La transcription de votre réunion est en cours. Vous recevrez le resultat par email une fois la réunion terminée.",
|
||||
"button": "Arrêter la transcription"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Sauvegarde de vos données…",
|
||||
"body": "Vous pouvez quitter la réunion si vous le souhaitez, la sauvegarde se terminera automatiquement."
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Devenez beta testeur",
|
||||
"body": "Enregistrer votre réunion pour plus tard. Vous recevrez un compte-rendu par email une fois la réunion terminée.",
|
||||
"button": "Inscrivez-vous"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Échec de transcription",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper la transcription. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer la transcription. Veuillez réessayer dans quelques instants."
|
||||
"linkMore": "En savoir plus",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Connexion requise",
|
||||
"body": "Seul le créateur de la réunion ou un administrateur peut démarrer la transcription. Connectez-vous pour vérifier vos autorisations."
|
||||
},
|
||||
"button": "OK"
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(limité à {{max_duration}}) "
|
||||
"premium": {
|
||||
"heading": "Fonctionnalité premium",
|
||||
"body": "Cette fonctionnalité est réservée aux agents publics. Si votre adresse email n’est pas autorisée, contactez le support pour obtenir l'accès.",
|
||||
"linkMore": "En savoir plus",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Vous n'êtes pas connecté !",
|
||||
"body": "Vous devez être connecté pour utiliser cette fonctionnalité. Connectez-vous, puis réessayez."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer la transcription pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Enregistrer cet appel",
|
||||
"body": "Enregistrez cet appel pour plus tard {{duration_message}} et recevez l'enregistrement vidéo par mail.",
|
||||
"button": "Démarrer l'enregistrement",
|
||||
"heading": "Enregistrez cet appel pour plus tard",
|
||||
"body": "Enregistrez jusqu'à {{max_duration}} de réunion.",
|
||||
"bodyWithoutMaxDuration": "Enregistrez votre réunion sans limite.",
|
||||
"linkMore": "En savoir plus",
|
||||
"details": {
|
||||
"receiver": "L'enregistrement sera envoyé à l'organisateur et aux coorganisateurs.",
|
||||
"destination": "Cet enregistrement sera conservé temporairement sur nos serveurs",
|
||||
"loading": "Démarrage de l'enregistrement",
|
||||
"linkMore": "En savoir plus"
|
||||
"linkMore": "En savoir plus",
|
||||
"transcription": "Transcrire cet enregistrement"
|
||||
},
|
||||
"button": {
|
||||
"start": "Commencer à enregistrer la réunion",
|
||||
"stop": "Arrêter d'enregistrer la réunion",
|
||||
"saving": "Sauvegarde…",
|
||||
"starting": "Démarrage…",
|
||||
"anotherModeStarted": "Une transcription est en cours. <br/> Changez de menu et arrêtez la."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Accès restreint",
|
||||
"body": "Pour des raisons de sécurité, seul le créateur ou un administrateur de la réunion peut lancer un enregistrement (beta).",
|
||||
"linkMore": "En savoir plus"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Sauvegarde de vos données…",
|
||||
"body": "Vous pouvez quitter la réunion si vous le souhaitez, la sauvegarde se terminera automatiquement."
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Enregistrement en cours …",
|
||||
"body": "Vous recevrez le resultat par email une fois l'enregistrement terminé.",
|
||||
"button": "Arrêter l'enregistrement"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Échec de l'enregistrement",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper l'enregistrement. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer l'enregistrement. Veuillez réessayer dans quelques instants."
|
||||
"linkMore": "En savoir plus",
|
||||
"dividerLabel": "OU",
|
||||
"login": {
|
||||
"heading": "Connexion requise",
|
||||
"body": "Seul le créateur de la réunion ou un administrateur peut démarrer l'enregistrement. Connectez-vous pour vérifier vos autorisations."
|
||||
},
|
||||
"button": "OK"
|
||||
"request": {
|
||||
"heading": "Demander à l'organisateur",
|
||||
"body": "L'hôte recevra une notification et pourra démarrer l'enregistrement pour vous.",
|
||||
"buttonLabel": "Demander"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(limité à {{max_duration}}) "
|
||||
},
|
||||
"errorRecordingAlertDialog": {
|
||||
"title": "Échec de l'enregistrement",
|
||||
"body": {
|
||||
"stop": "Nous n'avons pas pu stopper l'enregistrement. Veuillez réessayer dans quelques instants.",
|
||||
"start": "Nous n'avons pas pu démarrer l'enregistrement. Veuillez réessayer dans quelques instants."
|
||||
},
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Ces paramètres organisateur vous permettent de garder le contrôle de votre réunion. Seuls les organisateurs peuvent accéder à ces commandes.",
|
||||
"access": {
|
||||
@@ -496,7 +521,7 @@
|
||||
"starting": "Démarrage de la transcription",
|
||||
"stopping": "Arrêt de la transcription"
|
||||
},
|
||||
"screenRecording": {
|
||||
"screen_recording": {
|
||||
"started": "Enregistrement en cours",
|
||||
"starting": "Démarrage de l'enregistrement",
|
||||
"stopping": "Arrêt de l'enregistrement"
|
||||
|
||||
@@ -68,6 +68,17 @@
|
||||
},
|
||||
"permissionsRequired": "Autorisations nécessaires"
|
||||
},
|
||||
"transcription": {
|
||||
"heading": "Transcription",
|
||||
"language": {
|
||||
"label": "Langue de la réunion",
|
||||
"options": {
|
||||
"french": "Français (fr)",
|
||||
"english": "Anglais (en)",
|
||||
"auto": "Automatique"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Notifications sonores",
|
||||
"label": "la notification sonore pour",
|
||||
@@ -100,6 +111,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Vidéo",
|
||||
"general": "Général",
|
||||
"notifications": "Notifications"
|
||||
"notifications": "Notifications",
|
||||
"transcription": "Transcription"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
},
|
||||
"article4": {
|
||||
"title": "4. Utilisation",
|
||||
"content": "L'utilisation du service est libre et gratuite, pour administration un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
|
||||
"content": "L'utilisation du service est libre et gratuite, pour administrer un salon de visioconférence, il est nécessaire de se connecter par ProConnect."
|
||||
},
|
||||
"article5": {
|
||||
"title": "5. Fonctionnalités",
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. Engagements et responsabilités de la DINUM",
|
||||
"content": "La DINUM est responsable de l’administration générale du service. Elle transmet aux agents toutes les informations nécessaires pour faciliter son utilisation. Elle propose une assistance technique et fonctionnelle en vue d’assurer le bon fonctionnement du service. Elle informe par tout moyen raisonnable les utilisateurs de toute difficulté de nature à affecter ce bon fonctionnement.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Sécurité et accès à la plateforme",
|
||||
"content": "La DINUM s'engage à la sécurisation de Visio, notamment en prenant toutes les mesures nécessaires permettant de garantir la sécurité et la confidentialité des informations fournies.",
|
||||
"title": "7.1. Niveau de disponibilité",
|
||||
"paragraphs": [
|
||||
"La DINUM s'engage à fournir les moyens nécessaires et raisonnables pour assurer un accès continu à Visio.",
|
||||
"La DINUM se réserve le droit de faire évoluer, de modifier ou de suspendre, sans préavis, le service pour des raisons de maintenance ou pour tout autre motif jugé nécessaire.",
|
||||
"La DINUM se réserve le droit de suspendre ou supprimer un compte utilisateur ou utilisatrice du service qui aurait méconnu les présentes modalités d'utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l'encontre de l'utilisateur ou l'utilisatrice."
|
||||
"La plage d’ouverture du Service est 24h/24 7j/7, hors période d’indisponibilité pour maintenance.",
|
||||
"La DINUM poursuit un objectif de disponibilité annuelle du service de 99,5%, hors indisponibilités planifiées. En cas d’incident ou de maintenance, elle vise un délai de rétablissement de 72h en heures non ouvrables.",
|
||||
"Dans le cadre d’une maintenance de l’environnement d’exécution du service, La DINUM se réserve le droit de suspendre temporairement le fonctionnement du service. Elle tient informée les utilisateurs au minimum 48h à l’avance. En cas d’urgence, cette suspension peut intervenir sans préavis.",
|
||||
"Ces arrêts exceptionnels peuvent être rendus nécessaires par exemple pour des opérations de gestion des données en back office, des opérations de mise en production ou des changements d’architecture.",
|
||||
"L’indisponibilité du service n’ouvre droit à aucune compensation de quelque nature que ce soit."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2 Open Source et Licences",
|
||||
"content": "Le code source de Visio est libre et disponible ici : https://github.com/suitenumerique/meet\nLes contenus proposés par la DINUM sont sous Licence Ouverte, à l'exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
|
||||
"title": "7.2. Sécurité du service",
|
||||
"paragraphs": [
|
||||
"La DINUM prend toutes les précautions utiles pour préserver la sécurité de la plateforme et des outils mis en œuvre dans le cadre du service, notamment s’agissant de l’accès au service, de la gestion des comptes utilisateurs et du traitement des données collectées."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Gestion du support",
|
||||
"paragraphs": [
|
||||
"La DINUM assure le support de premier niveau auprès des utilisateurs, exclusivement sur les parties techniques du service.",
|
||||
"Ce support est joignable sur notre formulaire de contact en ligne.",
|
||||
"L’objectif de réponse aux sollicitations est de deux jours ouvrés à compter de l’envoi du courriel, ce délai peut varier selon la complexité et le nombre de demandes à traiter."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Contrôle de l’utilisation du service",
|
||||
"paragraphs": [
|
||||
"La DINUM se réserve le droit de suspendre ou supprimer un compte agent qui aurait méconnu les présentes modalités d’utilisation, sans préjudice des éventuelles actions en responsabilité pénale et civile qui pourraient être engagées à l’encontre de l’agent concerné.",
|
||||
"La suspension ou la révocation d’un ou plusieurs comptes ne donne lieu à aucune compensation d’aucune sorte."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source et Licences",
|
||||
"content": "Le code source du service est libre et disponible ici : https://github.com/suitenumerique/meet. Les contenus proposés par la DINUM sont sous Licence Ouverte, à l’exception des logos et des représentations iconographiques et photographiques pouvant être régis par leurs licences propres."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"heading": "Er is een fout opgetreden bij het laden van de pagina"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Product in ontwikkeling - uw input is belangrijk!",
|
||||
"context": "Uw feedback vormt het product!",
|
||||
"cta": "Deel uw feedback - nieuw tabblad"
|
||||
},
|
||||
"forbidden": {
|
||||
|
||||
@@ -30,12 +30,14 @@
|
||||
"transcript": {
|
||||
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
|
||||
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
|
||||
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
|
||||
"requested": "{{name}} wil graag de transcriptie starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
||||
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
|
||||
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen."
|
||||
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
|
||||
"requested": "{{name}} wil graag de opname starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
@@ -46,5 +48,6 @@
|
||||
"message": "We zijn uw opname aan het voltooien! U ontvangt een e-mail op <strong>{{email}}</strong> zodra deze klaar is.",
|
||||
"default": "We zijn uw opname aan het voltooien! U ontvangt een e-mail zodra deze klaar is."
|
||||
}
|
||||
}
|
||||
},
|
||||
"openMenu": "Menu openen"
|
||||
}
|
||||
|
||||
@@ -260,8 +260,8 @@
|
||||
"participants": "Deelnemers",
|
||||
"effects": "Effecten",
|
||||
"chat": "Berichten in de chat",
|
||||
"transcript": "Transcriptie",
|
||||
"screenRecording": "Schermopname",
|
||||
"transcript": "Transcriberen",
|
||||
"screenRecording": "Opnemen",
|
||||
"admin": "Beheerdersbediening",
|
||||
"tools": "Meer tools",
|
||||
"info": "Vergaderinformatie"
|
||||
@@ -270,8 +270,8 @@
|
||||
"participants": "deelnemers",
|
||||
"effects": "effecten",
|
||||
"chat": "berichten",
|
||||
"screenRecording": "transcriptie",
|
||||
"transcript": "schermopname",
|
||||
"screenRecording": "opnemen",
|
||||
"transcript": "transcriberen",
|
||||
"admin": "beheerdersbediening",
|
||||
"tools": "meer tools",
|
||||
"info": "vergaderinformatie"
|
||||
@@ -286,12 +286,12 @@
|
||||
"moreLink": "Lees meer",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transcriptie",
|
||||
"body": "Bewaar een schriftelijk verslag van je vergadering."
|
||||
"title": "Transcriberen",
|
||||
"body": "Het gesprek opnemen."
|
||||
},
|
||||
"screenRecording": {
|
||||
"title": "Opname",
|
||||
"body": "Neem je vergadering op om die later opnieuw te bekijken."
|
||||
"title": "Opnemen",
|
||||
"body": "De vergadering opnemen."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -310,74 +310,99 @@
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"start": {
|
||||
"heading": "Transcribeer dit gesprek",
|
||||
"body": "Transcribeer dit gesprek automatisch {{duration_message}} en ontvang het verslag in Docs.",
|
||||
"button": "Transcriptie starten",
|
||||
"loading": "Transcriptie begint",
|
||||
"linkMore": "Meer informatie"
|
||||
"heading": "Transcribeer uw vergadering met de AI-assistent",
|
||||
"body": "Transcribeer tot {{max_duration}} aan vergadertijd.",
|
||||
"bodyWithoutMaxDuration": "Transcribeer uw vergadering zonder limiet.",
|
||||
"details": {
|
||||
"receiver": "Het transcript wordt verzonden naar de host en de co-host.",
|
||||
"destination": "Er wordt een nieuw document aangemaakt op",
|
||||
"destinationUnknown": "Een nieuw document wordt aangemaakt",
|
||||
"language": "Vergadertalen:",
|
||||
"recording": "Start ook een opname"
|
||||
},
|
||||
"button": {
|
||||
"start": "Begin met het transcriberen van de vergadering",
|
||||
"stop": "Stop met het transcriberen van de vergadering",
|
||||
"saving": "Opslaan…",
|
||||
"starting": "Wordt gestart…",
|
||||
"anotherModeStarted": "Er loopt een opname. <br/> Ga naar het menu en stop deze."
|
||||
},
|
||||
"linkMore": "Meer informatie",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Toegang beperkt",
|
||||
"body": "Om veiligheidsredenen kan alleen de maker of een beheerder van de vergadering een transcriptie starten (beta).",
|
||||
"linkMore": "Meer informatie"
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Transcriptie bezig...",
|
||||
"body": "De transcriptie van uw vergadering is bezig. U ontvangt het resultaat per e-mail zodra de vergadering is afgelopen.",
|
||||
"button": "Transcriptie stoppen"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Uw gegevens worden opgeslagen…",
|
||||
"body": "U kunt de vergadering verlaten als u dat wilt; de opname wordt automatisch voltooid."
|
||||
},
|
||||
"beta": {
|
||||
"heading": "Word betatester",
|
||||
"body": "Neem uw vergadering op voor later. U ontvangt een samenvatting per e-mail zodra de vergadering is afgelopen.",
|
||||
"button": "Aanmelden"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Transcriptie mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de transcriptie niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de transcriptie niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
"linkMore": "Meer informatie",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Alleen de maker van de vergadering of een beheerder kan de transcriptie starten. Log in om uw machtigingen te controleren."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
"request": {
|
||||
"heading": "Transcriptie aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(beperkt tot {{max_duration}}) "
|
||||
"premium": {
|
||||
"heading": "Premiumfunctie",
|
||||
"body": "Deze functie is voorbehouden aan openbare medewerkers. Als uw e-mailadres niet is toegestaan, neem dan contact op met de support om toegang te krijgen.",
|
||||
"linkMore": "Meer informatie",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Alleen de maker van de vergadering of een beheerder kan de transcriptie starten. Log in om uw machtigingen te controleren."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Transcriptie aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de transcriptie voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"start": {
|
||||
"heading": "Dit gesprek opnemen",
|
||||
"body": "Neem dit gesprek op om het later terug te kijken {{duration_message}}. Je ontvangt de video-opname per e-mail.",
|
||||
"button": "Opname starten",
|
||||
"loading": "Opname gestarten",
|
||||
"linkMore": "Meer informatie"
|
||||
"heading": "Neem dit gesprek op voor later",
|
||||
"body": "Neem tot {{max_duration}} van de vergadering op.",
|
||||
"bodyWithoutMaxDuration": "Neem je vergadering onbeperkt op.",
|
||||
"linkMore": "Meer informatie",
|
||||
"details": {
|
||||
"receiver": "De opname wordt verzonden naar de organisator en co-organisatoren.",
|
||||
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
|
||||
"loading": "Opname wordt gestart",
|
||||
"linkMore": "Meer informatie",
|
||||
"transcription": "Transcribeer deze opname"
|
||||
},
|
||||
"button": {
|
||||
"start": "Start met opnemen van de vergadering",
|
||||
"stop": "Stop met opnemen van de vergadering",
|
||||
"saving": "Bezig met opslaan…",
|
||||
"starting": "Wordt gestart…",
|
||||
"anotherModeStarted": "Er loopt een transcriptie. <br/> Ga naar het menu en stop deze."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Toegang beperkt",
|
||||
"body": "Om veiligheidsredenen kan alleen de maker of een beheerder van de vergadering een opname starten (beta).",
|
||||
"linkMore": "Meer informatie"
|
||||
},
|
||||
"stopping": {
|
||||
"heading": "Uw gegevens worden opgeslagen…",
|
||||
"body": "U kunt de vergadering verlaten als u dat wilt; de opname wordt automatisch voltooid."
|
||||
},
|
||||
"stop": {
|
||||
"heading": "Opname bezig …",
|
||||
"body": "Je ontvangt het resultaat per e-mail zodra de opname is voltooid.",
|
||||
"button": "Opname stoppen"
|
||||
},
|
||||
"alert": {
|
||||
"title": "Opname mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de opname niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de opname niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
"linkMore": "Meer informatie",
|
||||
"dividerLabel": "OF",
|
||||
"login": {
|
||||
"heading": "Inloggen vereist",
|
||||
"body": "Alleen de maker van de vergadering of een beheerder kan de opname starten. Log in om uw machtigingen te controleren."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
"request": {
|
||||
"heading": "Opname aanvragen",
|
||||
"body": "De host wordt op de hoogte gebracht en kan de opname voor u inschakelen.",
|
||||
"buttonLabel": "Aanvragen"
|
||||
}
|
||||
},
|
||||
"durationMessage": "(beperkt tot {{max_duration}})"
|
||||
},
|
||||
"errorRecordingAlertDialog": {
|
||||
"title": "Opname mislukt",
|
||||
"body": {
|
||||
"stop": "We konden de opname niet stoppen. Probeer het over enkele ogenblikken opnieuw.",
|
||||
"start": "We konden de opname niet starten. Probeer het over enkele ogenblikken opnieuw."
|
||||
},
|
||||
"button": "Opnieuw proberen"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Deze organisatorinstellingen geven u controle over uw vergadering. Alleen organisatoren hebben toegang tot deze bedieningselementen.",
|
||||
"access": {
|
||||
@@ -496,7 +521,7 @@
|
||||
"starting": "Transcriptie begint",
|
||||
"stopping": "Transcriptie stopt"
|
||||
},
|
||||
"screenRecording": {
|
||||
"screen_recording": {
|
||||
"started": "Opname bezig",
|
||||
"starting": "Opname starten",
|
||||
"stopping": "Opname stoppen"
|
||||
|
||||
@@ -68,6 +68,17 @@
|
||||
},
|
||||
"permissionsRequired": "Machtigingen vereist"
|
||||
},
|
||||
"transcription": {
|
||||
"heading": "Transcriptie",
|
||||
"language": {
|
||||
"label": "Vergadertaal",
|
||||
"options": {
|
||||
"french": "Frans (fr)",
|
||||
"english": "Engels (en)",
|
||||
"auto": "Automatisch"
|
||||
}
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Geluidsmeldingen",
|
||||
"label": "Geluidsmeldingen voor",
|
||||
@@ -100,6 +111,7 @@
|
||||
"audio": "Audio",
|
||||
"video": "Video",
|
||||
"general": "Algemeen",
|
||||
"notifications": "Meldingen"
|
||||
"notifications": "Meldingen",
|
||||
"transcription": "Transcriptie"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,19 +70,42 @@
|
||||
},
|
||||
"article7": {
|
||||
"title": "7. DINUM Commitments and Responsibilities",
|
||||
"content": "De DINUM is verantwoordelijk voor de algemene administratie van de dienst. Zij verstrekt de medewerkers alle nodige informatie om het gebruik te vergemakkelijken. Zij biedt technische en functionele ondersteuning om de goede werking van de dienst te verzekeren. Zij informeert de gebruikers met elk redelijk middel over moeilijkheden die deze goede werking kunnen beïnvloeden.",
|
||||
"sections": {
|
||||
"section1": {
|
||||
"title": "7.1. Security and Platform Access",
|
||||
"content": "DINUM is committed to securing Visio, notably by taking all necessary measures to ensure the security and confidentiality of the information provided.",
|
||||
"title": "7.1. Beschikbaarheidsniveau",
|
||||
"paragraphs": [
|
||||
"DINUM commits to providing the necessary and reasonable means to ensure continuous access to Visio.",
|
||||
"DINUM reserves the right to evolve, modify, or suspend the service without notice for maintenance reasons or for any other reason deemed necessary.",
|
||||
"DINUM reserves the right to suspend or delete a user account of the service that has violated these terms of use, without prejudice to any potential criminal and civil liability actions that could be taken against the user."
|
||||
"De dienstverlening is 24u/24 en 7d/7 beschikbaar, behalve tijdens onderhoudsonderbrekingen.",
|
||||
"DINUM streeft naar een jaarlijkse beschikbaarheid van 99,5% van de dienst, buiten geplande onbeschikbaarheden. Bij een incident of onderhoud mikt zij op een hersteltermijn van 72 uur buiten kantooruren.",
|
||||
"In het kader van onderhoud van de uitvoeringsomgeving van de dienst behoudt DINUM zich het recht voor de werking tijdelijk op te schorten. Zij informeert de gebruikers minstens 48 uur vooraf. In noodgevallen kan deze opschorting zonder voorafgaande kennisgeving plaatsvinden.",
|
||||
"Deze uitzonderlijke stilstanden kunnen bijvoorbeeld nodig zijn voor backoffice-gegevensbeheer, inproductiestellingen of architectuurwijzigingen.",
|
||||
"Onbeschikbaarheid van de dienst geeft geen recht op enige vorm van compensatie."
|
||||
]
|
||||
},
|
||||
"section2": {
|
||||
"title": "7.2 Open Source and Licenses",
|
||||
"content": "The source code of Visio is free and available here: https://github.com/suitenumerique/meet\nThe content offered by DINUM is under an Open License, with the exception of logos and iconographic and photographic representations that may be governed by their own licenses."
|
||||
"title": "7.2. Beveiliging van de dienst",
|
||||
"paragraphs": [
|
||||
"DINUM neemt alle nodige voorzorgsmaatregelen om de veiligheid van het platform en de in het kader van de dienst gebruikte hulpmiddelen te bewaren, met name wat betreft de toegang tot de dienst, het beheer van gebruikersaccounts en de verwerking van verzamelde gegevens."
|
||||
]
|
||||
},
|
||||
"section3": {
|
||||
"title": "7.3. Supportbeheer",
|
||||
"paragraphs": [
|
||||
"DINUM verzorgt eerstelijns support voor de gebruikers, uitsluitend over de technische onderdelen van de dienst.",
|
||||
"Deze support is bereikbaar via ons online contactformulier.",
|
||||
"De streefantwoordtermijn op verzoeken is twee werkdagen vanaf de verzending; deze termijn kan variëren naargelang de complexiteit en het aantal te behandelen aanvragen."
|
||||
]
|
||||
},
|
||||
"section4": {
|
||||
"title": "7.4. Controle van het gebruik van de dienst",
|
||||
"paragraphs": [
|
||||
"DINUM behoudt zich het recht voor om een agentaccount te schorsen of te verwijderen dat deze gebruiksvoorwaarden heeft geschonden, onverminderd eventuele strafrechtelijke en civielrechtelijke stappen tegen de betrokken agent.",
|
||||
"De schorsing of intrekking van één of meer accounts geeft geen recht op enige vorm van compensatie."
|
||||
]
|
||||
},
|
||||
"section5": {
|
||||
"title": "7.5. Open Source en licenties",
|
||||
"content": "De broncode van de dienst is vrij beschikbaar op: https://github.com/suitenumerique/meet. De inhoud die door DINUM wordt aangeboden valt onder een Open License, met uitzondering van logo's en iconografische en fotografische voorstellingen die onder hun eigen licenties kunnen vallen."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,6 +2,9 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
|
||||
// Supports weights 100-700
|
||||
import '@fontsource-variable/material-symbols-outlined'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -106,6 +106,11 @@ export const text = cva({
|
||||
marginBottom: 1,
|
||||
},
|
||||
},
|
||||
padding: {
|
||||
false: {
|
||||
padding: '0!',
|
||||
},
|
||||
},
|
||||
fullWidth: {
|
||||
true: {
|
||||
width: '100%',
|
||||
|
||||
@@ -31,6 +31,12 @@ export const buttonRecipe = cva({
|
||||
borderRadius: 4,
|
||||
'--square-padding': '0',
|
||||
},
|
||||
compact: {
|
||||
borderRadius: 4,
|
||||
paddingX: '0.5',
|
||||
paddingY: '0.625',
|
||||
'--square-padding': '{spacing.0.625}',
|
||||
},
|
||||
},
|
||||
square: {
|
||||
true: {
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
export enum RecordingStatus {
|
||||
TRANSCRIPT_STARTING,
|
||||
TRANSCRIPT_STARTED,
|
||||
TRANSCRIPT_STOPPING,
|
||||
STOPPED,
|
||||
SCREEN_RECORDING_STARTING,
|
||||
SCREEN_RECORDING_STARTED,
|
||||
SCREEN_RECORDING_STOPPING,
|
||||
ANY_STARTED,
|
||||
export enum RecordingLanguage {
|
||||
ENGLISH = 'en',
|
||||
FRENCH = 'fr',
|
||||
AUTOMATIC = 'auto',
|
||||
}
|
||||
|
||||
type State = {
|
||||
status: RecordingStatus
|
||||
language: RecordingLanguage
|
||||
isErrorDialogOpen: string
|
||||
}
|
||||
|
||||
export const recordingStore = proxy<State>({
|
||||
status: RecordingStatus.STOPPED,
|
||||
language: RecordingLanguage.FRENCH,
|
||||
isErrorDialogOpen: '',
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
@import '@fontsource/material-icons-outlined';
|
||||
|
||||
.material-icons,
|
||||
.material-symbols {
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
text-rendering: optimizeLegibility;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons Outlined';
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-family: 'Material Symbols Outlined Variable';
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
@import './livekit.css';
|
||||
@import './icons.css';
|
||||
@layer reset, base, tokens, recipes, utilities;
|
||||
html,
|
||||
body,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Fragment, ReactNode } from 'react'
|
||||
|
||||
export const parseLineBreaks = (text: string): ReactNode[] => {
|
||||
const parts = text.split(/(<br\s*\/?>)/gi)
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (part.match(/^<br\s*\/?>$/gi)) {
|
||||
return <br key={index} />
|
||||
}
|
||||
return <Fragment key={index}>{part}</Fragment>
|
||||
})
|
||||
}
|
||||
@@ -49,10 +49,10 @@ backend:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
||||
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_CUSTOM_CSS_URL: './assets/dinum-styles.css'
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER: True
|
||||
@@ -68,7 +68,7 @@ backend:
|
||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN: password
|
||||
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
|
||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
SSL_CERT_FILE: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
|
||||
@@ -99,7 +99,7 @@ backend:
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
replicaCount: 1
|
||||
terminationGracePeriodSeconds: 18000
|
||||
|
||||
image:
|
||||
tag: v1.11.0
|
||||
|
||||
egress:
|
||||
log_level: debug
|
||||
ws_url: ws://livekit-livekit-server:80
|
||||
|
||||
@@ -51,13 +51,14 @@ backend:
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
LIVEKIT_FORCE_WSS_PROTOCOL: True
|
||||
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
|
||||
LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX: "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
||||
FRONTEND_TRANSCRIPT: "{'form_beta_users': 'https://grist.numerique.gouv.fr/o/docs/forms/3fFfvJoTBEQ6ZiMi8zsQwX/17'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
@@ -68,7 +69,7 @@ backend:
|
||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
||||
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||
SUMMARY_SERVICE_API_TOKEN: password
|
||||
SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings
|
||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
|
||||
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
|
||||
@@ -149,7 +150,7 @@ summary:
|
||||
APP_NAME: summary-microservice
|
||||
APP_API_TOKEN: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
@@ -186,7 +187,7 @@ celeryTranscribe:
|
||||
APP_NAME: summary-microservice
|
||||
APP_API_TOKEN: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
@@ -224,7 +225,7 @@ celerySummarize:
|
||||
APP_NAME: summary-microservice
|
||||
APP_API_TOKEN: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user