Refactored Custom Layout
This commit is contained in:
parent
e57970648e
commit
3676fb5543
@ -1 +0,0 @@
|
||||
{}
|
||||
@ -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 };
|
||||
}
|
||||
|
||||
@ -24,4 +24,4 @@ export function VideoTrack({ ref: trackRef }: VideoTrackProps) {
|
||||
}, [trackRef.publication?.track]);
|
||||
|
||||
return <video ref={videoRef} className="video-element" />;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
7
dev.yaml
7
dev.yaml
@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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
2538
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
103
styles/ParticipantTile.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);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user