Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 867a14d0a3 🐛(frontend) promote the active speaker in the carousel layout
The stage stopped re-rendering on ActiveSpeakersChanged when useTracks
moved to `updateOnlyOn: []`, a change made to avoid re-rendering the
whole stage on every speaker update. useVisualStableUpdate promotes
speakers only when the layout renders, so in the focus layout's
carousel a speaker outside the visible tiles was no longer brought
into view. The grid layout already had a fix,
useSpeakerPromotionTrigger, but the carousel did not.

Fix: the carousel now uses the same tripwire on the tiles it
guarantees to show. The tripwire also ignores speakers with no tile in
the layout (e.g. the pinned participant, shown in the focus view);
otherwise they would trigger a useless render on every speaker event.

Enhancement: the active speaker is now pinned near the top of the
column instead of landing anywhere in the visible span:
- the local camera stays first and the top speaker's camera second;
  the rest is still ordered by useVisualStableUpdate
- the top speaker is the loudest remote participant with a tile;
  the local participant is never promoted
- the slot is sticky through silence, and a new speaker only takes it
  after the current one has held it for 2s and only if still speaking,
  so a back-and-forth doesn't make tiles flip
- the slot is reserved before anyone speaks, so the first promotion
  doesn't reshuffle the whole column
- the tripwire still promotes other hidden speakers into the rest

Re-renders: useTopSpeaker only renders when the top speaker changes.
It runs on the same event as the tripwire, and React 18 merges their
updates into a single render.

