UNPKG

communication-react-19

Version:

React library for building modern communication user experiences utilizing Azure Communication Services (React 19 compatible fork)

227 lines 15.1 kB
// 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