(frontend) cap and paginate tiles in picture-in-picture

Cap PiP grid at 5 tiles per page with active-speaker priority and pagination.
This commit is contained in:
Cyril
2026-06-02 08:47:55 +02:00
parent b498926353
commit f98e884067
5 changed files with 143 additions and 9 deletions
+1
View File
@@ -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
@@ -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 (
<Nav aria-label={t('label')}>
<ArrowButton
type="button"
onClick={prevPage}
disabled={currentPage === 1}
aria-label={t('previous')}
>
<RiArrowLeftSLine size={18} />
</ArrowButton>
<Counter role="status">
{t('count', { currentPage, totalPageCount })}
</Counter>
<ArrowButton
type="button"
onClick={nextPage}
disabled={currentPage === totalPageCount}
aria-label={t('next')}
>
<RiArrowRightSLine size={18} />
</ArrowButton>
</Nav>
)
}
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',
},
})
@@ -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 (
<StageFrame>
<PipGridLayout tracks={gridTracks} />
</StageFrame>
<StageWrapper>
<StageFrame>
<PipGridLayout tracks={pagination.tracks} />
</StageFrame>
<PipPagination
totalPageCount={pagination.totalPageCount}
currentPage={pagination.currentPage}
nextPage={pagination.nextPage}
prevPage={pagination.prevPage}
/>
</StageWrapper>
)
}
@@ -75,3 +95,12 @@ export const PipStage = () => {
</StageFrame>
)
}
const StageWrapper = styled('div', {
base: {
display: 'flex',
flexDirection: 'column',
minWidth: 0,
minHeight: 0,
},
})
@@ -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',
@@ -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