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',