Skip to content

Commit 67ca6b5

Browse files
[Custom Layouts] Speaker gallery component (#3443)
* add movement to the overflow gallery * move the local tile fix vertical * fix sample build * Change files * Duplicate change files for beta release * CC component level * update composite controls * add localization and icons * Beta API updates * fix cc * fix lint * Update packages/react-composites CallWithChatComposite browser test snapshots * Update packages/react-composites CallComposite browser test snapshots * create test for validating gallery movement * remove test only * Change files * Duplicate change files for beta release * update more button styles * create component * update logic to just show speaker * maybe? * Update packages/react-composites CallWithChatComposite browser test snapshots * cc test * fix local tile * Change files * Duplicate change files for beta release * return composite to default * fix issues when alone in call with speaker layout * build API * fix build * Update packages/react-composites CallComposite browser test snapshots * update API * fix tests * fix icon positioning * remove not implemented API * Update packages/react-composites CallWithChatComposite browser test snapshots * Update packages/react-composites CallComposite browser test snapshots --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 0dec467 commit 67ca6b5

51 files changed

Lines changed: 363 additions & 54 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "prerelease",
3+
"area": "feature",
4+
"workstream": "Custom Layouts",
5+
"comment": "Introduces Speaker gallery layout to the video gallery component",
6+
"packageName": "@azure/communication-react",
7+
"email": "94866715+dmceachernmsft@users.noreply.github.com",
8+
"dependentChangeType": "patch"
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"type": "prerelease",
3+
"area": "feature",
4+
"workstream": "Custom Layouts",
5+
"comment": "Introduces Speaker gallery layout to the video gallery component",
6+
"packageName": "@azure/communication-react",
7+
"email": "94866715+dmceachernmsft@users.noreply.github.com",
8+
"dependentChangeType": "patch"
9+
}

packages/communication-react/review/beta/communication-react.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3850,7 +3850,7 @@ export interface VideoBackgroundReplacementEffect extends BackgroundReplacementC
38503850
export const VideoGallery: (props: VideoGalleryProps) => JSX.Element;
38513851

38523852
// @public (undocumented)
3853-
export type VideoGalleryLayout = 'default' | 'floatingLocalVideo';
3853+
export type VideoGalleryLayout = 'default' | 'floatingLocalVideo' | /* @conditional-compile-remove(gallery-layouts) */ 'speaker';
38543854

