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