Merge pull request #6 from anujsb/fix-LiveKit-call-redesign---Bottom-bar

Update Bottom Bar and Control Buttons Based on Sphinx Mac Design
This commit is contained in:
Tom 2025-02-26 11:35:53 -03:00 committed by GitHub
commit b450b759a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 523 additions and 184 deletions

View File

@ -1,30 +0,0 @@
# 1. Copy this file and rename it to .env.local
# 2. Update the enviroment variables below.
# REQUIRED SETTINGS
# #################
# If you are using LiveKit Cloud, the API key and secret can be generated from the Cloud Dashboard.
LIVEKIT_API_KEY=
LIVEKIT_API_SECRET=
# URL pointing to the LiveKit server. (example: `wss://my-livekit-project.livekit.cloud`)
LIVEKIT_URL=
# OPTIONAL SETTINGS
# #################
# Recording
# S3_KEY_ID=
# S3_KEY_SECRET=
# S3_ENDPOINT=
# S3_BUCKET=
# S3_REGION=
# PUBLIC
# Uncomment settings menu when using a LiveKit Cloud, it'll enable Krisp noise filters.
# NEXT_PUBLIC_SHOW_SETTINGS_MENU=true
# NEXT_PUBLIC_LK_RECORD_ENDPOINT=/api/record
# Optional, to pipe logs to datadog
# NEXT_PUBLIC_DATADOG_CLIENT_TOKEN=client-token
# NEXT_PUBLIC_DATADOG_SITE=datadog-site

View File

@ -0,0 +1,111 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
TrackToggle,
DisconnectButton
} from '@livekit/components-react';
import {
Room,
RoomEvent,
Track
} from 'livekit-client';
import '../../styles/CustomControlBar.css';
interface CustomControlBarProps {
room: Room;
roomName: string;
}
export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
const [recording, setRecording] = useState(false);
const [participantCount, setParticipantCount] = useState(1);
useEffect(() => {
if (room) {
const updateRecordingStatus = () => setRecording(room.isRecording);
const updateParticipantCount = () => {
if (room && room.participants) {
setParticipantCount(room.participants.size + 1);
}
};
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);
room.off(RoomEvent.ParticipantDisconnected, updateParticipantCount);
room.off(RoomEvent.RecordingStatusChanged, updateRecordingStatus);
};
}
}, [room]);
const handleCopyLink = () => {
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}>
<span className="material-symbols-outlined">content_copy</span>
</button>
</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'}`}>
<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">
<span className="material-symbols-outlined">call_end</span>
</DisconnectButton>
</div>
{/* Participants, Settings btn */}
<div className="top-right-controls">
<div className="participant-box">
<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>
);
}

27
app/custom/VideoTrack.tsx Normal file
View File

@ -0,0 +1,27 @@
'use client';
import React, { useRef, useEffect } from 'react';
import { TrackReferenceOrPlaceholder } from '@livekit/components-react';
import '../../styles/VideoTrack.css';
interface VideoTrackProps {
ref: TrackReferenceOrPlaceholder;
}
export function VideoTrack({ ref: trackRef }: VideoTrackProps) {
const videoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const videoEl = videoRef.current;
const track = trackRef.publication?.track;
if (videoEl && track) {
track.attach(videoEl);
return () => {
track.detach(videoEl);
};
}
}, [trackRef.publication?.track]);
return <video ref={videoRef} className="video-element" />;
}

View File

