@azure/communication-react
Version:
React library for building modern communication user experiences utilizing Azure Communication Services
244 lines • 15.3 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { DirectionalHint, Icon, IconButton, mergeStyles, Persona, Stack, Text } from '@fluentui/react';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useIdentifiers } from '../identifiers';
import { useLocale } from '../localization';
import { useTheme } from '../theming';
import { RaisedHandIcon } from './assets/RaisedHandIcon';
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 { formatMoreButtonAriaDescription } from './utils';
// 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) => {
const { text, noVideoAvailableAriaLabel, coinSize, hidePersonaDetails } = props;
return React.createElement(Stack, { className: mergeStyles({
position: 'absolute',
height: '100%',
width: '100%'
}) },
React.createElement(Stack, { styles: defaultPersonaStyles }, coinSize && React.createElement(Persona, { coinSize: coinSize, hidePersonaDetails: hidePersonaDetails, text: text !== null && text !== void 0 ? text : '', initialsTextColor: "white", "aria-label": noVideoAvailableAriaLabel !== null && noVideoAvailableAriaLabel !== void 0 ? noVideoAvailableAriaLabel : '', showOverflowTooltip: false })));
};
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) => {
const locale = useLocale().strings.videoTile;
const theme = useTheme();
const { contextualMenu, canShowContextMenuButton, participantDisplayName, participantHandRaised, participantIsSpeaking, participantState, participantIsMuted, isMicDisabled, isCameraDisabled } = props;
const [moreButtonAiraDescription, setMoreButtonAriaDescription] = useState('');
useEffect(() => {
setMoreButtonAriaDescription(formatMoreButtonAriaDescription(participantDisplayName, participantIsMuted, participantHandRaised, participantState, participantIsSpeaking, locale, isMicDisabled, isCameraDisabled));
}, [participantDisplayName, participantHandRaised, participantIsMuted, participantIsSpeaking, participantState, locale, isMicDisabled, isCameraDisabled]);
if (!contextualMenu) {
return React.createElement(React.Fragment, null);
}
const optionsIcon = canShowContextMenuButton ? 'VideoTileMoreOptions' : undefined;
return React.createElement(IconButton, { "data-ui-id": "video-tile-more-options-button", ariaLabel: moreButtonAiraDescription, styles: moreButtonStyles(theme), menuIconProps: videoTileMoreMenuIconProps, menuProps: Object.assign(Object.assign({}, 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) => {
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(false);
const [isFocused, setIsFocused] = useState(false);
// need to set a default otherwise the resizeObserver will get stuck in an infinite loop.
const [personaSize, setPersonaSize] = useState(1);
const videoTileRef = useRef(null);
const locale = useLocale();
const theme = useTheme();
const callingPalette = theme.callingPalette;
const isVideoRendered = !!renderElement;
const observer = useRef(new ResizeObserver((entries) => {
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(() => {
var _a;
// PPTLive stream id is null
if ((_a = videoTileRef.current) === null || _a === void 0 ? void 0 : _a.id) {
return;
}
let observer;
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 === null || observer === void 0 ? void 0 : observer.disconnect();
};
}, [displayName, renderElement]);
const useLongPressProps = useMemo(() => {
return {
onLongPress: () => {
var _a;
(_a = props.onLongTouch) === null || _a === void 0 ? void 0 : _a.call(props);
},
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 !== null && initialsName !== void 0 ? 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 === null || styles === void 0 ? void 0 : styles.displayNameContainer), [isVideoRendered, videoHintWithBorderRadius, styles === null || styles === void 0 ? void 0 : 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 React.createElement(Stack, Object.assign({ "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 === null || styles === void 0 ? void 0 : styles.root) }, longPressHandlers),
React.createElement("div", Object.assign({ ref: videoTileRef, style: {
width: '100%',
height: '100%'
} }, hoverHandlers, { "data-is-focusable": true }),
isVideoRendered ? React.createElement(Stack, { className: mergeStyles(videoContainerStyles, isMirrored && {
transform: 'scaleX(-1)'
}, styles === null || styles === void 0 ? void 0 : styles.videoContainer) }, renderElement) : React.createElement(Stack, { className: mergeStyles(videoContainerStyles, {
opacity: participantStateString || props.participantState === 'Idle' ? 0.4 : 1
}) }, onRenderPlaceholder ? onRenderPlaceholder(userId !== null && userId !== void 0 ? userId : '', placeholderOptions, DefaultPlaceholder) : React.createElement(DefaultPlaceholder, Object.assign({}, placeholderOptions))),
reactionOverlay,
(canShowLabel || participantStateString) && React.createElement(Stack, { horizontal: true, className: tileInfoContainerStyle, tokens: tileInfoContainerTokens },
React.createElement(Stack, { horizontal: true, className: tileInfoStyle },
canShowLabel && React.createElement(Text, { className: mergeStyles(displayNameStyle), title: displayName, style: {
color: participantStateString ? theme.palette.neutralSecondary : 'inherit'
}, "data-ui-id": "video-tile-display-name" }, displayName),
participantStateString && React.createElement(Text, { className: mergeStyles(participantStateStringStyles(theme)) }, bracketedParticipantString(participantStateString, !!canShowLabel)),
canShowParticipantIcons && React.createElement(Stack, { horizontal: true, className: mergeStyles(iconsGroupContainerStyle) },
participantMediaAccessIcons,
isSpotlighted && React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "VideoTileSpotlighted" })),
isPinned && React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "VideoTilePinned", className: mergeStyles(pinIconStyle) }))),
React.createElement(VideoTileMoreOptionsButton, { contextualMenu: contextualMenu, participantDisplayName: displayName, participantHandRaised: !!raisedHand, participantIsMuted: isMuted, participantState: participantStateString, participantIsSpeaking: isSpeaking, canShowContextMenuButton: canShowContextMenuButton, isMicDisabled: (mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isAudioPermitted) === false, isCameraDisabled: (mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isVideoPermitted) === false }))),
children && React.createElement(Stack, { className: mergeStyles(overlayContainerStyles, styles === null || styles === void 0 ? void 0 : styles.overlayContainer) }, children),
raisedHand && React.createElement(Stack, { horizontal: true, tokens: {
childrenGap: '0.2rem'
}, className: raiseHandContainerStyles(theme, !canShowLabel) },
React.createElement(Stack.Item, null,
React.createElement(Text, null, raisedHand.raisedHandOrderPosition)),
React.createElement(Stack.Item, null,
React.createElement(RaisedHandIcon, null)))));
};
const getMediaAccessIcons = (showMuteIndicator, isMuted, mediaAccess) => {
const cameraForbidIcon = mediaAccess && !(mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isVideoPermitted) ? React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "ControlButtonCameraProhibitedSmall" })) : undefined;
const micOffIcon = (mediaAccess ? mediaAccess.isAudioPermitted : true) && showMuteIndicator && isMuted ? React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "VideoTileMicOff" })) : undefined;
const micForbidIcon = mediaAccess && !(mediaAccess === null || mediaAccess === void 0 ? void 0 : mediaAccess.isAudioPermitted) && showMuteIndicator ? React.createElement(Stack, { className: mergeStyles(iconContainerStyle) },
React.createElement(Icon, { iconName: "ControlButtonMicProhibitedSmall" })) : undefined;
if (!(cameraForbidIcon || micOffIcon || micForbidIcon)) {
return undefined;
}
return React.createElement(React.Fragment, null,
cameraForbidIcon,
micOffIcon,
micForbidIcon);
};
const getParticipantStateString = (props, locale) => {
const strings = Object.assign(Object.assign({}, locale.strings.videoTile), props.strings);
return props.participantState === 'EarlyMedia' || props.participantState === 'Ringing' ? strings === null || strings === void 0 ? void 0 : strings.participantStateRinging : props.participantState === 'Hold' ? strings === null || strings === void 0 ? void 0 : 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, withBrackets) => {
return withBrackets ? `(${participantString})` : participantString;
};
//# sourceMappingURL=VideoTile.js.map