diff --git a/app/layout.tsx b/app/layout.tsx
index 984b4ec..cd7a298 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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 (
+
+
+
+
{children}
);
diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx
index 8881900..ce418f5 100644
--- a/app/rooms/[roomName]/PageClientImpl.tsx
+++ b/app/rooms/[roomName]/PageClientImpl.tsx
@@ -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: {
)}
-
+
+
);
}
\ No newline at end of file
diff --git a/lib/CustomVideoLayout.tsx b/lib/CustomVideoLayout.tsx
new file mode 100644
index 0000000..7492c48
--- /dev/null
+++ b/lib/CustomVideoLayout.tsx
@@ -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 (
+ {},
+ },
+ widget: {
+ state: {
+ showChat,
+ unreadMessages: 0,
+ },
+ dispatch: (action: any) => {
+ if ('msg' in action && action.msg === 'toggle_chat') {
+ setShowChat((prev) => !prev);
+ }
+ },
+ },
+ }}
+ >
+
+
+
+ {showChat && (
+
+
+
+ )}
+
+
+
+
+ );
+};
+
+export default CustomVideoLayout;
diff --git a/lib/ParticipantTile.tsx b/lib/ParticipantTile.tsx
new file mode 100644
index 0000000..f31a146
--- /dev/null
+++ b/lib/ParticipantTile.tsx
@@ -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 = ({
+ participant: propParticipant,
+}) => {
+ const trackRef = useTrackRefContext();
+ const participant = propParticipant || trackRef?.participant;
+
+ if (!participant) return null;
+
+ const [profilePictureUrl, setProfilePictureUrl] = useState(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 (
+
+ {isCameraEnabled ? (
+
+
+
+ ) : (
+
+ {profilePictureUrl ? (
+

+ ) : (
+
{initials}
+ )}
+
+ )}
+
+
+ {isMicrophoneEnabled ? (
+ isSpeaking ? (
+
+ graphic_eq
+
+ ) : (
+ mic
+ )
+ ) : (
+ mic_off
+ )}
+ {participant.name || participant.identity}
+
+
+ {hasMicrophone && microphoneTrack &&
}
+
+ );
+};
+
+export default ParticipantTile;
diff --git a/styles/participant-tile.css b/styles/participant-tile.css
new file mode 100644
index 0000000..7720b0a
--- /dev/null
+++ b/styles/participant-tile.css
@@ -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);
+}