Merge pull request #12 from SujithThirumalaisamy/develop
Refactored Custom Layout
This commit is contained in:
commit
40145f7e49
@ -1 +0,0 @@
|
||||
{}
|
||||
@ -1,17 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TrackToggle,
|
||||
DisconnectButton
|
||||
} from '@livekit/components-react';
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track
|
||||
} from 'livekit-client';
|
||||
import { DisconnectButton, useLayoutContext, 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';
|
||||
|
||||
import { CameraOffSVG, CameraOnSVG } from '../svg/camera';
|
||||
import { MicOffSVG, MicOnSVG } from '../svg/mic';
|
||||
import { ScreenShareOnSVG } from '../svg/screen-share';
|
||||
|
||||
interface CustomControlBarProps {
|
||||
room: Room;
|
||||
@ -20,30 +17,21 @@ interface CustomControlBarProps {
|
||||
|
||||
export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [participantCount, setParticipantCount] = useState(1);
|
||||
const [participantCount, setParticipantCount] = useState(1);
|
||||
const { dispatch } = useLayoutContext().widget;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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,27 +58,16 @@ 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-button record-sign ${recording ? '' : 'disabled'}`}>
|
||||
<div className="control-bar control-buttons">
|
||||
<TrackToggle source={Track.Source.Microphone} />
|
||||
<TrackToggle source={Track.Source.Camera} />
|
||||
|
||||
<div className={`control-btn ${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>
|
||||
|
||||
<DisconnectButton className="control-button end-call-button">
|
||||
|
||||
<TrackToggle source={Track.Source.ScreenShare} />
|
||||
<DisconnectButton className="end-call-button">
|
||||
<span className="material-symbols-outlined">call_end</span>
|
||||
</DisconnectButton>
|
||||
</div>
|
||||
@ -101,11 +78,103 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
|
||||
<span className="material-symbols-outlined">people</span>
|
||||
<span className="participant-count">{participantCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="settings-box">
|
||||
|
||||
<div
|
||||
className="settings-box"
|
||||
onClick={() => {
|
||||
if (dispatch) dispatch({ msg: 'toggle_settings' });
|
||||
}}
|
||||
>
|
||||
<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 ? <CameraOnSVG /> : <CameraOffSVG />;
|
||||
case Track.Source.Microphone:
|
||||
return enabled ? <MicOnSVG /> : <MicOffSVG />;
|
||||
case Track.Source.ScreenShare:
|
||||
return enabled ? (
|
||||
<ScreenShareOnSVG />
|
||||
) : (
|
||||
<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 };
|
||||
}
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
import { DebugMode } from '@/lib/Debug';
|
||||
import { useMemo, useEffect, useState } from 'react';
|
||||
import { decodePassphrase } from '@/lib/client-utils';
|
||||
import { SettingsMenu } from '@/lib/SettingsMenu';
|
||||
|
||||
export function VideoConferenceClientImpl(props: {
|
||||
liveKitUrl: string;
|
||||
@ -65,11 +64,7 @@ export function VideoConferenceClientImpl(props: {
|
||||
return;
|
||||
}
|
||||
console.log('ROOM!!!');
|
||||
const updateTranscriptions = (
|
||||
segments: TranscriptionSegment[],
|
||||
participant: any,
|
||||
publication: any,
|
||||
) => {
|
||||
const updateTranscriptions = (segments: TranscriptionSegment[]) => {
|
||||
console.log('received transcriptions', segments);
|
||||
setTranscriptions((prev) => {
|
||||
const newTranscriptions = { ...prev };
|
||||
@ -95,12 +90,7 @@ export function VideoConferenceClientImpl(props: {
|
||||
audio={true}
|
||||
video={true}
|
||||
>
|
||||
<VideoConference
|
||||
chatMessageFormatter={formatChatMessageLinks}
|
||||
SettingsComponent={
|
||||
process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU === 'true' ? SettingsMenu : undefined
|
||||
}
|
||||
/>
|
||||
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
|
||||
<DebugMode logLevel={LogLevel.debug} />
|
||||
</LiveKitRoom>
|
||||
);
|
||||
|
||||
@ -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,19 +1,14 @@
|
||||
|
||||
|
||||
'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,
|
||||
VideoConference,
|
||||
} from '@livekit/components-react';
|
||||
import {
|
||||
ExternalE2EEKeyProvider,
|
||||
@ -23,14 +18,12 @@ import {
|
||||
Room,
|
||||
DeviceUnsupportedError,
|
||||
RoomConnectOptions,
|
||||
Track,
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
37
app/svg/camera.tsx
Normal file
37
app/svg/camera.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
const CameraOnSVG = () => {
|
||||
return (
|
||||
<svg width="18" height="12" viewBox="0 0 18 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M2.35107 11.8945C0.878906 11.8945 0 11.0449 0 9.57275V2.31445C0 0.849609 0.944824 0 2.35107 0H10.2905C11.7627 0 12.5684 0.849609 12.5684 2.31445V9.57275C12.5684 11.0449 11.6895 11.8945 10.2173 11.8945H2.35107ZM13.5645 8.10791V3.7793L16.2305 1.47949C16.4795 1.25977 16.7725 1.12061 17.0361 1.12061C17.6074 1.12061 17.981 1.53809 17.981 2.13867V9.75586C17.981 10.3564 17.6074 10.7739 17.0361 10.7739C16.7725 10.7739 16.4795 10.6348 16.2305 10.415L13.5645 8.10791Z"
|
||||
fill="#ED7474"
|
||||
/>
|
||||
<path
|
||||
d="M2.35107 11.8945C0.878906 11.8945 0 11.0449 0 9.57275V2.31445C0 0.849609 0.944824 0 2.35107 0H10.2905C11.7627 0 12.5684 0.849609 12.5684 2.31445V9.57275C12.5684 11.0449 11.6895 11.8945 10.2173 11.8945H2.35107ZM13.5645 8.10791V3.7793L16.2305 1.47949C16.4795 1.25977 16.7725 1.12061 17.0361 1.12061C17.6074 1.12061 17.981 1.53809 17.981 2.13867V9.75586C17.981 10.3564 17.6074 10.7739 17.0361 10.7739C16.7725 10.7739 16.4795 10.6348 16.2305 10.415L13.5645 8.10791Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const CameraOffSVG = () => {
|
||||
return (
|
||||
<svg width="22" height="19" viewBox="0 0 22 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M4.10204 2.98412C3.66551 3.38015 3.41406 3.9721 3.41406 4.72778V11.9861C3.41406 13.4583 4.29297 14.3079 5.76514 14.3079H13.6313C14.201 14.3079 14.6818 14.1806 15.0573 13.9394L4.10204 2.98412ZM15.9717 12.2615C15.9788 12.1723 15.9824 12.0805 15.9824 11.9861V4.72778C15.9824 3.26294 15.1768 2.41333 13.7046 2.41333H6.1235L15.9717 12.2615ZM16.9785 6.19263V10.5212L19.6445 12.8284C19.8936 13.0481 20.1865 13.1873 20.4502 13.1873C21.0215 13.1873 21.395 12.7698 21.395 12.1692V4.552C21.395 3.95142 21.0215 3.53394 20.4502 3.53394C20.1865 3.53394 19.8936 3.6731 19.6445 3.89282L16.9785 6.19263Z"
|
||||
fill="#ED7473"
|
||||
/>
|
||||
<rect
|
||||
y="1.29611"
|
||||
width="1.833"
|
||||
height="23.7102"
|
||||
rx="0.916499"
|
||||
transform="rotate(-45 0 1.29611)"
|
||||
fill="#ED7473"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { CameraOnSVG, CameraOffSVG };
|
||||
36
app/svg/mic.tsx
Normal file
36
app/svg/mic.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
const MicOnSVG = () => {
|
||||
return (
|
||||
<svg width="14" height="19" viewBox="0 0 14 19" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.00032 12.0852C6.17792 12.0852 5.47926 11.7977 4.90436 11.2228C4.32946 10.6479 4.04201 9.94925 4.04201 9.12684V3.66534C4.04201 2.84294 4.32946 2.14429 4.90436 1.56939C5.47926 0.994483 6.17792 0.707031 7.00032 0.707031C7.82272 0.707031 8.52137 0.994483 9.09628 1.56939C9.67118 2.14429 9.95863 2.84294 9.95863 3.66534V9.12684C9.95863 9.94925 9.67118 10.6479 9.09628 11.2228C8.52137 11.7977 7.82272 12.0852 7.00032 12.0852ZM6.00138 17.5852V15.7433C4.5448 15.5411 3.29874 14.9251 2.26321 13.8956C1.22784 12.866 0.597707 11.617 0.372818 10.1487C0.335082 9.86713 0.409332 9.62643 0.595568 9.42659C0.781957 9.22676 1.01594 9.12684 1.29751 9.12684C1.57923 9.12684 1.81794 9.22386 2.01365 9.41788C2.20951 9.61176 2.34167 9.84956 2.41011 10.1313C2.64218 11.2154 3.19042 12.1021 4.05484 12.7914C4.9191 13.4806 5.90093 13.8252 7.00032 13.8252C8.11498 13.8252 9.10063 13.4768 9.95726 12.78C10.814 12.083 11.3585 11.2001 11.5905 10.1313C11.659 9.84956 11.7911 9.61176 11.987 9.41788C12.1827 9.22386 12.4214 9.12684 12.7031 9.12684C12.9847 9.12684 13.2178 9.22768 13.4023 9.42934C13.5869 9.63086 13.6602 9.87248 13.6223 10.1542C13.3974 11.592 12.772 12.8324 11.7461 13.8754C10.7201 14.9184 9.47112 15.5411 7.99926 15.7433V17.5852C7.99926 17.8669 7.90316 18.1038 7.71096 18.2958C7.51877 18.488 7.28189 18.5841 7.00032 18.5841C6.71875 18.5841 6.48187 18.488 6.28967 18.2958C6.09748 18.1038 6.00138 17.8669 6.00138 17.5852Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const MicOffSVG = () => {
|
||||
return (
|
||||
<svg width="45" height="45" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask
|
||||
id="mask0_5659_3520"
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="30"
|
||||
height="30"
|
||||
>
|
||||
<rect width="30" height="30" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_5659_3520)">
|
||||
<path
|
||||
d="M19.8 17.575C19.5667 17.4417 19.4167 17.2375 19.35 16.9625C19.2833 16.6875 19.3167 16.4333 19.45 16.2C19.5667 16.0167 19.6625 15.8208 19.7375 15.6125C19.8125 15.4042 19.8667 15.1917 19.9 14.975C19.9667 14.6917 20.0958 14.4583 20.2875 14.275C20.4792 14.0917 20.7167 14 21 14C21.2833 14 21.5167 14.1 21.7 14.3C21.8833 14.5 21.9583 14.7417 21.925 15.025C21.875 15.4083 21.7875 15.7833 21.6625 16.15C21.5375 16.5167 21.375 16.8667 21.175 17.2C21.0417 17.4333 20.8375 17.5875 20.5625 17.6625C20.2875 17.7375 20.0333 17.7083 19.8 17.575ZM16.3 13.45L12.575 9.725C12.3917 9.54167 12.25 9.32917 12.15 9.0875C12.05 8.84583 12 8.59167 12 8.325V8C12 7.16667 12.2917 6.45833 12.875 5.875C13.4583 5.29167 14.1667 5 15 5C15.8333 5 16.5417 5.29167 17.125 5.875C17.7083 6.45833 18 7.16667 18 8V12.725C18 13.175 17.7958 13.4875 17.3875 13.6625C16.9792 13.8375 16.6167 13.7667 16.3 13.45ZM14 23V20.9C12.4667 20.7 11.1542 20.0583 10.0625 18.975C8.97083 17.8917 8.30833 16.575 8.075 15.025C8.04166 14.7417 8.11666 14.5 8.3 14.3C8.48333 14.1 8.71666 14 9 14C9.28333 14 9.52083 14.0958 9.7125 14.2875C9.90416 14.4792 10.0333 14.7167 10.1 15C10.3333 16.1667 10.9125 17.125 11.8375 17.875C12.7625 18.625 13.8167 19 15 19C15.5667 19 16.1042 18.9125 16.6125 18.7375C17.1208 18.5625 17.5833 18.3167 18 18L19.425 19.425C18.9417 19.8083 18.4125 20.1292 17.8375 20.3875C17.2625 20.6458 16.65 20.8167 16 20.9V23C16 23.2833 15.9042 23.5208 15.7125 23.7125C15.5208 23.9042 15.2833 24 15 24C14.7167 24 14.4792 23.9042 14.2875 23.7125C14.0958 23.5208 14 23.2833 14 23ZM22.1 24.9L5.1 7.9C4.91666 7.71667 4.825 7.48333 4.825 7.2C4.825 6.91667 4.91666 6.68333 5.1 6.5C5.28333 6.31667 5.51666 6.225 5.8 6.225C6.08333 6.225 6.31666 6.31667 6.5 6.5L23.5 23.5C23.6833 23.6833 23.775 23.9167 23.775 24.2C23.775 24.4833 23.6833 24.7167 23.5 24.9C23.3167 25.0833 23.0833 25.175 22.8 25.175C22.5167 25.175 22.2833 25.0833 22.1 24.9Z"
|
||||
fill="#ED7474"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { MicOnSVG, MicOffSVG };
|
||||
32
app/svg/screen-share.tsx
Normal file
32
app/svg/screen-share.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
const ScreenShareOnSVG = () => {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask
|
||||
id="mask0_5675_17827"
|
||||
style={{ maskType: 'alpha' }}
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="24"
|
||||
height="24"
|
||||
>
|
||||
<rect width="24" height="24" fill="#D9D9D9" />
|
||||
</mask>
|
||||
<g mask="url(#mask0_5675_17827)">
|
||||
<path
|
||||
d="M11.25 11.2192V14.9038C11.25 15.1166 11.3218 15.2948 11.4655 15.4385C11.609 15.582 11.7872 15.6538 12 15.6538C12.2128 15.6538 12.391 15.582 12.5345 15.4385C12.6782 15.2948 12.75 15.1166 12.75 14.9038V11.2345L14.073 12.5423C14.2218 12.6808 14.3959 12.7542 14.5953 12.7625C14.7946 12.7708 14.9718 12.6974 15.127 12.5423C15.282 12.3871 15.3595 12.2089 15.3595 12.0078C15.3595 11.8064 15.282 11.6282 15.127 11.473L12.6328 8.97875C12.4519 8.79808 12.241 8.70775 12 8.70775C11.759 8.70775 11.5481 8.79808 11.3673 8.97875L8.873 11.473C8.72433 11.6218 8.651 11.7959 8.653 11.9953C8.65483 12.1946 8.73333 12.3718 8.8885 12.527C9.04367 12.6718 9.21933 12.7468 9.4155 12.752C9.6115 12.757 9.78708 12.682 9.94225 12.527L11.25 11.2192ZM4.30775 18.5C3.80258 18.5 3.375 18.325 3.025 17.975C2.675 17.625 2.5 17.1974 2.5 16.6923V6.30775C2.5 5.80258 2.675 5.375 3.025 5.025C3.375 4.675 3.80258 4.5 4.30775 4.5H19.6923C20.1974 4.5 20.625 4.675 20.975 5.025C21.325 5.375 21.5 5.80258 21.5 6.30775V16.6923C21.5 17.1974 21.325 17.625 20.975 17.975C20.625 18.325 20.1974 18.5 19.6923 18.5H4.30775Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M7 21H17"
|
||||
stroke="white"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { ScreenShareOnSVG };
|
||||
@ -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,102 @@
|
||||
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, LayoutContextProvider, Chat } from '@livekit/components-react';
|
||||
import { Track, Room } from 'livekit-client';
|
||||
import { ParticipantTile } from './ParticipantTile';
|
||||
import { CustomControlBar } from '@/app/custom/CustomControlBar';
|
||||
import { SettingsMenu } from './SettingsMenu';
|
||||
|
||||
interface CustomVideoLayoutProps {
|
||||
room: Room;
|
||||
roomName: string;
|
||||
}
|
||||
|
||||
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
|
||||
const [showChat, setShowChat] = React.useState(false);
|
||||
const [showSettings, setShowSettings] = 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,
|
||||
showSettings,
|
||||
unreadMessages: 0,
|
||||
},
|
||||
dispatch: (action: any) => {
|
||||
if ('msg' in action && action.msg === 'toggle_chat') {
|
||||
setShowChat((prev) => !prev);
|
||||
}
|
||||
if ('msg' in action && action.msg === 'toggle_settings') {
|
||||
setShowSettings((prev) => !prev);
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
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%',
|
||||
padding: '1rem 1rem 0.5rem 1rem',
|
||||
}}
|
||||
>
|
||||
<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} />
|
||||
<SettingsMenu showSettings={showSettings} />
|
||||
</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,
|
||||
useEnsureTrackRef,
|
||||
TrackRefContextIfNeeded,
|
||||
} from '@livekit/components-react';
|
||||
import { Track, Participant } from 'livekit-client';
|
||||
import { isTrackReference } from '@livekit/components-core';
|
||||
|
||||
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 trackReference = useEnsureTrackRef(trackRef);
|
||||
const participant = propParticipant || trackRef?.participant;
|
||||
|
||||
if (!participant) return null;
|
||||
|
||||
const [profilePictureUrl, setProfilePictureUrl] = useState<string | null>(null);
|
||||
|
||||
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 isCameraEnabled =
|
||||
(trackReference.source === Track.Source.Camera && !trackReference.publication?.isMuted) ||
|
||||
trackReference.source === Track.Source.ScreenShare;
|
||||
|
||||
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' : ''}`}>
|
||||
{isTrackReference(trackReference) && isCameraEnabled ? (
|
||||
<div className="video-container">
|
||||
<VideoTrack trackRef={trackReference} />
|
||||
</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;
|
||||
|
||||
@ -9,13 +9,14 @@ import {
|
||||
useRoomContext,
|
||||
useIsRecording,
|
||||
} from '@livekit/components-react';
|
||||
import styles from '../styles/SettingsMenu.module.css';
|
||||
import type { KrispNoiseFilterProcessor } from '@livekit/krisp-noise-filter';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
showSettings: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
@ -111,14 +112,16 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
}
|
||||
};
|
||||
|
||||
if (!props.showSettings) return null;
|
||||
|
||||
return (
|
||||
<div className="settings-menu" style={{ width: '100%' }} {...props}>
|
||||
<div className={styles.tabs}>
|
||||
<div className="settings-menu" {...props}>
|
||||
<div className="tabs">
|
||||
{tabs.map(
|
||||
(tab) =>
|
||||
settings[tab] && (
|
||||
<button
|
||||
className={`${styles.tab} lk-button`}
|
||||
className={`tab lk-button`}
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
aria-pressed={tab === activeTab}
|
||||
@ -131,11 +134,11 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="tab-content">
|
||||
<div className="tab-content" style={{ padding: '1rem' }}>
|
||||
{activeTab === 'media' && (
|
||||
<>
|
||||
{settings.media && settings.media.camera && (
|
||||
<>
|
||||
<div>
|
||||
<h3>Camera</h3>
|
||||
<section className="lk-button-group">
|
||||
<TrackToggle source={Track.Source.Camera}>Camera</TrackToggle>
|
||||
@ -143,10 +146,10 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
<MediaDeviceMenu kind="videoinput" />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{settings.media && settings.media.microphone && (
|
||||
<>
|
||||
<div>
|
||||
<h3>Microphone</h3>
|
||||
<section className="lk-button-group">
|
||||
<TrackToggle source={Track.Source.Microphone}>Microphone</TrackToggle>
|
||||
@ -154,10 +157,10 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
<MediaDeviceMenu kind="audioinput" />
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{settings.media && settings.media.speaker && (
|
||||
<>
|
||||
<div>
|
||||
<h3>Speaker & Headphones</h3>
|
||||
<section className="lk-button-group">
|
||||
<span className="lk-button">Audio Output</span>
|
||||
@ -165,7 +168,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
<MediaDeviceMenu kind="audiooutput"></MediaDeviceMenu>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@ -201,7 +204,7 @@ export function SettingsMenu(props: SettingsMenuProps) {
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className={`lk-button ${styles.settingsCloseButton}`}
|
||||
className={`lk-button settingsCloseButton`}
|
||||
onClick={() => layoutContext?.widget.dispatch?.({ msg: 'toggle_settings' })}
|
||||
>
|
||||
Close
|
||||
|
||||
@ -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:
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@ -5,33 +5,42 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
padding: 0.8rem 1rem;
|
||||
position: fixed;
|
||||
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-color: #181d23;
|
||||
border-radius: 8px;
|
||||
padding: 5px 10px;
|
||||
padding: 0.4rem 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.room-name-box:hover {
|
||||
background-color: #1f242b;
|
||||
}
|
||||
|
||||
.room-name {
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-size: 14px;
|
||||
color: #909baa;
|
||||
font-size: 1rem;
|
||||
color: #909baa;
|
||||
}
|
||||
|
||||
.copy-link-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
@ -51,53 +60,50 @@
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: rgba(144, 155, 170, 0.1);
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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;
|
||||
}
|
||||
|
||||
.screen-share-button[data-lk-screen-share-enabled='true']:hover {
|
||||
background-color: #3d1d20;
|
||||
}
|
||||
|
||||
/* Record */
|
||||
.record-sign .material-symbols-outlined {
|
||||
color: #ff6f6f;
|
||||
@ -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;
|
||||
@ -138,17 +151,22 @@
|
||||
}
|
||||
|
||||
.participant-box {
|
||||
background-color: rgba(144, 155, 170, 0.1);
|
||||
background-color: #181d23;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 10px;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.participant-box:hover {
|
||||
background-color: #1f242b;
|
||||
}
|
||||
|
||||
.participant-box .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.participant-count {
|
||||
@ -159,16 +177,19 @@
|
||||
}
|
||||
|
||||
.settings-box {
|
||||
background-color: rgba(144, 155, 170, 0.1);
|
||||
background-color: #181d23;
|
||||
border-radius: 8px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
padding: 0.6rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-box:hover {
|
||||
background-color: #1f242b;
|
||||
}
|
||||
|
||||
.settings-box .material-symbols-outlined {
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
@ -177,4 +198,4 @@
|
||||
/* Fix for video layout */
|
||||
.lk-grid-layout {
|
||||
height: calc(100vh - 60px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,25 +1,66 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.settings-menu {
|
||||
display: flex;
|
||||
width: 45vw;
|
||||
height: 50vh;
|
||||
flex-direction: column;
|
||||
align-items: left;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: -50% -50%;
|
||||
background: var(--background-primary);
|
||||
border-radius: 0.4rem;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.settingsCloseButton {
|
||||
position: absolute;
|
||||
right: var(--lk-grid-gap);
|
||||
bottom: var(--lk-grid-gap);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-content: space-between;
|
||||
}
|
||||
|
||||
.tabs > .tab {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 3px solid;
|
||||
border-color: var(--bg5);
|
||||
}
|
||||
|
||||
.tabs > .tab[aria-pressed='true'] {
|
||||
border-color: var(--lk-accent-bg);
|
||||
}
|
||||
|
||||
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,23 +0,0 @@
|
||||
.settingsCloseButton {
|
||||
position: absolute;
|
||||
right: var(--lk-grid-gap);
|
||||
bottom: var(--lk-grid-gap);
|
||||
}
|
||||
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-content: space-between;
|
||||
}
|
||||
|
||||
.tabs > .tab {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 3px solid;
|
||||
border-color: var(--bg5);
|
||||
}
|
||||
|
||||
.tabs > .tab[aria-pressed='true'] {
|
||||
border-color: var(--lk-accent-bg);
|
||||
}
|
||||
@ -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,89 @@ 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 .control-btn:hover {
|
||||
background-color: #1f242b;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.custom-control-bar [data-lk-active='true']:hover {
|
||||
background-color: #2d1e22;
|
||||
}
|
||||
|
||||
.control-link {
|
||||
display: flex;
|
||||
color: #909baa;
|
||||
}
|
||||
|
||||
.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