38553855
// @public
38563856
export interface VideoGalleryLocalParticipant extends VideoGalleryParticipant {

packages/react-components/review/beta/react-components.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2134,7 +2134,7 @@ export interface _VideoEffectsItemStyles {
21342134
export const VideoGallery: (props: VideoGalleryProps) => JSX.Element;
21352135

21362136
// @public (undocumented)
2137-
export type VideoGalleryLayout = 'default' | 'floatingLocalVideo';
2137+
export type VideoGalleryLayout = 'default' | 'floatingLocalVideo' | /* @conditional-compile-remove(gallery-layouts) */ 'speaker';
21382138

21392139
// @public
21402140
export interface VideoGalleryLocalParticipant extends VideoGalleryParticipant {

packages/react-components/src/components/VideoGallery.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { floatingLocalVideoTileStyle } from './VideoGallery/styles/FloatingLocal
3636
import { useId } from '@fluentui/react-hooks';
3737
/* @conditional-compile-remove(vertical-gallery) */
3838
import { VerticalGalleryStyles } from './VerticalGallery';
39+
/* @conditional-compile-remove(gallery-layouts) */
40+
import { SpeakerVideoLayout } from './VideoGallery/SpeakerVideoLayout';
3941

4042
/**
4143
* @private
@@ -123,7 +125,10 @@ export interface VideoGalleryStrings {
123125
/**
124126
* @public
125127
*/
126-
export type VideoGalleryLayout = 'default' | 'floatingLocalVideo';
128+
export type VideoGalleryLayout =
129+
| 'default'
130+
| 'floatingLocalVideo'
131+
| /* @conditional-compile-remove(gallery-layouts) */ 'speaker';
127132

128133
/**
129134
* {@link VideoGallery} Component Styles.
@@ -353,7 +358,10 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
353358
/* @conditional-compile-remove(pinned-participants) */
354359
const drawerMenuHostId = useId('drawerMenuHost', drawerMenuHostIdFromProp);
355360

356-
const shouldFloatLocalVideo = !!(layout === 'floatingLocalVideo' && remoteParticipants.length > 0);
361+
const localTileNotInGrid = !!(
362+
(layout === 'floatingLocalVideo' || /* @conditional-compile-remove(gallery-layouts) */ layout === 'speaker') &&
363+
remoteParticipants.length > 0
364+
);
357365

358366
const containerRef = useRef<HTMLDivElement>(null);
359367
const containerWidth = _useContainerWidth(containerRef);
@@ -388,7 +396,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
388396
}
389397

390398
const localVideoTileStyles = concatStyleSets(
391-
shouldFloatLocalVideo ? floatingLocalVideoTileStyle : {},
399+
localTileNotInGrid ? floatingLocalVideoTileStyle : {},
392400
{
393401
root: { borderRadius: theme.effects.roundedCorner4 }
394402
},
@@ -418,7 +426,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
418426
onRenderAvatar={onRenderAvatar}
419427
showLabel={
420428
!(
421-
(shouldFloatLocalVideo && isNarrow) ||
429+
(localTileNotInGrid && isNarrow) ||
422430
/*@conditional-compile-remove(click-to-call) */ /* @conditional-compile-remove(rooms) */ localVideoTileSize ===
423431
'9:16'
424432
)
@@ -441,7 +449,7 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
441449
onDisposeLocalStreamView,
442450
onRenderAvatar,
443451
onRenderLocalVideoTile,
444-
shouldFloatLocalVideo,
452+
localTileNotInGrid,
445453
showCameraSwitcherInLocalPreview,
446454
showMuteIndicator,
447455
strings.localVideoCameraSwitcherLabel,
@@ -620,6 +628,10 @@ export const VideoGallery = (props: VideoGalleryProps): JSX.Element => {
620628
if (layout === 'floatingLocalVideo') {
621629
return <FloatingLocalVideoLayout {...layoutProps} />;
622630
}
631+
/* @conditional-compile-remove(gallery-layouts) */
632+
if (layout === 'speaker') {
633+
return <SpeakerVideoLayout {...layoutProps} />;
634+
}
623635
return <DefaultLayout {...layoutProps} />;
624636
}, [layout, layoutProps]);
625637

packages/react-components/src/components/VideoGallery/DefaultLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,8 @@ export const DefaultLayout = (props: DefaultLayoutProps): JSX.Element => {
6161
maxOverflowGalleryDominantSpeakers: screenShareComponent
6262
? childrenPerPage.current - ((pinnedParticipantUserIds.length + 1) % childrenPerPage.current)
6363
: childrenPerPage.current,
64-
/* @conditional-compile-remove(pinned-participants) */ pinnedParticipantUserIds
64+
/* @conditional-compile-remove(pinned-participants) */ pinnedParticipantUserIds,
65+
/* @conditional-compile-remove(gallery-layouts) */ layout: 'default'
6566
});
6667

6768
let activeVideoStreams = 0;

packages/react-components/src/components/VideoGallery/FloatingLocalVideoLayout.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export const FloatingLocalVideoLayout = (props: FloatingLocalVideoLayoutProps):
9292
maxOverflowGalleryDominantSpeakers: screenShareComponent
9393
? childrenPerPage.current - (pinnedParticipantUserIds.length % childrenPerPage.current)
9494
: childrenPerPage.current,
95-
/* @conditional-compile-remove(pinned-participants) */ pinnedParticipantUserIds
95+
/* @conditional-compile-remove(pinned-participants) */ pinnedParticipantUserIds,
96+
/* @conditional-compile-remove(gallery-layouts) */ layout: 'floatingLocalVideo'
9697
});
9798

