From 40e83ca98ac37b3f4c77d872863a98be8f11bd5c Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 13 Jul 2026 16:54:20 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20isolate=20track?= =?UTF-8?q?=20rendering=20in=20a=20StageLayout=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move track manipulation into a leaf component to avoid re-rendering the whole videoconference tree when a track update occurs. The new StageLayout component is now responsible for rendering the video tracks in the relevant layout, while the Videoconference component keeps the responsibility of building the overall conference view. We save re-rendering the ControlBar for every track/layout changes for example. --- .../layout/components/StageLayout.tsx | 204 ++++++++++++++++++ .../rooms/livekit/prefabs/VideoConference.tsx | 194 +---------------- 2 files changed, 210 insertions(+), 188 deletions(-) create mode 100644 src/frontend/src/features/layout/components/StageLayout.tsx diff --git a/src/frontend/src/features/layout/components/StageLayout.tsx b/src/frontend/src/features/layout/components/StageLayout.tsx new file mode 100644 index 00000000..c3c55ac3 --- /dev/null +++ b/src/frontend/src/features/layout/components/StageLayout.tsx @@ -0,0 +1,204 @@ +import { + FocusLayoutContainer, + useMaybeLayoutContext, + usePinnedTracks, + useRoomContext, + useTracks, +} from '@livekit/components-react' +import { CarouselLayout } from '@/features/layout/components/CarouselLayout' +import { FocusLayout } from '@/features/layout/components/FocusLayout' +import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' +import { GridLayout } from '@/features/layout/components/GridLayout' +import { + isEqualTrackRef, + isTrackReference, + log, + type TrackReferenceOrPlaceholder, +} from '@livekit/components-core' +import { type Participant, RoomEvent, Track } from 'livekit-client' +import { useCallback, useEffect, useRef } from 'react' +import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' +import { useTranslation } from 'react-i18next' +import { getParticipantName } from '@/features/rooms/utils/getParticipantName' + + +export const StageLayout = () => { + + const lastAutoFocusedScreenShareTrack = + useRef(null) + + const lastPinnedParticipantIdentityRef = useRef(null) + + const layoutContext = useMaybeLayoutContext() + + const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' }) + const { t: tRooms } = useTranslation('rooms') + + const room = useRoomContext() + const announce = useScreenReaderAnnounce() + + const getAnnouncementName = useCallback( + (participant?: Participant | null) => { + if (!participant) return tRooms('participants.unknown') + return participant.isLocal + ? tRooms('participants.you') + : getParticipantName(participant) + }, + [tRooms] + ) + + const tracks = useTracks( + [ + { source: Track.Source.Camera, withPlaceholder: true }, + { source: Track.Source.ScreenShare, withPlaceholder: false }, + ], + { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } + ) + + const screenShareTracks = tracks + .filter(isTrackReference) + .filter((track) => track.publication.source === Track.Source.ScreenShare) + + const focusTrack = usePinnedTracks(layoutContext)?.[0] + const carouselTracks = tracks.filter( + (track) => !isEqualTrackRef(track, focusTrack) + ) + + // // handle pin announcements + + useEffect(() => { + const participant = focusTrack?.participant + + // 1. unpin + if (!participant) { + if (!lastPinnedParticipantIdentityRef.current) return + + const lastIdentity = lastPinnedParticipantIdentityRef.current + const lastParticipant = + room.localParticipant.identity === lastIdentity + ? room.localParticipant + : room.remoteParticipants.get(lastIdentity) + const announcementName = getAnnouncementName(lastParticipant) + + announce( + lastParticipant?.isLocal + ? t('self.unpin') + : t('unpin', { + name: announcementName, + }) + ) + + lastPinnedParticipantIdentityRef.current = null + return + } + + // 2. same pin → do nothing + if (lastPinnedParticipantIdentityRef.current === participant.identity) { + return + } + + // 3. new pin + const participantName = participant.isLocal + ? tRooms('participants.you') + : getParticipantName(participant) + + lastPinnedParticipantIdentityRef.current = participant.identity + + announce( + participant.isLocal ? t('self.pin') : t('pin', { name: participantName }) + ) + }, [ + announce, + focusTrack, + getAnnouncementName, + room.localParticipant, + room.remoteParticipants, + t, + tRooms, + ]) + + /* eslint-disable react-hooks/exhaustive-deps */ + // Code duplicated from LiveKit; this warning will be addressed in the refactoring. + useEffect(() => { + + if (!layoutContext) return + + // If screen share tracks are published, and no pin is set explicitly, auto set the screen share. + if ( + screenShareTracks.some((track) => track.publication.isSubscribed) && + lastAutoFocusedScreenShareTrack.current === null + ) { + log.debug('Auto set screen share focus:', { + newScreenShareTrack: screenShareTracks[0], + }) + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: screenShareTracks[0], + }) + lastAutoFocusedScreenShareTrack.current = screenShareTracks[0] + } else if ( + lastAutoFocusedScreenShareTrack.current && + !screenShareTracks.some( + (track) => + track.publication.trackSid === + lastAutoFocusedScreenShareTrack.current?.publication?.trackSid + ) + ) { + log.debug('Auto clearing screen share focus.') + layoutContext.pin.dispatch?.({ msg: 'clear_pin' }) + lastAutoFocusedScreenShareTrack.current = null + } + if (focusTrack && !isTrackReference(focusTrack)) { + const updatedFocusTrack = tracks.find( + (tr) => + tr.participant.identity === focusTrack.participant.identity && + tr.source === focusTrack.source + ) + if ( + updatedFocusTrack !== focusTrack && + isTrackReference(updatedFocusTrack) + ) { + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: updatedFocusTrack, + }) + } + } + }, [ + screenShareTracks + .map( + (ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}` + ) + .join(), + focusTrack?.publication?.trackSid, + tracks, + ]) + /* eslint-enable react-hooks/exhaustive-deps */ + + + return ( + <> + {!focusTrack ? ( +
+ + + +
+ ) : ( +
+ + + + + {focusTrack && } + +
+ )} + + ) +} \ No newline at end of file diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 6cf45253..860f94c6 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -1,26 +1,17 @@ -import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' + import { - isEqualTrackRef, - isTrackReference, isWeb, - log, } from '@livekit/components-core' -import { type Participant, RoomEvent, Track } from 'livekit-client' -import React, { useCallback, useRef, useState, useEffect } from 'react' +import { Track } from 'livekit-client' +import React, { useCallback, useState } from 'react' import { ConnectionStateToast, - FocusLayoutContainer, LayoutContextProvider, RoomAudioRenderer, - usePinnedTracks, - useTracks, useCreateLayoutContext, - useRoomContext, } from '@livekit/components-react' -import { useTranslation } from 'react-i18next' import { ControlBar } from './ControlBar/ControlBar' -import { ParticipantTile } from '../components/ParticipantTile' import { SidePanel } from '../components/SidePanel' import { RecordingProvider } from '@/features/recording' import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal' @@ -34,15 +25,11 @@ import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubsc import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata' import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider' import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal' -import { getParticipantName } from '@/features/rooms/utils/getParticipantName' -import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce' import { ReactionPortals } from '@/features/reactions/components/ReactionPortals' -import { CarouselLayout } from '@/features/layout/components/CarouselLayout' -import { GridLayout } from '@/features/layout/components/GridLayout' import { RoomContentArea } from '@/features/layout/components/RoomContentArea' -import { FocusLayout } from '@/features/layout/components/FocusLayout' import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture' import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder' +import { StageLayout } from '@/features/layout/components/StageLayout.tsx' /** * @public @@ -71,24 +58,8 @@ export interface VideoConferenceProps extends React.HTMLAttributes(null) - const lastPinnedParticipantIdentityRef = useRef(null) - const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' }) - const { t: tRooms } = useTranslation('rooms') - const room = useRoomContext() - const announce = useScreenReaderAnnounce() - const { toggleSettingsDialog } = useSettingsDialog() - const getAnnouncementName = useCallback( - (participant?: Participant | null) => { - if (!participant) return tRooms('participants.unknown') - return participant.isLocal - ? tRooms('participants.you') - : getParticipantName(participant) - }, - [tRooms] - ) + const { toggleSettingsDialog } = useSettingsDialog() useConnectionObserver() useRoomPageTitle() @@ -102,137 +73,12 @@ export function VideoConference({ ...props }: VideoConferenceProps) { }, [toggleSettingsDialog]), }) - const tracks = useTracks( - [ - { source: Track.Source.Camera, withPlaceholder: true }, - { source: Track.Source.ScreenShare, withPlaceholder: false }, - ], - { updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false } - ) - const layoutContext = useCreateLayoutContext() useNoiseReduction() - const screenShareTracks = tracks - .filter(isTrackReference) - .filter((track) => track.publication.source === Track.Source.ScreenShare) - - const focusTrack = usePinnedTracks(layoutContext)?.[0] - const carouselTracks = tracks.filter( - (track) => !isEqualTrackRef(track, focusTrack) - ) - const { isOpen: isPictureInPictureOpen } = usePictureInPicture() - // handle pin announcements - - useEffect(() => { - const participant = focusTrack?.participant - - // 1. unpin - if (!participant) { - if (!lastPinnedParticipantIdentityRef.current) return - - const lastIdentity = lastPinnedParticipantIdentityRef.current - const lastParticipant = - room.localParticipant.identity === lastIdentity - ? room.localParticipant - : room.remoteParticipants.get(lastIdentity) - const announcementName = getAnnouncementName(lastParticipant) - - announce( - lastParticipant?.isLocal - ? t('self.unpin') - : t('unpin', { - name: announcementName, - }) - ) - - lastPinnedParticipantIdentityRef.current = null - return - } - - // 2. same pin → do nothing - if (lastPinnedParticipantIdentityRef.current === participant.identity) { - return - } - - // 3. new pin - const participantName = participant.isLocal - ? tRooms('participants.you') - : getParticipantName(participant) - - lastPinnedParticipantIdentityRef.current = participant.identity - - announce( - participant.isLocal ? t('self.pin') : t('pin', { name: participantName }) - ) - }, [ - announce, - focusTrack, - getAnnouncementName, - room.localParticipant, - room.remoteParticipants, - t, - tRooms, - ]) - - /* eslint-disable react-hooks/exhaustive-deps */ - // Code duplicated from LiveKit; this warning will be addressed in the refactoring. - useEffect(() => { - // If screen share tracks are published, and no pin is set explicitly, auto set the screen share. - if ( - screenShareTracks.some((track) => track.publication.isSubscribed) && - lastAutoFocusedScreenShareTrack.current === null - ) { - log.debug('Auto set screen share focus:', { - newScreenShareTrack: screenShareTracks[0], - }) - layoutContext.pin.dispatch?.({ - msg: 'set_pin', - trackReference: screenShareTracks[0], - }) - lastAutoFocusedScreenShareTrack.current = screenShareTracks[0] - } else if ( - lastAutoFocusedScreenShareTrack.current && - !screenShareTracks.some( - (track) => - track.publication.trackSid === - lastAutoFocusedScreenShareTrack.current?.publication?.trackSid - ) - ) { - log.debug('Auto clearing screen share focus.') - layoutContext.pin.dispatch?.({ msg: 'clear_pin' }) - lastAutoFocusedScreenShareTrack.current = null - } - if (focusTrack && !isTrackReference(focusTrack)) { - const updatedFocusTrack = tracks.find( - (tr) => - tr.participant.identity === focusTrack.participant.identity && - tr.source === focusTrack.source - ) - if ( - updatedFocusTrack !== focusTrack && - isTrackReference(updatedFocusTrack) - ) { - layoutContext.pin.dispatch?.({ - msg: 'set_pin', - trackReference: updatedFocusTrack, - }) - } - } - }, [ - screenShareTracks - .map( - (ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}` - ) - .join(), - focusTrack?.publication?.trackSid, - tracks, - ]) - /* eslint-enable react-hooks/exhaustive-deps */ - const [isShareErrorVisible, setIsShareErrorVisible] = useState(false) return ( @@ -257,35 +103,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) { {isPictureInPictureOpen ? ( ) : ( - <> - {!focusTrack ? ( -
- - - -
- ) : ( -
- - - - - {focusTrack && } - -
- )} - + )}