packages/react-components/src/components/VideoTile.tsx (474 lines of code) (raw):
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import {
DirectionalHint,
Icon,
IconButton,
IContextualMenuProps,
IStyle,
mergeStyles,
Persona,
Stack,
Text
} from '@fluentui/react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useIdentifiers } from '../identifiers';
import { ComponentLocale, useLocale } from '../localization';
import { useTheme } from '../theming';
import { BaseCustomStyles, CustomAvatarOptions, OnRenderAvatarCallback } from '../types';
import { CallingTheme } from '../theming';
import { RaisedHand, MediaAccess } from '../types';
import { RaisedHandIcon } from './assets/RaisedHandIcon';
import { ParticipantState } from '../types';
import {
disabledVideoHint,
displayNameStyle,
iconContainerStyle,
overlayContainerStyles,
rootStyles,
videoContainerStyles,
tileInfoContainerStyle,
participantStateStringStyles,
videoTileHighContrastStyles,
iconsGroupContainerStyle
} from './styles/VideoTile.styles';
import { pinIconStyle } from './styles/VideoTile.styles';
import useLongPress from './utils/useLongPress';
import { moreButtonStyles } from './styles/VideoTile.styles';
import { raiseHandContainerStyles } from './styles/VideoTile.styles';
import { ReactionResources } from '../types/ReactionTypes';
import { formatMoreButtonAriaDescription } from './utils';
/**
* Strings of {@link VideoTile} that can be overridden.
* @public
*/
export interface VideoTileStrings {
/** Aria label for announcing the remote video tile drawer menu */
moreOptionsButtonAriaLabel: string;
/** String for displaying the Ringing of the remote participant */
participantStateRinging: string;
/** String for displaying the Hold state of the remote participant */
participantStateHold: string;
/* @conditional-compile-remove(remote-ufd) */
/** String for displaying the reconnecting state of the remote participant */
participantReconnecting?: string;
/** String for the announcement of the muted state of the participant when muted */
moreOptionsParticipantMutedStateMutedAriaLabel: string;
/** String for the announcement of the unmuted state of the participant when unmuted */
moreOptionsParticipantMutedStateUnmutedAriaLabel: string;
/** String for the announcement of the participant has their hand raised */
moreOptionsParticipantHandRaisedAriaLabel: string;
/** String for the announcement of whether the participant is speaking or not */
moreOptionsParticipantIsSpeakingAriaLabel: string;
/** String for the announcement of whether the participant microphone disabled */
moreOptionsParticipantMicDisabledAriaLabel: string;
/** String for the announcement of whether the participant camera disabled */
moreOptionsParticipantCameraDisabledAriaLabel: string;
}
/**
* Fluent styles for {@link VideoTile}.
*
* @public
*/
export interface VideoTileStylesProps extends BaseCustomStyles {
/** Styles for video container. */
videoContainer?: IStyle;
/** Styles for container overlayed on the video container. */
overlayContainer?: IStyle;
/** Styles for displayName on the video container. */
displayNameContainer?: IStyle;
}
/**
* Props for {@link VideoTile}.
*
* @public
*/
export interface VideoTileProps {
/** React Child components. Child Components will show as overlay component in the VideoTile. */
children?: React.ReactNode;
/**
* Allows users to pass in an object contains custom CSS styles.
* @Example
* ```
* <VideoTile styles={{ root: { background: 'blue' } }} />
* ```
*/
styles?: VideoTileStylesProps;
/** user id for the VideoTile placeholder. */
userId?: string;
/** Component with the video stream. */
renderElement?: JSX.Element | null;
/**
* Overlay component responsible for rendering reaction
*/
overlay?: JSX.Element | null;
/** Determines if the video is mirrored or not. */
isMirrored?: boolean;
/** Custom render Component function for no video is available. Render a Persona Icon if undefined. */
onRenderPlaceholder?: OnRenderAvatarCallback;
/**
* Show label on the VideoTile
* @defaultValue true
*/
showLabel?: boolean;
/**
* Show label background on the VideoTile
* @defaultValue false
*/
alwaysShowLabelBackground?: boolean;
/**
* Whether to display a mute icon beside the user's display name.
* @defaultValue true
*/
showMuteIndicator?: boolean;
/**
* Whether the video is muted or not.
*/
isMuted?: boolean;
/**
* If true, the video tile will show the pin icon.
*/
isPinned?: boolean;
/**
* Display Name of the Participant to be shown in the label.
* @remarks `displayName` is used to generate avatar initials if `initialsName` is not provided.
*/
displayName?: string;
/**
* Name of the participant used to generate initials. For example, a name `John Doe` will display `JD` as initials.
* @remarks `displayName` is used if this property is not specified.
*/
initialsName?: string;
/**
* Minimum size of the persona avatar in px.
* The persona avatar is the default placeholder shown when no video stream is available.
* For more information see https://developer.microsoft.com/en-us/fluentui#/controls/web/persona
* @defaultValue 32px
*/
personaMinSize?: number;
/**
* Maximum size of the personal avatar in px.
* The persona avatar is the default placeholder shown when no video stream is available.
* For more information see https://developer.microsoft.com/en-us/fluentui#/controls/web/persona
* @defaultValue 100px
*/
personaMaxSize?: number;
/** Optional property to set the aria label of the video tile if there is no available stream. */
noVideoAvailableAriaLabel?: string;
/** Whether the participant in the videoTile is speaking. Shows a speaking indicator (border). */
isSpeaking?: boolean;
/** Whether the participant is raised hand. Show a indicator (border) and icon with order */
raisedHand?: RaisedHand;
/**
* The call connection state of the participant.
* For example, `Hold` means the participant is on hold.
*/
participantState?: ParticipantState;
/**
* Strings to override in the component.
*/
strings?: VideoTileStrings;
/**
* Display custom menu items in the VideoTile's contextual menu.
* Uses Fluent UI ContextualMenu.
* An ellipses icon will be displayed to open the contextual menu if this prop is defined.
*/
contextualMenu?: IContextualMenuProps;
/**
* Callback triggered by video tile on touch and hold.
*/
onLongTouch?: () => void;
/**
* If true, the video tile will show the spotlighted icon.
*/
isSpotlighted?: boolean;
/**
* Reactions resources' url and metadata.
*/
reactionResources?: ReactionResources;
/**
* Media access state of the participant.
*/
mediaAccess?: MediaAccess;
}
// Coin max size is set to PersonaSize.size100
const DEFAULT_PERSONA_MAX_SIZE_PX = 100;
// Coin min size is set PersonaSize.size32
const DEFAULT_PERSONA_MIN_SIZE_PX = 32;
const DefaultPlaceholder = (props: CustomAvatarOptions): JSX.Element => {
const { text, noVideoAvailableAriaLabel, coinSize, hidePersonaDetails } = props;
return (
<Stack className={mergeStyles({ position: 'absolute', height: '100%', width: '100%' })}>
<Stack styles={defaultPersonaStyles}>
{coinSize && (
<Persona
coinSize={coinSize}
hidePersonaDetails={hidePersonaDetails}
text={text ?? ''}
initialsTextColor="white"
aria-label={noVideoAvailableAriaLabel ?? ''}
showOverflowTooltip={false}
/>
)}
</Stack>
</Stack>
);
};
const defaultPersonaStyles = { root: { margin: 'auto', maxHeight: '100%' } };
const videoTileMoreMenuIconProps = { iconName: undefined, style: { display: 'none' } };
const videoTileMoreMenuProps = {
directionalHint: DirectionalHint.topLeftEdge,
isBeakVisible: false,
styles: { container: { maxWidth: '8rem' } }
};
const VideoTileMoreOptionsButton = (props: {
contextualMenu?: IContextualMenuProps;
participantDisplayName: string | undefined;
participantState: string | undefined;
participantHandRaised: boolean;
participantIsSpeaking: boolean | undefined;
participantIsMuted: boolean | undefined;
canShowContextMenuButton: boolean;
isMicDisabled?: boolean;
isCameraDisabled?: boolean;
}): JSX.Element => {
const locale = useLocale().strings.videoTile;
const theme = useTheme();
const {
contextualMenu,
canShowContextMenuButton,
participantDisplayName,
participantHandRaised,
participantIsSpeaking,
participantState,
participantIsMuted,
isMicDisabled,
isCameraDisabled
} = props;
const [moreButtonAiraDescription, setMoreButtonAriaDescription] = useState<string>('');
useEffect(() => {
setMoreButtonAriaDescription(
formatMoreButtonAriaDescription(
participantDisplayName,
participantIsMuted,
participantHandRaised,
participantState,
participantIsSpeaking,
locale,
isMicDisabled,
isCameraDisabled
)
);
}, [
participantDisplayName,
participantHandRaised,
participantIsMuted,
participantIsSpeaking,
participantState,
locale,
isMicDisabled,
isCameraDisabled
]);
if (!contextualMenu) {
return <></>;
}
const optionsIcon = canShowContextMenuButton ? 'VideoTileMoreOptions' : undefined;
return (
<IconButton
data-ui-id="video-tile-more-options-button"
ariaLabel={moreButtonAiraDescription}
styles={moreButtonStyles(theme)}
menuIconProps={videoTileMoreMenuIconProps}
menuProps={{ ...videoTileMoreMenuProps, ...contextualMenu }}
iconProps={{ iconName: optionsIcon }}
/>
);
};
/**
* A component to render the video stream for a single call participant.
*
* Use with {@link GridLayout} in a {@link VideoGallery}.
*
* @public
*/
export const VideoTile = (props: VideoTileProps): JSX.Element => {
const {
children,
displayName,
initialsName,
isMirrored,
isMuted,
isSpotlighted,
isPinned,
onRenderPlaceholder,
renderElement,
overlay: reactionOverlay,
showLabel = true,
showMuteIndicator = true,
styles,
userId,
noVideoAvailableAriaLabel,
isSpeaking,
raisedHand,
personaMinSize = DEFAULT_PERSONA_MIN_SIZE_PX,
personaMaxSize = DEFAULT_PERSONA_MAX_SIZE_PX,
contextualMenu,
mediaAccess
} = props;
const [isHovered, setIsHovered] = useState<boolean>(false);
const [isFocused, setIsFocused] = useState<boolean>(false);
// need to set a default otherwise the resizeObserver will get stuck in an infinite loop.
const [personaSize, setPersonaSize] = useState<number>(1);
const videoTileRef = useRef<HTMLDivElement>(null);
const locale = useLocale();
const theme = useTheme();
const callingPalette = (theme as unknown as CallingTheme).callingPalette;
const isVideoRendered = !!renderElement;
const observer = useRef(
new ResizeObserver((entries): void => {
if (!entries[0]) {
return;
}
const { width, height } = entries[0].contentRect;
const personaCalcSize = Math.min(width, height) / 3;
// we only want to set the persona size if it has changed
if (personaCalcSize !== personaSize) {
setPersonaSize(Math.max(Math.min(personaCalcSize, personaMaxSize), personaMinSize));
}
})
);
useLayoutEffect(() => {
if (videoTileRef.current) {
observer.current.observe(videoTileRef.current);
}
const currentObserver = observer.current;
return () => currentObserver.disconnect();
}, [videoTileRef]);
// TODO: Remove after calling sdk fix the keybaord focus
useEffect(() => {
// PPTLive stream id is null
if (videoTileRef.current?.id) {
return;
}
let observer: MutationObserver | undefined;
if (videoTileRef.current) {
observer = new MutationObserver((mutationsList) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
const iframe = document.querySelector('iframe');
if (iframe) {
if (!iframe.getAttribute('tabIndex')) {
iframe.setAttribute('tabIndex', '-1');
}
}
}
}
});
observer.observe(videoTileRef.current, { childList: true, subtree: true });
}
return () => {
observer?.disconnect();
};
}, [displayName, renderElement]);
const useLongPressProps = useMemo(() => {
return {
onLongPress: () => {
props.onLongTouch?.();
},
touchEventsOnly: true
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onLongTouch]);
const longPressHandlers = useLongPress(useLongPressProps);
const hoverHandlers = useMemo(() => {
return {
onMouseEnter: () => setIsHovered(true),
onMouseLeave: () => setIsHovered(false),
onFocus: () => setIsFocused(true),
onBlur: () => setIsFocused(false)
};
}, []);
const placeholderOptions = {
userId,
text: initialsName ?? displayName,
noVideoAvailableAriaLabel,
coinSize: personaSize,
styles: defaultPersonaStyles,
hidePersonaDetails: true
};
const videoHintWithBorderRadius = mergeStyles(disabledVideoHint, {
borderRadius: theme.effects.roundedCorner4,
backgroundColor: callingPalette.videoTileLabelBackgroundLight
});
const tileInfoStyle = useMemo(
() =>
mergeStyles(
isVideoRendered || props.alwaysShowLabelBackground ? videoHintWithBorderRadius : disabledVideoHint,
styles?.displayNameContainer
),
[isVideoRendered, videoHintWithBorderRadius, styles?.displayNameContainer, props.alwaysShowLabelBackground]
);
const ids = useIdentifiers();
const canShowLabel = showLabel && (displayName || (showMuteIndicator && isMuted));
const participantStateString = getParticipantStateString(props, locale);
const canShowContextMenuButton = isHovered || isFocused;
let raisedHandBackgroundColor = '';
raisedHandBackgroundColor = callingPalette.raiseHandGold;
const participantMediaAccessIcons = useMemo(
() =>
canShowLabel || participantStateString ? getMediaAccessIcons(showMuteIndicator, isMuted, mediaAccess) : undefined,
[canShowLabel, isMuted, mediaAccess, participantStateString, showMuteIndicator]
);
const canShowParticipantIcons = participantMediaAccessIcons || isSpotlighted || isPinned;
return (
<Stack
data-ui-id={ids.videoTile}
className={mergeStyles(
rootStyles,
{
background: theme.palette.neutralLighter,
borderRadius: theme.effects.roundedCorner4
},
isSpeaking || raisedHand
? {
'&::after': {
content: `''`,
position: 'absolute',
border: `0.25rem solid ${isSpeaking ? theme.palette.themePrimary : raisedHandBackgroundColor}`,
borderRadius: theme.effects.roundedCorner4,
width: '100%',
height: '100%',
pointerEvents: 'none'
}
}
: {},
videoTileHighContrastStyles(theme),
styles?.root
)}
{...longPressHandlers}
>
<div ref={videoTileRef} style={{ width: '100%', height: '100%' }} {...hoverHandlers} data-is-focusable={true}>
{isVideoRendered ? (
<Stack
className={mergeStyles(
videoContainerStyles,
isMirrored && { transform: 'scaleX(-1)' },
styles?.videoContainer
)}
>
{renderElement}
</Stack>
) : (
<Stack
className={mergeStyles(videoContainerStyles, {
opacity: participantStateString || props.participantState === 'Idle' ? 0.4 : 1
})}
>
{onRenderPlaceholder ? (
onRenderPlaceholder(userId ?? '', placeholderOptions, DefaultPlaceholder)
) : (
<DefaultPlaceholder {...placeholderOptions} />
)}
</Stack>
)}
{reactionOverlay}
{(canShowLabel || participantStateString) && (
<Stack horizontal className={tileInfoContainerStyle} tokens={tileInfoContainerTokens}>
<Stack horizontal className={tileInfoStyle}>
{canShowLabel && (
<Text
className={mergeStyles(displayNameStyle)}
title={displayName}
style={{ color: participantStateString ? theme.palette.neutralSecondary : 'inherit' }}
data-ui-id="video-tile-display-name"
>
{displayName}
</Text>
)}
{participantStateString && (
<Text className={mergeStyles(participantStateStringStyles(theme))}>
{bracketedParticipantString(participantStateString, !!canShowLabel)}
</Text>
)}
{canShowParticipantIcons && (
<Stack horizontal className={mergeStyles(iconsGroupContainerStyle)}>
{participantMediaAccessIcons}
{isSpotlighted && (
<Stack className={mergeStyles(iconContainerStyle)}>
<Icon iconName="VideoTileSpotlighted" />
</Stack>
)}
{isPinned && (
<Stack className={mergeStyles(iconContainerStyle)}>
<Icon iconName="VideoTilePinned" className={mergeStyles(pinIconStyle)} />
</Stack>
)}
</Stack>
)}
<VideoTileMoreOptionsButton
contextualMenu={contextualMenu}
participantDisplayName={displayName}
participantHandRaised={!!raisedHand}
participantIsMuted={isMuted}
participantState={participantStateString}
participantIsSpeaking={isSpeaking}
canShowContextMenuButton={canShowContextMenuButton}
isMicDisabled={mediaAccess?.isAudioPermitted === false}
isCameraDisabled={mediaAccess?.isVideoPermitted === false}
/>
</Stack>
</Stack>
)}
{children && (
<Stack className={mergeStyles(overlayContainerStyles, styles?.overlayContainer)}>{children}</Stack>
)}
{raisedHand && (
<Stack
horizontal={true}
tokens={{ childrenGap: '0.2rem' }}
className={raiseHandContainerStyles(theme, !canShowLabel)}
>
<Stack.Item>
<Text>{raisedHand.raisedHandOrderPosition}</Text>
</Stack.Item>
<Stack.Item>
<RaisedHandIcon />
</Stack.Item>
</Stack>
)}
</div>
</Stack>
);
};
const getMediaAccessIcons = (
showMuteIndicator: boolean,
isMuted?: boolean,
mediaAccess?: MediaAccess
): JSX.Element | undefined => {
const cameraForbidIcon =
mediaAccess && !mediaAccess?.isVideoPermitted ? (
<Stack className={mergeStyles(iconContainerStyle)}>
<Icon iconName="ControlButtonCameraProhibitedSmall" />
</Stack>
) : undefined;
const micOffIcon =
(mediaAccess ? mediaAccess.isAudioPermitted : true) && showMuteIndicator && isMuted ? (
<Stack className={mergeStyles(iconContainerStyle)}>
<Icon iconName="VideoTileMicOff" />
</Stack>
) : undefined;
const micForbidIcon =
mediaAccess && !mediaAccess?.isAudioPermitted && showMuteIndicator ? (
<Stack className={mergeStyles(iconContainerStyle)}>
<Icon iconName="ControlButtonMicProhibitedSmall" />
</Stack>
) : undefined;
if (!(cameraForbidIcon || micOffIcon || micForbidIcon)) {
return undefined;
}
return (
<>
{cameraForbidIcon}
{micOffIcon}
{micForbidIcon}
</>
);
};
const getParticipantStateString = (props: VideoTileProps, locale: ComponentLocale): string | undefined => {
const strings = { ...locale.strings.videoTile, ...props.strings };
return props.participantState === 'EarlyMedia' || props.participantState === 'Ringing'
? strings?.participantStateRinging
: props.participantState === 'Hold'
? strings?.participantStateHold
: undefined;
};
const tileInfoContainerTokens = {
// A horizontal Stack sets the left margin to 0 for all it's children.
// We need to allow the children to set their own margins
childrenGap: 'none'
};
const bracketedParticipantString = (participantString: string, withBrackets: boolean): string => {
return withBrackets ? `(${participantString})` : participantString;
};