diff --git a/CHANGELOG.md b/CHANGELOG.md index df622321..e5b2f4aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ and this project adheres to - ✨(backend) add fallback to save recordings without S3/MinIO webhooks - 🩹(frontend) enable screen share button in PiP #1458 - 🐛(backend) support unencoded S3 notification object keys #1455 +- ✨(frontend) prioritize screen share in picture-in-picture layout #1467 ### Changed diff --git a/src/frontend/src/features/pip/components/layout/PipScreenShareLayout.tsx b/src/frontend/src/features/pip/components/layout/PipScreenShareLayout.tsx new file mode 100644 index 00000000..7328b9ea --- /dev/null +++ b/src/frontend/src/features/pip/components/layout/PipScreenShareLayout.tsx @@ -0,0 +1,93 @@ +import { memo } from 'react' +import type { TrackReferenceOrPlaceholder } from '@livekit/components-core' +import { styled } from '@/styled-system/jsx' +import { ParticipantTile } from '@/features/rooms/livekit/components/ParticipantTile' +import { getTrackKey } from '../../utils/pipTrackSelection' + +type PipScreenShareLayoutProps = { + screenShareTrack: TrackReferenceOrPlaceholder + cameraTracks: TrackReferenceOrPlaceholder[] +} + +/** + * Layout when a screen share is active. + * Camera tiles are shown as a compact row at the top; the screen share occupies + * the remaining space below, much larger than the camera tiles. + */ +export const PipScreenShareLayout = memo( + ({ screenShareTrack, cameraTracks }: PipScreenShareLayoutProps) => { + return ( + + {cameraTracks.length > 0 && ( + + {cameraTracks.map((track) => ( + + + + ))} + + )} + + + + + ) + } +) +PipScreenShareLayout.displayName = 'PipScreenShareLayout' + +const LayoutContainer = styled('div', { + base: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: '0.25rem', + overflow: 'hidden', + }, +}) + +const CameraTilesRow = styled('div', { + base: { + display: 'flex', + flexDirection: 'row', + gap: '0.25rem', + flexShrink: 0, + height: '22%', + minHeight: '60px', + maxHeight: '120px', + }, +}) + +const CameraTile = styled('div', { + base: { + position: 'relative', + flex: '1 1 0', + minWidth: 0, + borderRadius: '4px', + overflow: 'hidden', + backgroundColor: 'primaryDark.100', + '& .lk-participant-tile': { + width: '100%', + height: '100%', + }, + }, +}) + +const ScreenShareSlot = styled('div', { + base: { + position: 'relative', + flex: 1, + minHeight: 0, + borderRadius: '4px', + 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/pip/components/layout/PipStage.tsx b/src/frontend/src/features/pip/components/layout/PipStage.tsx index d954060b..64de384c 100644 --- a/src/frontend/src/features/pip/components/layout/PipStage.tsx +++ b/src/frontend/src/features/pip/components/layout/PipStage.tsx @@ -1,10 +1,11 @@ -import { useMemo } from 'react' +import React, { useMemo } from 'react' import { usePagination, useTracks } from '@livekit/components-react' import { RoomEvent, Track } from 'livekit-client' import { styled } from '@/styled-system/jsx' import { PipFocusLayout } from './PipFocusLayout' import { PipGridLayout } from './PipGridLayout' import { PipPagination } from './PipPagination' +import { PipScreenShareLayout } from './PipScreenShareLayout' import { StageFrame } from './StageFrame' import { MAX_PIP_TILES } from '../../utils/pipGrid' import { @@ -13,9 +14,12 @@ import { } from '@livekit/components-core' /** - * PipStage picks between two layouts based on track count: - * - Focus mode (≤ 2 tracks): one main track + one thumbnail overlay. - * - Grid mode (3+ tracks): adaptive tiling. + * PipStage picks between three layouts: + * - Screen share mode (any screen share active): + * small camera tiles in a row at the top, large screen share below. + * - Grid mode (3+ camera tracks, no screen share): adaptive tiling. + * - Focus mode (≤ 2 camera tracks, no screen share): one main track + * + one thumbnail overlay. */ export const PipStage = () => { const tracks = useTracks( @@ -41,51 +45,43 @@ export const PipStage = () => { [tracks] ) - // Grid mode order: screen share leads, then cameras (already ordered by - // active speaker via the `ActiveSpeakersChanged` update above). - const gridTracks = useMemo( - () => - screenShareTrack ? [screenShareTrack, ...cameraTracks] : cameraTracks, - [screenShareTrack, cameraTracks] - ) + // Cap camera tiles in screen-share mode. Called unconditionally for hook rules. + const paginatedCameraTracks = usePagination(MAX_PIP_TILES - 1, cameraTracks) - // Cap the grid at MAX_PIP_TILES per page. `usePagination` keeps the visible - // page visually stable (active/recent speakers stay put) via its internal - // `useVisualStableUpdate`. Called unconditionally to respect hook rules. - const pagination = usePagination(MAX_PIP_TILES, gridTracks) + // Cap the grid at MAX_PIP_TILES per page for the non-screenshare grid mode. + const pagination = usePagination(MAX_PIP_TILES, cameraTracks) if (tracks.length === 0) return null - /** - * The focus layout shows one main track with one thumbnail overlay, - * so it can only fit 2 tracks. Beyond that we switch to the grid. - */ - if (gridTracks.length > 2) { + // Screen share active → Google Meet-style layout + if (screenShareTrack) { return ( - - - - - + - + ) } + // 3+ camera tracks → adaptive grid + if (cameraTracks.length > 2) { + return ( + + + + ) + } + + // ≤ 2 camera tracks → focus layout (main + optional thumbnail) const localCameraTrack = cameraTracks.find( (track) => track.participant?.isLocal ) - const remoteCameraTrack = cameraTracks.find( (track) => !track.participant?.isLocal ) - - const mainTrack = screenShareTrack ?? remoteCameraTrack ?? localCameraTrack - + const mainTrack = remoteCameraTrack ?? localCameraTrack const thumbnailTrack = mainTrack === localCameraTrack ? undefined : localCameraTrack @@ -96,6 +92,31 @@ export const PipStage = () => { ) } +type PaginationResult = { + totalPageCount: number + currentPage: number + nextPage: () => void + prevPage: () => void +} + +const PaginatedStage = ({ + pagination, + children, +}: { + pagination: PaginationResult + children: React.ReactNode +}) => ( + + {children} + + +) + const StageWrapper = styled('div', { base: { display: 'flex',