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]) +}