Refactored Custom Layout

This commit is contained in:
SujithThirumalaisamy 2025-03-17 21:18:15 +05:30
parent e57970648e
commit 3676fb5543
20 changed files with 2688 additions and 1183 deletions

View File

@ -1 +0,0 @@
{}

View File

@ -1,18 +1,12 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
TrackToggle,
DisconnectButton
} from '@livekit/components-react';
import {
Room,
RoomEvent,
Track
} from 'livekit-client';
import { DisconnectButton, useRoomContext } from '@livekit/components-react';
import { Room, RoomEvent, Track } from 'livekit-client';
import { mergeClasses } from '@/lib/client-utils';
import { ToggleSource } from '@livekit/components-core';
import '../../styles/CustomControlBar.css';
interface CustomControlBarProps {
room: Room;
roomName: string;
@ -20,30 +14,24 @@ interface CustomControlBarProps {
export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
const [recording, setRecording] = useState(false);
const [participantCount, setParticipantCount] = useState(1);
const [participantCount, setParticipantCount] = useState(1);
useEffect(() => {
if (room) {
const updateRecordingStatus = () => setRecording(room.isRecording);
const updateParticipantCount = () => {
if (room && room.participants) {
setParticipantCount(room.participants.size + 1);
}
setParticipantCount(room.numParticipants);
};
if (room.state === 'connected') {
updateParticipantCount();
updateParticipantCount();
}
room.on(RoomEvent.Connected, updateParticipantCount);
room.on(RoomEvent.ParticipantConnected, updateParticipantCount);
room.on(RoomEvent.ParticipantDisconnected, updateParticipantCount);
room.on(RoomEvent.RecordingStatusChanged, updateRecordingStatus);
return () => {
room.off(RoomEvent.Connected, updateParticipantCount);
room.off(RoomEvent.ParticipantConnected, updateParticipantCount);
@ -54,14 +42,14 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
}, [room]);
const handleCopyLink = () => {
navigator.clipboard.writeText(window.location.href)
navigator.clipboard
.writeText(window.location.href)
.then(() => alert('Link copied to clipboard!'))
.catch((err) => console.error('Failed to copy link:', err));
};
return (
<div className="custom-control-bar">
<div className="room-name-box">
<span className="room-name">{roomName}</span>
<button className="copy-link-button" onClick={handleCopyLink}>
@ -70,26 +58,15 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
</div>
{/* Center: Control Buttons */}
<div className="control-buttons">
<TrackToggle source={Track.Source.Microphone} className="control-button mic-button">
{/* <span className="material-symbols-outlined">mic</span>
<span data-lk-audio-enabled="false" className="material-symbols-outlined">mic_off</span> */}
</TrackToggle>
<TrackToggle source={Track.Source.Camera} className="control-button camera-button">
{/* <span className="material-symbols-outlined">videocam</span>
<span data-lk-video-enabled="false" className="material-symbols-outlined">videocam_off</span> */}
</TrackToggle>
<div className="control-bar control-buttons">
<TrackToggle source={Track.Source.Microphone} />
<TrackToggle source={Track.Source.Camera} />
<div className={`control-button record-sign ${recording ? '' : 'disabled'}`}>
<span className="material-symbols-outlined">radio_button_checked</span>
</div>
<TrackToggle source={Track.Source.ScreenShare} className="control-button screen-share-button">
{/* <span className="material-symbols-outlined">screen_share</span>
<span data-lk-screen-share-enabled="true" className="material-symbols-outlined">screen_share</span> */}
</TrackToggle>
<TrackToggle source={Track.Source.ScreenShare} />
<DisconnectButton className="control-button end-call-button">
<span className="material-symbols-outlined">call_end</span>
</DisconnectButton>
@ -101,11 +78,118 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
<span className="material-symbols-outlined">people</span>
<span className="participant-count">{participantCount}</span>
</div>
<div className="settings-box">
<span className="material-symbols-outlined">settings</span>
</div>
</div>
</div>
);
}
}
interface ControlButtonProps {
enabled?: boolean;
icon: React.ReactNode;
className?: string;
onClick?: () => void;
}
function ControlButton({ enabled = true, icon, className, onClick }: ControlButtonProps) {
return (
<button
className={mergeClasses('control-btn', className)}
data-lk-active={!enabled}
onClick={onClick}
>
{icon}
</button>
);
}
function TrackToggle({ source }: { source: ToggleSource }) {
const { enabled, toggle } = useTrackToggle({ source });
const isScreenShare = source === Track.Source.ScreenShare;
return (
<ControlButton
onClick={toggle}
enabled={isScreenShare ? !enabled : enabled}
icon={<TrackIcon trackSource={source} enabled={isScreenShare ? !enabled : enabled} />}
/>
);
}
interface TrackIconProps {
trackSource: ToggleSource;
enabled: boolean;
}
function TrackIcon({ trackSource, enabled }: TrackIconProps) {
switch (trackSource) {
case Track.Source.Camera:
return enabled ? (
<span className="material-symbols-outlined">videocam</span>
) : (
<span
button-state="inactive"
data-lk-video-enabled="false"
className="material-symbols-outlined"
>
videocam_off
</span>
);
case Track.Source.Microphone:
return enabled ? (
<span className="material-symbols-outlined">mic</span>
) : (
<span
button-state="inactive"
data-lk-audio-enabled="false"
className="material-symbols-outlined"
>
mic_off
</span>
);
case Track.Source.ScreenShare:
return enabled ? (
<span className="material-symbols-outlined">screen_share</span>
) : (
<span
button-state="inactive"
data-lk-screen-share-enabled="true"
className="material-symbols-outlined"
>
stop_screen_share
</span>
);
}
}
// Custom hook for track toggle
function useTrackToggle({ source }: { source: ToggleSource }) {
const { localParticipant } = useRoomContext();
const toggle = () => {
switch (source) {
case Track.Source.Camera:
return localParticipant.setCameraEnabled(!enabled);
case Track.Source.Microphone:
return localParticipant.setMicrophoneEnabled(!enabled);
case Track.Source.ScreenShare:
return localParticipant.setScreenShareEnabled(!enabled);
}
};
const enabled = (() => {
switch (source) {
case Track.Source.Camera:
return localParticipant.isCameraEnabled;
case Track.Source.Microphone:
return localParticipant.isMicrophoneEnabled;
case Track.Source.ScreenShare:
return localParticipant.isScreenShareEnabled;
}
})();
return { enabled, toggle };
}

View File

@ -24,4 +24,4 @@ export function VideoTrack({ ref: trackRef }: VideoTrackProps) {
}, [trackRef.publication?.track]);
return <video ref={videoRef} className="video-element" />;
}
}

