mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-13 16:42:18 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 867a14d0a3 |
+1
-6
@@ -10,12 +10,6 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### 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
|
- ✨(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
|
- ⚡️(backend) send recording notification emails from a Celery task
|
||||||
@@ -37,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
|
||||||
|
|
||||||
|
|||||||
+13
-13
@@ -5,17 +5,17 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:15432:5432"
|
- "15432:5432"
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:5
|
image: redis:5
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:6379:6379"
|
- "6379:6379"
|
||||||
|
|
||||||
mailcatcher:
|
mailcatcher:
|
||||||
image: sj26/mailcatcher:latest
|
image: sj26/mailcatcher:latest
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:1081:1080"
|
- "1081:1080"
|
||||||
|
|
||||||
garage:
|
garage:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
@@ -71,7 +71,7 @@ services:
|
|||||||
- env.d/development/common
|
- env.d/development/common
|
||||||
- env.d/development/postgresql
|
- env.d/development/postgresql
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8071:8000"
|
- "8071:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
@@ -137,7 +137,7 @@ services:
|
|||||||
nginx:
|
nginx:
|
||||||
image: nginx:1.25
|
image: nginx:1.25
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8083:8083"
|
- "8083:8083"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -159,7 +159,7 @@ services:
|
|||||||
VITE_APP_TITLE: "LaSuite Meet"
|
VITE_APP_TITLE: "LaSuite Meet"
|
||||||
image: meet:frontend-development
|
image: meet:frontend-development
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:3000:8080"
|
- "3000:8080"
|
||||||
|
|
||||||
dockerize:
|
dockerize:
|
||||||
image: jwilder/dockerize
|
image: jwilder/dockerize
|
||||||
@@ -185,7 +185,7 @@ services:
|
|||||||
kc_postgresql:
|
kc_postgresql:
|
||||||
image: postgres:14.3
|
image: postgres:14.3
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5433:5432"
|
- "5433:5432"
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/kc_postgresql
|
- env.d/development/kc_postgresql
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ services:
|
|||||||
KC_DB_SCHEMA: public
|
KC_DB_SCHEMA: public
|
||||||
PROXY_ADDRESS_FORWARDING: 'true'
|
PROXY_ADDRESS_FORWARDING: 'true'
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8080:8080"
|
- "8080:8080"
|
||||||
depends_on:
|
depends_on:
|
||||||
- kc_postgresql
|
- kc_postgresql
|
||||||
|
|
||||||
@@ -222,10 +222,10 @@ services:
|
|||||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||||
ports:
|
ports:
|
||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
- "127.0.0.1:7881:7881"
|
- "7881:7881"
|
||||||
- "127.0.0.1:7882:7882/udp"
|
- "7882:7882/udp"
|
||||||
- "127.0.0.1:3478:3478/udp"
|
- "3478:3478/udp"
|
||||||
- "127.0.0.1:30000-30100:30000-30100/udp"
|
- "30000-30100:30000-30100/udp"
|
||||||
volumes:
|
volumes:
|
||||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -283,7 +283,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8001:8000"
|
- "8001:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- ./src/summary:/app
|
- ./src/summary:/app
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -45,4 +45,4 @@ services:
|
|||||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||||
keycloak:
|
keycloak:
|
||||||
ports: !override
|
ports: !override
|
||||||
- "127.0.0.1:8081:8080"
|
- "8081:8080"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.35.0"
|
version = "1.34.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.7.0",
|
"livekit-agents==1.7.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.35.0"
|
version = "1.34.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
|
|||||||
@@ -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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -246,9 +246,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
name="unique_email_when_sub_is_null",
|
name="unique_email_when_sub_is_null",
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
indexes = [
|
|
||||||
models.Index(models.functions.Upper("email"), name="user_email_upper_idx"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email or self.admin_email or str(self.id)
|
return self.email or self.admin_email or str(self.id)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.35.0"
|
version = "1.34.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1297,7 +1297,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.35.0"
|
version = "1.34.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.1",
|
"@html-to/text-cli": "0.6.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"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",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.35.0",
|
"version": "1.34.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.35.0"
|
version = "1.34.0"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1484,7 +1484,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.35.0"
|
version = "1.34.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "boto3" },
|
{ name = "boto3" },
|
||||||
|
|||||||
Reference in New Issue
Block a user