9899
let activeVideoStreams = 0;
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
import { LayerHost, Stack, mergeStyles, useTheme } from '@fluentui/react';
5+
/* @conditional-compile-remove(click-to-call) */
6+
import { LocalVideoTileSize } from '../VideoGallery';
7+
import { LayoutProps } from './Layout';
8+
import { isNarrowWidth } from '../utils/responsive';
9+
/* @conditional-compile-remove(vertical-gallery) */
10+
import { isShortHeight } from '../utils/responsive';
11+
import React, { useMemo, useRef, useState } from 'react';
12+
import { OverflowGallery } from './OverflowGallery';
13+
import {
14+
SMALL_FLOATING_MODAL_SIZE_REM,
15+
LARGE_FLOATING_MODAL_SIZE_REM,
16+
localVideoTileContainerStyle
17+
} from './styles/FloatingLocalVideo.styles';
18+
/* @conditional-compile-remove(vertical-gallery) */
19+
import {
20+
VERTICAL_GALLERY_FLOATING_MODAL_SIZE_REM,
21+
SHORT_VERTICAL_GALLERY_FLOATING_MODAL_SIZE_REM
22+
} from './styles/FloatingLocalVideo.styles';
23+
import { useOrganizedParticipants } from './utils/videoGalleryLayoutUtils';
24+
import { GridLayout } from '../GridLayout';
25+
import { rootLayoutStyle } from './styles/FloatingLocalVideoLayout.styles';
26+
import { layerHostStyle, innerLayoutStyle } from './styles/FloatingLocalVideoLayout.styles';
27+
import { videoGalleryLayoutGap } from './styles/Layout.styles';
28+
import { useId } from '@fluentui/react-hooks';
29+
30+
/**
31+
* Props for {@link FloatingLocalVideoLayout}.
32+
*
33+
* @private
34+
*/
35+
export interface SpeakerVideoLayoutProps extends LayoutProps {
36+
/**
37+
* Whether to display the local video camera switcher button
38+
*/
39+
showCameraSwitcherInLocalPreview?: boolean;
40+
/**
41+
* Height of parent element
42+
*/
43+
parentHeight?: number;
44+
/* @conditional-compile-remove(click-to-call) */
45+
/**
46+
* Local video tile mode
47+
*/
48+
localVideoTileSize?: LocalVideoTileSize;
49+
}
50+
51+
/**
52+
* Layout for the gallery mode to highlight the current dominant speaker
53+
*
54+
* @private
55+
*/
56+
export const SpeakerVideoLayout = (props: SpeakerVideoLayoutProps): JSX.Element => {
57+
const {
58+
remoteParticipants = [],
59+
dominantSpeakers,
60+
localVideoComponent,
61+
screenShareComponent,
62+
onRenderRemoteParticipant,
63+
styles,
64+
maxRemoteVideoStreams,
65+
parentWidth,
66+
/* @conditional-compile-remove(vertical-gallery) */ parentHeight,
67+
/* @conditional-compile-remove(vertical-gallery) */ overflowGalleryPosition = 'HorizontalBottom',
68+
pinnedParticipantUserIds = [],
69+
/* @conditional-compile-remove(click-to-call) */ localVideoTileSize
70+
} = props;
71+
72+
const theme = useTheme();
73+
74+
const isNarrow = parentWidth ? isNarrowWidth(parentWidth) : false;
75+
76+
/* @conditional-compile-remove(vertical-gallery) */
77+
const isShort = parentHeight ? isShortHeight(parentHeight) : false;
78+
79+
// This is for tracking the number of children in the first page of overflow gallery.
80+
// This number will be used for the maxOverflowGalleryDominantSpeakers when organizing the remote participants.
81+
const childrenPerPage = useRef(4);
82+
const { gridParticipants, overflowGalleryParticipants } = useOrganizedParticipants({
83+
remoteParticipants,
84+
dominantSpeakers,
85+
maxRemoteVideoStreams,
86+
isScreenShareActive: !!screenShareComponent,
87+
maxOverflowGalleryDominantSpeakers: screenShareComponent
88+
? childrenPerPage.current - (pinnedParticipantUserIds.length % childrenPerPage.current)
89+
: childrenPerPage.current,
90+
/* @conditional-compile-remove(pinned-participants) */ pinnedParticipantUserIds,
91+
/* @conditional-compile-remove(gallery-layouts) */ layout: 'speaker'
92+
});
93+
94+
let activeVideoStreams = 0;
95+
96+
const gridTiles = gridParticipants.map((p) => {
97+
return onRenderRemoteParticipant(
98+
p,
99+
maxRemoteVideoStreams && maxRemoteVideoStreams >= 0
100+
? p.videoStream?.isAvailable && activeVideoStreams++ < maxRemoteVideoStreams
101+
: p.videoStream?.isAvailable
102+
);
103+
});
104+
105+
const shouldFloatLocalVideo = remoteParticipants.length > 0;
106+
107+
if (!shouldFloatLocalVideo && localVideoComponent) {
108+
gridTiles.push(localVideoComponent);
109+
}
110+
111+
/**
112+
* instantiate indexes available to render with indexes available that would be on first page
113+
*
114+
* For some components which do not strictly follow the order of the array, we might
115+
* re-render the initial tiles -> dispose them -> create new tiles, we need to take care of
116+
* this case when those components are here
117+
*/
118+
const [indexesToRender, setIndexesToRender] = useState<number[]>([]);
119+
120+
const overflowGalleryTiles = overflowGalleryParticipants.map((p, i) => {
121+
return onRenderRemoteParticipant(
122+
p,
123+
maxRemoteVideoStreams && maxRemoteVideoStreams >= 0
124+
? p.videoStream?.isAvailable &&
125+
indexesToRender &&
126+
indexesToRender.includes(i) &&
127+
activeVideoStreams++ < maxRemoteVideoStreams
128+
: p.videoStream?.isAvailable
129+
);
130+
});
131+
132+
const layerHostId = useId('layerhost');
133+
134+
const localVideoSizeRem = useMemo(() => {
135+
if (isNarrow || /*@conditional-compile-remove(click-to-call) */ localVideoTileSize === '9:16') {
136+
return SMALL_FLOATING_MODAL_SIZE_REM;
137+
}
138+
/* @conditional-compile-remove(vertical-gallery) */
139+
if ((overflowGalleryTiles.length > 0 || screenShareComponent) && overflowGalleryPosition === 'VerticalRight') {
140+
return isNarrow
141+
? SMALL_FLOATING_MODAL_SIZE_REM
142+
: isShort
143+
? SHORT_VERTICAL_GALLERY_FLOATING_MODAL_SIZE_REM
144+
: VERTICAL_GALLERY_FLOATING_MODAL_SIZE_REM;
145+
}
146+
/*@conditional-compile-remove(click-to-call) */
147+
if ((overflowGalleryTiles.length > 0 || screenShareComponent) && overflowGalleryPosition === 'HorizontalBottom') {
148+
return localVideoTileSize === '16:9' || !isNarrow ? LARGE_FLOATING_MODAL_SIZE_REM : SMALL_FLOATING_MODAL_SIZE_REM;
149+
}
150+
return LARGE_FLOATING_MODAL_SIZE_REM;
151+
}, [
152+
overflowGalleryTiles.length,
153+
isNarrow,
154+
screenShareComponent,
155+
/* @conditional-compile-remove(vertical-gallery) */ isShort,
156+
/* @conditional-compile-remove(vertical-gallery) */ overflowGalleryPosition,
157+
/* @conditional-compile-remove(click-to-call) */ localVideoTileSize
158+
]);
159+
160+
const wrappedLocalVideoComponent =
161+
localVideoComponent || (screenShareComponent && localVideoComponent) ? (
162+
<Stack
163+
className={mergeStyles(
164+
localVideoTileContainerStyle(
165+
theme,
166+
localVideoSizeRem,
167+
!!screenShareComponent,
168+
/* @conditional-compile-remove(gallery-layouts) */ overflowGalleryPosition
169+
)
170+
)}
171+
>
172+
{localVideoComponent}
173+
</Stack>
174+
) : undefined;
175+
176+
const overflowGallery = useMemo(() => {
177+
if (overflowGalleryTiles.length === 0 && !screenShareComponent) {
178+
return null;
179+
}
180+
return (
181+
<OverflowGallery
182+
/* @conditional-compile-remove(vertical-gallery) */
183+
isShort={isShort}
184+
onFetchTilesToRender={setIndexesToRender}
185+
isNarrow={isNarrow}
186+
shouldFloatLocalVideo={true}
187+
overflowGalleryElements={overflowGalleryTiles}
188+
horizontalGalleryStyles={styles?.horizontalGallery}
189+
/* @conditional-compile-remove(vertical-gallery) */
190+
verticalGalleryStyles={styles?.verticalGallery}
191+
/* @conditional-compile-remove(vertical-gallery) */
192+
overflowGalleryPosition={overflowGalleryPosition}
193+
onChildrenPerPageChange={(n: number) => {
194+
childrenPerPage.current = n;
195+
}}
196+
/>
197+
);
198+
}, [
199+
isNarrow,
200+
/* @conditional-compile-remove(vertical-gallery) */ isShort,
201+
screenShareComponent,
202+
overflowGalleryTiles,
203+
styles?.horizontalGallery,
204+
/* @conditional-compile-remove(vertical-gallery) */ overflowGalleryPosition,
205+
setIndexesToRender,
206+
/* @conditional-compile-remove(vertical-gallery) */ styles?.verticalGallery
207+
]);
208+
209+
return (
210+
<Stack styles={rootLayoutStyle}>
211+
{wrappedLocalVideoComponent}
212+
<LayerHost id={layerHostId} className={mergeStyles(layerHostStyle)} />
213+
<Stack
214+
/* @conditional-compile-remove(vertical-gallery) */
215+
horizontal={overflowGalleryPosition === 'VerticalRight'}
216+
styles={innerLayoutStyle}
217+
tokens={videoGalleryLayoutGap}
218+
>
219+
{
220+
/* @conditional-compile-remove(gallery-layouts) */ props.overflowGalleryPosition === 'HorizontalTop' ? (
221+
overflowGallery
222+
) : (
223+
<></>
224+
)
225+
}
226+
{screenShareComponent ? (
227+
screenShareComponent
228+
) : (
229+
<GridLayout key="grid-layout" styles={styles?.gridLayout}>
230+
{gridTiles}
231+
</GridLayout>
232+
)}
233+
{overflowGalleryTrampoline(
234+
overflowGallery,
235+
/* @conditional-compile-remove(gallery-layouts) */ props.overflowGalleryPosition
236+
)}
237+
</Stack>
238+
</Stack>
239+
);
240+
};
241+
242+
const overflowGalleryTrampoline = (
243+
gallery: JSX.Element | null,
244+
galleryPosition?: 'HorizontalBottom' | 'VerticalRight' | 'HorizontalTop'
245+
): JSX.Element | null => {
246+
/* @conditional-compile-remove(gallery-layouts) */
247+
return galleryPosition !== 'HorizontalTop' ? gallery : <></>;
248+
return gallery;
249+
};

0 commit comments

Comments
 (0)