@ -1,36 +1,37 @@
'use client';
import React, { useEffect, useState, useMemo } from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import Transcript from '@/lib/Transcript';
import { RecordingIndicator } from '@/lib/RecordingIndicator';
import { SettingsMenu } from '@/lib/SettingsMenu';
import { ConnectionDetails } from '@/lib/types';
import {
formatChatMessageLinks,
LiveKitRoom,
LocalUserChoices,
PreJoin,
VideoConference,
LiveKitRoom,
useTracks,
TrackReferenceOrPlaceholder,
GridLayout,
RoomAudioRenderer,
} from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
RoomOptions,
RoomEvent,
TranscriptionSegment,
VideoCodec,
VideoPresets,
Room,
DeviceUnsupportedError,
RoomConnectOptions,
Track,
} from 'livekit-client';
import { setLazyProp } from 'next/dist/server/api-utils';
import { useRouter } from 'next/navigation';
import React from 'react';
import { VideoTrack } from '@/app/custom/VideoTrack';
import { CustomControlBar } from '@/app/custom/CustomControlBar';
import '../../../styles/PageClientImpl.css';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';
const SHOW_SETTINGS_MENU = process.env.NEXT_PUBLIC_SHOW_SETTINGS_MENU == 'true';
console.log('SHOW_SETTINGS_MENU', SHOW_SETTINGS_MENU);
export function PageClientImpl(props: {
roomName: string;
@ -38,21 +39,18 @@ export function PageClientImpl(props: {
hq: boolean;
codec: VideoCodec;
}) {
const [preJoinChoices, setPreJoinChoices] = React.useState<LocalUserChoices | undefined>(
undefined,
);
const preJoinDefaults = React.useMemo(() => {
const [preJoinChoices, setPreJoinChoices] = useState<LocalUserChoices | undefined>(undefined);
const [connectionDetails, setConnectionDetails] = useState<ConnectionDetails | undefined>(undefined);
const preJoinDefaults = useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
};
}, []);
const [connectionDetails, setConnectionDetails] = React.useState<ConnectionDetails | undefined>(
undefined,
);
const handlePreJoinSubmit = React.useCallback(async (values: LocalUserChoices) => {
const handlePreJoinSubmit = async (values: LocalUserChoices) => {
setPreJoinChoices(values);
const url = new URL(CONN_DETAILS_ENDPOINT, window.location.origin);
url.searchParams.append('roomName', props.roomName);
@ -63,13 +61,14 @@ export function PageClientImpl(props: {
const connectionDetailsResp = await fetch(url.toString());
const connectionDetailsData = await connectionDetailsResp.json();
setConnectionDetails(connectionDetailsData);
}, []);
const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
};
const handlePreJoinError = (e: any) => console.error(e);
return (
<main data-lk-theme="default" style={{ height: '100%' }}>
<main data-lk-theme="default" className="main-container">
{connectionDetails === undefined || preJoinChoices === undefined ? (
<div style={{ display: 'grid', placeItems: 'center', height: '100%' }}>
<div className="pre-join-container">
<PreJoin
defaults={preJoinDefaults}
onSubmit={handlePreJoinSubmit}
@ -90,24 +89,37 @@ export function PageClientImpl(props: {
function VideoConferenceComponent(props: {
userChoices: LocalUserChoices;
connectionDetails: ConnectionDetails;
options: {
hq: boolean;
codec: VideoCodec;
};
options: { hq: boolean; codec: VideoCodec };
}) {
const e2eePassphrase =
typeof window !== 'undefined' && decodePassphrase(location.hash.substring(1));
const worker =
typeof window !== 'undefined' &&
e2eePassphrase &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const e2eeEnabled = !!(e2eePassphrase && worker);
const keyProvider = new ExternalE2EEKeyProvider();
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
const roomOptions = React.useMemo((): RoomOptions => {
const getE2EEConfig = () => {
if (typeof window === 'undefined') return { enabled: false };
const e2eePassphrase = decodePassphrase(location.hash.substring(1));
const worker = e2eePassphrase &&
new Worker(new URL('livekit-client/e2ee-worker', import.meta.url));
const e2eeEnabled = !!(e2eePassphrase && worker);
return {
enabled: e2eeEnabled,
passphrase: e2eePassphrase,
worker
};
};
const e2eeConfig = useMemo(() => getE2EEConfig(), []);
const keyProvider = useMemo(() => new ExternalE2EEKeyProvider(), []);
const roomOptions = useMemo((): RoomOptions => {
let videoCodec: VideoCodec | undefined = props.options.codec ? props.options.codec : 'vp9';
if (e2eeEnabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
if (e2eeConfig.enabled && (videoCodec === 'av1' || videoCodec === 'vp9')) {
videoCodec = undefined;
}
return {
@ -120,7 +132,7 @@ function VideoConferenceComponent(props: {
videoSimulcastLayers: props.options.hq
? [VideoPresets.h1080, VideoPresets.h720]
: [VideoPresets.h540, VideoPresets.h216],
red: !e2eeEnabled,
red: !e2eeConfig.enabled,
videoCodec,
},
audioCaptureDefaults: {
@ -128,89 +140,70 @@ function VideoConferenceComponent(props: {
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
e2ee: e2eeEnabled
? {
keyProvider,
worker,
}
: undefined,
e2ee: e2eeConfig.enabled ? { keyProvider, worker: e2eeConfig.worker } : undefined,
};
// @ts-ignore
setLogLevel('debug', 'lk-e2ee');
}, [props.userChoices, props.options.hq, props.options.codec]);
}, [props.userChoices, props.options.hq, props.options.codec, e2eeConfig]);
const room = React.useMemo(() => new Room(roomOptions), []);
const room = useMemo(() => new Room(roomOptions), [roomOptions]);
if (e2eeEnabled) {
keyProvider.setKey(decodePassphrase(e2eePassphrase));
room.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
alert(
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
);
console.error(e);
}
});
}
const connectOptions = React.useMemo((): RoomConnectOptions => {
return {
autoSubscribe: true,
};
useEffect(() => {
if (e2eeConfig.enabled && e2eeConfig.passphrase) {
keyProvider.setKey(decodePassphrase(e2eeConfig.passphrase));
room.setE2EEEnabled(true).catch((e) => {
if (e instanceof DeviceUnsupportedError) {
alert(
`You're trying to join an encrypted meeting, but your browser does not support it. Please update it to the latest version and try again.`,
);
console.error(e);
}
});
}
}, [room, e2eeConfig, keyProvider]);
const connectOptions = useMemo((): RoomConnectOptions => {
return { autoSubscribe: true };
}, []);
const [transcriptions, setTranscriptions] = React.useState<{
[id: string]: TranscriptionSegment;
}>({});
const [latestText, setLatestText] = React.useState('');
React.useEffect(() => {
if (!room) {
return;
}
const updateTranscriptions = (
segments: TranscriptionSegment[],
participant: any,
publication: any,
) => {
if (segments.length > 0) {
setLatestText(segments[0].text);
}
// setTranscriptions((prev) => {
// const newTranscriptions = { ...prev };
// for (const segment of segments) {
// newTranscriptions[segment.id] = segment;
// }
// console.log('===>', newTranscriptions);
// return newTranscriptions;
// });
};
room.on(RoomEvent.TranscriptionReceived, updateTranscriptions);
return () => {
room.off(RoomEvent.TranscriptionReceived, updateTranscriptions);
};
}, [room]);
const router = useRouter();
const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
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 (
<>
<LiveKitRoom
room={room}
token={props.connectionDetails.participantToken}
serverUrl={props.connectionDetails.serverUrl}
connectOptions={connectOptions}
video={props.userChoices.videoEnabled}
audio={props.userChoices.audioEnabled}
onDisconnected={handleOnLeave}
>
<VideoConference
chatMessageFormatter={formatChatMessageLinks}
SettingsComponent={SHOW_SETTINGS_MENU ? SettingsMenu : undefined}
/>
{/* <DebugMode /> */}
<RecordingIndicator />
<Transcript latestText={latestText} />
</LiveKitRoom>
</>
<LiveKitRoom
room={room}
token={props.connectionDetails.participantToken}
serverUrl={props.connectionDetails.serverUrl}
connectOptions={connectOptions}
video={props.userChoices.videoEnabled}
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>
)}
<RoomAudioRenderer />
<CustomControlBar room={room} roomName={props.connectionDetails.roomName} />
<Transcript latestText={''} />
</LiveKitRoom>
);
}
}

View File

@ -15,6 +15,7 @@
"@livekit/krisp-noise-filter": "^0.2.8",
"livekit-client": "2.8.1",
"livekit-server-sdk": "2.9.7",
"material-symbols": "^0.28.2",
"next": "14.2.12",
"react": "18.3.1",
"react-dom": "18.3.1",

105
pnpm-lock.yaml generated
View File

@ -13,19 +13,22 @@ importers:
version: 5.26.0
'@livekit/components-react':
specifier: 2.6.0
version: 2.6.0(@livekit/protocol@1.20.1)(livekit-client@2.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.7.0)
version: 2.6.0(@livekit/protocol@1.34.0)(livekit-client@2.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)
'@livekit/components-styles':
specifier: 1.1.2
version: 1.1.2
'@livekit/krisp-noise-filter':
specifier: ^0.2.8
version: 0.2.8(livekit-client@2.5.2)
version: 0.2.8(livekit-client@2.8.1)
livekit-client:
specifier: 2.5.2
version: 2.5.2
specifier: 2.8.1
version: 2.8.1
livekit-server-sdk:
specifier: 2.6.2
version: 2.6.2
specifier: 2.9.7
version: 2.9.7
material-symbols:
specifier: ^0.28.2
version: 0.28.2
next:
specifier: 14.2.12
version: 14.2.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -43,8 +46,8 @@ importers:
specifier: 20.16.3
version: 20.16.3
'@types/react':
specifier: 18.3.5
version: 18.3.5
specifier: 18.3.8
version: 18.3.8
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
@ -172,8 +175,14 @@ packages:
peerDependencies:
livekit-client: ^2.0.8
'@livekit/protocol@1.20.1':
resolution: {integrity: sha512-TgyuwOx+XJn9inEYT9OKfFNs9YIPS4BdLa4pF5FDf9MhWRnahKwPe7jxr/+sVdWxYbZmy9hRrH58jSAFu0ONHw==}
'@livekit/mutex@1.1.1':
resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==}
'@livekit/protocol@1.30.0':
resolution: {integrity: sha512-SDI9ShVKj8N3oOSinr8inaxD3FXgmgoJlqN35uU/Yx1sdoDeQbzAuBFox7bYjM+VhnZ1V22ivIDjAsKr00H+XQ==}
'@livekit/protocol@1.34.0':
resolution: {integrity: sha512-bU7pCLAMRVTVZb1KSxA46q55bhOc4iATrY/gccy2/oX1D57tiZEI+8wGRWHeDwBb0UwnABu6JXzC4tTFkdsaOg==}
'@next/env@14.2.12':
resolution: {integrity: sha512-3fP29GIetdwVIfIRyLKM7KrvJaqepv+6pVodEbx0P5CaMLYBtx+7eEg8JYO5L9sveJO87z9eCReceZLi0hxO1Q==}
@ -282,8 +291,8 @@ packages:
'@types/react-dom@18.3.0':
resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==}
'@types/react@18.3.5':
resolution: {integrity: sha512-WeqMfGJLGuLCqHGYRGHxnKrXcTitc6L/nBUWfWPcTarG3t9PsquqUMuVeXZeca+mglY4Vo5GZjCi0A3Or2lnxA==}
'@types/react@18.3.8':
resolution: {integrity: sha512-syBUrW3/XpnW4WJ41Pft+I+aPoDVbrBVQGEnbD7NijDGlVC+8gV/XKRY+7vMDlfPpbwYt0l1vd/Sj8bJGMbs9Q==}
'@typescript-eslint/parser@7.2.0':
resolution: {integrity: sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==}
@ -1101,12 +1110,12 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
livekit-client@2.5.2:
resolution: {integrity: sha512-rzWFH02UznHxpnbj+WEEoHxL1ZSo9BdFK+7ltSZWniTt2llnNckdqeXNsjkBH6k+C9agHTF4XikmxKcpWa4YrQ==}
livekit-client@2.8.1:
resolution: {integrity: sha512-HPv9iHNrnBANI9ucK7CKZspx0sBZK3hjR2EbwaV08+J3RM9+tNGL2ob2n76nxJLEZG7LzdWlLZdbr4fQBP6Hkg==}
livekit-server-sdk@2.6.2:
resolution: {integrity: sha512-3fFzHu7sAynUaUFTCKtRP9lgQCU0Qe/x7XA99GpT1ro7fTy1ZVzaWq34WcXEyUGBBMFxG19LlSIAQBcGZVStWQ==}
engines: {node: '>=19'}
livekit-server-sdk@2.9.7:
resolution: {integrity: sha512-uIkFOaqBCJnVgYOidZdanPWQH5G0LMxe0+Qp5zbx7MZCkJ7lGiju//yonfEvFofriJBKACjMq/KQHBex96QpeA==}
engines: {node: '>=18'}
loader-runner@4.3.0:
resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==}
@ -1137,6 +1146,9 @@ packages:
resolution: {integrity: sha512-2L3MIgJynYrZ3TYMriLDLWocz15okFakV6J12HXvMXDHui2x/zgChzg1u9mFFGbbGWE+GsLpQByt4POb9Or+uA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
material-symbols@0.28.2:
resolution: {integrity: sha512-JLK+Bgtfg5Dn9V2WYk6lSwmxciNNF2zmqc/V8MLmH0K9LttUhPCaauJzrS1Vw3mJPs/Tyfi/tszynNRX6nWQOA==}
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
@ -1577,6 +1589,9 @@ packages:
tslib@2.7.0:
resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@ -1777,33 +1792,39 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@livekit/components-core@0.11.5(@livekit/protocol@1.20.1)(livekit-client@2.5.2)(tslib@2.7.0)':
'@livekit/components-core@0.11.5(@livekit/protocol@1.34.0)(livekit-client@2.8.1)(tslib@2.8.1)':
dependencies:
'@floating-ui/dom': 1.6.11
'@livekit/protocol': 1.20.1
livekit-client: 2.5.2
'@livekit/protocol': 1.34.0
livekit-client: 2.8.1
loglevel: 1.9.1
rxjs: 7.8.1
tslib: 2.7.0
tslib: 2.8.1
'@livekit/components-react@2.6.0(@livekit/protocol@1.20.1)(livekit-client@2.5.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.7.0)':
'@livekit/components-react@2.6.0(@livekit/protocol@1.34.0)(livekit-client@2.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.11.5(@livekit/protocol@1.20.1)(livekit-client@2.5.2)(tslib@2.7.0)
'@livekit/protocol': 1.20.1
'@livekit/components-core': 0.11.5(@livekit/protocol@1.34.0)(livekit-client@2.8.1)(tslib@2.8.1)
'@livekit/protocol': 1.34.0
clsx: 2.1.1
livekit-client: 2.5.2
livekit-client: 2.8.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
tslib: 2.7.0
tslib: 2.8.1
usehooks-ts: 3.1.0(react@18.3.1)
'@livekit/components-styles@1.1.2': {}
'@livekit/krisp-noise-filter@0.2.8(livekit-client@2.5.2)':
'@livekit/krisp-noise-filter@0.2.8(livekit-client@2.8.1)':
dependencies:
livekit-client: 2.5.2
livekit-client: 2.8.1
'@livekit/protocol@1.20.1':
'@livekit/mutex@1.1.1': {}
'@livekit/protocol@1.30.0':
dependencies:
'@bufbuild/protobuf': 1.10.0
'@livekit/protocol@1.34.0':
dependencies:
'@bufbuild/protobuf': 1.10.0
@ -1880,9 +1901,9 @@ snapshots:
'@types/react-dom@18.3.0':
dependencies:
'@types/react': 18.3.5
'@types/react': 18.3.8
'@types/react@18.3.5':
'@types/react@18.3.8':
dependencies:
'@types/prop-types': 15.7.12
csstype: 3.1.3
@ -2403,7 +2424,7 @@ snapshots:
eslint: 9.9.1
eslint-import-resolver-node: 0.3.9
eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.9.1))(eslint@9.9.1)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@9.9.1)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.9.1))(eslint@9.9.1))(eslint@9.9.1)
eslint-plugin-jsx-a11y: 6.9.0(eslint@9.9.1)
eslint-plugin-react: 7.35.0(eslint@9.9.1)
eslint-plugin-react-hooks: 4.6.2(eslint@9.9.1)
@ -2434,7 +2455,7 @@ snapshots:
is-bun-module: 1.1.0
is-glob: 4.0.3
optionalDependencies:
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@9.9.1)
eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.9.1))(eslint@9.9.1))(eslint@9.9.1)
transitivePeerDependencies:
- '@typescript-eslint/parser'
- eslint-import-resolver-node
@ -2452,7 +2473,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3)(eslint@9.9.1):
eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.2.0(eslint@9.9.1)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.29.1(eslint@9.9.1))(eslint@9.9.1))(eslint@9.9.1):
dependencies:
array-includes: 3.1.8
array.prototype.findlastindex: 1.2.5
@ -2924,20 +2945,22 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
livekit-client@2.5.2:
livekit-client@2.8.1:
dependencies:
'@livekit/protocol': 1.20.1
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.30.0
events: 3.3.0
loglevel: 1.9.1
sdp-transform: 2.14.2
ts-debounce: 4.0.0
tslib: 2.7.0
tslib: 2.8.1
typed-emitter: 2.1.0
webrtc-adapter: 9.0.1
livekit-server-sdk@2.6.2:
livekit-server-sdk@2.9.7:
dependencies:
'@livekit/protocol': 1.20.1
'@bufbuild/protobuf': 1.10.0
'@livekit/protocol': 1.34.0
camelcase-keys: 9.1.3
jose: 5.8.0
@ -2961,6 +2984,8 @@ snapshots:
map-obj@5.0.0: {}
material-symbols@0.28.2: {}
merge-stream@2.0.0: {}
merge2@1.4.1: {}
@ -3408,6 +3433,8 @@ snapshots:
tslib@2.7.0: {}
tslib@2.8.1: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1

