mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edb9198a59 | |||
| 2f59d9281c | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 |
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v5
|
||||
|
||||
@@ -10,6 +10,35 @@ and this project adheres to
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||
- ✨(all) allow forcing SSO display name for authenticated users
|
||||
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||
- ♻️(frontend) refactor background processors to use the new API
|
||||
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) identify externally provisioned users to PostHog
|
||||
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||
- ♿️(frontend) focus side panel container on open #1452
|
||||
- 🩹(backend) handle failed and aborted egresses
|
||||
|
||||
## [1.23.0] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) extend analytics module to support feature flags
|
||||
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||
@@ -23,6 +52,12 @@ and this project adheres to
|
||||
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||
- 🔥(summary) remove call to summary enabled feature flag
|
||||
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||
- ⬆️(frontend) update the frontend build image to Node 22
|
||||
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||
- ✨(summary) more precise analytics events
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -42,6 +77,7 @@ and this project adheres to
|
||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||
- 🩹(frontend) enable screen share button in PiP #1458
|
||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
|
||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
||||
(Note : in your development environment, you can `make migrate`.)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v1.23.0
|
||||
|
||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||
|
||||
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||
|
||||
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||
|
||||
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
||||
# Update agents pyproject.toml
|
||||
update_python_version "agents"
|
||||
|
||||
# Run uv lock in agents
|
||||
print_info "Running uv lock in agents..."
|
||||
cd "src/agents"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update CHANGELOG
|
||||
print_info "Updating CHANGELOG..."
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---- Front-end image ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.4",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "livekit-agents" },
|
||||
|
||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
search_fields = [
|
||||
"status",
|
||||
"=id",
|
||||
"worker_id",
|
||||
"room__slug",
|
||||
"=room__id",
|
||||
"accesses__user__email",
|
||||
]
|
||||
list_display = (
|
||||
"id",
|
||||
"status",
|
||||
|
||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -92,6 +92,7 @@ from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..models import RoomAccessLevel
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
@@ -267,6 +268,9 @@ class RoomViewSet(
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"is_administrable": False,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
|
||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -57,6 +57,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED = "failed", _("Failed")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
@@ -80,6 +81,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.ABORTED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
@@ -87,7 +89,12 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
return status in {
|
||||
cls.ABORTED,
|
||||
cls.FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
|
||||
@@ -26,14 +26,16 @@ class RecordingEventsService:
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
"""Handle egress update and sync recording state to room metadata.
|
||||
|
||||
Egress updates are sent for statuses EGRESS_ACTIVE and EGRESS_ENDING.
|
||||
"""
|
||||
|
||||
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)
|
||||
@@ -59,6 +61,13 @@ class RecordingEventsService:
|
||||
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
logger.warning(
|
||||
"Could not find notification type for: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -79,6 +88,18 @@ class RecordingEventsService:
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def handle_failed(recording: Recording):
|
||||
"""Set recording status to failed, matching egress status."""
|
||||
recording.status = models.RecordingStatusChoices.FAILED
|
||||
recording.save()
|
||||
|
||||
@staticmethod
|
||||
def handle_aborted(recording: Recording):
|
||||
"""Set recording status to aborted, matching egress status."""
|
||||
recording.status = models.RecordingStatusChoices.ABORTED
|
||||
recording.save()
|
||||
|
||||
@staticmethod
|
||||
def handle_complete(recording: Recording):
|
||||
"""Notify external services and save recording."""
|
||||
|
||||
@@ -200,7 +200,21 @@ class LiveKitEventsService:
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_ABORTED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
return self.recording_events.handle_aborted(recording)
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_FAILED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
return self.recording_events.handle_failed(recording)
|
||||
|
||||
# Fallback for completion when no MinIO/S3 webhooks are configured.
|
||||
# If RECORDING_STORAGE_EVENT_ENABLE is True, completion is handled
|
||||
# by ``on_storage_event_received``.
|
||||
if (
|
||||
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||
) and data.egress_info.status in [
|
||||
@@ -216,8 +230,6 @@ class LiveKitEventsService:
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest import mock
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory
|
||||
from core.models import RecordingStatusChoices
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
@@ -70,3 +71,22 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("handler_name", "expected_status"),
|
||||
(
|
||||
("handle_aborted", RecordingStatusChoices.ABORTED),
|
||||
("handle_failed", RecordingStatusChoices.FAILED),
|
||||
),
|
||||
)
|
||||
def test_handle_unsuccessful_egress(handler_name, expected_status, service):
|
||||
"""Test unsuccessful egress handlers persist a final, unsuccessful status."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
getattr(service, handler_name)(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
assert RecordingStatusChoices.is_final(recording.status)
|
||||
assert RecordingStatusChoices.is_unsuccessful(recording.status)
|
||||
|
||||
@@ -130,6 +130,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "unregistered-room",
|
||||
@@ -162,6 +165,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "reunion",
|
||||
|
||||
@@ -101,7 +101,6 @@ def test_handle_egress_ended_success(
|
||||
(
|
||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@@ -126,6 +125,7 @@ def test_handle_egress_updated_success(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@@ -420,8 +420,6 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
||||
EgressStatus.EGRESS_STARTING,
|
||||
EgressStatus.EGRESS_ACTIVE,
|
||||
EgressStatus.EGRESS_ENDING,
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@@ -442,6 +440,47 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "expected_status"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_marks_unsuccessful_egress( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
expected_status,
|
||||
service,
|
||||
settings,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""An aborted/failed egress moves an active recording to its terminal
|
||||
unsuccessful status, and never notifies external services."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify_external_services.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 == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
|
||||
@@ -160,6 +160,7 @@ def test_models_recording_is_savable_normal():
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
RecordingStatusChoices.FAILED,
|
||||
],
|
||||
)
|
||||
def test_models_recording_is_savable_error(status):
|
||||
@@ -282,6 +283,7 @@ def test_models_recording_is_saved_false_initiated():
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
RecordingStatusChoices.FAILED,
|
||||
],
|
||||
)
|
||||
def test_models_recording_is_saved_false_error_states(status):
|
||||
|
||||
@@ -2,13 +2,109 @@
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||
from core.factories import UserFactory
|
||||
from core.utils import (
|
||||
NotificationError,
|
||||
create_livekit_client,
|
||||
generate_token,
|
||||
notify_participants,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode a LiveKit JWT access token for inspection."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_uses_full_name():
|
||||
"""The token's display name should default to the user's full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
assert claims["sub"] == str(user.sub)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_fallback_user_representation():
|
||||
"""
|
||||
When the user has no full name, the token's display name should fall back
|
||||
to the user's string representation.
|
||||
"""
|
||||
user = UserFactory(full_name=None)
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == str(user)
|
||||
|
||||
|
||||
def test_generate_token_explicit_username_overrides_default():
|
||||
"""An explicitly provided username should take precedence over the full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Custom Name"
|
||||
|
||||
|
||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||
"""With editing disabled, an authenticated user's username is ignored."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||
"""Disabling editing doesn't disturb the default full-name path."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user)
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_anonymous_uses_username_when_provided():
|
||||
"""An anonymous user's provided username is used as the display name."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_falls_back_to_anonymous_label():
|
||||
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser())
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Anonymous"
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
|
||||
@@ -109,11 +109,16 @@ def generate_token(
|
||||
default_username = "Anonymous"
|
||||
else:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
default_username = user.full_name or str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
can_edit = (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||
)
|
||||
display_name = (username or default_username) if can_edit else default_username
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -121,7 +126,7 @@ def generate_token(
|
||||
)
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
@@ -670,6 +671,11 @@ class Base(Configuration):
|
||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||
True,
|
||||
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Recording settings
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
@@ -1128,6 +1134,20 @@ class Base(Configuration):
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
):
|
||||
warnings.warn(
|
||||
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||
"The legacy v1 API has been removed from the experimental "
|
||||
"Summary service. Please update your Summary service deployment to "
|
||||
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||
# We use UserWarning to make sure it shows up in production deployment
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
USER node
|
||||
|
||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
Generated
+228
-49
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
@@ -15,10 +15,11 @@
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -28,14 +29,14 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.386.5",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.49.0",
|
||||
"react-aria-components": "1.18.0",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.47.0",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -63,6 +64,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
},
|
||||
@@ -110,6 +112,62 @@
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.35.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"aria-hidden": "^1.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
"react-stately": "3.47.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@react-types/shared": "^3.35.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"client-only": "^0.0.1",
|
||||
"react-aria": "3.49.0",
|
||||
"react-stately": "3.47.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.35.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -1752,9 +1810,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
|
||||
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -2357,9 +2415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
|
||||
"integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2378,12 +2436,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.100.14",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
||||
"version": "5.101.0",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
|
||||
"integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.100.14"
|
||||
"@tanstack/query-core": "5.101.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -3192,6 +3250,19 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||
@@ -7094,6 +7165,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"binary-extensions": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-boolean-object": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||
@@ -8035,9 +8119,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.0",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
|
||||
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
@@ -8675,6 +8759,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-map": {
|
||||
"version": "7.0.5",
|
||||
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/package-manager-detector": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||
@@ -9033,19 +9130,19 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.386.5",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz",
|
||||
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==",
|
||||
"version": "1.391.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.391.2.tgz",
|
||||
"integrity": "sha512-q0DZN6ljchSnAFJIXf+sQFTPlsLjTlRa+TvrL+QRb6413BGtib/MNiQy1bnwLKt8KR+f6xJYvkqdLyty9s4Aww==",
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.32.3",
|
||||
"@posthog/types": "^1.386.3",
|
||||
"@posthog/core": "^1.35.3",
|
||||
"@posthog/types": "^1.390.2",
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.3.2",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.28.2",
|
||||
"preact": "^10.29.2",
|
||||
"query-selector-shadow-dom": "^1.0.1",
|
||||
"web-vitals": "^5.1.0"
|
||||
"web-vitals": "^5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/powershell-utils": {
|
||||
@@ -9062,13 +9159,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/preact": {
|
||||
"version": "10.28.3",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
||||
"version": "10.29.7",
|
||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/preact"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact-render-to-string": ">=5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"preact-render-to-string": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/prelude-ls": {
|
||||
@@ -9275,19 +9380,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-aria": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.35.0",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"aria-hidden": "^1.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
"react-stately": "3.47.0",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -9296,17 +9401,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-aria-components": {
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@react-types/shared": "^3.35.0",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"client-only": "^0.0.1",
|
||||
"react-aria": "3.49.0",
|
||||
"react-stately": "3.47.0"
|
||||
"react-aria": "3.50.0",
|
||||
"react-stately": "3.48.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
@@ -9360,15 +9465,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-stately": {
|
||||
"version": "3.47.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.35.0",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
@@ -11137,6 +11242,80 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.6.0",
|
||||
"p-map": "^7.0.4",
|
||||
"picocolors": "^1.1.1",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/sapphi-red"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"anymatch": "~3.1.2",
|
||||
"braces": "~3.0.2",
|
||||
"glob-parent": "~5.1.2",
|
||||
"is-binary-path": "~2.1.0",
|
||||
"is-glob": "~4.0.1",
|
||||
"normalize-path": "~3.0.0",
|
||||
"readdirp": "~3.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"is-glob": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"picomatch": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-svgr": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||
@@ -11470,9 +11649,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/web-vitals": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -17,15 +17,16 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@mediapipe/tasks-vision": "0.10.14",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.0",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
@@ -35,14 +36,14 @@
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.386.5",
|
||||
"livekit-client": "2.19.2",
|
||||
"posthog-js": "1.391.2",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.49.0",
|
||||
"react-aria-components": "1.18.0",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.47.0",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -70,6 +71,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -58,6 +58,7 @@ export interface ApiConfig {
|
||||
transcription_destination?: string
|
||||
max_participants_for_sound: number
|
||||
auto_mute_on_join_threshold: number
|
||||
authenticated_users_can_edit_display_name: boolean
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
||||
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||
import { loadUserChoices } from '@livekit/components-core'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const CreateMeetingMenu = () => {
|
||||
const { username } = loadUserChoices()
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const { t } = useTranslation('home')
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
@@ -26,6 +26,7 @@ export const PipFocusLayout = memo(
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</MainSlot>
|
||||
)}
|
||||
@@ -34,6 +35,7 @@ export const PipFocusLayout = memo(
|
||||
<ParticipantTile
|
||||
key={getTrackKey(thumbnailTrack)}
|
||||
trackRef={thumbnailTrack}
|
||||
disableTileControls
|
||||
/>
|
||||
</Thumbnail>
|
||||
)}
|
||||
@@ -48,9 +50,10 @@ const FocusContainer = styled('div', {
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -58,6 +61,8 @@ const MainSlot = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
@@ -67,13 +72,13 @@ const MainSlot = styled('div', {
|
||||
const Thumbnail = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
right: '1rem',
|
||||
bottom: '1rem',
|
||||
right: '1.25rem',
|
||||
bottom: '1.25rem',
|
||||
width: '42%',
|
||||
maxWidth: '220px',
|
||||
minWidth: '140px',
|
||||
aspectRatio: '16 / 9',
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: 'md',
|
||||
zIndex: 2,
|
||||
|
||||
@@ -41,7 +41,7 @@ export const PipGridLayout = memo(({ tracks }: PipGridLayoutProps) => {
|
||||
<GridContainer ref={containerRef} style={gridStyle}>
|
||||
{tracks.map((track, index) => (
|
||||
<GridCell key={getTrackKey(track)} style={placements[index]}>
|
||||
<ParticipantTile trackRef={track} />
|
||||
<ParticipantTile trackRef={track} disableTileControls />
|
||||
</GridCell>
|
||||
))}
|
||||
</GridContainer>
|
||||
@@ -54,7 +54,8 @@ const GridContainer = styled('div', {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gap: '0.25rem',
|
||||
gap: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -63,7 +64,7 @@ const GridCell = styled('div', {
|
||||
position: 'relative',
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
borderRadius: '4px',
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
// Paint on own layer so FLIP transforms don't trigger layout thrash.
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { memo } from 'react'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile'
|
||||
import { getTrackKey } from '../../utils/pipTrackSelection'
|
||||
|
||||
type PipScreenShareLayoutProps = {
|
||||
screenShareTrack: TrackReferenceOrPlaceholder
|
||||
cameraTracks: TrackReferenceOrPlaceholder[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout when a screen share is active.
|
||||
* Camera tiles are shown as a compact row at the top; the screen share occupies
|
||||
* the remaining space below, much larger than the camera tiles.
|
||||
*/
|
||||
export const PipScreenShareLayout = memo(
|
||||
({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => {
|
||||
return (
|
||||
<LayoutContainer>
|
||||
{cameraTracks.length > 0 && (
|
||||
<CameraTilesRow>
|
||||
{cameraTracks.map((track) => (
|
||||
<CameraTile key={getTrackKey(track)}>
|
||||
<ParticipantTile trackRef={track} disableTileControls />
|
||||
</CameraTile>
|
||||
))}
|
||||
</CameraTilesRow>
|
||||
)}
|
||||
<ScreenShareSlot>
|
||||
<ParticipantTile trackRef={screenShareTrack} disableTileControls />
|
||||
</ScreenShareSlot>
|
||||
</LayoutContainer>
|
||||
)
|
||||
}
|
||||
)
|
||||
PipScreenShareLayout.displayName = 'PipScreenShareLayout'
|
||||
|
||||
const LayoutContainer = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
})
|
||||
|
||||
const CameraTilesRow = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
flexShrink: 0,
|
||||
height: '22%',
|
||||
minHeight: '60px',
|
||||
maxHeight: '120px',
|
||||
},
|
||||
})
|
||||
|
||||
const CameraTile = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
flex: '0 1 auto',
|
||||
height: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
minWidth: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const ScreenShareSlot = styled('div', {
|
||||
base: {
|
||||
position: 'relative',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
'& .lk-participant-tile': {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
'& .lk-participant-media-video': {
|
||||
objectFit: 'contain',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { usePagination, useTracks } from '@livekit/components-react'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { PipFocusLayout } from './PipFocusLayout'
|
||||
import { PipGridLayout } from './PipGridLayout'
|
||||
import { PipPagination } from './PipPagination'
|
||||
import { PipScreenShareLayout } from './PipScreenShareLayout'
|
||||
import { StageFrame } from './StageFrame'
|
||||
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||
import {
|
||||
@@ -13,9 +14,12 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
|
||||
/**
|
||||
* PipStage picks between two layouts based on track count:
|
||||
* - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay.
|
||||
* - Grid mode (3+ tracks): adaptive tiling.
|
||||
* PipStage picks between three layouts:
|
||||
* - Screen share mode (any screen share active):
|
||||
* small camera tiles in a row at the top, large screen share below.
|
||||
* - Grid mode (3+ camera tracks, no screen share): adaptive tiling.
|
||||
* - Focus mode (≤ 2 camera tracks, no screen share): one main track
|
||||
* + one thumbnail overlay.
|
||||
*/
|
||||
export const PipStage = () => {
|
||||
const tracks = useTracks(
|
||||
@@ -41,51 +45,55 @@ export const PipStage = () => {
|
||||
[tracks]
|
||||
)
|
||||
|
||||
// Grid mode order: screen share leads, then cameras (already ordered by
|
||||
// active speaker via the `ActiveSpeakersChanged` update above).
|
||||
const gridTracks = useMemo(
|
||||
() =>
|
||||
screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks,
|
||||
[screenShareTrack, cameraTracks]
|
||||
)
|
||||
// Cap camera tiles in screen-share mode. Called unconditionally for hook rules.
|
||||
const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks)
|
||||
|
||||
// Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible
|
||||
// page visually stable (active/recent speakers stay put) via its internal
|
||||
// `useVisualStableUpdate`. Called unconditionally to respect hook rules.
|
||||
const pagination = usePagination(MAX_PIP_TILES, gridTracks)
|
||||
// Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode.
|
||||
const pagination = usePagination(MAX_PIP_TILES, cameraTracks)
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
|
||||
/**
|
||||
* The focus layout shows one main track with one thumbnail overlay,
|
||||
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
||||
*/
|
||||
if (gridTracks.length > 2) {
|
||||
return (
|
||||
<StageWrapper>
|
||||
// Screen share active
|
||||
if (screenShareTrack) {
|
||||
// Solo presenter: screen share fills the area, camera as small thumbnail
|
||||
if (cameraTracks.length <= 1) {
|
||||
return (
|
||||
<StageFrame>
|
||||
<PipGridLayout tracks={pagination.tracks} />
|
||||
<PipFocusLayout
|
||||
mainTrack={screenShareTrack}
|
||||
thumbnailTrack={cameraTracks[0]}
|
||||
/>
|
||||
</StageFrame>
|
||||
<PipPagination
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
nextPage={pagination.nextPage}
|
||||
prevPage={pagination.prevPage}
|
||||
)
|
||||
}
|
||||
// Multiple cameras: camera row at top, screen share below
|
||||
return (
|
||||
<PaginatedStage pagination={paginatedCameraTracks}>
|
||||
<PipScreenShareLayout
|
||||
screenShareTrack={screenShareTrack}
|
||||
cameraTracks={paginatedCameraTracks.tracks}
|
||||
/>
|
||||
</StageWrapper>
|
||||
</PaginatedStage>
|
||||
)
|
||||
}
|
||||
|
||||
// 3+ camera tracks → adaptive grid
|
||||
if (cameraTracks.length > 2) {
|
||||
return (
|
||||
<PaginatedStage pagination={pagination}>
|
||||
<PipGridLayout tracks={pagination.tracks} />
|
||||
</PaginatedStage>
|
||||
)
|
||||
}
|
||||
|
||||
// ≤ 2 camera tracks → focus layout (main + optional thumbnail)
|
||||
const localCameraTrack = cameraTracks.find(
|
||||
(track) => track.participant?.isLocal
|
||||
)
|
||||
|
||||
const remoteCameraTrack = cameraTracks.find(
|
||||
(track) => !track.participant?.isLocal
|
||||
)
|
||||
|
||||
const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack
|
||||
|
||||
const mainTrack = remoteCameraTrack ?? localCameraTrack
|
||||
const thumbnailTrack =
|
||||
mainTrack === localCameraTrack ? undefined : localCameraTrack
|
||||
|
||||
@@ -96,6 +104,31 @@ export const PipStage = () => {
|
||||
)
|
||||
}
|
||||
|
||||
type PaginationResult = {
|
||||
totalPageCount: number
|
||||
currentPage: number
|
||||
nextPage: () => void
|
||||
prevPage: () => void
|
||||
}
|
||||
|
||||
const PaginatedStage = ({
|
||||
pagination,
|
||||
children,
|
||||
}: {
|
||||
pagination: PaginationResult
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<StageWrapper>
|
||||
<StageFrame>{children}</StageFrame>
|
||||
<PipPagination
|
||||
totalPageCount={pagination.totalPageCount}
|
||||
currentPage={pagination.currentPage}
|
||||
nextPage={pagination.nextPage}
|
||||
prevPage={pagination.prevPage}
|
||||
/>
|
||||
</StageWrapper>
|
||||
)
|
||||
|
||||
const StageWrapper = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
|
||||
@@ -25,8 +25,8 @@ const Container = styled('div', {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
marginLeft: '0.5rem',
|
||||
marginRight: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
pin_code: string
|
||||
pin_code?: string
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
|
||||
@@ -36,6 +36,7 @@ import { PictureInPictureConference } from '@/features/pip/components/PictureInP
|
||||
import { notifyAutoMutedOnJoin } from '@/features/notifications/utils'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userPreferencesStore } from '@/stores/userPreferences'
|
||||
import { userStore } from '@/stores/user'
|
||||
|
||||
export const Conference = ({
|
||||
roomId,
|
||||
@@ -53,6 +54,8 @@ export const Conference = ({
|
||||
userChoices: LocalUserChoices
|
||||
}
|
||||
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
useEffect(() => {
|
||||
posthog.capture('visit-room', { slug: roomId })
|
||||
}, [roomId, posthog])
|
||||
@@ -83,10 +86,10 @@ export const Conference = ({
|
||||
queryFn: () =>
|
||||
fetchRoom({
|
||||
roomId: roomId as string,
|
||||
username: userConfig.username,
|
||||
username: username,
|
||||
}).catch((error) => {
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({ slug: roomId, username: userConfig.username })
|
||||
createRoom({ slug: roomId, username })
|
||||
}
|
||||
}),
|
||||
retry: false,
|
||||
|
||||
@@ -42,14 +42,17 @@ import {
|
||||
saveAudioInputDeviceId,
|
||||
saveAudioInputEnabled,
|
||||
saveAudioOutputDeviceId,
|
||||
saveUsername,
|
||||
saveVideoInputDeviceId,
|
||||
saveVideoInputEnabled,
|
||||
userChoicesStore,
|
||||
} from '@/stores/userChoices'
|
||||
|
||||
import { saveUsername, userStore } from '@/stores/user'
|
||||
|
||||
import { useCannotUseDevice } from '../livekit/hooks/useCannotUseDevice'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useUser } from '@/features/auth/api/useUser'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
@@ -114,6 +117,9 @@ export const Join = ({
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join' })
|
||||
|
||||
const { data: configData } = useConfig()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
|
||||
const {
|
||||
audioEnabled,
|
||||
videoEnabled,
|
||||
@@ -121,9 +127,10 @@ export const Join = ({
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
username,
|
||||
} = useSnapshot(userChoicesStore)
|
||||
|
||||
const { username } = useSnapshot(userStore)
|
||||
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
|
||||
if (initialUserChoices.current === null) {
|
||||
@@ -134,7 +141,6 @@ export const Join = ({
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorConfig,
|
||||
username,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,20 +454,23 @@ export const Join = ({
|
||||
<H lvl={1} margin="sm" centered>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
{(!isLoggedIn ||
|
||||
configData?.authenticated_users_can_edit_display_name) && (
|
||||
<Field
|
||||
type="text"
|
||||
onChange={saveUsername}
|
||||
label={t('usernameLabel')}
|
||||
id="input-name"
|
||||
defaultValue={username || user?.full_name}
|
||||
validate={(value) => !value && t('errors.usernameEmpty')}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
autoComplete="name"
|
||||
maxLength={50}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Participant } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useIsSpeaking } from '@livekit/components-react'
|
||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
import { useMemo, useRef } from 'react'
|
||||
@@ -10,7 +11,6 @@ const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -26,6 +26,10 @@ export const ParticipantPlaceholder = ({
|
||||
}: ParticipantPlaceholderProps) => {
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const participantColor = getParticipantColor(participant)
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(participantColor),
|
||||
[participantColor]
|
||||
)
|
||||
|
||||
const placeholderEl = useRef<HTMLDivElement>(null)
|
||||
const { width, height } = useSize(placeholderEl)
|
||||
@@ -39,7 +43,13 @@ export const ParticipantPlaceholder = ({
|
||||
const initialSize = useMemo(() => Math.round(avatarSize * 0.3), [avatarSize])
|
||||
|
||||
return (
|
||||
<StyledParticipantPlaceHolder ref={placeholderEl}>
|
||||
<StyledParticipantPlaceHolder
|
||||
ref={placeholderEl}
|
||||
style={{
|
||||
backgroundColor: participantColor,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
|
||||
@@ -52,6 +52,7 @@ export function TrackRefContextIfNeeded(
|
||||
|
||||
interface ParticipantTileExtendedProps extends ParticipantTileProps {
|
||||
disableMetadata?: boolean
|
||||
disableTileControls?: boolean
|
||||
}
|
||||
|
||||
export const ParticipantTile: (
|
||||
@@ -66,6 +67,7 @@ export const ParticipantTile: (
|
||||
onParticipantClick,
|
||||
disableSpeakingIndicator,
|
||||
disableMetadata,
|
||||
disableTileControls,
|
||||
...htmlProps
|
||||
}: ParticipantTileExtendedProps,
|
||||
ref
|
||||
@@ -231,7 +233,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { Button, Div } from '@/primitives'
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { ReactNode } from 'react'
|
||||
import { PanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import React, { ReactNode, useCallback, useRef } from 'react'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
@@ -15,6 +15,7 @@ import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -29,103 +30,115 @@ type StyledSidePanelProps = {
|
||||
isReactionToolbarOpen?: boolean
|
||||
}
|
||||
|
||||
const StyledSidePanel = ({
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
isReactionToolbarOpen,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
}: StyledSidePanelProps) => (
|
||||
<aside
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'box.border',
|
||||
backgroundColor: 'box.bg',
|
||||
color: 'box.text',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: 'var(--sizes-room-side-panel-margin)',
|
||||
marginLeft: 0,
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 'var(--sizes-room-side-panel)',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
})}
|
||||
style={{
|
||||
transform: isClosed
|
||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||
: 'none',
|
||||
bottom: isReactionToolbarOpen
|
||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||
: 'var(--sizes-room-control-bar)',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
const StyledSidePanel = React.forwardRef<HTMLElement, StyledSidePanelProps>(
|
||||
(
|
||||
{
|
||||
title,
|
||||
ariaLabel,
|
||||
children,
|
||||
onClose,
|
||||
isClosed,
|
||||
isReactionToolbarOpen,
|
||||
closeButtonTooltip,
|
||||
isSubmenu = false,
|
||||
onBack,
|
||||
backButtonLabel,
|
||||
},
|
||||
ref
|
||||
) => (
|
||||
<aside
|
||||
ref={ref}
|
||||
tabIndex={-1}
|
||||
className={css({
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'box.border',
|
||||
backgroundColor: 'box.bg',
|
||||
color: 'box.text',
|
||||
borderRadius: 8,
|
||||
flex: 1,
|
||||
position: 'absolute',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
margin: 'var(--sizes-room-side-panel-margin)',
|
||||
marginLeft: 0,
|
||||
marginBottom: 0,
|
||||
padding: 0,
|
||||
gap: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
width: 'var(--sizes-room-side-panel)',
|
||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||
'&:focus': {
|
||||
outline: 'none',
|
||||
},
|
||||
})}
|
||||
style={{
|
||||
transform: isClosed
|
||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||
: 'none',
|
||||
bottom: isReactionToolbarOpen
|
||||
? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))'
|
||||
: 'var(--sizes-room-control-bar)',
|
||||
}}
|
||||
aria-hidden={isClosed}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<HStack alignItems="center">
|
||||
{isSubmenu && (
|
||||
<Button
|
||||
variant="secondaryText"
|
||||
size="sm"
|
||||
square
|
||||
className={css({ marginRight: '0.5rem', marginLeft: '1rem' })}
|
||||
aria-label={backButtonLabel}
|
||||
onPress={onBack}
|
||||
>
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<RiArrowLeftLine size={20} aria-hidden="true" />
|
||||
</Button>
|
||||
)}
|
||||
<Heading
|
||||
slot="title"
|
||||
level={1}
|
||||
className={text({ variant: 'h2' })}
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
style={{
|
||||
paddingLeft: isSubmenu ? 0 : '1.5rem',
|
||||
paddingTop: '1rem',
|
||||
display: isClosed ? 'none' : 'flex',
|
||||
justifyContent: 'start',
|
||||
alignItems: 'center',
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
</HStack>
|
||||
<Div
|
||||
position="absolute"
|
||||
top="5"
|
||||
right="5"
|
||||
style={{
|
||||
display: isClosed ? 'none' : undefined,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
tooltip={closeButtonTooltip}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</aside>
|
||||
<Button
|
||||
invisible
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={onClose}
|
||||
aria-label={closeButtonTooltip}
|
||||
tooltip={closeButtonTooltip}
|
||||
>
|
||||
<RiCloseLine />
|
||||
</Button>
|
||||
</Div>
|
||||
{children}
|
||||
</aside>
|
||||
)
|
||||
)
|
||||
|
||||
StyledSidePanel.displayName = 'StyledSidePanel'
|
||||
|
||||
type PanelProps = {
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
@@ -162,8 +175,28 @@ export const SidePanel = () => {
|
||||
|
||||
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||
|
||||
const asideRef = useRef<HTMLElement>(null)
|
||||
|
||||
const focusAside = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
asideRef.current?.focus({ preventScroll: true })
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePanelOpened = useCallback(() => {
|
||||
if (activePanelId === PanelId.CHAT) return
|
||||
focusAside()
|
||||
}, [activePanelId, focusAside])
|
||||
|
||||
useRestoreFocus(isSidePanelOpen, {
|
||||
onOpened: handlePanelOpened,
|
||||
preventScroll: true,
|
||||
activeKey: activePanelId,
|
||||
})
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
ref={asideRef}
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
|
||||
@@ -95,8 +95,13 @@ const ToolButton = ({
|
||||
|
||||
export const Tools = () => {
|
||||
const { data } = useConfig()
|
||||
const { openTranscript, openScreenRecording, activeSubPanelId, isToolsOpen } =
|
||||
useSidePanel()
|
||||
const {
|
||||
openTranscript,
|
||||
openScreenRecording,
|
||||
activeSubPanelId,
|
||||
isToolsOpen,
|
||||
isSidePanelOpen,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
@@ -113,6 +118,7 @@ export const Tools = () => {
|
||||
},
|
||||
restoreFocusRaf: true,
|
||||
preventScroll: true,
|
||||
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||
})
|
||||
|
||||
const isTranscriptEnabled = useIsRecordingModeEnabled(
|
||||
|
||||
+4
-5
@@ -13,8 +13,10 @@ import {
|
||||
} from './TimerWorker'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
SELFIE_SEGMENTER_MODEL_PATH,
|
||||
type ProcessorConfig,
|
||||
type ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
@@ -153,13 +155,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
|
||||
async initSegmenter() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||
this.imageSegmenter = await ImageSegmenter.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_segmenter_landscape/float16/latest/selfie_segmenter_landscape.tflite',
|
||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||
delegate: 'CPU', // Use CPU for Firefox.
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import { ProcessorType } from '.'
|
||||
import {
|
||||
FACE_LANDMARKS_MODEL_PATH,
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
} from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256 * 3
|
||||
const PROCESSING_HEIGHT = 144 * 3
|
||||
@@ -128,13 +132,10 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
}
|
||||
|
||||
async initFaceLandmarker() {
|
||||
const vision = await FilesetResolver.forVisionTasks(
|
||||
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
|
||||
)
|
||||
const vision = await FilesetResolver.forVisionTasks(MEDIAPIPE_PATH_WASM)
|
||||
this.faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
|
||||
baseOptions: {
|
||||
modelAssetPath:
|
||||
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
|
||||
modelAssetPath: FACE_LANDMARKS_MODEL_PATH,
|
||||
delegate: 'GPU',
|
||||
},
|
||||
runningMode: 'VIDEO',
|
||||
|
||||
+19
-4
@@ -1,13 +1,14 @@
|
||||
import type { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
ProcessorWrapper,
|
||||
VirtualBackground,
|
||||
BackgroundProcessor,
|
||||
} from '@livekit/track-processors'
|
||||
import {
|
||||
type ProcessorConfig,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
MEDIAPIPE_PATH_WASM,
|
||||
SELFIE_SEGMENTER_MODEL_PATH,
|
||||
} from '.'
|
||||
|
||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||
@@ -20,10 +21,24 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
|
||||
if (opts.type === 'virtual') {
|
||||
this.processorType = ProcessorType.VIRTUAL
|
||||
this.processor = VirtualBackground(opts.imagePath)
|
||||
this.processor = BackgroundProcessor({
|
||||
mode: 'virtual-background',
|
||||
imagePath: opts.imagePath,
|
||||
assetPaths: {
|
||||
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||
},
|
||||
})
|
||||
} else if (opts.type === 'blur') {
|
||||
this.processorType = ProcessorType.BLUR
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
this.processor = BackgroundProcessor({
|
||||
mode: 'background-blur',
|
||||
blurRadius: opts.blurRadius,
|
||||
assetPaths: {
|
||||
tasksVisionFileSet: MEDIAPIPE_PATH_WASM,
|
||||
modelAssetPath: SELFIE_SEGMENTER_MODEL_PATH,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
throw new Error(
|
||||
'Must provide either imagePath for virtual background or blurRadius for blur'
|
||||
|
||||
@@ -4,6 +4,14 @@ import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||
|
||||
export const SELFIE_SEGMENTER_MODEL_PATH =
|
||||
'/assets/mediapipe/models/selfie_segmenter_landscape.tflite'
|
||||
|
||||
export const FACE_LANDMARKS_MODEL_PATH =
|
||||
'/assets/mediapipe/models/face_landmarker.task'
|
||||
|
||||
export const MEDIAPIPE_PATH_WASM = '/assets/mediapipe/wasm'
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
VIRTUAL = 'virtual',
|
||||
|
||||
@@ -37,7 +37,7 @@ export function Chat({ ...props }: ChatProps) {
|
||||
const room = useRoomContext()
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
|
||||
const { isChatOpen } = useSidePanel()
|
||||
const { isChatOpen, isSidePanelOpen } = useSidePanel()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
// Keep track of the element that opened the chat so we can restore focus
|
||||
@@ -51,6 +51,7 @@ export function Chat({ ...props }: ChatProps) {
|
||||
})
|
||||
},
|
||||
preventScroll: true,
|
||||
shouldRestoreOnClose: () => !isSidePanelOpen,
|
||||
})
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Builds a radial gradient from the participant's avatar color:
|
||||
* brighter in the center (where the avatar sits), darker at the edges.
|
||||
* Mixed in oklch so the color stays vivid as it darkens instead of greying out.
|
||||
*/
|
||||
export const getParticipantBackgroundGradient = (color: string): string =>
|
||||
`radial-gradient(circle at 50% 45%,
|
||||
color-mix(in oklch, ${color} 82%, white) 0%,
|
||||
color-mix(in oklch, ${color} 90%, white) 8%,
|
||||
${color} 35%,
|
||||
color-mix(in oklch, ${color} 85%, black) 65%,
|
||||
color-mix(in oklch, ${color} 65%, black) 100%)`
|
||||
@@ -8,8 +8,9 @@ import { HStack } from '@/styled-system/jsx'
|
||||
import { useState } from 'react'
|
||||
import { LoginButton } from '@/components/LoginButton'
|
||||
import { useRenameParticipant } from '@/features/rooms/api/renameParticipant'
|
||||
import { saveUsername } from '@/stores/userChoices'
|
||||
import { saveUsername } from '@/stores/user'
|
||||
import { logout } from '@/features/auth/utils/logout'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
Pick<TabPanelProps, 'id'>
|
||||
@@ -17,6 +18,7 @@ export type AccountTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
const { t } = useTranslation('settings')
|
||||
const room = useRoomContext()
|
||||
const { data } = useConfig()
|
||||
const { user, isLoggedIn } = useUser()
|
||||
|
||||
const { renameParticipant } = useRenameParticipant()
|
||||
@@ -45,15 +47,17 @@ export const AccountTab = ({ id, onOpenChange }: AccountTabProps) => {
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<H lvl={2}>{t('account.heading')}</H>
|
||||
<Field
|
||||
type="text"
|
||||
label={t('account.nameLabel')}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('account.nameError')}</p> : null
|
||||
}}
|
||||
/>
|
||||
{(!isLoggedIn || data?.authenticated_users_can_edit_display_name) && (
|
||||
<Field
|
||||
type="text"
|
||||
label={t('account.nameLabel')}
|
||||
value={name}
|
||||
onChange={setName}
|
||||
validate={(value) => {
|
||||
return !value ? <p>{t('account.nameError')}</p> : null
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<H lvl={2}>{t('account.authentication')}</H>
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
|
||||
@@ -6,6 +6,10 @@ export type RestoreFocusOptions = {
|
||||
onClosed?: () => void
|
||||
restoreFocusRaf?: boolean
|
||||
preventScroll?: boolean
|
||||
/** When the panel stays open but its content changes, update the restore target. */
|
||||
activeKey?: string | null
|
||||
/** Return false to skip restoring focus on close (e.g. when switching to another panel). */
|
||||
shouldRestoreOnClose?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,9 +26,12 @@ export function useRestoreFocus(
|
||||
onClosed,
|
||||
restoreFocusRaf = false,
|
||||
preventScroll = true,
|
||||
activeKey,
|
||||
shouldRestoreOnClose,
|
||||
} = options
|
||||
|
||||
const prevIsOpenRef = useRef(false)
|
||||
const prevActiveKeyRef = useRef(activeKey)
|
||||
const triggerRef = useRef<HTMLElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,25 +44,41 @@ export function useRestoreFocus(
|
||||
onOpened?.()
|
||||
}
|
||||
|
||||
// Panel switched while staying open
|
||||
if (wasOpen && isOpen && activeKey !== prevActiveKeyRef.current) {
|
||||
const activeEl = document.activeElement as HTMLElement | null
|
||||
triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl
|
||||
onOpened?.()
|
||||
}
|
||||
|
||||
// Just closed
|
||||
if (wasOpen && !isOpen) {
|
||||
const trigger = triggerRef.current
|
||||
if (trigger && document.contains(trigger)) {
|
||||
const shouldRestore =
|
||||
(shouldRestoreOnClose?.() ?? true) &&
|
||||
trigger &&
|
||||
document.contains(trigger)
|
||||
|
||||
if (shouldRestore) {
|
||||
const focus = () => trigger.focus({ preventScroll })
|
||||
if (restoreFocusRaf) requestAnimationFrame(focus)
|
||||
else focus()
|
||||
}
|
||||
|
||||
triggerRef.current = null
|
||||
onClosed?.()
|
||||
}
|
||||
|
||||
prevIsOpenRef.current = isOpen
|
||||
prevActiveKeyRef.current = activeKey
|
||||
}, [
|
||||
isOpen,
|
||||
activeKey,
|
||||
onClosed,
|
||||
onOpened,
|
||||
preventScroll,
|
||||
resolveTrigger,
|
||||
restoreFocusRaf,
|
||||
shouldRestoreOnClose,
|
||||
])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { STORAGE_KEYS } from '@/utils/storageKeys'
|
||||
|
||||
type State = {
|
||||
username: string
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
username: '',
|
||||
}
|
||||
|
||||
function getUserState(): State {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.USER)
|
||||
if (!stored) return DEFAULT_STATE
|
||||
const parsed = JSON.parse(stored)
|
||||
return {
|
||||
...parsed,
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'[UserPreferencesStore] Failed to parse stored settings:',
|
||||
error
|
||||
)
|
||||
return DEFAULT_STATE
|
||||
}
|
||||
}
|
||||
|
||||
export const userStore = proxy<State>(getUserState())
|
||||
|
||||
subscribe(userStore, () => {
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(userStore))
|
||||
})
|
||||
|
||||
export const saveUsername = (username: string) => {
|
||||
userStore.username = username
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import { VideoQuality } from 'livekit-client'
|
||||
|
||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
export type LocalUserChoices = Omit<LocalUserChoicesLK, 'username'> & {
|
||||
processorConfig?: ProcessorConfig
|
||||
noiseReductionEnabled?: boolean
|
||||
audioOutputDeviceId?: string
|
||||
@@ -32,7 +32,13 @@ function getUserChoicesState(): LocalUserChoices {
|
||||
|
||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||
subscribe(userChoicesStore, () => {
|
||||
saveUserChoices(userChoicesStore, false)
|
||||
// TEMPORARY: cast needed because our store omits `username`, which we no
|
||||
// longer persis in this store, while LiveKit's `saveUserChoices` still expects the full
|
||||
// `LocalUserChoices` shape. `username` ends up `undefined` in the saved
|
||||
// object, which `saveUserChoices` tolerates at runtime.
|
||||
// We are migrating away from LiveKit's persistence logic to our own store
|
||||
// handling for more control — this cast can be removed once that lands.
|
||||
saveUserChoices(userChoicesStore as LocalUserChoicesLK, false)
|
||||
})
|
||||
|
||||
// we run some logic on store loading to check if the processor config is still valid
|
||||
@@ -90,10 +96,6 @@ export const saveVideoSubscribeQuality = (quality: VideoQuality) => {
|
||||
userChoicesStore.videoSubscribeQuality = quality
|
||||
}
|
||||
|
||||
export const saveUsername = (username: string) => {
|
||||
userChoicesStore.username = username
|
||||
}
|
||||
|
||||
export const saveNoiseReductionEnabled = (enabled: boolean) => {
|
||||
userChoicesStore.noiseReductionEnabled = enabled
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
export const STORAGE_KEYS = {
|
||||
NOTIFICATIONS: 'app_notification_settings',
|
||||
USER_PREFERENCES: 'app_user_preferences',
|
||||
USER: 'app_user',
|
||||
ACCESSIBILITY: 'app_accessibility_settings',
|
||||
} as const
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { visualizer } from 'rollup-plugin-visualizer'
|
||||
import svgr from 'vite-plugin-svgr'
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(({ mode }) => {
|
||||
@@ -11,18 +12,32 @@ export default defineConfig(({ mode }) => {
|
||||
react(),
|
||||
svgr({
|
||||
svgrOptions: {
|
||||
replaceAttrValues: { '#000': 'currentColor', '#000000': 'currentColor', '#1f1f1f': 'currentColor' },
|
||||
replaceAttrValues: {
|
||||
'#000': 'currentColor',
|
||||
'#000000': 'currentColor',
|
||||
'#1f1f1f': 'currentColor',
|
||||
},
|
||||
},
|
||||
}),
|
||||
env.VITE_ANALYZE === 'true' && visualizer({
|
||||
open: true,
|
||||
filename: 'rollup-plugin-visualizer/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
viteStaticCopy({
|
||||
targets: [
|
||||
{
|
||||
src: 'node_modules/@mediapipe/tasks-vision/wasm/*',
|
||||
dest: 'assets/mediapipe/wasm',
|
||||
rename: { stripBase: 4 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
env.VITE_ANALYZE === 'true' &&
|
||||
visualizer({
|
||||
open: true,
|
||||
filename: 'rollup-plugin-visualizer/stats.html',
|
||||
gzipSize: true,
|
||||
brotliSize: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
tsconfigPaths: true
|
||||
tsconfigPaths: true,
|
||||
},
|
||||
build: {
|
||||
sourcemap: env.VITE_BUILD_SOURCEMAP === 'true',
|
||||
|
||||
@@ -191,6 +191,8 @@ backend:
|
||||
SUMMARY_SERVICE_API_TOKEN: password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||
OIDC_USERINFO_FULLNAME_FIELDS: first_name, last_name
|
||||
OIDC_USERINFO_SHORTNAME_FIELD: first_name
|
||||
|
||||
migrate:
|
||||
command:
|
||||
|
||||
@@ -6,4 +6,4 @@ DIR_MAILS="../backend/core/templates/mail/html/"
|
||||
if [ ! -d "${DIR_MAILS}" ]; then
|
||||
mkdir -p "${DIR_MAILS}";
|
||||
fi
|
||||
mjml mjml/*.mjml -o "${DIR_MAILS}";
|
||||
mjml mjml/*.mjml -o "${DIR_MAILS}" --config.allowIncludes true;
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<!--
|
||||
We load django tags here so they appear in the body of the HTML output.
|
||||
This ensures html-to-text also includes them in the plain text template.
|
||||
-->
|
||||
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
@@ -59,7 +64,7 @@
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-wrapper>
|
||||
</mj-body>
|
||||
|
||||
<mj-include path="./partial/footer.mjml" />
|
||||
<mj-include path="./partial/footer.mjml" />
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
<mj-head>
|
||||
<mj-title>{{ title }}</mj-title>
|
||||
<mj-preview>
|
||||
<!--
|
||||
We load django tags here, in this way there are put within the body in html output
|
||||
so the html-to-text command includes it within its output
|
||||
-->
|
||||
{% load i18n static extra_tags %}
|
||||
{{ title }}
|
||||
</mj-preview>
|
||||
<mj-preview>{{ title }}</mj-preview>
|
||||
<mj-font name="Roboto" href="https://fonts.bunny.net/css?family=roboto:400,700,900" />
|
||||
<mj-attributes>
|
||||
<mj-font name="Roboto" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700;900&display=swap" />
|
||||
<mj-all
|
||||
font-family="Roboto, -apple-system, BlinkMacSystemFont, 'Segoe UI', Oxygen, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif"
|
||||
font-size="16px"
|
||||
line-height="1.5em"
|
||||
color="#3A3A3A"
|
||||
/>
|
||||
<mj-text font-family="Roboto, sans-serif" />
|
||||
<mj-button font-family="Roboto, sans-serif" />
|
||||
</mj-attributes>
|
||||
<mj-style>
|
||||
/* Reset */
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<mj-include path="./partial/header.mjml" />
|
||||
|
||||
<mj-body mj-class="bg--blue-100">
|
||||
<!--
|
||||
We load django tags here so they appear in the body of the HTML output.
|
||||
This ensures html-to-text also includes them in the plain text template.
|
||||
-->
|
||||
<mj-raw>{% load i18n static extra_tags %}</mj-raw>
|
||||
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
|
||||
<mj-section css-class="wrapper-logo">
|
||||
<mj-column>
|
||||
|
||||
Generated
+1904
-942
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
"mjml": "4.18.0"
|
||||
"@html-to/text-cli": "0.6.0",
|
||||
"mjml": "5.4.0"
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.22.0",
|
||||
"version": "1.23.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.22.0"
|
||||
version = "1.23.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
|
||||
@@ -59,17 +59,16 @@ async def create_transcribe_task_v2(
|
||||
]
|
||||
)
|
||||
|
||||
properties = {}
|
||||
if request.user_email:
|
||||
properties["$set"] = {"email": request.user_email}
|
||||
|
||||
# We track the request, this also properly initializes the user in the
|
||||
# analytics system, so that later feature flags work properly
|
||||
analytics.capture(
|
||||
settings.posthog_event_request,
|
||||
settings.posthog_transcript_request,
|
||||
request.user_sub,
|
||||
properties={
|
||||
"kind": "transcribe",
|
||||
"$set": {
|
||||
"email": request.user_email,
|
||||
},
|
||||
},
|
||||
properties=properties,
|
||||
)
|
||||
|
||||
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||
@@ -93,15 +92,15 @@ async def create_summarize_task_v2(
|
||||
|
||||
# We track the request, this also properly initializes the user in the
|
||||
# analytics system, so that later feature flags work properly
|
||||
|
||||
properties = {}
|
||||
if request.user_email:
|
||||
properties["$set"] = {"email": request.user_email}
|
||||
|
||||
analytics.capture(
|
||||
settings.posthog_event_request,
|
||||
settings.posthog_summary_request,
|
||||
request.user_sub,
|
||||
properties={
|
||||
"kind": "summarize",
|
||||
"$set": {
|
||||
"email": request.user_email,
|
||||
},
|
||||
},
|
||||
properties=properties,
|
||||
)
|
||||
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from celery.utils.log import get_task_logger
|
||||
from posthog import Posthog
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.models import TranscribeTaskJob
|
||||
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
settings = get_settings()
|
||||
@@ -109,19 +109,15 @@ class MetadataManager:
|
||||
"""Check if task_id exists in tasks metadata cache."""
|
||||
return self._redis.exists(self._get_redis_key(task_id))
|
||||
|
||||
def create(self, task_id: str, task_payload: TranscribeTaskJob):
|
||||
def create(self, task_id: str, task_payload: TranscribeTaskJob | SummarizeTaskJob):
|
||||
"""Create initial metadata entry for a new task."""
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
start_time = time.time()
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
initial_metadata = {
|
||||
"start_time": start_time,
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
"filename": clean_url,
|
||||
"sub": task_payload.user_sub,
|
||||
# avoid None in redis, it shouldn't happen anyway in prod
|
||||
"email": task_payload.user_email or "",
|
||||
@@ -129,6 +125,15 @@ class MetadataManager:
|
||||
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
|
||||
}
|
||||
|
||||
if isinstance(task_payload, TranscribeTaskJob):
|
||||
parts = urlsplit(task_payload.cloud_storage_url)
|
||||
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||
initial_metadata["source_url"] = clean_url
|
||||
initial_metadata["asr_model"] = settings.whisperx_asr_model
|
||||
elif isinstance(task_payload, SummarizeTaskJob):
|
||||
initial_metadata["content_length"] = len(task_payload.content)
|
||||
initial_metadata["llm_model"] = settings.llm_model
|
||||
|
||||
self._save_metadata(task_id, initial_metadata)
|
||||
|
||||
def retry(self, task_id):
|
||||
|
||||
@@ -542,28 +542,28 @@ def process_audio_transcribe_v2_task(
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_event_success)
|
||||
metadata_manager.capture(job_id, settings.posthog_transcript_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
def task_started_transcript(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, TranscribeTaskJob.model_validate(args[0]))
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
def task_retry_handler_transcript(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
def task_failure_handler_transcript(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
metadata_manager.capture(task_id, settings.posthog_transcript_failure)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||
@@ -648,9 +648,30 @@ def summarize_v2_task(
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_summary_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=summarize_v2_task)
|
||||
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
if args:
|
||||
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=summarize_v2_task)
|
||||
def task_retry_handler_summary(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||
def task_failure_handler_summary(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_summary_failure)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=summarize_v2_task)
|
||||
def handle_summarize_v2_failed(
|
||||
sender,
|
||||
|
||||
@@ -130,9 +130,12 @@ class Settings(BaseSettings):
|
||||
posthog_enabled: bool = False
|
||||
posthog_api_key: Optional[str] = None
|
||||
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
||||
posthog_event_failure: str = "transcript-failure"
|
||||
posthog_event_success: str = "transcript-success"
|
||||
posthog_event_request: str = "transcript-request"
|
||||
posthog_transcript_request: str = "transcript-request"
|
||||
posthog_transcript_failure: str = "transcript-failure"
|
||||
posthog_transcript_success: str = "transcript-success"
|
||||
posthog_summary_request: str = "summary-request"
|
||||
posthog_summary_failure: str = "summary-failure"
|
||||
posthog_summary_success: str = "summary-success"
|
||||
|
||||
# Langfuse (LLM Observability)
|
||||
langfuse_enabled: bool = False
|
||||
|
||||
Reference in New Issue
Block a user