Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 1b6509982d ✨(frontend) let users pick the recording encoding
Add a Recording tab to the settings dialog with two selectors, a
content profile and a resolution, building on the per-recording
encoding API.

The goal is to let an instance default to a cheaper profile and
resolution to save egress CPU, while users who really need a smooth
HD recording can still opt in. Most users only need a recording of
acceptable quality, so the default stays cheap and configuring it is
optional.

Profiles are labelled by content ("Talking heads", "Presentations and
text", ...) rather than by fps or bitrate: users know what they are
about to record, not which encoder settings fit it. Each profile shows
a one-line description of its trade-off, so picking one requires no
technical knowledge.

The panel gets a third row, matching the meeting language row of the
transcript panel. It is a shortcut to the settings, without adding
mental load about how to configure a recording. It reads as a short
statement with a trailing "Change" link, like the informational rows
above it, and stays on a single line in every locale: the statement
truncates if a translation gets too long, the link never wraps.

The selected options are intentionally not shown in the panel.
Several users can access it, and each would see their own local
configuration while the running recording may use another user's
encoding, which would be misleading. The issue is milder in the
settings tab, which is frozen while a recording is starting or in
progress.

The selectors are disabled from the moment a start request is sent
until the recording is saved, for both screen recordings and
transcripts. Settings are sent once, when the recording starts, so
changing them afterwards would have no effect and suggest otherwise.

Choices are persisted in localStorage to keep things simple,
consistent with the meeting language, which is already stored
client-side.

The backend now exposes the available resolutions and profiles, and
their defaults, in the frontend configuration, so the UI follows
whatever the operator configures instead of hardcoding values. Stored
choices are checked against it before being sent: a profile removed by
the operator falls back to the instance default instead of being
rejected by the API. No encoding is sent when per-recording encoding
is disabled, as the backend would reject it. Operator-defined values
without a translation are shown by their raw key.

Alternatives considered:

- Storing the preference in room metadata: it would turn a pre-start,
  personal choice into shared editable state, requiring a write
  endpoint, permission checks and handling of concurrent edits.
- Notifying participants of the chosen encoding: notifications are
  one-off, so users who join or reload after the recording started
  would miss them, and the information is noise for most participants.

Next steps:

This is a deliberately small first iteration, meant to deliver most
of the value and evolve with feedback and usage. Possible next steps:
- persist the preferences in the backend instead of localStorage
- store the encoding chosen for a recording on the room (or on a
  session, if such a model is introduced) and read it from there
  rather than from user settings, so every user sees the same value
2026-10-11 22:56:13 +02:00
16 changed files with 58 additions and 445 deletions
-3
View File
@@ -22,9 +22,6 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length]
# line-length=80
-2
View File
@@ -12,7 +12,6 @@ and this project adheres to
- ✨(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
### Changed
@@ -31,7 +30,6 @@ 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
-1
View File
@@ -113,7 +113,6 @@ DJANGO_EMAIL_FROM=<your email address>
#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_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"
-1
View File
@@ -357,7 +357,6 @@ These are the environmental options available on meet backend.
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS 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 |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.utils.translation import get_language, gettext, gettext_noop, override
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 django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from celery.exceptions import OperationalError as CeleryOperationalError
from livekit import api as livekit_api
from core import models, utils
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
logger = logging.getLogger(__name__)
@@ -68,14 +68,10 @@ class NotificationService:
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Queue an email notification to recording owners when their recording is ready.
Send an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
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 = (
@@ -92,6 +88,10 @@ class NotificationService:
return False
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,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}",
@@ -104,22 +104,37 @@ class NotificationService:
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
local_created_at = recording.created_at.astimezone(user.timezone)
try:
send_email.delay(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=[user.email],
language=user.language or get_language(),
context={
**context,
"recording_date": local_created_at.strftime("%Y-%m-%d"),
"recording_time": local_created_at.strftime("%H:%M"),
},
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
except (smtplib.SMTPException, CeleryOperationalError) as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
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
-2
View File
@@ -2,10 +2,8 @@
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.tasks.mail import send_email
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
"send_email",
)
-74
View File
@@ -1,74 +0,0 @@
"""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,7 +12,6 @@ from unittest import mock
from django.contrib.sites.models import Site
import pytest
from celery.exceptions import OperationalError as CeleryOperationalError
from core import factories, models
from core.analytics import UserFeatureFlag
@@ -143,7 +142,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
notification_service = NotificationService()
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
@@ -238,7 +237,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
notification_service = NotificationService()
with mock.patch(
"core.tasks.mail.send_mail",
"core.recording.event.notification.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
@@ -248,60 +247,6 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
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.generate_download_s3_url")
@mock.patch.object(
-98
View File
@@ -1,98 +0,0 @@
"""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,7 +508,6 @@ class Base(Configuration):
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
@@ -3,9 +3,6 @@ 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
@@ -69,20 +66,7 @@ export function CarouselLayout({
setPrevTiles(tilesThatFit)
}
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)
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
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, tracks)
useSpeakerPromotionTrigger(pagination.tracks)
useSwipe(gridEl, {
onLeftSwipe: pagination.nextPage,
@@ -8,42 +8,35 @@ import { useEffect, useReducer, useRef } from 'react'
* Tripwire for speaker promotion.
*
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
* 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.
* 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.
*
* 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).
* Speakers already visible are ignored. Everything else costs zero React work.
*
* Requires the parent to NOT re-render on speaker events itself, i.e.
* `useTracks(..., { updateOnlyOn: [] })` upstream.
*/
export function useSpeakerPromotionTrigger(
/** Tiles inside the span the layout guarantees to speakers. */
visibleTiles: TrackReferenceOrPlaceholder[],
/** Every tile the layout shows, visible or not. */
allTiles: TrackReferenceOrPlaceholder[]
sortedTiles: TrackReferenceOrPlaceholder[]
) {
const room = useRoomContext()
const [, forceRender] = useReducer((n: number) => n + 1, 0)
// Read by the listener at event time, so it never re-subscribes.
const tilesRef = useRef({ visibleTiles, allTiles })
tilesRef.current = { visibleTiles, allTiles }
// Refs so the listener reads current values without re-subscribing
// and without itself being a render dependency.
const tilesRef = useRef(sortedTiles)
tilesRef.current = sortedTiles
useEffect(() => {
const onActiveSpeakersChanged = (speakers: Participant[]) => {
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))) {
const tiles = tilesRef.current
const hiddenSpeakerExists = speakers.some(
(speaker) =>
!tiles.some((t) => t.participant.identity === speaker.identity)
)
if (hiddenSpeakerExists) {
forceRender()
}
}
@@ -1,40 +0,0 @@
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
}
@@ -1,39 +0,0 @@
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 }
}
@@ -1,63 +0,0 @@
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)
}
}