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