Compare commits

...

3 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
lebaudantoine 0194f7f3f3 ⚡️(backend) send recording notification emails from a Celery task
Emails were rendered and sent inline in the LiveKit webhook, so a
slow SMTP server blocked the request and a transient failure lost
the email. Delivery now runs in a worker, with retries and backoff.

The task is generic (template, subject, recipients, context,
language) so other emails can reuse it. The subject is translated
in the worker, and dates are formatted per owner before queuing,
so the arguments stay JSON-serializable.

Without Celery, the task still runs synchronously. Success now
means "queued", not "delivered".
2026-10-11 23:37:00 +02:00
Roee Sivan 0bde103911 🔧(project) silence gitlint regex-style deprecation warning
gitlint prints a warning for each ignore-by-title rule because it will switch
its regexes from match (start of the string) to search (anywhere) semantics.

Both ignore-by-title regexes are already anchored with ^, so they match the
same titles either way. Setting regex-style-search opts in to the new behavior
now and removes the warning.

Fixes #96
2026-10-11 23:34:02 +02:00
16 changed files with 445 additions and 58 deletions
+3
View File
@@ -22,6 +22,9 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details # See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/ extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length] # [title-max-length]
# line-length=80 # line-length=80
+2
View File
@@ -12,6 +12,7 @@ and this project adheres to
- ✨(frontend) let users pick the recording encoding - ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds - ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
### Changed ### Changed
@@ -30,6 +31,7 @@ and this project adheres to
- 🐛(frontend) omit the default speaker id sent to LiveKit - 🐛(frontend) omit the default speaker id sent to LiveKit
- 🐛(frontend) play the speaker test when no output is picked - 🐛(frontend) play the speaker test when no output is picked
- 🐛(frontend) keep the admin panel in place when changing a setting #1804 - 🐛(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 ## [1.34.0] - 2026-10-07
+1
View File
@@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending. #DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending. #DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique" DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
+1
View File
@@ -357,6 +357,7 @@ These are the environmental options available on meet backend.
| DJANGO_EMAIL_PORT | Port to connect to the email server | | | DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false | | DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false | | DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
| DJANGO_EMAIL_FROM | Email from account | from@example.com | | DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | | | EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | | | EMAIL_SUPPORT_EMAIL | Support email address | |
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings from django.conf import settings
from django.core.mail import send_mail from django.utils.translation import get_language, gettext, gettext_noop, override
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
import aiohttp import aiohttp
import requests import requests
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from celery.exceptions import OperationalError as CeleryOperationalError
from livekit import api as livekit_api from livekit import api as livekit_api
from core import models, utils from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.tasks.mail import send_email
from core.utils import generate_download_s3_url from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -68,10 +68,14 @@ class NotificationService:
@staticmethod @staticmethod
def _notify_user_by_email(recording) -> bool: def _notify_user_by_email(recording) -> bool:
""" """
Send an email notification to recording owners when their recording is ready. Queue an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording. page in the frontend where they can access their specific recording.
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
this only does one DB query and enqueues one message per owner.
Returns True when every email was queued (or sent, if Celery is disabled).
""" """
owner_accesses = ( owner_accesses = (
@@ -88,10 +92,6 @@ class NotificationService:
return False return False
context = { context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name, "room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS, "recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}", "link": f"{get_recording_download_base_url()}/{recording.id}",
@@ -104,37 +104,22 @@ class NotificationService:
# 2. The number of recipients per recording is typically small (not thousands) # 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses: for access in owner_accesses:
user = access.user user = access.user
language = user.language or get_language() local_created_at = recording.created_at.astimezone(user.timezone)
with override(language): try:
personalized_context = { send_email.delay(
"recording_date": recording.created_at.astimezone( template="screen_recording",
user.timezone subject=gettext_noop("Your recording is ready"),
).strftime("%Y-%m-%d"), recipients=[user.email],
"recording_time": recording.created_at.astimezone( language=user.language or get_language(),
user.timezone context={
).strftime("%H:%M"), **context,
**context, "recording_date": local_created_at.strftime("%Y-%m-%d"),
} "recording_time": local_created_at.strftime("%H:%M"),
msg_html = render_to_string( },
"mail/html/screen_recording.html", personalized_context
) )
msg_plain = render_to_string( except (smtplib.SMTPException, CeleryOperationalError) as exception:
"mail/text/screen_recording.txt", personalized_context logger.error("notification could not be sent: %s", exception)
) has_failures = True
subject = gettext("Your recording is ready") # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures return not has_failures
+2
View File
@@ -2,8 +2,10 @@
from core.tasks.connection_test import delete_connection_test_room from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion from core.tasks.file import process_file_deletion
from core.tasks.mail import send_email
__all__ = ( __all__ = (
"delete_connection_test_room", "delete_connection_test_room",
"process_file_deletion", "process_file_deletion",
"send_email",
) )
+74
View File
@@ -0,0 +1,74 @@
"""Send mail using celery task."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from core.tasks._task import task
logger = logging.getLogger(__name__)
def get_base_email_context() -> dict:
"""Context shared by every email template (branding, support, domain)."""
return {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
}
@task(
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def send_email(
*,
template: str,
subject: str,
recipients: list[str],
context: dict | None = None,
language: str | None = None,
):
"""Render a mail template and send it.
Generic on purpose: any feature can reuse it with its own template and context.
Args:
template: Template base name, e.g. "screen_recording". Both
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
call site; it is translated here, in ``language``.
recipients: Email addresses, all receiving the same rendered message.
context: Template context. Must be JSON-serializable (str, int, list, dict…):
pre-format dates, cast UUIDs to str. Merged over the base branding context.
language: Language to render the email in. Defaults to the active language.
Raises:
OSError: SMTP or network failure, after retries are exhausted (or
immediately when Celery is disabled and the call runs synchronously).
"""
full_context = {**get_base_email_context(), **(context or {})}
with override(language or get_language()):
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
translated_subject = gettext(subject)
send_mail(
translated_subject,
msg_plain,
settings.EMAIL_FROM,
recipients,
html_message=msg_html,
fail_silently=False,
)
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
@@ -12,6 +12,7 @@ from unittest import mock
from django.contrib.sites.models import Site from django.contrib.sites.models import Site
import pytest import pytest
from celery.exceptions import OperationalError as CeleryOperationalError
from core import factories, models from core import factories, models
from core.analytics import UserFeatureFlag from core.analytics import UserFeatureFlag
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
notification_service = NotificationService() notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail: with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording) result = notification_service._notify_user_by_email(recording)
assert result is True assert result is True
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
notification_service = NotificationService() notification_service = NotificationService()
with mock.patch( with mock.patch(
"core.recording.event.notification.send_mail", "core.tasks.mail.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"), side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail: ) as mock_send_mail:
result = notification_service._notify_user_by_email(recording) result = notification_service._notify_user_by_email(recording)
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert "notification could not be sent:" in caplog.text assert "notification could not be sent:" in caplog.text
def test_notify_user_by_email_queues_generic_task(settings):
"""One generic send_email task is queued per owner, with serializable args."""
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_EXPIRATION_DAYS = 7
recording = factories.RecordingFactory(room__name="Conference Room A")
recording.created_at = datetime.datetime(
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
)
user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=user
)
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_delay.assert_called_once_with(
template="screen_recording",
subject="Your recording is ready",
recipients=["franc@test.com"],
language="fr-fr",
context={
"room_name": "Conference Room A",
"recording_expiration_days": 7,
"link": f"https://acme.com/recordings/{recording.id}",
"recording_date": "2023-05-15",
"recording_time": "16:30",
},
)
# Celery's JSON serializer must accept every argument
json.dumps(mock_delay.call_args.kwargs)
def test_notify_user_by_email_broker_unavailable(caplog):
"""A broker outage is reported as a failure without crashing the caller."""
recording = factories.RecordingFactory()
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
with mock.patch(
"core.recording.event.notification.send_email.delay",
side_effect=CeleryOperationalError("broker down"),
):
result = notification_service._notify_user_by_email(recording)
assert result is False
assert "notification could not be sent: broker down" in caplog.text
@mock.patch("core.recording.event.notification.requests.post") @mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url") @mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object( @mock.patch.object(
+98
View File
@@ -0,0 +1,98 @@
"""Tests for the generic send_email task."""
# pylint: disable=unused-argument,redefined-outer-name
import smtplib
from unittest import mock
from django.core.mail import get_connection
from django.utils.translation import gettext_noop
import pytest
from core.tasks.mail import send_email
@pytest.fixture
def email_settings(settings):
"""Branding settings shared by every email."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_DOMAIN = "acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
return settings
@mock.patch("core.tasks.mail.send_mail")
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
def test_send_email_renders_template_and_sends(
mock_render, mock_send_mail, email_settings
):
"""Both template variants are rendered with base + caller context, then sent."""
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com", "b@test.com"],
context={"foo": "bar", "brandname": "Overridden"},
)
expected_context = {
"brandname": "Overridden",
"support_email": "support@acme.com",
"logo_img": "https://acme.com/logo",
"domain": "acme.com",
"foo": "bar",
}
assert mock_render.call_args_list == [
mock.call("mail/html/some_template.html", expected_context),
mock.call("mail/text/some_template.txt", expected_context),
]
mock_send_mail.assert_called_once_with(
"Any subject",
"text",
"notifications@acme.com",
["a@test.com", "b@test.com"],
html_message="<p>html</p>",
fail_silently=False,
)
@mock.patch("core.tasks.mail.send_mail")
def test_send_email_translates_subject_in_requested_language(
mock_send_mail, email_settings
):
"""The subject msgid is translated in the worker, in the recipient language."""
send_email(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=["franc@test.com"],
context={"room_name": "Room", "link": "https://acme.com/r/1"},
language="fr-fr",
)
subject, body, *_ = mock_send_mail.call_args[0]
assert subject == "Votre enregistrement est prêt"
assert "Votre enregistrement est prêt !" in body
@mock.patch(
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
)
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
"""SMTP errors propagate so Celery can retry the task."""
with pytest.raises(smtplib.SMTPException):
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com"],
)
def test_smtp_backend_uses_email_timeout(settings):
"""A stalled SMTP server cannot hold the worker forever."""
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
settings.EMAIL_TIMEOUT = 12
assert get_connection().timeout == 12
+1
View File
@@ -508,6 +508,7 @@ class Base(Configuration):
EMAIL_PORT = values.PositiveIntegerValue(None) EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False) EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False) EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
EMAIL_FROM = values.Value("from@example.com") EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None) EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None) EMAIL_SUPPORT_EMAIL = values.Value(None)
@@ -3,6 +3,9 @@ import { getScrollBarWidth } from '@livekit/components-core'
import * as React from 'react' import * as React from 'react'
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react' import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver' 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_HEIGHT = 130
const MIN_WIDTH = 140 const MIN_WIDTH = 140
@@ -66,7 +69,20 @@ export function CarouselLayout({
setPrevTiles(tilesThatFit) 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(() => { React.useLayoutEffect(() => {
if (asideEl.current) { if (asideEl.current) {
@@ -46,7 +46,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
const { layout } = useGridLayout(gridEl, tracks.length) const { layout } = useGridLayout(gridEl, tracks.length)
const pagination = usePagination(layout.maxTiles, tracks) const pagination = usePagination(layout.maxTiles, tracks)
useSpeakerPromotionTrigger(pagination.tracks) useSpeakerPromotionTrigger(pagination.tracks, tracks)
useSwipe(gridEl, { useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage, onLeftSwipe: pagination.nextPage,
@@ -8,35 +8,42 @@ import { useEffect, useReducer, useRef } from 'react'
* Tripwire for speaker promotion. * Tripwire for speaker promotion.
* *
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE * Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* re-render of the host component only when an active speaker has none of * re-render of the host when a speaker has a tile in `allTiles` but none in
* their tiles are within the visible span (`maxVisibleTiles`). That render * `visibleTiles`. That render re-runs `useVisualStableUpdate`, which reads
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking` * live `participant.isSpeaking` state and performs the actual swap.
* 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. * Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream. * `useTracks(..., { updateOnlyOn: [] })` upstream.
*/ */
export function useSpeakerPromotionTrigger( 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 room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0) const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Refs so the listener reads current values without re-subscribing // Read by the listener at event time, so it never re-subscribes.
// and without itself being a render dependency. const tilesRef = useRef({ visibleTiles, allTiles })
const tilesRef = useRef(sortedTiles) tilesRef.current = { visibleTiles, allTiles }
tilesRef.current = sortedTiles
useEffect(() => { useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => { const onActiveSpeakersChanged = (speakers: Participant[]) => {
const tiles = tilesRef.current const { visibleTiles, allTiles } = tilesRef.current
const hiddenSpeakerExists = speakers.some( const hasTileIn =
(speaker) => (tiles: TrackReferenceOrPlaceholder[]) => (identity: string) =>
!tiles.some((t) => t.participant.identity === speaker.identity) tiles.some((t) => t.participant.identity === identity)
) const isShown = hasTileIn(allTiles)
if (hiddenSpeakerExists) { const isVisible = hasTileIn(visibleTiles)
const isHidden = (identity: string) =>
isShown(identity) && !isVisible(identity)
if (speakers.some(({ identity }) => isHidden(identity))) {
forceRender() 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)
}
}