Added custom Layout for Focus
This commit is contained in:
parent
37d134c81c
commit
e34b8a39af
@ -1,6 +1,5 @@
|
||||
import { useLayoutContext, useRoomContext } from '@livekit/components-react';
|
||||
import { useRoomContext } from '@livekit/components-react';
|
||||
import { Participant, RemoteParticipant } from 'livekit-client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MicOffSVG, MicOnSVG } from '../svg/mic';
|
||||
import { CameraOffSVG, CameraOnSVG } from '../svg/camera';
|
||||
import { ScreenShareOnSVG } from '../svg/screen-share';
|
||||
@ -9,8 +8,7 @@ import { useCustomLayoutContext } from '../contexts/layout-context';
|
||||
|
||||
const ParticipantList = () => {
|
||||
const room = useRoomContext();
|
||||
const { localParticipant } = room;
|
||||
const [participants, setParticipants] = useState<Record<string, RemoteParticipant>>({});
|
||||
const { localParticipant, remoteParticipants } = room;
|
||||
const { isParticipantsListOpen } = useCustomLayoutContext();
|
||||
|
||||
function ToggleParticipantList() {
|
||||
@ -18,42 +16,6 @@ const ParticipantList = () => {
|
||||
isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
room.on('connectionStateChanged', () => {
|
||||
setParticipants({});
|
||||
room.remoteParticipants.forEach((participant) => {
|
||||
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
|
||||
});
|
||||
});
|
||||
room.on('participantConnected', (participant) => {
|
||||
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
|
||||
});
|
||||
room.on('participantDisconnected', (participant) => {
|
||||
setParticipants((prev) => {
|
||||
const { [participant.identity]: toDelete, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
room.on('participantNameChanged', (name, participant) => {
|
||||
if (participant instanceof RemoteParticipant)
|
||||
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
|
||||
});
|
||||
return () => {
|
||||
room.off('participantConnected', (participant) => {
|
||||
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
|
||||
});
|
||||
room.off('participantDisconnected', (participant) => {
|
||||
setParticipants((prev) => {
|
||||
const { [participant.identity]: toDelete, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
room.off('participantNameChanged', (name, participant) => {
|
||||
if (participant instanceof RemoteParticipant)
|
||||
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@ -95,8 +57,8 @@ const ParticipantList = () => {
|
||||
</div>
|
||||
</div>
|
||||
<ParticipantItem participant={localParticipant} />
|
||||
{Object.values(participants).map((participant: RemoteParticipant) => {
|
||||
return <ParticipantItem participant={participant} />;
|
||||
{[...remoteParticipants.entries()].map((participant) => {
|
||||
return <ParticipantItem participant={participant[1]} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
@ -134,7 +96,7 @@ const ParticipantItem: React.FC<ParticipantItemProps> = ({ participant }) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{participant.name}</div>
|
||||
<div style={{ fontSize: '0.9rem' }}>{participant.name}</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||
{participant.isScreenShareEnabled ? <ScreenShareOnSVG /> : <></>}
|
||||
|
||||
99
app/custom/layout/CustomVideoLayout.tsx
Normal file
99
app/custom/layout/CustomVideoLayout.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
GridLayout,
|
||||
useTracks,
|
||||
Chat,
|
||||
CarouselLayout,
|
||||
usePinnedTracks,
|
||||
useLayoutContext,
|
||||
} from '@livekit/components-react';
|
||||
import { isEqualTrackRef } from '@livekit/components-core';
|
||||
import { Track, Room } from 'livekit-client';
|
||||
import { CustomControlBar } from '@/app/custom/CustomControlBar';
|
||||
import ParticipantList from '@/app/custom/ParticipantList';
|
||||
import { ParticipantTile } from '@/lib/ParticipantTile';
|
||||
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||
import { useCustomLayoutContext } from '@/app/contexts/layout-context';
|
||||
import '@/styles/Chat.css';
|
||||
import { FocusLayout, FocusLayoutContainer } from './FocusLayout';
|
||||
|
||||
interface CustomVideoLayoutProps {
|
||||
room: Room;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
|
||||
const { isChatOpen, isParticipantsListOpen } = useCustomLayoutContext();
|
||||
const layoutContext = useLayoutContext();
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false },
|
||||
);
|
||||
|
||||
const focusTrack = usePinnedTracks()[0];
|
||||
const carouselTracks = tracks.filter((track) => !isEqualTrackRef(track, focusTrack));
|
||||
const test = usePinnedTracks();
|
||||
|
||||
useEffect(() => {
|
||||
console.log({ test });
|
||||
}, [test]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
backgroundColor: '#070707',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
{!focusTrack ? (
|
||||
<GridLayout
|
||||
tracks={tracks}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem 1rem 0.5rem 1rem',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
) : (
|
||||
<FocusLayoutContainer
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '93%',
|
||||
padding: '1rem 1rem 0.5rem 1rem',
|
||||
}}
|
||||
>
|
||||
<CarouselLayout tracks={carouselTracks}>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{focusTrack && <FocusLayout style={{ width: '100%' }} trackRef={focusTrack} />}{' '}
|
||||
</FocusLayoutContainer>
|
||||
)}
|
||||
<CustomControlBar room={room} roomName={roomName} />{' '}
|
||||
</div>
|
||||
</div>
|
||||
{isParticipantsListOpen.state && <ParticipantList />}
|
||||
{isChatOpen.state && <Chat />}
|
||||
<SettingsMenu showSettings={layoutContext.widget.state?.showSettings || false} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomVideoLayout;
|
||||
24
app/custom/layout/FocusLayout.tsx
Normal file
24
app/custom/layout/FocusLayout.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from 'react';
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
|
||||
import type { ParticipantClickEvent } from '@livekit/components-core';
|
||||
import { ParticipantTile } from '@/lib/ParticipantTile';
|
||||
|
||||
export interface FocusLayoutContainerProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function FocusLayoutContainer(props: FocusLayoutContainerProps) {
|
||||
return (
|
||||
<div className="lk-focus-layout" {...props}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface FocusLayoutProps extends React.HTMLAttributes<HTMLElement> {
|
||||
trackRef?: TrackReferenceOrPlaceholder;
|
||||
|
||||
onParticipantClick?: (evt: ParticipantClickEvent) => void;
|
||||
}
|
||||
|
||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||
return <ParticipantTile trackRef={trackRef} {...htmlProps} />;
|
||||
}
|
||||
85
app/custom/layout/LayoutContextProvider.tsx
Normal file
85
app/custom/layout/LayoutContextProvider.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import React, { useState } from 'react';
|
||||
import { LayoutContextProvider, TrackReferenceOrPlaceholder } from '@livekit/components-react';
|
||||
import { CustomLayoutContextProvider } from '@/app/contexts/layout-context';
|
||||
import { PinAction } from '@livekit/components-react/dist/context/pin-context';
|
||||
import { PanelTopInactive, Pi } from 'lucide-react';
|
||||
|
||||
interface CustomVideoLayoutContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const CustomVideoLayoutContextProvider: React.FC<CustomVideoLayoutContextProviderProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showParticipantsList, setShowParticipantsList] = useState(false);
|
||||
const [pinnedTracks, setPinnedTracks] = useState<TrackReferenceOrPlaceholder[]>();
|
||||
|
||||
const toggleParticipantsList = () => {
|
||||
if (showChat) setShowChat(false);
|
||||
setShowParticipantsList((prev) => !prev);
|
||||
};
|
||||
|
||||
const toggleChat = () => {
|
||||
if (showParticipantsList) setShowParticipantsList(false);
|
||||
setShowChat((prev) => !prev);
|
||||
};
|
||||
|
||||
const toggleSettings = () => {
|
||||
setShowSettings((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<CustomLayoutContextProvider
|
||||
layoutContextValue={{
|
||||
isParticipantsListOpen: {
|
||||
state: showParticipantsList,
|
||||
dispatch: toggleParticipantsList,
|
||||
},
|
||||
isChatOpen: {
|
||||
state: showChat,
|
||||
dispatch: toggleChat,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LayoutContextProvider
|
||||
value={{
|
||||
pin: {
|
||||
state: pinnedTracks,
|
||||
dispatch: (action: PinAction) => {
|
||||
if (action.msg === 'set_pin') {
|
||||
setPinnedTracks([action.trackReference]);
|
||||
}
|
||||
if (action.msg === 'clear_pin') {
|
||||
setPinnedTracks([]);
|
||||
}
|
||||
},
|
||||
},
|
||||
widget: {
|
||||
state: {
|
||||
showChat,
|
||||
showSettings,
|
||||
unreadMessages: 0,
|
||||
},
|
||||
dispatch: (action: any) => {
|
||||
switch (action && action.msg) {
|
||||
case 'toggle_settings':
|
||||
toggleSettings();
|
||||
break;
|
||||
case 'toggle_chat':
|
||||
toggleChat();
|
||||
break;
|
||||
case 'toggle_participants_list':
|
||||
toggleParticipantsList();
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LayoutContextProvider>
|
||||
</CustomLayoutContextProvider>
|
||||
);
|
||||
};
|
||||
@ -20,7 +20,8 @@ import {
|
||||
} from 'livekit-client';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import '../../../styles/PageClientImpl.css';
|
||||
import { CustomVideoLayout } from '@/lib/CustomVideoLayout';
|
||||
import { CustomVideoLayoutContextProvider } from '@/app/custom/layout/LayoutContextProvider';
|
||||
import CustomVideoLayout from '@/app/custom/layout/CustomVideoLayout';
|
||||
|
||||
const CONN_DETAILS_ENDPOINT =
|
||||
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
|
||||
@ -177,8 +178,10 @@ function VideoConferenceComponent(props: {
|
||||
audio={props.userChoices.audioEnabled}
|
||||
onDisconnected={handleOnLeave}
|
||||
>
|
||||
<CustomVideoLayout room={room} roomName={props.connectionDetails.roomName} />
|
||||
<RoomAudioRenderer />
|
||||
<CustomVideoLayoutContextProvider>
|
||||
<CustomVideoLayout room={room} roomName={props.connectionDetails.roomName} />
|
||||
<RoomAudioRenderer />
|
||||
</CustomVideoLayoutContextProvider>
|
||||
</LiveKitRoom>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,116 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GridLayout, useTracks, LayoutContextProvider, Chat } from '@livekit/components-react';
|
||||
import { Track, Room } from 'livekit-client';
|
||||
import { ParticipantTile } from './ParticipantTile';
|
||||
import { CustomControlBar } from '@/app/custom/CustomControlBar';
|
||||
import { SettingsMenu } from './SettingsMenu';
|
||||
import ParticipantList from '@/app/custom/ParticipantList';
|
||||
import { CustomLayoutContextProvider } from '@/app/contexts/layout-context';
|
||||
import '../styles/Chat.css';
|
||||
|
||||
interface CustomVideoLayoutProps {
|
||||
room: Room;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
|
||||
const [showChat, setShowChat] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showParticipantsList, setShowParticipantsList] = useState(false);
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ onlySubscribed: false },
|
||||
);
|
||||
|
||||
return (
|
||||
<CustomLayoutContextProvider
|
||||
layoutContextValue={{
|
||||
isParticipantsListOpen: {
|
||||
state: showParticipantsList,
|
||||
dispatch: () => {
|
||||
if (showChat) setShowChat(false);
|
||||
setShowParticipantsList((prev) => !prev);
|
||||
},
|
||||
},
|
||||
isChatOpen: {
|
||||
state: showChat,
|
||||
dispatch: () => {
|
||||
if (showParticipantsList) setShowParticipantsList(false);
|
||||
setShowChat((prev) => !prev);
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LayoutContextProvider
|
||||
value={{
|
||||
pin: {
|
||||
state: [],
|
||||
dispatch: () => {},
|
||||
},
|
||||
widget: {
|
||||
state: {
|
||||
showChat,
|
||||
showSettings,
|
||||
unreadMessages: 0,
|
||||
},
|
||||
dispatch: (action: any) => {
|
||||
if ('msg' in action && action.msg === 'toggle_settings') {
|
||||
setShowSettings((prev) => !prev);
|
||||
}
|
||||
if ('msg' in action && action.msg === 'toggle_chat') {
|
||||
if (showParticipantsList) setShowParticipantsList(false);
|
||||
setShowChat((prev) => !prev);
|
||||
}
|
||||
if ('msg' in action && action.msg === 'toggle_participants_list') {
|
||||
if (showChat) setShowChat(false);
|
||||
setShowParticipantsList((prev) => !prev);
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
position: 'relative',
|
||||
backgroundColor: '#070707',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<GridLayout
|
||||
tracks={tracks}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '1rem 1rem 0.5rem 1rem',
|
||||
}}
|
||||
>
|
||||
<ParticipantTile />
|
||||
</GridLayout>
|
||||
<CustomControlBar room={room} roomName={roomName} />{' '}
|
||||
</div>
|
||||
</div>
|
||||
{showParticipantsList && <ParticipantList />}
|
||||
{showChat && <Chat />}
|
||||
<SettingsMenu showSettings={showSettings} />
|
||||
</div>
|
||||
</LayoutContextProvider>
|
||||
</CustomLayoutContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomVideoLayout;
|
||||
@ -1,132 +1,192 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import type { Participant } from 'livekit-client';
|
||||
import { Track } from 'livekit-client';
|
||||
import type { ParticipantClickEvent, TrackReferenceOrPlaceholder } from '@livekit/components-core';
|
||||
import { isTrackReference, isTrackReferencePinned } from '@livekit/components-core';
|
||||
import {
|
||||
AudioTrack,
|
||||
useTracks,
|
||||
VideoTrack,
|
||||
useTrackRefContext,
|
||||
ParticipantContext,
|
||||
TrackRefContext,
|
||||
useEnsureTrackRef,
|
||||
TrackRefContextIfNeeded,
|
||||
useFeatureContext,
|
||||
useIsEncrypted,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeParticipantContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
ParticipantPlaceholder,
|
||||
LockLockedIcon,
|
||||
TrackMutedIndicator,
|
||||
ParticipantName,
|
||||
ConnectionQualityIndicator,
|
||||
FocusToggle,
|
||||
useIsSpeaking,
|
||||
useIsMuted,
|
||||
useTrackByName,
|
||||
useTracks,
|
||||
} from '@livekit/components-react';
|
||||
import { Track, Participant } from 'livekit-client';
|
||||
import { isTrackReference } from '@livekit/components-core';
|
||||
import { getAvatarColor, getInitials } from './client-utils';
|
||||
|
||||
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 function ParticipantContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
participant?: Participant;
|
||||
}>,
|
||||
) {
|
||||
const hasContext = !!useMaybeParticipantContext();
|
||||
return props.participant && !hasContext ? (
|
||||
<ParticipantContext.Provider value={props.participant}>
|
||||
{props.children}
|
||||
</ParticipantContext.Provider>
|
||||
) : (
|
||||
<>{props.children}</>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default ParticipantTile;
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
trackRef?: TrackReferenceOrPlaceholder;
|
||||
}>,
|
||||
) {
|
||||
const hasContext = !!useMaybeTrackRefContext();
|
||||
return props.trackRef && !hasContext ? (
|
||||
<TrackRefContext.Provider value={props.trackRef}>{props.children}</TrackRefContext.Provider>
|
||||
) : (
|
||||
<>{props.children}</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ParticipantTileProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
trackRef?: TrackReferenceOrPlaceholder;
|
||||
disableSpeakingIndicator?: boolean;
|
||||
|
||||
onParticipantClick?: (event: ParticipantClickEvent) => void;
|
||||
}
|
||||
|
||||
export const ParticipantTile: (
|
||||
props: ParticipantTileProps & React.RefAttributes<HTMLDivElement>,
|
||||
) => React.ReactNode = React.forwardRef<HTMLDivElement, ParticipantTileProps>(
|
||||
function ParticipantTile(
|
||||
{
|
||||
trackRef,
|
||||
children,
|
||||
onParticipantClick,
|
||||
disableSpeakingIndicator,
|
||||
...htmlProps
|
||||
}: ParticipantTileProps,
|
||||
ref,
|
||||
) {
|
||||
const trackReference = useEnsureTrackRef(trackRef);
|
||||
const {
|
||||
name,
|
||||
identity,
|
||||
metadata,
|
||||
isEncrypted,
|
||||
isSpeaking,
|
||||
isMicrophoneEnabled,
|
||||
isScreenShareEnabled,
|
||||
} = trackReference.participant;
|
||||
|
||||
const { elementProps } = useParticipantTile<HTMLDivElement>({
|
||||
htmlProps,
|
||||
disableSpeakingIndicator,
|
||||
onParticipantClick,
|
||||
trackRef: trackReference,
|
||||
});
|
||||
const layoutContext = useMaybeLayoutContext();
|
||||
|
||||
const autoManageSubscription = useFeatureContext()?.autoSubscription;
|
||||
|
||||
const handleSubscribe = React.useCallback(
|
||||
(subscribed: boolean) => {
|
||||
if (
|
||||
trackReference.source &&
|
||||
!subscribed &&
|
||||
layoutContext &&
|
||||
layoutContext.pin.dispatch &&
|
||||
isTrackReferencePinned(trackReference, layoutContext.pin.state)
|
||||
) {
|
||||
layoutContext.pin.dispatch({ msg: 'clear_pin' });
|
||||
}
|
||||
},
|
||||
[trackReference, layoutContext],
|
||||
);
|
||||
|
||||
const [profilePictureUrl, setProfilePictureUrl] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (metadata) {
|
||||
try {
|
||||
const parsedMetadata = JSON.parse(metadata);
|
||||
if (parsedMetadata.profilePictureUrl) {
|
||||
setProfilePictureUrl(parsedMetadata.profilePictureUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse participant metadata', e);
|
||||
}
|
||||
}
|
||||
}, [metadata]);
|
||||
|
||||
const avatarColor = getAvatarColor(identity);
|
||||
const initials = getInitials(name || identity);
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
(trackReference.publication?.kind === 'video' ||
|
||||
trackReference.source === Track.Source.Camera ||
|
||||
trackReference.source === Track.Source.ScreenShare) ? (
|
||||
<VideoTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
manageSubscription={autoManageSubscription}
|
||||
/>
|
||||
) : (
|
||||
isTrackReference(trackReference) && (
|
||||
<AudioTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div className="lk-participant-placeholder">
|
||||
<div className="avatar-container" style={{ backgroundColor: avatarColor }}>
|
||||
{profilePictureUrl ? (
|
||||
<img src={profilePictureUrl} alt={name} className="avatar-image" />
|
||||
) : (
|
||||
<span className="avatar-initials">{initials}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="lk-participant-metadata">
|
||||
<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">{name || identity}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FocusToggle trackRef={trackReference} />
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
.participant-tile {
|
||||
position: relative;
|
||||
background-color: #1a242e;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.participant-tile.speaking {
|
||||
border: 2px solid #618aff;
|
||||
}
|
||||
|
||||
.participant-info {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.participant-name {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: white;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.mic-icon {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.mic-on {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.mic-off {
|
||||
color: #ff5252;
|
||||
}
|
||||
|
||||
.speaking-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: #618aff;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.speaking-icon {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.avatar-initials {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 50px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.custom-control-bar.lk-control-bar {
|
||||
padding: 6px !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
margin-right: -10px;
|
||||
width: calc(100% + 10px);
|
||||
}
|
||||
@ -33,6 +33,12 @@
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-size: 18px;
|
||||
padding: 0.1rem 0.3rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.mic-on {
|
||||
@ -43,15 +49,11 @@
|
||||
color: #ff5252;
|
||||
}
|
||||
|
||||
.speaking-icon {
|
||||
background-color: #618aff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.speaking-icon {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
background-color: #618aff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
@ -93,3 +95,26 @@
|
||||
margin-right: -10px;
|
||||
width: calc(100% + 10px);
|
||||
}
|
||||
|
||||
.focus-toggle {
|
||||
width: 2rem;
|
||||
background-color: #151e27;
|
||||
position: absolute;
|
||||
right: 0.5rem;
|
||||
top: 0.5rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.lk-participant-tile .lk-participant-placeholder {
|
||||
background-color: #1a242e;
|
||||
}
|
||||
|
||||
.lk-participant-metadata-item,
|
||||
.lk-participant-tile .lk-focus-toggle-button,
|
||||
.lk-participant-tile .lk-focus-toggle-button:hover {
|
||||
background-color: #151e27;
|
||||
}
|
||||
|
||||
.data-lk-quality {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user