import React, { CSSProperties, ReactElement, useCallback, useContext, useEffect, useState, } from 'react'; import { faMicrophone, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Property } from 'csstype'; import { ConnectionQuality, LocalTrack, Participant, RemoteTrack } from 'livekit-client'; import { useParticipant, VideoRenderer } from '@livekit/react-core'; import { AspectRatio } from 'react-aspect-ratio'; import { ReactComponent as connectionQuality1 } from '../../static/connection-quality-1.svg'; import { ReactComponent as connectionQuality2 } from '../../static/connection-quality-2.svg'; import { ReactComponent as connectionQuality3 } from '../../static/connection-quality-3.svg'; import { DisplayContext } from './DisplayContext'; import styles from './styles.module.css'; export interface ParticipantProps { participant: Participant; displayName?: string; // width in CSS width?: Property.Width; // height in CSS height?: Property.Height; className?: string; // aspect ratio width, if set, maintains aspect ratio aspectWidth?: number; // aspect ratio height aspectHeight?: number; // determine whether to contain or cover video. // cover mode is used when layout orientation matches video orientation orientation?: 'landscape' | 'portrait'; // true if overlay with participant info should be shown showOverlay?: boolean; // true if connection quality should be shown showConnectionQuality?: boolean; // additional classname when participant is currently speaking speakerClassName?: string; onMouseEnter?: () => void; onMouseLeave?: () => void; onClick?: () => void; } export const ParticipantView = ({ participant, width, height, className, speakerClassName, aspectWidth, aspectHeight, orientation, displayName, showOverlay, showConnectionQuality, onMouseEnter, onMouseLeave, onClick, }: ParticipantProps) => { const { cameraPublication, isLocal, connectionQuality, isSpeaking } = useParticipant(participant); const [videoSize, setVideoSize] = useState<string>(); const [currentBitrate, setCurrentBitrate] = useState<number>(); const context = useContext(DisplayContext); const handleResize = useCallback((width: number, height: number) => { setVideoSize(`${width}x${height}`); }, []); useEffect(() => { const interval = setInterval(() => { let total = 0; participant.tracks.forEach((pub) => { if (pub.track instanceof LocalTrack || pub.track instanceof RemoteTrack) { total += pub.track.currentBitrate; } }); setCurrentBitrate(total); }, 1000); return () => { clearInterval(interval); }; }, []); const containerStyles: CSSProperties = { width: width, height: height, }; // when aspect matches, cover instead let objectFit: Property.ObjectFit = 'contain'; let videoOrientation: 'landscape' | 'portrait' | undefined; if (!orientation && aspectWidth && aspectHeight) { orientation = aspectWidth > aspectHeight ? 'landscape' : 'portrait'; } if (cameraPublication?.dimensions) { videoOrientation = cameraPublication.dimensions.width > cameraPublication.dimensions.height ? 'landscape' : 'portrait'; } if (videoOrientation === orientation) { objectFit = 'cover'; } if (!displayName) { displayName = participant.name || participant.identity; if (isLocal) { displayName += ' (You)'; } } let mainElement: ReactElement; if (cameraPublication?.isSubscribed && cameraPublication?.track && !cameraPublication?.isMuted) { mainElement = ( <VideoRenderer track={cameraPublication.track} isLocal={isLocal} objectFit={objectFit} width="100%" height="100%" className={styles.video} onSizeChanged={handleResize} /> ); } else { mainElement = <div className={styles.placeholder} />; } const classes = [styles.participant]; if (className) { classes.push(className); } if (isSpeaking) { classes.push(speakerClassName ?? styles.speaker); } const isAudioMuted = !participant.isMicrophoneEnabled; // gather stats let statsContent: ReactElement | undefined; if (context.showStats) { statsContent = ( <div className={styles.stats}> <span>{videoSize}</span> {currentBitrate !== undefined && currentBitrate > 0 && ( <span> {Math.round(currentBitrate / 1024)} kbps</span> )} </div> ); } let ConnectionQualityIndicator: typeof connectionQuality1 | undefined; if (showConnectionQuality) { switch (connectionQuality) { case ConnectionQuality.Excellent: ConnectionQualityIndicator = connectionQuality3; break; case ConnectionQuality.Good: ConnectionQualityIndicator = connectionQuality2; break; case ConnectionQuality.Poor: ConnectionQualityIndicator = connectionQuality1; break; } } return ( <div className={classes.join(' ')} style={containerStyles} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={onClick} > {aspectWidth && aspectHeight && ( <AspectRatio ratio={aspectWidth / aspectHeight}>{mainElement}</AspectRatio> )} {(!aspectWidth || !aspectHeight) && mainElement} {(showOverlay || context.showStats) && ( <div className={styles.participantBar}> <div className={styles.name}>{displayName}</div> <div className={styles.center}>{statsContent}</div> <div>{ConnectionQualityIndicator && <ConnectionQualityIndicator />}</div> <div> <FontAwesomeIcon icon={isAudioMuted ? faMicrophoneSlash : faMicrophone} height={24} className={isAudioMuted ? styles.iconRed : styles.iconNormal} /> </div> </div> )} </div> ); };