180
styles/CustomControlBar.css Normal file
View File

@ -0,0 +1,180 @@
@import url('https://fonts.googleapis.com/icon?family=Material+Symbols+Outlined');
@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap');
.custom-control-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px); /* Added for Safari compatibility */
}
.room-name-box {
background: rgba(144, 155, 170, 0.1);
border-radius: 8px;
padding: 5px 10px;
display: flex;
align-items: center;
gap: 5px;
}
.room-name {
font-family: 'Roboto', sans-serif;
font-size: 14px;
color: #909baa;
}
.copy-link-button {
background: none;
border: none;
cursor: pointer;
margin-left: 5px;
padding: 0;
}
.copy-link-button .material-symbols-outlined {
font-size: 20px;
color: #909baa;
}
.control-buttons {
display: flex;
gap: 10px;
justify-content: center;
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;
}
/* Mic button */
.mic-button[data-lk-audio-enabled="false"] {
background: rgba(255, 82, 82, 0.2);
}
.mic-button[data-lk-audio-enabled="true"] .material-symbols-outlined {
color: #ffffff;
}
.mic-button[data-lk-audio-enabled="false"] .material-symbols-outlined {
color: #ff6f6f;
}
/* Camera btn */
.camera-button[data-lk-video-enabled="false"] {
background: rgba(255, 82, 82, 0.2);
}
.camera-button[data-lk-video-enabled="true"] .material-symbols-outlined {
color: #ffffff;
}
.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 {
color: #49c998;
}
.screen-share-button[data-lk-screen-share-enabled="false"] .material-symbols-outlined {
color: #ffffff;
}
/* Record */
.record-sign .material-symbols-outlined {
color: #ff6f6f;
animation: pulse 1.5s infinite;
}
.record-sign.disabled .material-symbols-outlined {
color: #ffffff;
animation: none;
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.5; }
100% { opacity: 1; }
}
/* End call */
.end-call-button {
width: 64px;
height: 40px;
background: #ff5252;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.end-call-button .material-symbols-outlined {
color: #ffffff;
}
.top-right-controls {
display: flex;
gap: 10px;
align-items: center;
}
.participant-box {
background-color: rgba(144, 155, 170, 0.1);
border-radius: 8px;
display: flex;
align-items: center;
padding: 5px 10px;
gap: 8px;
}
.participant-box .material-symbols-outlined {
font-size: 20px;
color: white;
}
.participant-count {
font-size: 16px;
font-weight: 500;
color: white;
font-family: 'Roboto', sans-serif;
}
.settings-box {
background-color: rgba(144, 155, 170, 0.1);
border-radius: 8px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.settings-box .material-symbols-outlined {
font-size: 20px;
color: white;
}
/* Fix for video layout */
.lk-grid-layout {
height: calc(100vh - 60px) !important;
}

25
styles/PageClientImpl.css Normal file
View File

@ -0,0 +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;
}

5
styles/VideoTrack.css Normal file
View File

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