diff --git a/app/custom/CustomControlBar.tsx b/app/custom/CustomControlBar.tsx
index 4269dba..9b3d4fe 100644
--- a/app/custom/CustomControlBar.tsx
+++ b/app/custom/CustomControlBar.tsx
@@ -1,123 +1,111 @@
-// // import React from 'react';
-// // import { TrackToggle, DisconnectButton } from '@livekit/components-react';
-// // import SettingsMenu from '@/lib/SettingsMenu';
-// // import { MaterialSymbol } from 'material-symbols';
-// // import '../../styles/CustomControlBar.css';
+'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';
-// // const CustomControlBar = ({ roomName, room }) => {
-// // const [recording, setRecording] = React.useState(false);
-// // const [showSettings, setShowSettings] = React.useState(false);
+interface CustomControlBarProps {
+ room: Room;
+ roomName: string;
+}
-// // React.useEffect(() => {
-// // if (room) {
-// // room.on('recordedStatusChanged', () => {
-// // setRecording(room.isRecording);
-// // });
-// // return () => {
-// // room.off('recordedStatusChanged');
-// // };
-// // }
-// // }, [room]);
+export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
+ const [recording, setRecording] = useState(false);
+ const [participantCount, setParticipantCount] = useState(1);
-// // const handleCopyLink = () => {
-// // navigator.clipboard.writeText(window.location.href);
-// // };
+ useEffect(() => {
+ if (room) {
-// // return (
-// //
-// //
-// //
-// // {roomName}
-// //
-// //
-// //
-// //
-// //
-// //
-// // {recording && (
-// //
-// // {/* */}
-// //
-// // )}
-// //
-// //
-// //
-// //
-// //
-// // {showSettings && setShowSettings(false)} />}
-// //
-// //
-// // );
-// // };
+ const updateRecordingStatus = () => setRecording(room.isRecording);
+
-// // export default CustomControlBar;
+ 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]);
-// import { TrackToggle, DisconnectButton, RoomAudioRenderer, GridLayout } from '@livekit/components-react';
-// import { useState, useEffect } from 'react';
+ const handleCopyLink = () => {
+ navigator.clipboard.writeText(window.location.href)
+ .then(() => alert('Link copied to clipboard!'))
+ .catch((err) => console.error('Failed to copy link:', err));
+ };
-// function CustomControlBar({ room, roomName }) {
-// const [recording, setRecording] = useState(false);
+ return (
+
-// // Update recording status
-// useEffect(() => {
-// if (room) {
-// const updateRecordingStatus = () => setRecording(room.isRecording);
-// room.on(RoomEvent.RecordingStarted, updateRecordingStatus);
-// room.on(RoomEvent.RecordingStopped, updateRecordingStatus);
-// return () => {
-// room.off(RoomEvent.RecordingStarted, updateRecordingStatus);
-// room.off(RoomEvent.RecordingStopped, updateRecordingStatus);
-// };
-// }
-// }, [room]);
+
+ {roomName}
+
+
-// // Copy room link to clipboard
-// const handleCopyLink = () => {
-// navigator.clipboard.writeText(window.location.href)
-// .then(() => alert('Link copied to clipboard!'))
-// .catch((err) => console.error('Failed to copy link:', err));
-// };
+ {/* Center: Control Buttons */}
+
+
+ {/* mic
+ mic_off */}
+
+
+
+ {/* videocam
+ videocam_off */}
+
+
+
+ radio_button_checked
+
+
+
+ {/* screen_share
+ screen_share */}
+
+
+
+ call_end
+
+
-// return (
-//
-// {/* Left: Room Name Box */}
-//
-// {roomName}
-//
-//
-
-// {/* Center: Control Buttons */}
-//
-//
-//
-// {recording && (
-//
-// radio_button_checked
-//
-// )}
-//
-//
-// call_end
-//
-//
-
-// {/* Right: Settings Button */}
-//
-// {SHOW_SETTINGS_MENU && (
-//
-// )}
-//
-//
-// );
-// }
\ No newline at end of file
+ {/* Participants, Settings btn */}
+
+
+ people
+ {participantCount}
+
+
+
+ settings
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/app/custom/VideoTrack.tsx b/app/custom/VideoTrack.tsx
new file mode 100644
index 0000000..9fc9fb6
--- /dev/null
+++ b/app/custom/VideoTrack.tsx
@@ -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(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 ;
+}
\ No newline at end of file
diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx
index 063d7a0..8881900 100644
--- a/app/rooms/[roomName]/PageClientImpl.tsx
+++ b/app/rooms/[roomName]/PageClientImpl.tsx
@@ -1,25 +1,23 @@
+
'use client';
+import React, { useEffect, useState, useMemo } from 'react';
import { decodePassphrase } from '@/lib/client-utils';
import Transcript from '@/lib/Transcript';
-import { SettingsMenu } from '@/lib/SettingsMenu';
import { ConnectionDetails } from '@/lib/types';
import {
LocalUserChoices,
PreJoin,
LiveKitRoom,
- TrackToggle,
- DisconnectButton,
- RoomAudioRenderer,
- GridLayout,
useTracks,
TrackReferenceOrPlaceholder,
+ GridLayout,
+ RoomAudioRenderer,
} from '@livekit/components-react';
import {
ExternalE2EEKeyProvider,
RoomOptions,
- RoomEvent,
VideoCodec,
VideoPresets,
Room,
@@ -28,11 +26,12 @@ import {
Track,
} from 'livekit-client';
import { useRouter } from 'next/navigation';
-import React, { useEffect, useRef, useState } from 'react';
-import '../../../styles/CustomControlBar.css';
+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';
export function PageClientImpl(props: {
roomName: string;
@@ -40,17 +39,18 @@ export function PageClientImpl(props: {
hq: boolean;
codec: VideoCodec;
}) {
- const [preJoinChoices, setPreJoinChoices] = React.useState(undefined);
- const preJoinDefaults = React.useMemo(() => {
+ const [preJoinChoices, setPreJoinChoices] = useState(undefined);
+ const [connectionDetails, setConnectionDetails] = useState(undefined);
+
+ const preJoinDefaults = useMemo(() => {
return {
username: '',
videoEnabled: true,
audioEnabled: true,
};
}, []);
- const [connectionDetails, setConnectionDetails] = React.useState(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);
@@ -61,14 +61,14 @@ export function PageClientImpl(props: {
const connectionDetailsResp = await fetch(url.toString());
const connectionDetailsData = await connectionDetailsResp.json();
setConnectionDetails(connectionDetailsData);
- }, [props.roomName, props.region]);
+ };
- const handlePreJoinError = React.useCallback((e: any) => console.error(e), []);
+ const handlePreJoinError = (e: any) => console.error(e);
return (
-
+
{connectionDetails === undefined || preJoinChoices === undefined ? (
-
+
{
+ const [isClient, setIsClient] = useState(false);
+
+ useEffect(() => {
+ setIsClient(true);
+ }, []);
+
+
+ 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 {
@@ -115,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: {
@@ -123,37 +140,37 @@ function VideoConferenceComponent(props: {
},
adaptiveStream: { pixelDensity: 'screen' },
dynacast: true,
- e2ee: e2eeEnabled
- ? { keyProvider, worker }
- : undefined,
+ e2ee: e2eeConfig.enabled ? { keyProvider, worker: e2eeConfig.worker } : undefined,
};
- }, [props.userChoices, props.options.hq, props.options.codec]);
+ }, [props.userChoices, props.options.hq, props.options.codec, e2eeConfig]);
- const room = React.useMemo(() => new Room(roomOptions), [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);
- }
- });
- }
+ 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 = React.useMemo((): RoomConnectOptions => {
+ const connectOptions = useMemo((): RoomConnectOptions => {
return { autoSubscribe: true };
}, []);
const router = useRouter();
- const handleOnLeave = React.useCallback(() => router.push('/'), [router]);
+ const handleOnLeave = () => router.push('/');
- const tracks = useTracks(
- [{ source: Track.Source.Camera, withPlaceholder: true }],
- { room }
- );
+ const tracks = useTracks([{ source: Track.Source.Camera, withPlaceholder: true }], { room });
+
+ // Return null during SSR to prevent hydration errors
+ if (!isClient) return null;
return (
{tracks.length > 0 ? (
-
+
{(trackRef: TrackReferenceOrPlaceholder) => (
)}
) : (
-
+
No participants with video yet
)}
@@ -186,99 +206,4 @@ function VideoConferenceComponent(props: {
);
-}
-
-function VideoTrack({ ref: trackRef }: { ref: TrackReferenceOrPlaceholder }) {
- const videoRef = useRef
(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 (
-
- );
-}
-
-interface CustomControlBarProps {
- room: Room;
- roomName: string;
-}
-interface CustomControlBarProps {
- room: Room;
- roomName: string;
-}
-
-function CustomControlBar({ room, roomName }: CustomControlBarProps) {
- const [recording, setRecording] = useState(false);
-
- useEffect(() => {
- if (room) {
- const updateRecordingStatus = () => setRecording(room.isRecording);
- room.on(RoomEvent.LocalTrackPublished, updateRecordingStatus);
- room.on(RoomEvent.LocalTrackUnpublished, updateRecordingStatus);
- return () => {
- room.off(RoomEvent.LocalTrackPublished, updateRecordingStatus);
- room.off(RoomEvent.LocalTrackUnpublished, 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 (
-
- {/* Left: Room Name Box */}
-
- {roomName}
-
-
-
- {/* Center: Control Buttons */}
-
-
-
- {recording ? (
-
- radio_button_checked
-
- ) : (
-
- radio_button_checked
-
- )}
-
-
- call_end
-
-
-
- {/* Right: Settings Button */}
-
- {SHOW_SETTINGS_MENU && (
-
- )}
-
-
- );
-}
-
+}
\ No newline at end of file
diff --git a/styles/CustomControlBar.css b/styles/CustomControlBar.css
index bc719a9..f7c9e59 100644
--- a/styles/CustomControlBar.css
+++ b/styles/CustomControlBar.css
@@ -12,7 +12,8 @@
right: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 1000;
- backdrop-filter: blur(5px);
+ backdrop-filter: blur(5px);
+ -webkit-backdrop-filter: blur(5px); /* Added for Safari compatibility */
}
.room-name-box {
@@ -60,69 +61,52 @@
display: flex;
align-items: center;
justify-content: center;
- font-size: 24px;
}
+/* Mic button */
.mic-button[data-lk-audio-enabled="false"] {
- background: #ff5252;
- opacity: 0.8;
+ background: rgba(255, 82, 82, 0.2);
}
.mic-button[data-lk-audio-enabled="true"] .material-symbols-outlined {
- content: 'mic';
color: #ffffff;
}
.mic-button[data-lk-audio-enabled="false"] .material-symbols-outlined {
- content: 'mic_off';
- color: #ffffff;
+ color: #ff6f6f;
}
+/* Camera btn */
.camera-button[data-lk-video-enabled="false"] {
- background: #ff5252;
- opacity: 0.8;
+ background: rgba(255, 82, 82, 0.2);
}
.camera-button[data-lk-video-enabled="true"] .material-symbols-outlined {
- content: 'videocam';
- color: #ffffff;
+ color: #ffffff;
}
.camera-button[data-lk-video-enabled="false"] .material-symbols-outlined {
- content: 'videocam_off';
- color: #ffffff;
+ color: #ff6f6f;
}
+/* Screen share btn */
.screen-share-button[data-lk-screen-share-enabled="true"] .material-symbols-outlined {
- content: 'screen_share';
- color: #49c998;
+ color: #49c998;
}
.screen-share-button[data-lk-screen-share-enabled="false"] .material-symbols-outlined {
- content: 'screen_share';
- color: #ffffff;
-}
-
-.record-sign {
- background: rgba(144, 155, 170, 0.1);
-}
-
-.record-sign.disabled {
- background: rgba(144, 155, 170, 0.1);
-}
-
-.record-sign .material-symbols-outlined,
-.record-sign.disabled .material-symbols-outlined {
- font-size: 24px;
+ color: #ffffff;
}
+/* Record */
.record-sign .material-symbols-outlined {
- color: #ff6f6f;
- animation: pulse 1s infinite;
+ color: #ff6f6f;
+ animation: pulse 1.5s infinite;
}
.record-sign.disabled .material-symbols-outlined {
color: #ffffff;
+ animation: none;
}
@keyframes pulse {
@@ -131,56 +115,66 @@
100% { opacity: 1; }
}
+/* End call */
.end-call-button {
width: 64px;
height: 40px;
- background: #ff5252;
+ background: #ff5252;
border-radius: 8px;
- border: none;
- cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.end-call-button .material-symbols-outlined {
- font-size: 24px;
- color: #ffffff;
+ color: #ffffff;
}
-.settings-section {
- position: relative;
+
+.top-right-controls {
+ display: flex;
+ gap: 10px;
+ align-items: center;
}
-.settings-button {
+.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;
- background: rgba(144, 155, 170, 0.1);
- border-radius: 8px;
- border: none;
- cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
+ cursor: pointer;
}
-.settings-button .material-symbols-outlined {
- font-size: 24px;
- color: #ffffff;
+.settings-box .material-symbols-outlined {
+ font-size: 20px;
+ color: white;
}
-
+/* Fix for video layout */
.lk-grid-layout {
- height: calc(100vh - 60px) !important;
- width: 100% !important;
-}
-
-.lk-grid-layout > div {
- position: relative !important;
- overflow: hidden !important;
-}
-
-.lk-video {
- object-fit: cover !important;
- background-color: #000 !important;
+ height: calc(100vh - 60px) !important;
}
\ No newline at end of file
diff --git a/styles/PageClientImpl.css b/styles/PageClientImpl.css
new file mode 100644
index 0000000..67a47ed
--- /dev/null
+++ b/styles/PageClientImpl.css
@@ -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;
+ }
\ No newline at end of file
diff --git a/styles/VideoTrack.css b/styles/VideoTrack.css
new file mode 100644
index 0000000..be08892
--- /dev/null
+++ b/styles/VideoTrack.css
@@ -0,0 +1,5 @@
+.video-element {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
\ No newline at end of file