133 lines
3.7 KiB
TypeScript
133 lines
3.7 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import {
|
|
AudioTrack,
|
|
useTracks,
|
|
VideoTrack,
|
|
useTrackRefContext,
|
|
useEnsureTrackRef,
|
|
TrackRefContextIfNeeded,
|
|
} from '@livekit/components-react';
|
|
import { Track, Participant } from 'livekit-client';
|
|
import { isTrackReference } from '@livekit/components-core';
|
|
|
|
function getAvatarColor(identity: string): string {
|
|
const colors = [
|
|
'#4CAF50',
|
|
'#8BC34A',
|
|
'#CDDC39',
|
|
'#FFC107',
|
|
'#FF9800',
|
|
'#FF5722',
|
|
'#F44336',
|
|
'#E91E63',
|
|
'#9C27B0',
|
|
'#673AB7',
|
|
'#3F51B5',
|
|
'#2196F3',
|
|
'#03A9F4',
|
|
'#00BCD4',
|
|
'#009688',
|
|
];
|
|
|
|
let hash = 0;
|
|
for (let i = 0; i < identity.length; i++) {
|
|
hash = identity.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
|
|
const index = Math.abs(hash) % colors.length;
|
|
return colors[index];
|
|
}
|
|
|
|
function getInitials(name: string): string {
|
|
if (!name) return '?';
|
|
|
|
const parts = name.split(' ');
|
|
if (parts.length === 1) {
|
|
return parts[0].charAt(0).toUpperCase();
|
|
}
|
|
|
|
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
|
|
}
|
|
|
|
export interface ParticipantTileProps {
|
|
participant?: Participant;
|
|
}
|
|
|
|
export const ParticipantTile: React.FC<ParticipantTileProps> = ({
|
|
participant: propParticipant,
|
|
}) => {
|
|
const trackRef = useTrackRefContext();
|
|
const trackReference = useEnsureTrackRef(trackRef);
|
|
const participant = propParticipant || trackRef?.participant;
|
|
|
|
if (!participant) return null;
|
|
|
|
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null);
|
|
|
|
const microphoneTrack = useTracks([Track.Source.Microphone], { onlySubscribed: false }).filter(
|
|
(track) => track.participant.identity === participant.identity,
|
|
)[0];
|
|
|
|
const isSpeaking = participant.isSpeaking;
|
|
|
|
useEffect(() => {
|
|
if (participant.metadata) {
|
|
try {
|
|
const metadata = JSON.parse(participant.metadata);
|
|
if (metadata.profilePictureUrl) {
|
|
setProfilePictureUrl(metadata.profilePictureUrl);
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to parse participant metadata', e);
|
|
}
|
|
}
|
|
}, [participant.metadata]);
|
|
|
|
const isCameraEnabled =
|
|
(trackReference.source === Track.Source.Camera && !trackReference.publication?.isMuted) ||
|
|
trackReference.source === Track.Source.ScreenShare;
|
|
|
|
const hasMicrophone = !!microphoneTrack;
|
|
const isMicrophoneEnabled = hasMicrophone && !microphoneTrack.publication?.isMuted;
|
|
|
|
const avatarColor = getAvatarColor(participant.identity);
|
|
const initials = getInitials(participant.name || participant.identity);
|
|
|
|
return (
|
|
<div className={`participant-tile ${isSpeaking ? 'speaking' : ''}`}>
|
|
{isTrackReference(trackReference) && isCameraEnabled ? (
|
|
<div className="video-container">
|
|
<VideoTrack trackRef={trackReference} />
|
|
</div>
|
|
) : (
|
|
<div className="avatar-container" style={{ backgroundColor: avatarColor }}>
|
|
{profilePictureUrl ? (
|
|
<img src={profilePictureUrl} alt={participant.name} className="avatar-image" />
|
|
) : (
|
|
<span className="avatar-initials">{initials}</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="participant-info">
|
|
{isMicrophoneEnabled ? (
|
|
<>
|
|
{isSpeaking ? (
|
|
<span className="mic-icon speaking-icon">graphic_eq</span>
|
|
) : (
|
|
<span className="mic-icon mic-on">mic</span>
|
|
)}
|
|
</>
|
|
) : (
|
|
<span className="mic-icon mic-off">mic_off</span>
|
|
)}
|
|
<span className="participant-name">{participant.name || participant.identity}</span>
|
|
</div>
|
|
|
|
{hasMicrophone && microphoneTrack && <AudioTrack trackRef={microphoneTrack} />}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ParticipantTile;
|