Skip to content

Commit be82657

Browse files
authored
feat(react): Add Grid View During PIP (#2076)
### 💡 Overview Adds PipLayout.Grid, a grid layout for PiP mode with built-in pagination and automatic column adjustment based on the current participant count. ### 📝 Implementation notes 🎫 Ticket: https://linear.app/stream/issue/REACT-702/improve-pip-while-presenting-add-grid-view-during-screenshare
1 parent cee1282 commit be82657

File tree

6 files changed

+292
-16
lines changed

6 files changed

+292
-16
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import { useCall, useI18n } from '@stream-io/video-react-bindings';
2+
import { useEffect, useMemo, useState } from 'react';
3+
import clsx from 'clsx';
4+
import { hasScreenShare } from '@stream-io/video-client';
5+
import { Icon, IconButton } from '../../../../components';
6+
import {
7+
DefaultParticipantViewUI,
8+
ParticipantView,
9+
} from '../../ParticipantView';
10+
import {
11+
useFilteredParticipants,
12+
usePaginatedLayoutSortPreset,
13+
} from '../hooks';
14+
import { chunk } from '../../../../utilities';
15+
import { PipLayoutProps } from './Pip';
16+
17+
type GridDensity = 'single' | 'small' | 'medium' | 'large' | 'overflow';
18+
19+
export type PipLayoutGridProps = PipLayoutProps & {
20+
/**
21+
* The number of participants to display per page.
22+
* @default 9
23+
*/
24+
groupSize?: number;
25+
26+
/**
27+
* Whether to show pagination arrows when there are multiple pages.
28+
* @default true
29+
*/
30+
pageArrowsVisible?: boolean;
31+
};
32+
33+
const getGridDensity = (count: number): GridDensity => {
34+
if (count === 1) return 'single';
35+
if (count <= 5) return 'small';
36+
if (count <= 9) return 'medium';
37+
if (count <= 16) return 'large';
38+
return 'overflow';
39+
};
40+
41+
/**
42+
* A grid-based PIP layout with pagination support.
43+
* Use this when you need a more structured grid view in PIP mode.
44+
*/
45+
export const Grid = (props: PipLayoutGridProps) => {
46+
const { t } = useI18n();
47+
const {
48+
excludeLocalParticipant = false,
49+
filterParticipants,
50+
mirrorLocalParticipantVideo = true,
51+
groupSize = 9,
52+
pageArrowsVisible = true,
53+
VideoPlaceholder,
54+
ParticipantViewUI = DefaultParticipantViewUI,
55+
} = props;
56+
57+
const [page, setPage] = useState(0);
58+
const [wrapperElement, setWrapperElement] = useState<HTMLDivElement | null>(
59+
null,
60+
);
61+
62+
const call = useCall();
63+
const participants = useFilteredParticipants({
64+
excludeLocalParticipant,
65+
filterParticipants,
66+
});
67+
const screenSharingParticipant = participants.find((p) => hasScreenShare(p));
68+
69+
usePaginatedLayoutSortPreset(call);
70+
71+
useEffect(() => {
72+
if (!wrapperElement || !call) return;
73+
return call.setViewport(wrapperElement);
74+
}, [wrapperElement, call]);
75+
76+
const participantGroups = useMemo(
77+
() => chunk(participants, groupSize),
78+
[participants, groupSize],
79+
);
80+
81+
const pageCount = participantGroups.length;
82+
83+
if (page > pageCount - 1) {
84+
setPage(Math.max(0, pageCount - 1));
85+
}
86+
87+
const selectedGroup = participantGroups[page];
88+
const mirror = mirrorLocalParticipantVideo ? undefined : false;
89+
90+
if (!call) return null;
91+
92+
return (
93+
<div
94+
className="str-video__pip-layout str-video__pip-layout--grid"
95+
ref={setWrapperElement}
96+
>
97+
{screenSharingParticipant &&
98+
(screenSharingParticipant.isLocalParticipant ? (
99+
<div className="str-video__pip-screen-share-local">
100+
<Icon icon="screen-share-off" />
101+
<span className="str-video__pip-screen-share-local__title">
102+
{t('You are presenting your screen')}
103+
</span>
104+
</div>
105+
) : (
106+
<ParticipantView
107+
participant={screenSharingParticipant}
108+
trackType="screenShareTrack"
109+
muteAudio
110+
mirror={false}
111+
VideoPlaceholder={VideoPlaceholder}
112+
ParticipantViewUI={ParticipantViewUI}
113+
/>
114+
))}
115+
<div className="str-video__pip-layout__grid-container">
116+
{pageArrowsVisible && page > 0 && (
117+
<IconButton
118+
icon="caret-left"
119+
onClick={() =>
120+
setPage((currentPage) => Math.max(0, currentPage - 1))
121+
}
122+
className="str-video__pip-layout__pagination-button str-video__pip-layout__pagination-button--left"
123+
/>
124+
)}
125+
{selectedGroup && (
126+
<div
127+
className={clsx(
128+
'str-video__pip-layout__grid',
129+
`str-video__pip-layout__grid--${getGridDensity(selectedGroup.length)}`,
130+
)}
131+
>
132+
{selectedGroup.map((participant) => (
133+
<ParticipantView
134+
key={participant.sessionId}
135+
participant={participant}
136+
muteAudio
137+
mirror={mirror}
138+
VideoPlaceholder={VideoPlaceholder}
139+
ParticipantViewUI={ParticipantViewUI}
140+
/>
141+
))}
142+
</div>
143+
)}
144+
{pageArrowsVisible && page < pageCount - 1 && (
145+
<IconButton
146+
icon="caret-right"
147+
onClick={() =>
148+
setPage((currentPage) => Math.min(pageCount - 1, currentPage + 1))
149+
}
150+
className="str-video__pip-layout__pagination-button str-video__pip-layout__pagination-button--right"
151+
/>
152+
)}
153+
</div>
154+
</div>
155+
);
156+
};
157+
158+
Grid.displayName = 'PipLayout.Grid';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { ParticipantsAudio } from '../../Audio';
2+
import { useRawRemoteParticipants } from '../hooks';
3+
4+
export const Host = () => {
5+
const remoteParticipants = useRawRemoteParticipants();
6+
return <ParticipantsAudio participants={remoteParticipants} />;
7+
};
8+
9+
Host.displayName = 'PipLayout.Host';

packages/react-sdk/src/core/components/CallLayout/PipLayout.tsx renamed to packages/react-sdk/src/core/components/CallLayout/PipLayout/Pip.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,18 @@ import { useCall, useI18n } from '@stream-io/video-react-bindings';
22
import { useEffect, useState } from 'react';
33

44
import { hasScreenShare } from '@stream-io/video-client';
5-
import { Icon } from '../../../components';
6-
import { ParticipantsAudio } from '../Audio';
5+
import { Icon } from '../../../../components';
76
import {
87
DefaultParticipantViewUI,
98
ParticipantView,
109
ParticipantViewProps,
11-
} from '../ParticipantView';
10+
} from '../../ParticipantView';
1211
import {
1312
ParticipantFilter,
1413
ParticipantPredicate,
1514
useFilteredParticipants,
1615
usePaginatedLayoutSortPreset,
17-
useRawRemoteParticipants,
18-
} from './hooks';
16+
} from '../hooks';
1917

