mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-31 12:47:58 +00:00
✨(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:
@@ -10,6 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
|
||||||
- 📝(docs) document rebranding the favicon via a volume mount #1443
|
- 📝(docs) document rebranding the favicon via a volume mount #1443
|
||||||
- ✨(backend) add command to clean pending and deleted files
|
- ✨(backend) add command to clean pending and deleted files
|
||||||
- 🧱(helm) run clean files command as cronjob
|
- 🧱(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 { useMemo } from 'react'
|
||||||
import { useTracks } from '@livekit/components-react'
|
import { usePagination, useTracks } from '@livekit/components-react'
|
||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { RoomEvent, Track } from 'livekit-client'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { PipFocusLayout } from './PipFocusLayout'
|
import { PipFocusLayout } from './PipFocusLayout'
|
||||||
import { PipGridLayout } from './PipGridLayout'
|
import { PipGridLayout } from './PipGridLayout'
|
||||||
|
import { PipPagination } from './PipPagination'
|
||||||
import { StageFrame } from './StageFrame'
|
import { StageFrame } from './StageFrame'
|
||||||
|
import { MAX_PIP_TILES } from '../../utils/pipGrid'
|
||||||
import {
|
import {
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
TrackReferenceOrPlaceholder,
|
TrackReferenceOrPlaceholder,
|
||||||
@@ -38,21 +41,38 @@ export const PipStage = () => {
|
|||||||
[tracks]
|
[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
|
if (tracks.length === 0) return null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The focus layout shows one main track with one thumbnail overlay,
|
* 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.
|
* so it can only fit 2 tracks. Beyond that we switch to the grid.
|
||||||
*/
|
*/
|
||||||
if (tracks.length > 2) {
|
if (gridTracks.length > 2) {
|
||||||
// Grid mode: 3+ tracks. Screen share goes first so it leads the grid.
|
|
||||||
const gridTracks = screenShareTrack
|
|
||||||
? [screenShareTrack, ...cameraTracks]
|
|
||||||
: cameraTracks
|
|
||||||
return (
|
return (
|
||||||
<StageFrame>
|
<StageWrapper>
|
||||||
<PipGridLayout tracks={gridTracks} />
|
<StageFrame>
|
||||||
</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>
|
</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', {
|
const Container = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
marginLeft: '0.5rem',
|
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 = {
|
export type PipTilePlacement = {
|
||||||
gridColumn: string
|
gridColumn: string
|
||||||
gridRow: number
|
gridRow: number
|
||||||
|
|||||||
Reference in New Issue
Block a user