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 ? ( + {participant.name} + ) : ( + {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); +}