2018
export type PipLayoutProps = {
2119
/**
@@ -51,7 +49,7 @@ export type PipLayoutProps = {
5149
mirrorLocalParticipantVideo?: boolean;
5250
} & Pick<ParticipantViewProps, 'ParticipantViewUI' | 'VideoPlaceholder'>;
5351

54-
const Pip = (props: PipLayoutProps) => {
52+
export const Pip = (props: PipLayoutProps) => {
5553
const { t } = useI18n();
5654
const {
5755
excludeLocalParticipant = false,
@@ -116,12 +114,3 @@ const Pip = (props: PipLayoutProps) => {
116114
};
117115

118116
Pip.displayName = 'PipLayout.Pip';
119-
120-
const Host = () => {
121-
const remoteParticipants = useRawRemoteParticipants();
122-
return <ParticipantsAudio participants={remoteParticipants} />;
123-
};
124-
125-
Host.displayName = 'PipLayout.Host';
126-
127-
export const PipLayout = { Pip, Host };
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { Pip } from './Pip';
2+
import { Host } from './Host';
3+
import { Grid } from './Grid';
4+
5+
export const PipLayout = { Pip, Host, Grid };

packages/styling/src/CallLayout/PipLayout-layout.scss

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,118 @@
2121
height: 1rem;
2222
}
2323
}
24+
25+
.str-video__pip-layout--grid {
26+
flex-wrap: nowrap;
27+
}
28+
29+
.str-video__pip-layout__grid-container {
30+
position: relative;
31+
display: flex;
32+
align-items: center;
33+
justify-content: center;
34+
flex: 1 1 auto;
35+
min-height: 0;
36+
max-height: 100%;
37+
overflow: hidden;
38+
}
39+
40+
.str-video__pip-layout__pagination-button {
41+
position: absolute;
42+
top: 50%;
43+
transform: translateY(-50%);
44+
z-index: 1;
45+
padding: var(--str-video__spacing-xxs);
46+
min-width: auto;
47+
opacity: 0.8;
48+
transition: opacity 0.2s ease;
49+
50+
&:hover:not(:disabled) {
51+
opacity: 1;
52+
}
53+
54+
.str-video__icon {
55+
width: 1rem;
56+
height: 1rem;
57+
}
58+
59+
&--left {
60+
left: 0;
61+
}
62+
63+
&--right {
64+
right: 0;
65+
}
66+
}
67+
68+
.str-video__pip-layout__grid {
69+
display: grid;
70+
grid-template-columns: repeat(var(--pip-cols), 1fr);
71+
gap: var(--str-video__spacing-xxs);
72+
width: 100%;
73+
max-height: 100%;
74+
flex: 1 1 auto;
75+
76+
.str-video__participant-view {
77+
width: 100%;
78+
height: 100%;
79+
min-width: 0;
80+
min-height: 0;
81+
overflow: hidden;
82+
83+
&--speaking {
84+
outline: none;
85+
86+
&::before {
87+
content: '';
88+
position: absolute;
89+
inset: 0;
90+
border: 2px solid var(--str-video__primary-color);
91+
border-radius: var(--str-video__border-radius-sm);
92+
pointer-events: none;
93+
z-index: 1;
94+
}
95+
}
96+
97+
.str-video__notification {
98+
display: none;
99+
}
100+
}
101+
102+
@mixin pip-avatar-size($size, $font-size) {
103+
.str-video__video-placeholder__avatar,
104+
.str-video__video-placeholder__initials-fallback {
105+
width: $size;
106+
height: $size;
107+
font-size: $font-size;
108+
}
109+
}
110+
111+
@include pip-avatar-size(32px, var(--str-video__font-size-sm));
112+
113+
&.str-video__pip-layout__grid--single {
114+
--pip-cols: 1;
115+
@include pip-avatar-size(80px, var(--str-video__font-size-xxl));
116+
}
117+
118+
&.str-video__pip-layout__grid--small {
119+
--pip-cols: 2;
120+
@include pip-avatar-size(60px, var(--str-video__font-size-xl));
121+
}
122+
123+
&.str-video__pip-layout__grid--medium {
124+
--pip-cols: 3;
125+
@include pip-avatar-size(45px, var(--str-video__font-size-md));
126+
}
127+
128+
&.str-video__pip-layout__grid--large {
129+
--pip-cols: 4;
130+
@include pip-avatar-size(35px, var(--str-video__font-size-sm));
131+
}
132+
133+
&.str-video__pip-layout__grid--overflow {
134+
--pip-cols: 5;
135+
overflow-y: auto;
136+
@include pip-avatar-size(25px, var(--str-video__font-size-xs));
137+
}
138+
}

sample-apps/react/react-dogfood/components/StagePip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function StagePip() {
1111
return (
1212
<StreamTheme>
1313
<div className="rd__stage-pip">
14-
<PipLayout.Pip ParticipantViewUI={PipParticipantViewUI} />
14+
<PipLayout.Grid ParticipantViewUI={PipParticipantViewUI} />
1515
</div>
1616
<div className="str-video__call-controls">
1717
<div className="str-video__call-controls--group str-video__call-controls--media">

0 commit comments

Comments
 (0)