Merge pull request #19 from SujithThirumalaisamy/fix/recording

feat: Added Chat and fixed focus layout
This commit is contained in:
Tom 2025-04-07 15:37:07 -03:00 committed by GitHub
commit b1fe8f9ce7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 520 additions and 382 deletions

View File

@ -3,7 +3,6 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
DisconnectButton,
useIsRecording,
useLayoutContext,
useLocalParticipant,
useRoomContext,
@ -29,7 +28,7 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
const [isRecordingRequestPending, setIsRecordingRequestPending] = useState(false);
const [participantCount, setParticipantCount] = useState(1);
const { dispatch } = useLayoutContext().widget;
const { isParticipantsListOpen } = useCustomLayoutContext();
const { isParticipantsListOpen, isChatOpen } = useCustomLayoutContext();
const { toast } = useToast();
const [recordingState, setRecordingState] = useState({
recording: { isRecording: false, recorder: '' },
@ -65,6 +64,10 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' });
}
const toggleChat = () => {
if (isChatOpen.dispatch) isChatOpen.dispatch({ msg: 'toggle_chat' });
};
const toggleRoomRecording = async () => {
if (isRecordingRequestPending || (isRecording && !isSelfRecord)) return;
setIsRecordingRequestPending(true);
@ -93,7 +96,7 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
} else {
response = await fetch(
recordingEndpoint +
`/start?roomName=${room.name}&now=${now}&identity=${localParticipant.identity}`,
`/start?roomName=${room.name}&now=${now}&identity=${localParticipant.identity}`,
);
}
if (response.ok) {
@ -149,6 +152,7 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
<div className="room-name-box">
<span className="room-name">{roomName}</span>
<button className="copy-link-button" onClick={handleCopyLink}>
{' '}
<span className="material-symbols-outlined">content_copy</span>
</button>
</div>
@ -157,7 +161,6 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
<div className="control-bar control-buttons">
<TrackToggle source={Track.Source.Microphone} />
<TrackToggle source={Track.Source.Camera} />
<div
className={`control-btn ${isRecording ? '' : 'disabled'} ${isRecordingRequestPending || isRecording ? 'blinking' : ''}`}
onClick={toggleRoomRecording}
@ -184,7 +187,9 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
<span className="material-symbols-outlined">people</span>
<span className="participant-count">{participantCount}</span>
</div>
<div className={`control-btn`} onClick={toggleChat}>
<span className="material-symbols-outlined">chat</span>
</div>
<div
className="settings-box"
onClick={() => {

View File

@ -1,6 +1,5 @@
import { useLayoutContext, useRoomContext } from '@livekit/components-react';
import { useRoomContext } from '@livekit/components-react';
import { Participant, RemoteParticipant } from 'livekit-client';
import { useEffect, useState } from 'react';
import { MicOffSVG, MicOnSVG } from '../svg/mic';
import { CameraOffSVG, CameraOnSVG } from '../svg/camera';
import { ScreenShareOnSVG } from '../svg/screen-share';
@ -9,8 +8,7 @@ import { useCustomLayoutContext } from '../contexts/layout-context';
const ParticipantList = () => {
const room = useRoomContext();
const { localParticipant } = room;
const [participants, setParticipants] = useState<Record<string, RemoteParticipant>>({});
const { localParticipant, remoteParticipants } = room;
const { isParticipantsListOpen } = useCustomLayoutContext();
function ToggleParticipantList() {
@ -18,42 +16,6 @@ const ParticipantList = () => {
isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' });
}
useEffect(() => {
room.on('connectionStateChanged', () => {
setParticipants({});
room.remoteParticipants.forEach((participant) => {
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
});
});
room.on('participantConnected', (participant) => {
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
});
room.on('participantDisconnected', (participant) => {
setParticipants((prev) => {
const { [participant.identity]: toDelete, ...rest } = prev;
return rest;
});
});
room.on('participantNameChanged', (name, participant) => {
if (participant instanceof RemoteParticipant)
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
});
return () => {
room.off('participantConnected', (participant) => {
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
});
room.off('participantDisconnected', (participant) => {
setParticipants((prev) => {
const { [participant.identity]: toDelete, ...rest } = prev;
return rest;
});
});
room.off('participantNameChanged', (name, participant) => {
if (participant instanceof RemoteParticipant)
setParticipants((prev) => ({ ...prev, [participant.identity]: participant }));
});
};
}, []);
return (
<div
style={{
@ -95,8 +57,8 @@ const ParticipantList = () => {
</div>
</div>
<ParticipantItem participant={localParticipant} />
{Object.values(participants).map((participant: RemoteParticipant) => {
return <ParticipantItem participant={participant} />;
{[...remoteParticipants.entries()].map((participant) => {
return <ParticipantItem participant={participant[1]} />;
})}
</div>
);
@ -134,7 +96,7 @@ const ParticipantItem: React.FC<ParticipantItemProps> = ({ participant }) => {
</div>
)}
</div>
<div>{participant.name}</div>
<div style={{ fontSize: '0.9rem' }}>{participant.name}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
{participant.isScreenShareEnabled ? <ScreenShareOnSVG /> : <></>}

View File

@ -0,0 +1,97 @@
import React, { useEffect } from 'react';
import {
GridLayout,
useTracks,
Chat,
CarouselLayout,
usePinnedTracks,
useLayoutContext,
} from '@livekit/components-react';
import { Track, Room } from 'livekit-client';
import { CustomControlBar } from '@/app/custom/CustomControlBar';
import ParticipantList from '@/app/custom/ParticipantList';
import { ParticipantTile } from '@/lib/ParticipantTile';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { useCustomLayoutContext } from '@/app/contexts/layout-context';
import '@/styles/Chat.css';
import { FocusLayout, FocusLayoutContainer } from './FocusLayout';
interface CustomVideoLayoutProps {
room: Room;
roomName: string;
}
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
const { isChatOpen, isParticipantsListOpen } = useCustomLayoutContext();
const layoutContext = useLayoutContext();
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false },
);
const focusTrack = usePinnedTracks()[0];
const test = usePinnedTracks();
useEffect(() => {
console.log({ test });
}, [test]);
return (
<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 }}>
{!focusTrack ? (
<GridLayout
tracks={tracks}
style={{
width: '100%',
padding: '1rem 1rem 0.5rem 1rem',
}}
>
<ParticipantTile />
</GridLayout>
) : (
<FocusLayoutContainer
style={{
width: '100%',
height: '93%',
padding: '1rem 1rem 0.5rem 1rem',
}}
>
<CarouselLayout tracks={tracks}>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout style={{ width: '100%' }} trackRef={focusTrack} />}{' '}
</FocusLayoutContainer>
)}
<CustomControlBar room={room} roomName={roomName} />{' '}
</div>
</div>
{isParticipantsListOpen.state && <ParticipantList />}
{isChatOpen.state && <Chat />}
<SettingsMenu showSettings={layoutContext.widget.state?.showSettings || false} />
</div>
);
};
export default CustomVideoLayout;

View File

@ -0,0 +1,24 @@
import * as React from 'react';
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core';
import type { ParticipantClickEvent } from '@livekit/components-core';
import { ParticipantTile } from '@/lib/ParticipantTile';
export interface FocusLayoutContainerProps extends React.HTMLAttributes<HTMLDivElement> {}
export function FocusLayoutContainer(props: FocusLayoutContainerProps) {
return (
<div className="lk-focus-layout" {...props}>
{props.children}
</div>
);
}
export interface FocusLayoutProps extends React.HTMLAttributes<HTMLElement> {
trackRef?: TrackReferenceOrPlaceholder;
onParticipantClick?: (evt: ParticipantClickEvent) => void;
}
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
return <ParticipantTile trackRef={trackRef} {...htmlProps} />;
}

View File

@ -0,0 +1,84 @@
import React, { useState } from 'react';
import { LayoutContextProvider, TrackReferenceOrPlaceholder } from '@livekit/components-react';
import { CustomLayoutContextProvider } from '@/app/contexts/layout-context';
import { PinAction } from '@livekit/components-react/dist/context/pin-context';
interface CustomVideoLayoutContextProviderProps {
children: React.ReactNode;
}
export const CustomVideoLayoutContextProvider: React.FC<CustomVideoLayoutContextProviderProps> = ({
children,
}) => {
const [showChat, setShowChat] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showParticipantsList, setShowParticipantsList] = useState(false);
const [pinnedTracks, setPinnedTracks] = useState<TrackReferenceOrPlaceholder[]>();
const toggleParticipantsList = () => {
if (showChat) setShowChat(false);
setShowParticipantsList((prev) => !prev);
};
const toggleChat = () => {
if (showParticipantsList) setShowParticipantsList(false);
setShowChat((prev) => !prev);
};
const toggleSettings = () => {
setShowSettings((prev) => !prev);
};
return (
<CustomLayoutContextProvider
layoutContextValue={{
isParticipantsListOpen: {
state: showParticipantsList,
dispatch: toggleParticipantsList,
},
isChatOpen: {
state: showChat,
dispatch: toggleChat,
},
}}
>
<LayoutContextProvider
value={{
pin: {
state: pinnedTracks,
dispatch: (action: PinAction) => {
if (action.msg === 'set_pin') {
setPinnedTracks([action.trackReference]);
}
if (action.msg === 'clear_pin') {
setPinnedTracks([]);
}
},
},
widget: {
state: {
showChat,
showSettings,
unreadMessages: 0,
},
dispatch: (action: any) => {
switch (action && action.msg) {
case 'toggle_settings':
toggleSettings();
break;
case 'toggle_chat':
toggleChat();
break;
case 'toggle_participants_list':
toggleParticipantsList();
break;
}
},
},
}}
>
{children}
</LayoutContextProvider>
</CustomLayoutContextProvider>
);
};

View File

@ -20,7 +20,8 @@ import {
} from 'livekit-client';
import { useRouter } from 'next/navigation';
import '../../../styles/PageClientImpl.css';
import { CustomVideoLayout } from '@/lib/CustomVideoLayout';
import { CustomVideoLayoutContextProvider } from '@/app/custom/layout/LayoutContextProvider';
import CustomVideoLayout from '@/app/custom/layout/CustomVideoLayout';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
@ -177,8 +178,10 @@ function VideoConferenceComponent(props: {
audio={props.userChoices.audioEnabled}
onDisconnected={handleOnLeave}
>
<CustomVideoLayout room={room} roomName={props.connectionDetails.roomName} />
<RoomAudioRenderer />
<CustomVideoLayoutContextProvider>
<CustomVideoLayout room={room} roomName={props.connectionDetails.roomName} />
<RoomAudioRenderer />
</CustomVideoLayoutContextProvider>
</LiveKitRoom>
);
}

View File

@ -1,99 +0,0 @@
import React, { useState } from 'react';
import { GridLayout, useTracks, LayoutContextProvider } from '@livekit/components-react';
import { Track, Room } from 'livekit-client';
import { ParticipantTile } from './ParticipantTile';
import { CustomControlBar } from '@/app/custom/CustomControlBar';
import { SettingsMenu } from './SettingsMenu';
import ParticipantList from '@/app/custom/ParticipantList';
import { CustomLayoutContextProvider } from '@/app/contexts/layout-context';
interface CustomVideoLayoutProps {
room: Room;
roomName: string;
}
export const CustomVideoLayout: React.FC<CustomVideoLayoutProps> = ({ room, roomName }) => {
const showChat = false;
const [showSettings, setShowSettings] = useState(false);
const [showParticipantsList, setShowParticipantsList] = useState(false);
const tracks = useTracks(
[
{ source: Track.Source.Camera, withPlaceholder: true },
{ source: Track.Source.ScreenShare, withPlaceholder: false },
],
{ onlySubscribed: false },
);
return (
<CustomLayoutContextProvider
layoutContextValue={{
isParticipantsListOpen: {
state: showParticipantsList,
dispatch: () => setShowParticipantsList((prev) => !prev),
},
}}
>
<LayoutContextProvider
value={{
pin: {
state: [],
dispatch: () => {},
},
widget: {
state: {
showChat,
showSettings,
unreadMessages: 0,
},
dispatch: (action: any) => {
if ('msg' in action && action.msg === 'toggle_settings') {
setShowSettings((prev) => !prev);
}
if ('msg' in action && action.msg === 'toggle_participants_list') {
setShowParticipantsList((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>
<CustomControlBar room={room} roomName={roomName} />
</div>
</div>
{showParticipantsList && <ParticipantList />}
<SettingsMenu showSettings={showSettings} />
</div>
</LayoutContextProvider>
</CustomLayoutContextProvider>
);
};
export default CustomVideoLayout;

View File

@ -1,132 +1,188 @@
import React, { useEffect, useState } from 'react';
import * as React from 'react';
import type { Participant } from 'livekit-client';
import { Track } from 'livekit-client';
import type { ParticipantClickEvent, TrackReferenceOrPlaceholder } from '@livekit/components-core';
import { isTrackReference, isTrackReferencePinned } from '@livekit/components-core';
import {
AudioTrack,
useTracks,
VideoTrack,
useTrackRefContext,
ParticipantContext,
TrackRefContext,
useEnsureTrackRef,
TrackRefContextIfNeeded,
useFeatureContext,
useMaybeLayoutContext,
useMaybeParticipantContext,
useMaybeTrackRefContext,
useParticipantTile,
ConnectionQualityIndicator,
FocusToggle,
} from '@livekit/components-react';
import { Track, Participant } from 'livekit-client';
import { isTrackReference } from '@livekit/components-core';
import { getAvatarColor, getInitials } from './client-utils';
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 function ParticipantContextIfNeeded(
props: React.PropsWithChildren<{
participant?: Participant;
}>,
) {
const hasContext = !!useMaybeParticipantContext();
return props.participant && !hasContext ? (
<ParticipantContext.Provider value={props.participant}>
{props.children}
</ParticipantContext.Provider>
) : (
<>{props.children}</>
);
};
}
export default ParticipantTile;
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
trackRef?: TrackReferenceOrPlaceholder;
}>,
) {
const hasContext = !!useMaybeTrackRefContext();
return props.trackRef && !hasContext ? (
<TrackRefContext.Provider value={props.trackRef}>{props.children}</TrackRefContext.Provider>
) : (
<>{props.children}</>
);
}
export interface ParticipantTileProps extends React.HTMLAttributes<HTMLDivElement> {
trackRef?: TrackReferenceOrPlaceholder;
disableSpeakingIndicator?: boolean;
onParticipantClick?: (event: ParticipantClickEvent) => void;
}
export const ParticipantTile: (
props: ParticipantTileProps & React.RefAttributes<HTMLDivElement>,
) => React.ReactNode = React.forwardRef<HTMLDivElement, ParticipantTileProps>(
function ParticipantTile(
{
trackRef,
children,
onParticipantClick,
disableSpeakingIndicator,
...htmlProps
}: ParticipantTileProps,
ref,
) {
const trackReference = useEnsureTrackRef(trackRef);
const {
name,
identity,
metadata,
isEncrypted,
isSpeaking,
isMicrophoneEnabled,
isScreenShareEnabled,
} = trackReference.participant;
const { elementProps } = useParticipantTile<HTMLDivElement>({
htmlProps,
disableSpeakingIndicator,
onParticipantClick,
trackRef: trackReference,
});
const layoutContext = useMaybeLayoutContext();
const autoManageSubscription = useFeatureContext()?.autoSubscription;
const handleSubscribe = React.useCallback(
(subscribed: boolean) => {
if (
trackReference.source &&
!subscribed &&
layoutContext &&
layoutContext.pin.dispatch &&
isTrackReferencePinned(trackReference, layoutContext.pin.state)
) {
layoutContext.pin.dispatch({ msg: 'clear_pin' });
}
},
[trackReference, layoutContext],
);
const [profilePictureUrl, setProfilePictureUrl] = React.useState<string | null>(null);
React.useEffect(() => {
if (metadata) {
try {
const parsedMetadata = JSON.parse(metadata);
if (parsedMetadata.profilePictureUrl) {
setProfilePictureUrl(parsedMetadata.profilePictureUrl);
}
} catch (e) {
console.error('Failed to parse participant metadata', e);
}
}
}, [metadata]);
const avatarColor = getAvatarColor(identity);
const initials = getInitials(name || identity);
return (
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
<TrackRefContextIfNeeded trackRef={trackReference}>
<ParticipantContextIfNeeded participant={trackReference.participant}>
{children ?? (
<>
{isTrackReference(trackReference) &&
(trackReference.publication?.kind === 'video' ||
trackReference.source === Track.Source.Camera ||
trackReference.source === Track.Source.ScreenShare) ? (
<VideoTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
manageSubscription={autoManageSubscription}
/>
) : (
isTrackReference(trackReference) && (
<AudioTrack
trackRef={trackReference}
onSubscriptionStatusChanged={handleSubscribe}
/>
)
)}
<div className="lk-participant-placeholder">
<div className="avatar-container" style={{ backgroundColor: avatarColor }}>
{profilePictureUrl ? (
<img src={profilePictureUrl} alt={name} className="avatar-image" />
) : (
<span className="avatar-initials">{initials}</span>
)}
</div>
</div>
<div className="lk-participant-metadata">
<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">
{name || identity}
{trackReference.source === Track.Source.ScreenShare
? ' (Screen Share)'
: ''}
</span>
</div>
</div>
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
</div>
</>
)}
<FocusToggle trackRef={trackReference} />
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
</div>
);
},
);

View File

@ -6,7 +6,6 @@ import {
useLocalParticipant,
MediaDeviceMenu,
TrackToggle,
useRoomContext,
} from '@livekit/components-react';
import type { KrispNoiseFilterProcessor } from '@livekit/krisp-noise-filter';

76
styles/Chat.css Normal file
View File

@ -0,0 +1,76 @@
.lk-chat {
display: flex;
width: 25vw;
flex-direction: column;
background-color: #151e27;
margin: 1rem 1rem 4.1rem 0;
background-color: #151e27;
border-radius: 0.5rem;
border: 0px;
}
.lk-chat-header {
display: flex;
width: 100%;
padding: 1.25rem 1.75rem;
height: auto;
justify-content: start;
align-items: center;
font-weight: bold;
font-size: 1.05rem;
}
.lk-close-button.lk-button.lk-chat-toggle {
color: #556171;
cursor: pointer;
background-color: transparent;
padding: 0.5rem;
}
.lk-close-button.lk-button.lk-chat-toggle > svg {
width: 1.3rem;
height: 1.3rem;
}
.lk-close-button.lk-button.lk-chat-toggle > svg > path {
fill: #556171;
}
.lk-close-button.lk-button.lk-chat-toggle:hover {
background-color: transparent;
}
.lk-list.lk-chat-messages {
margin-bottom: auto;
padding-inline: 1rem;
}
.lk-form-control.lk-chat-form-input {
background-color: #151e27;
}
.lk-form-control.lk-chat-form-input:focus-visible {
outline: none;
}
.lk-button.lk-chat-form-button {
background-color: #151e27;
border: 1px solid var(--lk-border-color);
}
.lk-button.lk-chat-form-button:hover {
background-color: #212e3a;
}
.lk-chat-form {
display: flex;
width: 100%;
}
.lk-chat-entry[data-lk-message-origin='local'] .lk-message-body {
background-color: #212e3a;
}
.lk-chat-entry[data-lk-message-origin='remote'] .lk-message-body {
background-color: #3e6189;
}

View File

@ -1,103 +0,0 @@
.participant-tile {
position: relative;
background-color: #1a242e;
border-radius: 5px;
overflow: hidden;
width: 100%;
height: 100%;
}
.participant-tile.speaking {
border: 2px solid #618aff;
}
.participant-info {
position: absolute;
bottom: 0;
left: 0;
display: flex;
align-items: center;
padding: 8px;
z-index: 10;
}
.participant-name {
font-family: 'Roboto', sans-serif;
font-weight: 500;
font-size: 14px;
color: white;
margin-left: 5px;
}
.mic-icon {
font-family: 'Material Symbols Outlined';
font-size: 18px;
}
.mic-on {
color: #ffffff;
}
.mic-off {
color: #ff5252;
}
.speaking-indicator {
position: absolute;
top: 10px;
right: 10px;
background-color: #618aff;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.speaking-icon {
font-family: 'Material Symbols Outlined';
color: white;
font-size: 16px;
}
.avatar-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100px;
height: 100px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
}
.avatar-initials {
font-family: 'Roboto', sans-serif;
font-weight: 500;
font-size: 50px;
color: white;
}
.avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
}
.video-container {
width: 100%;
height: 100%;
}
.custom-control-bar.lk-control-bar {
padding: 6px !important;
border-top: 1px solid rgba(255, 255, 255, 0.1);
margin-right: -10px;
width: calc(100% + 10px);
}

View File

@ -33,6 +33,12 @@
font-family: 'Material Symbols Outlined';
font-size: 18px;
padding: 0.1rem 0.3rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
margin-right: 0.25rem;
}
.mic-on {
@ -44,14 +50,19 @@
}
.speaking-icon {
font-family: 'Material Symbols Outlined';
color: white;
background-color: #618aff;
border-radius: 50%;
}
.speaking-icon {
font-family: 'Material Symbols Outlined';
color: white;
font-size: 16px;
.lk-carousel .avatar-container {
width: 60px;
height: 60px;
}
.lk-carousel .avatar-initials {
font-size: 35px;
}
.avatar-container {
@ -93,3 +104,26 @@
margin-right: -10px;
width: calc(100% + 10px);
}
.focus-toggle {
width: 2rem;
background-color: #151e27;
position: absolute;
right: 0.5rem;
top: 0.5rem;
z-index: 10;
}
.lk-participant-tile .lk-participant-placeholder {
background-color: #1a242e;
}
.lk-participant-metadata-item,
.lk-participant-tile .lk-focus-toggle-button,
.lk-participant-tile .lk-focus-toggle-button:hover {
background-color: #151e27;
}
.data-lk-quality {
top: 0;
}