diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bf673e7..391edbce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,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 diff --git a/src/frontend/src/features/layout/components/CarouselLayout.tsx b/src/frontend/src/features/layout/components/CarouselLayout.tsx index e5403aa97..e0a743ef0 100644 --- a/src/frontend/src/features/layout/components/CarouselLayout.tsx +++ b/src/frontend/src/features/layout/components/CarouselLayout.tsx @@ -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) { diff --git a/src/frontend/src/features/layout/components/GridLayout.tsx b/src/frontend/src/features/layout/components/GridLayout.tsx index 60e463d6c..3c2a0bbbf 100644 --- a/src/frontend/src/features/layout/components/GridLayout.tsx +++ b/src/frontend/src/features/layout/components/GridLayout.tsx @@ -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, diff --git a/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts b/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts index d0030ebdd..aaf7b0c47 100644 --- a/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts +++ b/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts @@ -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() } } diff --git a/src/frontend/src/features/layout/hooks/useTopSpeaker.ts b/src/frontend/src/features/layout/hooks/useTopSpeaker.ts new file mode 100644 index 000000000..c9217b5a2 --- /dev/null +++ b/src/frontend/src/features/layout/hooks/useTopSpeaker.ts @@ -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() + + // 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 +} diff --git a/src/frontend/src/features/layout/utils/splitCarouselTiles.ts b/src/frontend/src/features/layout/utils/splitCarouselTiles.ts new file mode 100644 index 000000000..258d48531 --- /dev/null +++ b/src/frontend/src/features/layout/utils/splitCarouselTiles.ts @@ -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 } +} diff --git a/src/frontend/src/features/layout/utils/watchTopSpeaker.ts b/src/frontend/src/features/layout/utils/watchTopSpeaker.ts new file mode 100644 index 000000000..140710506 --- /dev/null +++ b/src/frontend/src/features/layout/utils/watchTopSpeaker.ts @@ -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 + +/** + * 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 | 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) + } +}