Merge pull request #11 from SujithThirumalaisamy/custom-layout

feat: Custom Layout
This commit is contained in:
Tom 2025-03-17 11:36:43 -03:00 committed by GitHub
commit e57970648e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 371 additions and 1 deletions

View File

@ -1,6 +1,7 @@
import '../styles/globals.css';
import '@livekit/components-styles';
import '@livekit/components-styles/prefabs';
import '../styles/participant-tile.css';
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {
@ -32,6 +33,16 @@ export const viewport: Viewport = {
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>{children}</body>
</html>
);

View File

@ -30,6 +30,7 @@ import { VideoTrack } from '@/app/custom/VideoTrack';
import { CustomControlBar } from '@/app/custom/CustomControlBar';
import '../../../styles/PageClientImpl.css';
import { CustomVideoLayout } from '@/lib/CustomVideoLayout';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
@ -203,7 +204,8 @@ function VideoConferenceComponent(props: {
)}
<RoomAudioRenderer />
<CustomControlBar room={room} roomName={props.connectionDetails.roomName} />
<Transcript latestText={''} />
<Transcript latestText={latestText} />
<RecordingIndicator />
</LiveKitRoom>
);
}

110
lib/CustomVideoLayout.tsx Normal file
View File

@ -0,0 +1,110 @@
import React from 'react';
import {
GridLayout,
ControlBar,
useTracks,
RoomAudioRenderer,
LayoutContextProvider,
Chat,
} from '@livekit/components-react';
import { Track } from 'livekit-client';
import { ParticipantTile } from './ParticipantTile';
export const CustomVideoLayout: React.FC = () => {
const [showChat, setShowChat] = React.useState(false);
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false },
);
return (
<LayoutContextProvider
value={{
pin: {
state: [],
dispatch: () => {},
},
widget: {
state: {
showChat,
unreadMessages: 0,
},
dispatch: (action: any) => {
if ('msg' in action && action.msg === 'toggle_chat') {
setShowChat((prev) => !prev);
}
},
},
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
height: '100vh',
width: '100%',
position: 'relative',
backgroundColor: '#070707',
}}
>
<div
style={{
flex: 1,
minHeight: 0,
padding: '10px',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ flex: 1, minHeight: 0 }}>
<GridLayout
tracks={tracks}
style={{
height: '100%',
width: '100%',
}}
>
<ParticipantTile />
</GridLayout>
</div>
<ControlBar
className="custom-control-bar"
variation="verbose"
controls={{
chat: true,
microphone: true,
camera: true,
screenShare: true,
leave: true,
}}
/>
</div>
{showChat && (
<div
className="lk-chat-container"
style={{
width: '470px',
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<Chat
style={{
height: '100%',
}}
/>
</div>
)}
<RoomAudioRenderer />
</div>
</LayoutContextProvider>
);
};
export default CustomVideoLayout;

144
lib/ParticipantTile.tsx Normal file
View File

@ -0,0 +1,144 @@
import React, { useEffect, useState } from 'react';
import { AudioTrack, useTracks, VideoTrack, useTrackRefContext } from '@livekit/components-react';
import { Track, Participant } from 'livekit-client';
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 participant = propParticipant || trackRef?.participant;
if (!participant) return null;
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null);
const isValidTrackRef =
trackRef && 'publication' in trackRef && trackRef.publication !== undefined;
const cameraTrack =
isValidTrackRef && trackRef.source === Track.Source.Camera
? trackRef
: useTracks([Track.Source.Camera], { onlySubscribed: false }).filter(
(track) => track.participant.identity === participant.identity,
)[0];
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 hasCamera = !!cameraTrack;
const isCameraEnabled = hasCamera && !cameraTrack.publication?.isMuted;
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' : ''}`}>
{isCameraEnabled ? (
<div className="video-container">
<VideoTrack trackRef={cameraTrack} />
</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"
style={{
backgroundColor: '#618AFF',
borderRadius: '50%',
width: '22px',
height: '22px',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
}}
>
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;

103
styles/participant-tile.css Normal file
View File

@ -0,0 +1,103 @@
.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);
}