diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6e3d9e..8d664ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(frontend) cap and paginate tiles in picture-in-picture #1383 - 📝(docs) document rebranding the favicon via a volume mount #1443 - ✨(backend) add command to clean pending and deleted files - 🧱(helm) run clean files command as cronjob diff --git a/src/frontend/src/features/pip/components/layout/PipPagination.tsx b/src/frontend/src/features/pip/components/layout/PipPagination.tsx new file mode 100644 index 00000000..228d332a --- /dev/null +++ b/src/frontend/src/features/pip/components/layout/PipPagination.tsx @@ -0,0 +1,96 @@ +import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react' +import { useTranslation } from 'react-i18next' +import { styled } from '@/styled-system/jsx' + +interface PipPaginationProps { + totalPageCount: number + currentPage: number + nextPage: () => void + prevPage: () => void +} + +export const PipPagination = ({ + totalPageCount, + currentPage, + nextPage, + prevPage, +}: PipPaginationProps) => { + const { t } = useTranslation('rooms', { keyPrefix: 'pagination' }) + + if (totalPageCount <= 1) return null + + return ( + + ) +} + +const Nav = styled('nav', { + base: { + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '0.25rem', + marginTop: '1rem', + flexShrink: 0, + }, +}) + +const ArrowButton = styled('button', { + base: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: '1.75rem', + height: '1.75rem', + borderRadius: '4px', + border: 'none', + cursor: 'pointer', + color: 'white', + backgroundColor: 'primaryDark.100', + transition: 'opacity 0.15s, background-color 0.15s', + '&:hover:not(:disabled)': { + backgroundColor: 'primaryDark.75', + }, + '&:focus-visible': { + outline: '2px solid', + outlineColor: 'white', + outlineOffset: '2px', + }, + '&:disabled': { + opacity: 0.3, + cursor: 'default', + }, + }, +}) + +const Counter = styled('span', { + base: { + fontSize: '0.75rem', + color: 'white', + opacity: 0.8, + whiteSpace: 'nowrap', + padding: '0 0.25rem', + minWidth: '3rem', + textAlign: 'center', + }, +}) diff --git a/src/frontend/src/features/pip/components/layout/PipStage.tsx b/src/frontend/src/features/pip/components/layout/PipStage.tsx index b59ffe7e..d954060b 100644 --- a/src/frontend/src/features/pip/components/layout/PipStage.tsx +++ b/src/frontend/src/features/pip/components/layout/PipStage.tsx @@ -1,9 +1,12 @@ import { useMemo } from 'react' -import { useTracks } from '@livekit/components-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 { StageFrame } from './StageFrame' +import { MAX_PIP_TILES } from '../../utils/pipGrid' import { isTrackReference, TrackReferenceOrPlaceholder, @@ -38,21 +41,38 @@ 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 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) + 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 (tracks.length > 2) { - // Grid mode: 3+ tracks. Screen share goes first so it leads the grid. - const gridTracks = screenShareTrack - ? [screenShareTrack, ...cameraTracks] - : cameraTracks + if (gridTracks.length > 2) { return ( - - - + + + + + + ) } @@ -75,3 +95,12 @@ export const PipStage = () => { ) } + +const StageWrapper = styled('div', { + base: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + minHeight: 0, + }, +}) diff --git a/src/frontend/src/features/pip/components/layout/StageFrame.tsx b/src/frontend/src/features/pip/components/layout/StageFrame.tsx index 46e34347..f0a54d53 100644 --- a/src/frontend/src/features/pip/components/layout/StageFrame.tsx +++ b/src/frontend/src/features/pip/components/layout/StageFrame.tsx @@ -15,6 +15,7 @@ export const StageFrame = ({ children }: { children: React.ReactNode }) => { const Container = styled('div', { base: { position: 'relative', + flex: 1, minWidth: 0, minHeight: 0, marginLeft: '0.5rem', diff --git a/src/frontend/src/features/pip/utils/pipGrid.ts b/src/frontend/src/features/pip/utils/pipGrid.ts index ce73dcdc..679c05c6 100644 --- a/src/frontend/src/features/pip/utils/pipGrid.ts +++ b/src/frontend/src/features/pip/utils/pipGrid.ts @@ -1,3 +1,10 @@ +/** + * Maximum number of tiles rendered per PiP page. Beyond this the grid + * paginates so large meetings stay glanceable (active speaker priority) + * instead of subscribing to and rendering every camera in a small window. + */ +export const MAX_PIP_TILES = 5 + export type PipTilePlacement = { gridColumn: string gridRow: number