diff --git a/src/frontend/src/features/layout/components/FocusArea.tsx b/src/frontend/src/features/layout/components/FocusArea.tsx new file mode 100644 index 00000000..d8949b1d --- /dev/null +++ b/src/frontend/src/features/layout/components/FocusArea.tsx @@ -0,0 +1,53 @@ +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' +import { styled } from '@/styled-system/jsx' +import { FocusLayout } from '@/features/rooms/livekit/components/FocusLayout' +import { SecondaryScreenShareStrip } from './SecondaryScreenShareStrip' + +export interface FocusAreaProps { + focusTrack: TrackReferenceOrPlaceholder + secondaryScreenShareTracks: TrackReferenceOrPlaceholder[] +} + +/** + * Main focus track with an optional strip of secondary screen shares below. + */ +export function FocusArea({ + focusTrack, + secondaryScreenShareTracks, +}: FocusAreaProps) { + return ( + + + + + + + ) +} + +const FocusColumn = styled('div', { + base: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, + minHeight: 0, + overflow: 'hidden', + }, +}) + +const MainFocusSlot = styled('div', { + base: { + position: 'relative', + flex: 1, + minHeight: 0, + overflow: 'hidden', + '& .lk-participant-tile': { + width: '100%', + height: '100%', + }, + '& .lk-participant-media-video': { + objectFit: 'contain', + }, + }, +}) diff --git a/src/frontend/src/features/layout/components/SecondaryScreenShareStrip.tsx b/src/frontend/src/features/layout/components/SecondaryScreenShareStrip.tsx new file mode 100644 index 00000000..8b2439b7 --- /dev/null +++ b/src/frontend/src/features/layout/components/SecondaryScreenShareStrip.tsx @@ -0,0 +1,66 @@ +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' +import { memo } from 'react' +import { styled } from '@/styled-system/jsx' +import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' +import { getTrackKey } from '@/features/pip/utils/pipTrackSelection' + +export interface SecondaryScreenShareStripProps { + tracks: TrackReferenceOrPlaceholder[] +} + +/** + * Compact horizontal strip for non-focused screen shares. + * Each tile keeps pin controls so users can switch focus to any share. + */ +export const SecondaryScreenShareStrip = memo( + ({ tracks }: SecondaryScreenShareStripProps) => { + if (tracks.length === 0) return null + + return ( + + {tracks.map((track) => ( + + + + ))} + + ) + } +) +SecondaryScreenShareStrip.displayName = 'SecondaryScreenShareStrip' + +const StripContainer = styled('div', { + base: { + display: 'flex', + flexDirection: 'row', + gap: '0.5rem', + flexShrink: 0, + height: '120px', + minHeight: '80px', + maxHeight: '140px', + padding: '0.5rem 0 0', + overflowX: 'auto', + overflowY: 'hidden', + boxSizing: 'border-box', + }, +}) + +const ShareTile = styled('div', { + base: { + position: 'relative', + flex: '0 0 auto', + height: '100%', + aspectRatio: '16 / 9', + minWidth: 0, + borderRadius: '8px', + overflow: 'hidden', + backgroundColor: 'primaryDark.100', + '& .lk-participant-tile': { + width: '100%', + height: '100%', + }, + '& .lk-participant-media-video': { + objectFit: 'contain', + }, + }, +}) diff --git a/src/frontend/src/features/layout/utils/screenShareLayout.ts b/src/frontend/src/features/layout/utils/screenShareLayout.ts new file mode 100644 index 00000000..073494a5 --- /dev/null +++ b/src/frontend/src/features/layout/utils/screenShareLayout.ts @@ -0,0 +1,84 @@ +import { + isEqualTrackRef, + isTrackReference, + type TrackReferenceOrPlaceholder, +} from '@livekit/components-core' +import { Track } from 'livekit-client' + +export function getSubscribedScreenShareTracks( + tracks: TrackReferenceOrPlaceholder[] +): TrackReferenceOrPlaceholder[] { + return tracks + .filter(isTrackReference) + .filter( + (track) => + track.publication.source === Track.Source.ScreenShare && + track.publication.isSubscribed + ) +} + +export function getNewScreenShareTracks( + screenShareTracks: TrackReferenceOrPlaceholder[], + knownTrackSids: Set +): TrackReferenceOrPlaceholder[] { + return screenShareTracks.filter( + (track) => + track.publication.trackSid && + !knownTrackSids.has(track.publication.trackSid) + ) +} + +export function syncKnownScreenShareSids( + screenShareTracks: TrackReferenceOrPlaceholder[], + knownTrackSids: Set +): void { + const currentSids = new Set( + screenShareTracks + .map((track) => track.publication.trackSid) + .filter((sid): sid is string => !!sid) + ) + + screenShareTracks.forEach((track) => { + if (track.publication.trackSid) { + knownTrackSids.add(track.publication.trackSid) + } + }) + + knownTrackSids.forEach((sid) => { + if (!currentSids.has(sid)) { + knownTrackSids.delete(sid) + } + }) +} + +export function getNewestScreenShareTrack( + screenShareTracks: TrackReferenceOrPlaceholder[] +): TrackReferenceOrPlaceholder | undefined { + if (screenShareTracks.length === 0) return undefined + return screenShareTracks[screenShareTracks.length - 1] +} + +export function splitTracksForFocusLayout( + tracks: TrackReferenceOrPlaceholder[], + focusTrack: TrackReferenceOrPlaceholder | undefined, + screenShareTracks: TrackReferenceOrPlaceholder[] +): { + carouselTracks: TrackReferenceOrPlaceholder[] + secondaryScreenShareTracks: TrackReferenceOrPlaceholder[] +} { + const hasActiveScreenShares = screenShareTracks.length > 0 + + const secondaryScreenShareTracks = screenShareTracks.filter( + (track) => !focusTrack || !isEqualTrackRef(track, focusTrack) + ) + + const carouselTracks = tracks.filter((track) => { + if (focusTrack && isEqualTrackRef(track, focusTrack)) return false + if (hasActiveScreenShares && track.source === Track.Source.ScreenShare) { + return false + } + return true + }) + + return { carouselTracks, secondaryScreenShareTracks } +} diff --git a/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts b/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts index d462a64d..b8909f16 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/useFocusToggleParticipant.ts @@ -1,21 +1,52 @@ -import { useFocusToggle } from '@livekit/components-react' +import { + isTrackReferencePinned, +} from '@livekit/components-core' +import { useFocusToggle, useMaybeLayoutContext } from '@livekit/components-react' import { type Participant, Track } from 'livekit-client' import { useCallback } from 'react' import type { MouseEvent } from 'react' import Source = Track.Source -export const useFocusToggleParticipant = (participant: Participant) => { - const trackRef = { - participant: participant, - publication: participant.getTrackPublication(Source.Camera), - source: Source.Camera, - } +const getParticipantPinTrackRef = (participant: Participant) => { + const screenSharePublication = participant.getTrackPublication( + Source.ScreenShare + ) + const source = + participant.isScreenShareEnabled && screenSharePublication + ? Source.ScreenShare + : Source.Camera - const { mergedProps, inFocus } = useFocusToggle({ + return { + participant, + publication: participant.getTrackPublication(source), + source, + } +} + +export const useFocusToggleParticipant = (participant: Participant) => { + const layoutContext = useMaybeLayoutContext() + const trackRef = getParticipantPinTrackRef(participant) + + const { mergedProps, inFocus: isFocusedTrack } = useFocusToggle({ trackRef, props: {}, }) + const inFocus = + isFocusedTrack || + (!!layoutContext?.pin.state && + [Source.Camera, Source.ScreenShare].some((source) => { + const ref = { + participant, + publication: participant.getTrackPublication(source), + source, + } + return ( + !!ref.publication && + isTrackReferencePinned(ref, layoutContext.pin.state) + ) + })) + const toggle = useCallback(() => { const syntheticEvent = { preventDefault: () => {}, diff --git a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx index 13d476fb..27fafe06 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/VideoConference.tsx @@ -20,7 +20,6 @@ import { import { useTranslation } from 'react-i18next' import { ControlBar } from './ControlBar/ControlBar' -import { FocusLayout } from '../components/FocusLayout' import { ParticipantTile } from '../components/ParticipantTile' import { SidePanel } from '../components/SidePanel' import { RecordingProvider } from '@/features/recording' @@ -39,8 +38,16 @@ 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 { FocusArea } from '@/features/layout/components/FocusArea' import { GridLayout } from '@/features/layout/components/GridLayout' import { RoomContentArea } from '@/features/layout/components/RoomContentArea' +import { + getNewScreenShareTracks, + getNewestScreenShareTrack, + getSubscribedScreenShareTracks, + splitTracksForFocusLayout, + syncKnownScreenShareSids, +} from '@/features/layout/utils/screenShareLayout' import { usePictureInPicture } from '@/features/pip/hooks/usePictureInPicture' import { PipRoomPlaceholder } from '@/features/pip/components/PipRoomPlaceholder' @@ -73,6 +80,7 @@ export interface VideoConferenceProps extends React.HTMLAttributes(null) + const knownScreenShareTrackSids = useRef>(new Set()) const lastPinnedParticipantIdentityRef = useRef(null) const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' }) const { t: tRooms } = useTranslation('rooms') @@ -114,14 +122,11 @@ export function VideoConference({ ...props }: VideoConferenceProps) { useNoiseReduction() - const screenShareTracks = tracks - .filter(isTrackReference) - .filter((track) => track.publication.source === Track.Source.ScreenShare) + const screenShareTracks = getSubscribedScreenShareTracks(tracks) const focusTrack = usePinnedTracks(layoutContext)?.[0] - const carouselTracks = tracks.filter( - (track) => !isEqualTrackRef(track, focusTrack) - ) + const { carouselTracks, secondaryScreenShareTracks } = + splitTracksForFocusLayout(tracks, focusTrack, screenShareTracks) const { isOpen: isPictureInPictureOpen } = usePictureInPicture() @@ -181,31 +186,48 @@ export function VideoConference({ ...props }: VideoConferenceProps) { /* 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] + const newScreenShares = getNewScreenShareTracks( + screenShareTracks, + knownScreenShareTrackSids.current + ) + syncKnownScreenShareSids( + screenShareTracks, + knownScreenShareTrackSids.current + ) + + if (newScreenShares.length > 0) { + const newestScreenShare = getNewestScreenShareTrack(newScreenShares) + if (newestScreenShare) { + log.debug('Auto set screen share focus:', { + newScreenShareTrack: newestScreenShare, + }) + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: newestScreenShare, + }) + lastAutoFocusedScreenShareTrack.current = newestScreenShare + } } else if ( - lastAutoFocusedScreenShareTrack.current && - !screenShareTracks.some( - (track) => - track.publication.trackSid === - lastAutoFocusedScreenShareTrack.current?.publication?.trackSid - ) + focusTrack?.source === Track.Source.ScreenShare && + !screenShareTracks.some((track) => isEqualTrackRef(track, focusTrack)) ) { - log.debug('Auto clearing screen share focus.') - layoutContext.pin.dispatch?.({ msg: 'clear_pin' }) - lastAutoFocusedScreenShareTrack.current = null + const newestRemaining = getNewestScreenShareTrack(screenShareTracks) + if (newestRemaining) { + log.debug('Auto switching to remaining screen share:', { + screenShareTrack: newestRemaining, + }) + layoutContext.pin.dispatch?.({ + msg: 'set_pin', + trackReference: newestRemaining, + }) + lastAutoFocusedScreenShareTrack.current = newestRemaining + } else { + 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) => @@ -281,7 +303,14 @@ export function VideoConference({ ...props }: VideoConferenceProps) { > - {focusTrack && } + {focusTrack && ( + + )} )}