Merge pull request #19 from SujithThirumalaisamy/fix/recording
feat: Added Chat and fixed focus layout
This commit is contained in:
commit
b1fe8f9ce7
@ -3,7 +3,6 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
DisconnectButton,
|
||||
useIsRecording,
|
||||
useLayoutContext,
|
||||
useLocalParticipant,
|
||||
useRoomContext,
|
||||
@ -29,7 +28,7 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
const [isRecordingRequestPending, setIsRecordingRequestPending] = useState(false);
|
||||
const [participantCount, setParticipantCount] = useState(1);
|
||||
const { dispatch } = useLayoutContext().widget;
|
||||
const { isParticipantsListOpen } = useCustomLayoutContext();
|
||||
const { isParticipantsListOpen, isChatOpen } = useCustomLayoutContext();
|
||||
const { toast } = useToast();
|
||||
const [recordingState, setRecordingState] = useState({
|
||||
recording: { isRecording: false, recorder: '' },
|
||||
@ -65,6 +64,10 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' });
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
if (isChatOpen.dispatch) isChatOpen.dispatch({ msg: 'toggle_chat' });
|
||||
};
|
||||
|
||||
const toggleRoomRecording = async () => {
|
||||
if (isRecordingRequestPending || (isRecording && !isSelfRecord)) return;
|
||||
setIsRecordingRequestPending(true);
|
||||
@ -93,7 +96,7 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
} else {
|
||||
response = await fetch(
|
||||
recordingEndpoint +
|
||||
`/start?roomName=${room.name}&now=${now}&identity=${localParticipant.identity}`,
|
||||
`/start?roomName=${room.name}&now=${now}&identity=${localParticipant.identity}`,
|
||||
);
|
||||
}
|
||||
if (response.ok) {
|
||||
@ -149,6 +152,7 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
<div className="room-name-box">
|
||||
<span className="room-name">{roomName}</span>
|
||||
<button className="copy-link-button" onClick={handleCopyLink}>
|
||||
{' '}
|
||||
<span className="material-symbols-outlined">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -157,7 +161,6 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
<div className="control-bar control-buttons">
|
||||
<TrackToggle source={Track.Source.Microphone} />
|
||||
<TrackToggle source={Track.Source.Camera} />
|
||||
|
||||
<div
|
||||
className={`control-btn ${isRecording ? '' : 'disabled'} ${isRecordingRequestPending || isRecording ? 'blinking' : ''}`}
|
||||
onClick={toggleRoomRecording}
|
||||
@ -184,7 +187,9 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
<span className="material-symbols-outlined">people</span>
|
||||
<span className="participant-count">{participantCount}</span>
|
||||
</div>
|
||||
|
||||
<div className={`control-btn`} onClick={toggleChat}>
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</div>
|
||||
<div
|
||||
className="settings-box"
|
||||
onClick={() => {
|
||||
|
||||
@ -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 /> : <></>}
|
||||
|
||||
97
app/custom/layout/CustomVideoLayout.tsx
Normal file
97
app/custom/layout/CustomVideoLayout.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
GridLayout,
|
||||
useTracks,
|
||||
Chat,
|
||||
CarouselLayout,
|
||||
usePinnedTracks,
|
||||
useLayoutContext,
|
||||
} from '@livekit/components-react';
|
||||
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 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={tracks}>
|
||||
<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} />;
|
||||
}
|
||||
84
app/custom/layout/LayoutContextProvider.tsx
Normal file
84
app/custom/layout/LayoutContextProvider.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
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';
|
||||
|
||||
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,99 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { GridLayout, useTracks, LayoutContextProvider } 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';
|
||||
|
||||
interface CustomVideoLayoutProps {
|
||||
room: Room;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
|
||||
const showChat = 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: () => setShowParticipantsList((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_participants_list') {
|
||||
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 />}
|
||||
<SettingsMenu showSettings={showSettings} />
|
||||
</div>
|
||||
</LayoutContextProvider>
|
||||
</CustomLayoutContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomVideoLayout;
|
||||
@ -1,132 +1,188 @@
|
||||
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,
|
||||
useMaybeLayoutContext,
|
||||
useMaybeParticipantContext,
|
||||
useMaybeTrackRefContext,
|
||||
useParticipantTile,
|
||||
ConnectionQualityIndicator,
|
||||
FocusToggle,
|
||||
} 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}
|
||||
{trackReference.source === Track.Source.ScreenShare
|
||||
? ' (Screen Share)'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<FocusToggle trackRef={trackReference} />
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@ -6,7 +6,6 @@ import {
|
||||
useLocalParticipant,
|
||||
MediaDeviceMenu,
|
||||
TrackToggle,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react';
|
||||
import type { KrispNoiseFilterProcessor } from '@livekit/krisp-noise-filter';
|
||||
|
||||
|
||||
76
styles/Chat.css
Normal file
76
styles/Chat.css
Normal file
@ -0,0 +1,76 @@
|
||||
.lk-chat {
|
||||
display: flex;
|
||||
width: 25vw;
|
||||
flex-direction: column;
|
||||
background-color: #151e27;
|
||||
margin: 1rem 1rem 4.1rem 0;
|
||||
background-color: #151e27;
|
||||
border-radius: 0.5rem;
|
||||
border: 0px;
|
||||
}
|
||||
|
||||
.lk-chat-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 1.25rem 1.75rem;
|
||||
height: auto;
|
||||
justify-content: start;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.lk-close-button.lk-button.lk-chat-toggle {
|
||||
color: #556171;
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.lk-close-button.lk-button.lk-chat-toggle > svg {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
|
||||
.lk-close-button.lk-button.lk-chat-toggle > svg > path {
|
||||
fill: #556171;
|
||||
}
|
||||
|
||||
.lk-close-button.lk-button.lk-chat-toggle:hover {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.lk-list.lk-chat-messages {
|
||||
margin-bottom: auto;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.lk-form-control.lk-chat-form-input {
|
||||
background-color: #151e27;
|
||||
}
|
||||
|
||||
.lk-form-control.lk-chat-form-input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.lk-button.lk-chat-form-button {
|
||||
background-color: #151e27;
|
||||
border: 1px solid var(--lk-border-color);
|
||||
}
|
||||
|
||||
.lk-button.lk-chat-form-button:hover {
|
||||
background-color: #212e3a;
|
||||
}
|
||||
|
||||
.lk-chat-form {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.lk-chat-entry[data-lk-message-origin='local'] .lk-message-body {
|
||||
background-color: #212e3a;
|
||||
}
|
||||
|
||||
.lk-chat-entry[data-lk-message-origin='remote'] .lk-message-body {
|
||||
background-color: #3e6189;
|
||||
}
|
||||
@ -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 {
|
||||
@ -44,14 +50,19 @@
|
||||
}
|
||||
|
||||
.speaking-icon {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
color: white;
|
||||
background-color: #618aff;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.speaking-icon {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
.lk-carousel .avatar-container {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.lk-carousel .avatar-initials {
|
||||
font-size: 35px;
|
||||
}
|
||||
|
||||
.avatar-container {
|
||||
@ -93,3 +104,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