Merge pull request #11 from SujithThirumalaisamy/custom-layout
feat: Custom Layout
This commit is contained in:
commit
e57970648e
@ -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>
|
||||
);
|
||||
|
||||
@ -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
110
lib/CustomVideoLayout.tsx
Normal 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
144
lib/ParticipantTile.tsx
Normal 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
103
styles/participant-tile.css
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user