View File

@ -1,7 +1,7 @@
import '../styles/globals.css';
import '@livekit/components-styles';
import '@livekit/components-styles/prefabs';
import '../styles/participant-tile.css';
import '../styles/globals.css';
import type { Metadata, Viewport } from 'next';
export const metadata: Metadata = {

View File

@ -1,18 +1,12 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import Transcript from '@/lib/Transcript';
import { ConnectionDetails } from '@/lib/types';
import {
LocalUserChoices,
PreJoin,
LiveKitRoom,
useTracks,
TrackReferenceOrPlaceholder,
GridLayout,
RoomAudioRenderer,
} from '@livekit/components-react';
import {
@ -23,14 +17,13 @@ import {
Room,
DeviceUnsupportedError,
RoomConnectOptions,
Track,
E2EEOptions,
} from 'livekit-client';
import { useRouter } from 'next/navigation';
import { VideoTrack } from '@/app/custom/VideoTrack';
import { CustomControlBar } from '@/app/custom/CustomControlBar';
import '../../../styles/PageClientImpl.css';
import { CustomVideoLayout } from '@/lib/CustomVideoLayout';
import { RecordingIndicator } from '@/lib/RecordingIndicator';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
@ -41,8 +34,10 @@ export function PageClientImpl(props: {
codec: VideoCodec;
}) {
const [preJoinChoices, setPreJoinChoices] = useState<LocalUserChoices | undefined>(undefined);
const [connectionDetails, setConnectionDetails] = useState<ConnectionDetails | undefined>(undefined);
const [connectionDetails, setConnectionDetails] = useState<ConnectionDetails | undefined>(
undefined,
);
const preJoinDefaults = useMemo(() => {
return {
username: '',
@ -92,26 +87,28 @@ function VideoConferenceComponent(props: {
connectionDetails: ConnectionDetails;
options: { hq: boolean; codec: VideoCodec };
}) {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const getE2EEConfig = () => {
if (typeof window === 'undefined') return { enabled: false };
if (typeof window === 'undefined')
return {
enabled: false,
passphrase: undefined,
worker: undefined,
};
const e2eePassphrase = decodePassphrase(location.hash.substring(1));
const worker = e2eePassphrase &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const worker = new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const e2eeEnabled = !!(e2eePassphrase && worker);
return {
enabled: e2eeEnabled,
passphrase: e2eePassphrase,
worker
worker,
};
};
@ -141,7 +138,9 @@ function VideoConferenceComponent(props: {
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: e2eeConfig.enabled ? { keyProvider, worker: e2eeConfig.worker } : undefined,
...(e2eeConfig.enabled && e2eeConfig.worker
? { e2ee: { keyProvider, worker: e2eeConfig.worker } }
: null),
};
}, [props.userChoices, props.options.hq, props.options.codec, e2eeConfig]);
@ -168,9 +167,6 @@ function VideoConferenceComponent(props: {
const router = useRouter();
const handleOnLeave = () => router.push('/');
const tracks = useTracks([{ source: Track.Source.Camera, withPlaceholder: true }], { room });
// Return null during SSR to prevent hydration errors
if (!isClient) return null;
return (
@ -183,29 +179,9 @@ function VideoConferenceComponent(props: {
audio={props.userChoices.audioEnabled}
onDisconnected={handleOnLeave}
>
{tracks.length > 0 ? (
<GridLayout tracks={tracks} className="video-grid">
{(trackRef: TrackReferenceOrPlaceholder) => (
<div
key={
trackRef.publication?.trackSid ||
`${trackRef.participant.identity}-${trackRef.source}`
}
className="video-container"
>
<VideoTrack ref={trackRef} />
</div>
)}
</GridLayout>
) : (
<div className="empty-video-container">
<p>No participants with video yet</p>
</div>
)}
<CustomVideoLayout room={room} roomName={props.connectionDetails.roomName} />
<RoomAudioRenderer />
<CustomControlBar room={room} roomName={props.connectionDetails.roomName} />
<Transcript latestText={latestText} />
<RecordingIndicator />
</LiveKitRoom>
);
}
}

View File

@ -1,2 +1,32 @@
[{"relation": ["delegate_permission/common.handle_all_urls"],"target": {"namespace": "android_app","package_name": "chat.sphinx.v2","sha256_cert_fingerprints":["26:10:D3:CB:C0:BF:28:0D:8C:CB:07:74:85:46:5D:44:58:03:B6:09:87:1D:11:CE:95:41:FB:AD:47:39:EE:4B"]}},{"relation": ["delegate_permission/common.handle_all_urls"],"target": {"namespace": "android_app","package_name": "chat.sphinx.v2","sha256_cert_fingerprints":["64:CF:11:B5:30:12:2F:C3:00:58:28:65:4F:24:41:97:98:EA:C1:74:51:39:CE:92:1E:86:A9:B5:64:FE:E1:DC"]}},{"relation": ["delegate_permission/common.handle_all_urls"],"target": {"namespace": "android_app","package_name": "chat.sphinx.v2.debug","sha256_cert_fingerprints":
["B8:FE:7D:3A:D5:E4:46:CF:54:A6:45:15:17:D7:1D:06:0A:78:D1:27:68:FC:D8:7D:94:58:DD:17:DE:A4:8B:9F"]}}]
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "chat.sphinx.v2",
"sha256_cert_fingerprints": [
"26:10:D3:CB:C0:BF:28:0D:8C:CB:07:74:85:46:5D:44:58:03:B6:09:87:1D:11:CE:95:41:FB:AD:47:39:EE:4B"
]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "chat.sphinx.v2",
"sha256_cert_fingerprints": [
"64:CF:11:B5:30:12:2F:C3:00:58:28:65:4F:24:41:97:98:EA:C1:74:51:39:CE:92:1E:86:A9:B5:64:FE:E1:DC"
]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "chat.sphinx.v2.debug",
"sha256_cert_fingerprints": [
"B8:FE:7D:3A:D5:E4:46:CF:54:A6:45:15:17:D7:1D:06:0A:78:D1:27:68:FC:D8:7D:94:58:DD:17:DE:A4:8B:9F"
]
}
}
]

View File

@ -1,15 +1,10 @@
services:
livekit:
image: sphinx-livekit
command: ["node", "server.js"]
command: ['node', 'server.js']
restart: on-failure
container_name: livekit.sphinx
environment:
- HOSTNAME=0.0.0.0
ports:
- 3000:3000

View File

@ -1,110 +1,101 @@
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;
import React from 'react';
import {
GridLayout,
useTracks,
RoomAudioRenderer,
LayoutContextProvider,
Chat,
} from '@livekit/components-react';
import { Track, Room } from 'livekit-client';
import { ParticipantTile } from './ParticipantTile';
import { CustomControlBar } from '@/app/custom/CustomControlBar';
interface CustomVideoLayoutProps {
room: Room;
roomName: string;
}
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
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,
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ flex: 1, minHeight: 0 }}>
<GridLayout
tracks={tracks}
style={{
width: '100%',
}}
>
<ParticipantTile />
</GridLayout>
</div>
</div>
{showChat && (
<div
className="lk-chat-container"
style={{
width: '470px',
borderLeft: '1px solid rgba(255, 255, 255, 0.1)',
}}
>
<Chat
style={{
height: '100%',
}}
/>
</div>
)}
<CustomControlBar room={room} roomName={roomName} />
<RoomAudioRenderer />
</div>
</LayoutContextProvider>
);
};
export default CustomVideoLayout;

View File

@ -1,144 +1,132 @@
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;
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">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;

View File

@ -19,3 +19,46 @@ export function randomString(length: number): string {
}
return result;
}
export function mergeClasses(...classes: (string | undefined | null | false)[]): string {
return classes.filter(Boolean).join(' ');
}
export 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];
}
export 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();
}

