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
7 changed files with 184 additions and 18 deletions
+1
View File
@@ -31,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
@@ -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)
}
}