From d70e6cf81b445132e8745644e6c03a06aa7dfa69 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 24 Jul 2026 17:27:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(frontend)=20tripwire=20promo?= =?UTF-8?q?tion=20of=20off-screen=20active=20speakers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a tripwire component that listens to RoomEvent.ActiveSpeakersChanged imperatively and forces a single re-render of its host only when an active speaker has none of their tiles within the visible span (maxVisibleTiles). That re-render re-runs useVisualStableUpdate, which reads live isSpeaking state and performs the actual tile swap. Speakers already visible are ignored, so this costs zero React work in the common case. This lets us drop the ActiveSpeakersChanged subscription from useTracks in the StageLayout upstream (updateOnlyOn: []), which was re-rendering the whole stage on every speaker change. --- .../features/layout/components/GridLayout.tsx | 2 + .../layout/components/StageLayout.tsx | 4 +- .../hooks/useSpeakerPromotionTrigger.ts | 48 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts diff --git a/src/frontend/src/features/layout/components/GridLayout.tsx b/src/frontend/src/features/layout/components/GridLayout.tsx index 226fc02e..1acef661 100644 --- a/src/frontend/src/features/layout/components/GridLayout.tsx +++ b/src/frontend/src/features/layout/components/GridLayout.tsx @@ -11,6 +11,7 @@ import { PaginationIndicator } from './PaginationIndicator' import { useGridLayout } from '../hooks/useGridLayout' import { PaginationControl } from './PaginationControl' import { useEffect, useRef, useState } from 'react' +import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger' interface GridLayoutObserverProps { gridEl: React.RefObject @@ -74,6 +75,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) { [props] ) const pagination = usePagination(maxTiles, tracks) + useSpeakerPromotionTrigger(pagination.tracks) useSwipe(gridEl, { onLeftSwipe: pagination.nextPage, diff --git a/src/frontend/src/features/layout/components/StageLayout.tsx b/src/frontend/src/features/layout/components/StageLayout.tsx index 2330c9d4..cb9ba05c 100644 --- a/src/frontend/src/features/layout/components/StageLayout.tsx +++ b/src/frontend/src/features/layout/components/StageLayout.tsx @@ -9,7 +9,7 @@ import { log, type TrackReferenceOrPlaceholder, } from '@livekit/components-core' -import { RoomEvent, Track } from 'livekit-client' +import { Track } from 'livekit-client' import { useSnapshot } from 'valtio' import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout' import { useEffect, useRef } from 'react' @@ -23,7 +23,7 @@ export const StageLayout = () => { { source: Track.Source.Camera, withPlaceholder: true }, { source: Track.Source.ScreenShare, withPlaceholder: false }, ], - { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } + { updateOnlyOn: [], onlySubscribed: false } ) const screenShareTracks = tracks diff --git a/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts b/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts new file mode 100644 index 00000000..53c01db3 --- /dev/null +++ b/src/frontend/src/features/layout/hooks/useSpeakerPromotionTrigger.ts @@ -0,0 +1,48 @@ +import { RoomEvent } from 'livekit-client' +import type { Participant } from 'livekit-client' +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' +import { useRoomContext } from '@livekit/components-react' +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. + * + * 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( + sortedTiles: 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 + + useEffect(() => { + const onActiveSpeakersChanged = (speakers: Participant[]) => { + const tiles = tilesRef.current + const hiddenSpeakerExists = speakers.some( + (speaker) => + !tiles.some((t) => t.participant.identity === speaker.identity) + ) + if (hiddenSpeakerExists) { + forceRender() + } + } + room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + return () => { + room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged) + } + }, [room]) +}