View File

@ -10,6 +10,7 @@
},
"dependencies": {
"@datadog/browser-logs": "^5.23.3",
"@livekit/components-core": "^0.12.1",
"@livekit/components-react": "2.6.0",
"@livekit/components-styles": "1.1.2",
"@livekit/krisp-noise-filter": "^0.2.8",

2538
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,4 @@
services:
caddy:
image: caddy:2.8.4-alpine
restart: unless-stopped
@ -16,7 +15,7 @@ services:
livekit:
image: sphinxlightning/sphinx-livekit:latest
restart: on-failure
command: ["node", "server.js"]
command: ['node', 'server.js']
container_name: livekit.sphinx
environment:
- HOSTNAME=0.0.0.0
@ -25,9 +24,3 @@ services:
volumes:
caddy:

View File

@ -10,14 +10,15 @@
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px); /* Added for Safari compatibility */
-webkit-backdrop-filter: blur(5px);
/* Added for Safari compatibility */
}
.room-name-box {
background: rgba(144, 155, 170, 0.1);
background: rgba(144, 155, 170, 0.1);
border-radius: 8px;
padding: 5px 10px;
display: flex;
@ -28,7 +29,7 @@
.room-name {
font-family: 'Roboto', sans-serif;
font-size: 14px;
color: #909baa;
color: #909baa;
}
.copy-link-button {
@ -54,7 +55,7 @@
.control-button {
width: 40px;
height: 40px;
background: rgba(144, 155, 170, 0.1);
background: rgba(144, 155, 170, 0.1);
border-radius: 8px;
border: none;
cursor: pointer;
@ -63,38 +64,43 @@
justify-content: center;
}
/* Status Disabled */
.material-symbols-outlined[button-state='inactive'] {
color: #ed7473;
}
/* Mic button */
.mic-button[data-lk-audio-enabled="false"] {
.mic-button[data-lk-audio-enabled='false'] {
background: rgba(255, 82, 82, 0.2);
}
.mic-button[data-lk-audio-enabled="true"] .material-symbols-outlined {
.mic-button[data-lk-audio-enabled='true'] .material-symbols-outlined {
color: #ffffff;
}
.mic-button[data-lk-audio-enabled="false"] .material-symbols-outlined {
.mic-button[data-lk-audio-enabled='false'] .material-symbols-outlined {
color: #ff6f6f;
}
/* Camera btn */
.camera-button[data-lk-video-enabled="false"] {
.camera-button[data-lk-video-enabled='false'] {
background: rgba(255, 82, 82, 0.2);
}
.camera-button[data-lk-video-enabled="true"] .material-symbols-outlined {
.camera-button[data-lk-video-enabled='true'] .material-symbols-outlined {
color: #ffffff;
}
.camera-button[data-lk-video-enabled="false"] .material-symbols-outlined {
.camera-button[data-lk-video-enabled='false'] .material-symbols-outlined {
color: #ff6f6f;
}
/* Screen share btn */
.screen-share-button[data-lk-screen-share-enabled="true"] .material-symbols-outlined {
.screen-share-button[data-lk-screen-share-enabled='true'] .material-symbols-outlined {
color: #49c998;
}
.screen-share-button[data-lk-screen-share-enabled="false"] .material-symbols-outlined {
.screen-share-button[data-lk-screen-share-enabled='false'] .material-symbols-outlined {
color: #ffffff;
}
@ -110,15 +116,23 @@
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
opacity: 1;
}
}
/* End call */
.end-call-button {
width: 64px;
height: 40px;
width: 5rem;
height: 2.4rem;
background: #ff5252;
border-radius: 8px;
display: flex;
@ -130,7 +144,6 @@
color: #ffffff;
}
.top-right-controls {
display: flex;
gap: 10px;
@ -177,4 +190,4 @@
/* Fix for video layout */
.lk-grid-layout {
height: calc(100vh - 60px) !important;
}
}

View File

@ -1,25 +1,25 @@
.main-container {
height: 100%;
}
.pre-join-container {
display: grid;
place-items: center;
height: 100%;
}
.video-grid {
height: calc(100vh - 60px) !important;
}
.video-container {
position: relative;
width: 100%;
height: 100%;
}
.empty-video-container {
height: calc(100vh - 60px);
display: grid;
place-items: center;
}
height: 100%;
}
.pre-join-container {
display: grid;
place-items: center;
height: 100%;
}
.video-grid {
height: calc(100vh - 60px) !important;
}
.video-container {
position: relative;
width: 100%;
height: 100%;
}
.empty-video-container {
height: calc(100vh - 60px);
display: grid;
place-items: center;
}

103
styles/ParticipantTile.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);
}

View File

@ -1,14 +1,13 @@
.wrap {
position:absolute;
top: 32px;
width:100%;
left:0;
right:0;
position: absolute;
top: 32px;
width: 100%;
left: 0;
right: 0;
}
.text{
color:white;
text-align:center;
margin:16px;
}
.text {
color: white;
text-align: center;
margin: 16px;
}

View File

@ -1,5 +1,5 @@
.video-element {
width: 100%;
height: 100%;
object-fit: cover;
}
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@ -65,3 +65,85 @@ h2 a {
h2 a {
text-decoration: none;
}
:root {
/* Primary Colors */
--background-primary: #0b0f13;
--background-secondary: #181d23;
--background-tertiary: #1a232d;
/* Danger Colors */
--danger-dark: #3d1d20;
--danger-primary: #ff5252;
--danger-light: #fe6f6f;
/* Success Colors */
--success-dark: #1b5e20;
--success-primary: #4caf50;
--success-light: #66bb6a;
/* Info Colors */
--info-dark: #0d47a1;
--info-primary: #2196f3;
--info-light: #64b5f6;
/* Warning Colors */
--warning-dark: #ff6f00;
--warning-primary: #ffc107;
--warning-light: #ffd54f;
}
.custom-control-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
}
.custom-control-bar .left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.custom-control-bar .control-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
background-color: var(--background-secondary);
border: none;
padding: 0.5rem;
border-radius: 0.5rem;
cursor: pointer;
width: 2.4rem;
height: 2.4rem;
}
.custom-control-bar .room-leave-btn {
background-color: var(--danger-dark);
color: var(--danger-light);
padding-inline: 1.2rem;
}
.custom-control-bar [data-lk-active='true'] {
background-color: var(--danger-dark);
}
.control-link {
display: flex;
color: #909baa;
}
.custom-control-bar span {
font-size: 1.3rem;
}
.left,
.center,
.right {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}

View File

@ -1,103 +1,95 @@
.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);
}
.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;
padding: 0.1rem 0.3rem;
}
.mic-on {
color: #ffffff;
}
.mic-off {
color: #ff5252;
}
.speaking-icon {
background-color: #618aff;
border-radius: 50%;
}
.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);
}