The ordering (splitCarouselTiles) and the slot timing
(watchTopSpeaker) are framework-free.
2026-10-12 00:19:48 +02:00
33 changed files with 236 additions and 495 deletions
+1 -8
View File
@@ -10,17 +10,9 @@ and this project adheres to
### Added
- ⚡️(backend) add UPPER(email) index for case-insensitive user lookups
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) allow static feature flags without an analytics backend #1809
- ⚡️(backend) add an opt-in psycopg connection pool
### Changed
@@ -39,6 +31,7 @@ and this project adheres to
- 🐛(frontend) omit the default speaker id sent to LiveKit
- 🐛(frontend) play the speaker test when no output is picked
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
- 🐛(frontend) promote the active speaker in the carousel layout
## [1.34.0] - 2026-10-07
+13 -13
View File
@@ -5,17 +5,17 @@ services:
env_file:
- env.d/development/postgresql
ports:
- "127.0.0.1:15432:5432"
- "15432:5432"
redis:
image: redis:5
ports:
- "127.0.0.1:6379:6379"
- "6379:6379"
mailcatcher:
image: sj26/mailcatcher:latest
ports:
- "127.0.0.1:1081:1080"
- "1081:1080"
garage:
user: ${DOCKER_USER:-1000}
@@ -71,7 +71,7 @@ services:
- env.d/development/common
- env.d/development/postgresql
ports:
- "127.0.0.1:8071:8000"
- "8071:8000"
volumes:
- ./src/backend:/app
- ./data/static:/data/static
@@ -137,7 +137,7 @@ services:
nginx:
image: nginx:1.25
ports:
- "127.0.0.1:8083:8083"
- "8083:8083"
volumes:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
@@ -159,7 +159,7 @@ services:
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
- "127.0.0.1:3000:8080"
- "3000:8080"
dockerize:
image: jwilder/dockerize
@@ -185,7 +185,7 @@ services:
kc_postgresql:
image: postgres:14.3
ports:
- "127.0.0.1:5433:5432"
- "5433:5432"
env_file:
- env.d/development/kc_postgresql
@@ -213,7 +213,7 @@ services:
KC_DB_SCHEMA: public
PROXY_ADDRESS_FORWARDING: 'true'
ports:
- "127.0.0.1:8080:8080"
- "8080:8080"
depends_on:
- kc_postgresql
@@ -222,10 +222,10 @@ services:
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports:
- "7880:7880"
- "127.0.0.1:7881:7881"
- "127.0.0.1:7882:7882/udp"
- "127.0.0.1:3478:3478/udp"
- "127.0.0.1:30000-30100:30000-30100/udp"
- "7881:7881"
- "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on:
@@ -283,7 +283,7 @@ services:
env_file:
- env.d/development/summary
ports:
- "127.0.0.1:8001:8000"
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
+1 -1
View File
@@ -45,4 +45,4 @@ services:
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "127.0.0.1:8081:8080"
- "8081:8080"
-20
View File
@@ -120,26 +120,6 @@ DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Nu
DJANGO_EMAIL_LOGO_IMG=<logo image to use in email templates.> # e.g. "https://meet.yourdomain.tld/assets/logo-suite-numerique.png"
```
### Feature flags without an analytics backend
Some features, such as automatic meeting summaries (`summary-enabled`), are gated by a feature flag read from the analytics backend. You do not need an analytics backend to enable them: leave `ANALYTICS_BACKEND` unset and declare the flags in `env.d/common`. No data is sent anywhere.
```env
ANALYTICS_BACKEND_SETTINGS={"feature_flags": {"summary-enabled": True}}
```
You can also set it directly in `compose.yaml`:
```yaml
services:
backend:
environment:
ANALYTICS_BACKEND_SETTINGS: '{"feature_flags": {"summary-enabled": True}}'
```
> [!NOTE]
> The value is a Python dictionary, not JSON: write `True`, not `true`. Set it on every container running the backend image.
## Step 3: Configure your firewall
If you are using a firewall as it is usually recommended in a production environment you will need to allow the webservice traffic on ports 80 and 443 but also to allow UDP traffic for the WebRTC service.
+1 -8
View File
@@ -318,17 +318,12 @@ These are the environmental options available on meet backend.
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| DB_PSYCOPG_POOL_ENABLED | Enable the psycopg connection pool | false |
| DB_PSYCOPG_POOL_MIN_SIZE | Connections each pool keeps open | 1 |
| DB_PSYCOPG_POOL_MAX_SIZE | Maximum connections per pool | 3 |
| DB_PSYCOPG_POOL_TIMEOUT | Seconds a request waits for a free pool connection before failing | 30 |
| DB_PSYCOPG_POOL_MAX_IDLE | Seconds an idle connection above the minimum stays open | 600 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
@@ -421,8 +416,6 @@ These are the environmental options available on meet backend.
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| ANALYTICS_BACKEND | Analytics backend class, e.g. `core.analytics.posthog.PostHogAnalytics`. Nothing is sent when unset | |
| ANALYTICS_BACKEND_SETTINGS | Analytics backend settings. Without a backend, `{"feature_flags": {"summary-enabled": True}}` sets feature flags statically | {} |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
+1 -2
View File
@@ -8,5 +8,4 @@ DB_HOST=postgresql
DB_NAME=meet
DB_USER=dinum
DB_PASSWORD=pass
DB_PORT=5432
DB_PSYCOPG_POOL_ENABLED=True
DB_PORT=5432
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.35.0"
version = "1.34.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.35.0"
version = "1.34.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
+1 -1
View File
@@ -39,7 +39,7 @@ def get_analytics() -> AnalyticsBackend:
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics(**options)
return NoOpAnalytics()
backend_class = import_string(dotted_path)
return backend_class(**options)
+1 -24
View File
@@ -53,23 +53,7 @@ class AnalyticsBackend(ABC):
class NoOpAnalytics(AnalyticsBackend):
"""
Default backend: discards events and serves static feature flags.
Feature flags can be declared without any analytics provider, e.g.:
ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
"""
def __init__(
self,
*,
feature_flags: Mapping[str, bool | str | None] | None = None,
**_kwargs: Any,
) -> None:
# Other settings are ignored, so that settings meant for another
# backend never break an instance running without one.
self._feature_flags = dict(feature_flags or {})
"""Default backend: silently discards everything."""
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
@@ -79,10 +63,3 @@ class NoOpAnalytics(AnalyticsBackend):
def shutdown(self) -> None:
"""No-op: nothing to flush."""
def get_user_feature_flags(
self,
user: User,
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return the feature flags declared in settings, for any user."""
return self._feature_flags
@@ -1,23 +0,0 @@
# Generated by Django 5.2.14 on 2026-10-07 18:45
from django.contrib.postgres.operations import AddIndexConcurrently
from django.db import migrations, models
from django.db.models.functions import Upper
class Migration(migrations.Migration):
atomic = False
dependencies = [
('core', '0026_alter_application_scopes'),
]
operations = [
AddIndexConcurrently(
model_name="user",
index=models.Index(
Upper("email"),
name="user_email_upper_idx",
),
),
]
-3
View File
@@ -246,9 +246,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
name="unique_email_when_sub_is_null",
)
]
indexes = [
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
]
def __str__(self):
return self.email or self.admin_email or str(self.id)
@@ -1,124 +0,0 @@
"""
Unit tests for NoOpAnalytics and the analytics backend loader.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from core.analytics import get_analytics
from core.analytics.base import NoOpAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture(autouse=True)
def clear_analytics_cache():
"""Clear the cached backend before and after each test."""
get_analytics.cache_clear()
yield
get_analytics.cache_clear()
# ==============================
# NoOpAnalytics
# ==============================
def test_noop_has_no_feature_flags_by_default():
"""Should keep every feature flag disabled when none is declared."""
backend = NoOpAnalytics()
user = UserFactory()
assert not backend.get_user_feature_flags(user)
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_enables_declared_feature_flags():
"""Should enable a feature flag declared as True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": True})
user = UserFactory()
assert backend.get_user_feature_flags(user) == {"summary-enabled": True}
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@pytest.mark.parametrize("value", [False, None, "true"])
def test_noop_only_enables_feature_flags_set_to_true(value):
"""Should keep a feature flag disabled unless its value is exactly True."""
backend = NoOpAnalytics(feature_flags={"summary-enabled": value})
user = UserFactory()
assert (
backend.is_user_feature_enabled(
user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is False
)
def test_noop_ignores_unrelated_settings():
"""Should accept settings meant for another backend without failing."""
backend = NoOpAnalytics(api_key="my-key", host="https://eu.i.posthog.com")
assert not backend.get_user_feature_flags(UserFactory())
# ==============================
# get_analytics
# ==============================
def test_get_analytics_defaults_to_noop_without_feature_flags(settings):
"""Should return a no-op backend with no feature flag by default."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert not backend.get_user_feature_flags(UserFactory())
def test_get_analytics_passes_feature_flags_to_noop(settings):
"""Should forward the feature flags when no backend is configured."""
settings.ANALYTICS_BACKEND = None
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
def test_get_analytics_instantiates_configured_backend_with_settings(settings):
"""Should instantiate the configured backend with its settings."""
settings.ANALYTICS_BACKEND = "core.analytics.base.NoOpAnalytics"
settings.ANALYTICS_BACKEND_SETTINGS = {"feature_flags": {"summary-enabled": True}}
backend = get_analytics()
assert isinstance(backend, NoOpAnalytics)
assert (
backend.is_user_feature_enabled(
UserFactory(), UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
is True
)
@@ -6,7 +6,6 @@ from concurrent.futures import ThreadPoolExecutor
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
from django.db import connection
from django.utils import timezone
import pytest
@@ -362,21 +361,18 @@ def test_api_files_create_file_race_condition():
"""
def create_item(title):
try:
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
finally:
connection.close()
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_item, "my item 1")
@@ -5,7 +5,6 @@ from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from django.core.files.storage import default_storage
from django.db import connection
import pytest
from rest_framework.test import APIClient
@@ -296,12 +295,9 @@ def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
def call_upload_ended():
try:
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
finally:
connection.close()
client = APIClient()
client.force_login(user)
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
with ThreadPoolExecutor(max_workers=2) as executor:
futures = [
@@ -1,130 +0,0 @@
"""Unit tests for the opt-in psycopg connection pool settings."""
# pylint: disable=protected-access
from django.core.exceptions import ImproperlyConfigured
import pytest
from meet import settings
def make_configuration(**overrides):
"""Build a throwaway configuration with plain values and its own DATABASES."""
attributes = {
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {"sslmode": "require"},
}
},
"DB_PSYCOPG_POOL_ENABLED": True,
"DB_PSYCOPG_POOL_MIN_SIZE": 1,
"DB_PSYCOPG_POOL_MAX_SIZE": 3,
"DB_PSYCOPG_POOL_TIMEOUT": 30.0,
"DB_PSYCOPG_POOL_MAX_IDLE": 600.0,
**overrides,
}
return type("PoolConfiguration", (settings.Test,), attributes)
def test_database_pool_disabled_leaves_options_untouched():
"""Without the flag, no connection pool is configured."""
configuration = make_configuration(DB_PSYCOPG_POOL_ENABLED=False)
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"] == {"sslmode": "require"}
assert "CONN_HEALTH_CHECKS" not in configuration.DATABASES["default"]
def test_database_pool_enabled_merges_pool_options():
"""The pool and keepalive options are added next to the existing ones."""
configuration = make_configuration()
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"] == {
"sslmode": "require",
"keepalives_idle": 60,
"keepalives_interval": 10,
"keepalives_count": 5,
"pool": {"min_size": 1, "max_size": 3, "timeout": 30.0, "max_idle": 600.0},
}
def test_database_pool_enabled_keeps_explicit_keepalives():
"""Keepalive values already set, e.g. from DATABASE_URL, are not overridden."""
configuration = make_configuration(
DATABASES={
"default": {
"ENGINE": "django.db.backends.postgresql",
"OPTIONS": {"keepalives_idle": "30", "keepalives": "0"},
}
}
)
configuration._configure_database_pool()
options = configuration.DATABASES["default"]["OPTIONS"]
assert options["keepalives_idle"] == "30"
assert options["keepalives"] == "0"
assert options["keepalives_interval"] == 10
@pytest.mark.parametrize("health_checks", [None, False])
def test_database_pool_enabled_forces_health_checks(health_checks):
"""Pooled connections are checked on checkout, even if DATABASE_URL said no."""
database = {"ENGINE": "django.db.backends.postgresql"}
if health_checks is not None:
database["CONN_HEALTH_CHECKS"] = health_checks
configuration = make_configuration(DATABASES={"default": database})
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["CONN_HEALTH_CHECKS"] is True
def test_database_pool_enabled_without_options():
"""A database without OPTIONS, as built from DATABASE_URL, gets them created."""
configuration = make_configuration(
DATABASES={"default": {"ENGINE": "django.db.backends.postgresql_psycopg2"}}
)
configuration._configure_database_pool()
assert configuration.DATABASES["default"]["OPTIONS"]["pool"]["max_size"] == 3
@pytest.mark.parametrize(
"overrides,match",
[
(
{"DATABASES": {"default": {"ENGINE": "django.db.backends.sqlite3"}}},
"requires the django.db.backends.postgresql engine",
),
(
{
"DATABASES": {
"default": {
"ENGINE": "django.db.backends.postgresql",
"CONN_MAX_AGE": 60,
}
}
},
"cannot be combined with persistent connections",
),
(
{"DB_PSYCOPG_POOL_MIN_SIZE": 6},
"MIN_SIZE cannot be greater than",
),
({"DB_PSYCOPG_POOL_TIMEOUT": 0.0}, "must be positive"),
({"DB_PSYCOPG_POOL_MAX_IDLE": -1.0}, "must be positive"),
],
)
def test_database_pool_invalid_configuration(overrides, match):
"""An invalid pool setup fails at boot."""
configuration = make_configuration(**overrides)
with pytest.raises(ImproperlyConfigured, match=match):
configuration._configure_database_pool()
+1 -64
View File
@@ -17,7 +17,6 @@ import warnings
from os import path
from socket import gethostbyname, gethostname
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import gettext_lazy as _
import dj_database_url
@@ -140,7 +139,7 @@ class Base(Configuration):
)
else {
"ENGINE": values.Value(
"django.db.backends.postgresql",
"django.db.backends.postgresql_psycopg2",
environ_name="DB_ENGINE",
environ_prefix=None,
),
@@ -155,21 +154,6 @@ class Base(Configuration):
"PORT": values.Value(5432, environ_name="DB_PORT", environ_prefix=None),
}
}
DB_PSYCOPG_POOL_ENABLED = values.BooleanValue(
False, environ_name="DB_PSYCOPG_POOL_ENABLED", environ_prefix=None
)
DB_PSYCOPG_POOL_MIN_SIZE = values.PositiveIntegerValue(
1, environ_name="DB_PSYCOPG_POOL_MIN_SIZE", environ_prefix=None
)
DB_PSYCOPG_POOL_MAX_SIZE = values.PositiveIntegerValue(
3, environ_name="DB_PSYCOPG_POOL_MAX_SIZE", environ_prefix=None
)
DB_PSYCOPG_POOL_TIMEOUT = values.FloatValue(
30.0, environ_name="DB_PSYCOPG_POOL_TIMEOUT", environ_prefix=None
)
DB_PSYCOPG_POOL_MAX_IDLE = values.FloatValue(
600.0, environ_name="DB_PSYCOPG_POOL_MAX_IDLE", environ_prefix=None
)
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Static files (CSS, JavaScript, Images)
@@ -1462,52 +1446,6 @@ class Base(Configuration):
stacklevel=2,
)
@classmethod
def _configure_database_pool(cls):
"""Inject the psycopg pool options into the default database when enabled."""
if not cls.DB_PSYCOPG_POOL_ENABLED:
return
database = cls.DATABASES["default"]
if database.get("ENGINE") not in (
"django.db.backends.postgresql",
"django.db.backends.postgresql_psycopg2",
):
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_ENABLED requires the django.db.backends.postgresql "
f"engine, got {database.get('ENGINE')!r}."
)
if database.get("CONN_MAX_AGE"):
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_ENABLED cannot be combined with persistent "
"connections; set CONN_MAX_AGE to 0."
)
if cls.DB_PSYCOPG_POOL_MIN_SIZE > cls.DB_PSYCOPG_POOL_MAX_SIZE:
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_MIN_SIZE cannot be greater than "
"DB_PSYCOPG_POOL_MAX_SIZE."
)
if cls.DB_PSYCOPG_POOL_TIMEOUT <= 0 or cls.DB_PSYCOPG_POOL_MAX_IDLE <= 0:
raise ImproperlyConfigured(
"DB_PSYCOPG_POOL_TIMEOUT and DB_PSYCOPG_POOL_MAX_IDLE must be positive."
)
database["OPTIONS"] = {
"keepalives_idle": 60,
"keepalives_interval": 10,
"keepalives_count": 5,
**database.get("OPTIONS", {}),
"pool": {
"min_size": cls.DB_PSYCOPG_POOL_MIN_SIZE,
"max_size": cls.DB_PSYCOPG_POOL_MAX_SIZE,
"timeout": cls.DB_PSYCOPG_POOL_TIMEOUT,
"max_idle": cls.DB_PSYCOPG_POOL_MAX_IDLE,
},
}
# The pool does not watch idle connections: check each one on checkout
# so a broken connection is replaced instead of failing the request.
database["CONN_HEALTH_CHECKS"] = True
@classmethod
def post_setup(cls):
"""Post setup configuration.
@@ -1522,7 +1460,6 @@ class Base(Configuration):
)
cls._check_recording_encoding_maps()
cls._configure_database_pool()
if (
cls.SUMMARY_SERVICE_VERSION == 1
+2 -2
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.35.0"
version = "1.34.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -51,7 +51,7 @@ dependencies = [
"markdown==3.10.3",
"nested-multipart-parser==1.6.0",
"posthog==7.44.0",
"psycopg[binary,pool]==3.3.4",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.14.0",
"python-frontmatter==1.3.0",
+3 -18
View File
@@ -1297,7 +1297,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.35.0"
version = "1.34.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -1331,7 +1331,7 @@ dependencies = [
{ name = "nested-multipart-parser" },
{ name = "phonenumbers" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary", "pool"] },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pyjwt" },
{ name = "python-frontmatter" },
@@ -1396,7 +1396,7 @@ requires-dist = [
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
{ name = "phonenumbers", specifier = "==9.0.37" },
{ name = "posthog", specifier = "==7.44.0" },
{ name = "psycopg", extras = ["binary", "pool"], specifier = "==3.3.4" },
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.4" },
{ name = "pydantic", specifier = "==2.13.4" },
{ name = "pyjwt", specifier = "==2.14.0" },
{ name = "python-frontmatter", specifier = "==1.3.0" },
@@ -1875,9 +1875,6 @@ wheels = [
binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
]
pool = [
{ name = "psycopg-pool" },
]
[[package]]
name = "psycopg-binary"
@@ -1908,18 +1905,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849, upload-time = "2026-05-01T23:31:51.165Z" },
]
[[package]]
name = "psycopg-pool"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/74/5e/c0664b968b102ff68b811d999c728546c48d5c1eec03e3bbaf88c0cb4472/psycopg_pool-3.3.3.tar.gz", hash = "sha256:df87b5d9d0ad7db37f6cdad4fa8ce113d250f5997f6db38e9a99192fb67f9e1d", size = 32006, upload-time = "2026-09-22T15:53:24.947Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/b4/452c6607a0f479465cd8a9b0d9956919fcb150050c1f83f9f11e6b8ee8dc/psycopg_pool-3.3.3-py3-none-any.whl", hash = "sha256:9b9cd6a4fcec47a410f7e82d408540e7f77b478509e91b44c1a5457a13e5ff37", size = 40304, upload-time = "2026-09-22T15:53:23.712Z" },
]
[[package]]
name = "ptyprocess"
version = "0.7.0"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.35.0",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.35.0",
"version": "1.34.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.35.0",
"version": "1.34.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -3,6 +3,9 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
import { useTopSpeaker } from '../hooks/useTopSpeaker'
import { splitCarouselTiles } from '../utils/splitCarouselTiles'
const MIN_HEIGHT = 130
const MIN_WIDTH = 140
@@ -66,7 +69,20 @@ export function CarouselLayout({
setPrevTiles(tilesThatFit)
}
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
const topSpeakerId = useTopSpeaker(tracks)
const { head, rest, restSlots } = splitCarouselTiles(
tracks,
topSpeakerId,
maxVisibleTiles
)
// useVisualStableUpdate needs a page of at least one tile.
const stableRest = useVisualStableUpdate(rest, Math.max(restSlots, 1))
const sortedTiles = [...head, ...stableRest]
// When the head fills the carousel, no rest speaker can be brought on
// screen, so the trigger ignores them instead of re-rendering for nothing.
const onScreen = sortedTiles.slice(0, maxVisibleTiles)
useSpeakerPromotionTrigger(onScreen, restSlots > 0 ? tracks : onScreen)
React.useLayoutEffect(() => {
if (asideEl.current) {
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSpeakerPromotionTrigger(pagination.tracks, tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
@@ -8,35 +8,42 @@ import { useEffect, useReducer, useRef } from 'react'
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* re-render of the host component only when an active speaker has none of
* their tiles are within the visible span (`maxVisibleTiles`). That render
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
* state and performs the actual swap.
* re-render of the host when a speaker has a tile in `allTiles` but none in
* `visibleTiles`. That render re-runs `useVisualStableUpdate`, which reads
* live `participant.isSpeaking` state and performs the actual swap.
*
* Speakers already visible are ignored. Everything else costs zero React work.
* Speakers already visible cost nothing, nor do speakers the layout doesn't
* show at all (e.g. the pinned participant, rendered by the focus view).
*
* Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
sortedTiles: TrackReferenceOrPlaceholder[]
/** Tiles inside the span the layout guarantees to speakers. */
visibleTiles: TrackReferenceOrPlaceholder[],
/** Every tile the layout shows, visible or not. */
allTiles: TrackReferenceOrPlaceholder[]
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
// Read by the listener at event time, so it never re-subscribes.
const tilesRef = useRef({ visibleTiles, allTiles })
tilesRef.current = { visibleTiles, allTiles }
useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
)
if (hiddenSpeakerExists) {
const { visibleTiles, allTiles } = tilesRef.current
const hasTileIn =
(tiles: TrackReferenceOrPlaceholder[]) => (identity: string) =>
tiles.some((t) => t.participant.identity === identity)
const isShown = hasTileIn(allTiles)
const isVisible = hasTileIn(visibleTiles)
const isHidden = (identity: string) =>
isShown(identity) && !isVisible(identity)
if (speakers.some(({ identity }) => isHidden(identity))) {
forceRender()
}
}
@@ -0,0 +1,40 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { useRoomContext } from '@livekit/components-react'
import { Track } from 'livekit-client'
import { useEffect, useRef, useState } from 'react'
import { watchTopSpeaker } from '../utils/watchTopSpeaker'
/**
* Identity of the participant who should hold the top speaker slot, among
* those with a camera tile in `tracks`: the slot only shows cameras, so a
* participant whose camera is pinned away can't hold it through their screen
* share. See `watchTopSpeaker` for the rules.
*
* Re-renders the host only when the holder changes. Expects
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useTopSpeaker(tracks: TrackReferenceOrPlaceholder[]) {
const room = useRoomContext()
const [topSpeakerId, setTopSpeakerId] = useState<string>()
// Read by the listener at event time; subscribing once keeps the hold
// state alive across renders.
const tracksRef = useRef(tracks)
tracksRef.current = tracks
useEffect(
() =>
watchTopSpeaker(room, {
isEligible: (identity) =>
tracksRef.current.some(
(t) =>
t.participant.identity === identity &&
t.source === Track.Source.Camera
),
onChange: setTopSpeakerId,
}),
[room]
)
return topSpeakerId
}
@@ -0,0 +1,39 @@
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
import { Track } from 'livekit-client'
/**
* Splits carousel tiles into a fixed head and a rest that is free to reorder.
*
* Head: the local camera, then the top speaker's camera. When only one tile
* fits, the speaker goes first so they are the one on screen. The rest goes
* through `useVisualStableUpdate`.
*
* `restSlots` is how many on-screen slots are left for the rest. It is 0 or
* negative when the head fills the carousel.
*
* The speaker slot is reserved even while it is empty: `useVisualStableUpdate`
* treats any change of page size as a relayout and re-sorts everything, so it
* must not move when the first speaker gets promoted.
*/
export function splitCarouselTiles(
tracks: TrackReferenceOrPlaceholder[],
topSpeakerId: string | undefined,
maxVisibleTiles: number
) {
const cameraOf = (match: (t: TrackReferenceOrPlaceholder) => boolean) =>
tracks.find((t) => t.source === Track.Source.Camera && match(t))
const local = cameraOf((t) => t.participant.isLocal)
const speaker = topSpeakerId
? cameraOf((t) => t.participant.identity === topSpeakerId)
: undefined
const order = maxVisibleTiles < 2 ? [speaker, local] : [local, speaker]
const head = order.filter((t) => t !== undefined)
const rest = tracks.filter((t) => !head.includes(t))
const reservedSlots = (local ? 1 : 0) + 1
const restSlots = maxVisibleTiles - reservedSlots
return { head, rest, restSlots }
}
@@ -0,0 +1,63 @@
import { RoomEvent } from 'livekit-client'
import type { Room } from 'livekit-client'
/** Minimum time a speaker keeps the top slot before someone else can take it. */
export const TOP_SPEAKER_HOLD_MS = 2000
type SpeakerSource = Pick<Room, 'on' | 'off' | 'activeSpeakers'>
/**
* Calls `onChange` with the participant who should hold the top speaker slot.
*
* The candidate is the loudest remote speaker for whom `isEligible` is true
* (e.g. who has a tile in the layout). The slot is sticky: silence keeps the
* current holder. A new speaker only takes over once the holder has had it for
* `holdMs`, and only if they are still the loudest at that point; someone who
* stops talking before then never steals it. A holder who is no longer
* eligible (left, got pinned) is replaced right away.
*
* `onChange` only fires on an actual change. Returns an unsubscribe function.
*/
export function watchTopSpeaker(
room: SpeakerSource,
{
isEligible,
onChange,
holdMs = TOP_SPEAKER_HOLD_MS,
}: {
isEligible: (identity: string) => boolean
onChange: (identity: string) => void
holdMs?: number
}
) {
let holder: string | undefined
let holdUntil = 0
let retry: ReturnType<typeof setTimeout> | undefined
const evaluate = () => {
clearTimeout(retry)
// `activeSpeakers` is sorted loudest first and is always current, which
// the deferred retry needs; the event payload would be stale by then.
const candidate = room.activeSpeakers.find(
(p) => !p.isLocal && isEligible(p.identity)
)?.identity
if (!candidate || candidate === holder) return
const wait = holder && isEligible(holder) ? holdUntil - Date.now() : 0
if (wait > 0) {
retry = setTimeout(evaluate, wait)
return
}
holder = candidate
holdUntil = Date.now() + holdMs
onChange(candidate)
}
room.on(RoomEvent.ActiveSpeakersChanged, evaluate)
return () => {
room.off(RoomEvent.ActiveSpeakersChanged, evaluate)
clearTimeout(retry)
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.35.0",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.35.0",
"version": "1.34.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.35.0",
"version": "1.34.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.35.0",
"version": "1.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.35.0",
"version": "1.34.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.35.0",
"version": "1.34.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.35.0"
version = "1.34.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.105.0",
+1 -1
View File
@@ -1484,7 +1484,7 @@ wheels = [
[[package]]
name = "summary"
version = "1.35.0"
version = "1.34.0"
source = { editable = "." }
dependencies = [
{ name = "boto3" },