Added Recording function to the control bar

This commit is contained in:
SujithThirumalaisamy 2025-04-05 22:03:34 +05:30
parent 27ceab26ec
commit 1947856c08
14 changed files with 2728 additions and 761 deletions

View File

@ -76,6 +76,7 @@ export async function GET(req: NextRequest) {
return new NextResponse(null, { status: 200 });
} catch (error) {
if (error instanceof Error) {
console.log({ error });
return new NextResponse(error.message, { status: 500 });
}
}

View File

@ -1,7 +1,12 @@
'use client';
import React, { useState, useEffect } from 'react';
import { DisconnectButton, useLayoutContext, useRoomContext } from '@livekit/components-react';
import {
DisconnectButton,
useIsRecording,
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';
@ -10,6 +15,7 @@ import { CameraOffSVG, CameraOnSVG } from '../svg/camera';
import { MicOffSVG, MicOnSVG } from '../svg/mic';
import { ScreenShareOnSVG } from '../svg/screen-share';
import { useCustomLayoutContext } from '../contexts/layout-context';
import { useToast } from './toast/use-toast';
interface CustomControlBarProps {
room: Room;
@ -17,19 +23,72 @@ interface CustomControlBarProps {
}
export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
const [recording, setRecording] = useState(false);
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
const isRecording = useIsRecording();
const [isRecordingRequestPending, setIsRecordingRequestPending] = useState(false);
const [participantCount, setParticipantCount] = useState(1);
const { dispatch } = useLayoutContext().widget;
const { isParticipantsListOpen } = useCustomLayoutContext();
const { toast } = useToast();
const [isFirstMount, setIsFirstMount] = useState(true);
useEffect(() => {
if (isFirstMount) return setIsFirstMount(false);
setIsRecordingRequestPending(false);
if (isRecording) {
toast({
title: 'Recording in progress. Please be aware this call is being recorded.',
});
} else {
toast({
title: 'Recorded ended. This call is no longer being recorded.',
});
}
}, [isRecording]);
function ToggleParticipantsList() {
if (isParticipantsListOpen.dispatch)
isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' });
}
const toggleRoomRecording = async () => {
if (isRecordingRequestPending) return;
setIsRecordingRequestPending(true);
if (!isRecording)
toast({
title: 'Starting call recording. Please wait...',
});
else
toast({
title: 'Stopping call recording. Please wait...',
});
if (!recordingEndpoint) {
throw TypeError('No recording endpoint specified');
}
if (room.isE2EEEnabled) {
throw Error('Recording of encrypted meetings is currently not supported');
}
let response: Response;
const now = new Date(Date.now()).toISOString();
// const fileName = `${now}-${room.name}.mp4`;
if (isRecording) {
response = await fetch(recordingEndpoint + `/stop?roomName=${room.name}`);
} else {
response = await fetch(recordingEndpoint + `/start?roomName=${room.name}&now=${now}`);
}
if (response.ok) {
} else {
console.error(
'Error handling recording request, check server logs:',
response.status,
response.statusText,
);
}
};
useEffect(() => {
if (room) {
const updateRecordingStatus = () => setRecording(room.isRecording);
const updateParticipantCount = () => {
setParticipantCount(room.numParticipants);
};
@ -37,13 +96,11 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
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]);
@ -69,8 +126,22 @@ export function CustomControlBar({ room, roomName }: CustomControlBarProps) {
<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
className={`control-btn ${isRecording ? '' : 'disabled'}`}
onClick={toggleRoomRecording}
data-lk-active={isRecording}
style={{
cursor: isRecordingRequestPending ? 'not-allowed' : 'pointer',
color: isRecordingRequestPending ? 'gray' : isRecording ? '#ED7473' : 'white',
}}
>
{isRecording ? (
<span className="material-symbols-outlined" style={{}}>
stop_circle
</span>
) : (
<span className="material-symbols-outlined">radio_button_checked</span>
)}
</div>
<TrackToggle source={Track.Source.ScreenShare} />

130
app/custom/toast/toast.tsx Normal file
View File

@ -0,0 +1,130 @@
'use client';
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from './utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-4 right-4 z-[100] flex max-h-[100vh] w-full flex-col gap-1.5 md:max-w-[380px]',
className,
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-3 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full',
{
variants: {
variant: {
default: 'border-gray-700 bg-gray-800 text-gray-100',
destructive: 'border-red-900 bg-red-900 text-red-50',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className,
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-1.5 top-1.5 rounded-md p-1 text-gray-400 hover:text-gray-50 focus:outline-none focus:ring-2 focus:ring-gray-600 group-[.destructive]:text-red-200 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400',
className,
)}
toast-close=""
{...props}
>
<X className="h-3.5 w-3.5" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn(
'text-xs font-semibold text-gray-100 group-[.destructive]:text-red-50',
className,
)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-xs text-gray-300 group-[.destructive]:text-red-100', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};

View File

@ -0,0 +1,37 @@
'use client';
import { useToast } from './use-toast';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from './toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1.5">
{title && <ToastTitle className="text-base">{title}</ToastTitle>}
{description && (
<ToastDescription className="text-sm leading-relaxed">
{description}
</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}

View File

@ -0,0 +1,190 @@
'use client';
// Inspired by react-hot-toast library
import * as React from 'react';
import type { ToastActionElement, ToastProps } from './toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t,
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, [state]);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };

View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@ -3,6 +3,7 @@ import '@livekit/components-styles/prefabs';
import '../styles/participant-tile.css';
import '../styles/globals.css';
import type { Metadata, Viewport } from 'next';
import Providers from './providers/providers';
export const metadata: Metadata = {
title: {
@ -42,8 +43,11 @@ export default function RootLayout({ children }: { children: React.ReactNode })
href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap"
rel="stylesheet"
/>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>{children}</body>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}

View File

@ -0,0 +1,11 @@
import React from 'react';
import { Toaster } from '../custom/toast/toaster';
export default function Providers({ children }: { children: React.ReactNode }) {
return (
<>
<Toaster />
{children}
</>
);
}

View File

@ -22,6 +22,7 @@ import { useRouter } from 'next/navigation';
import '../../../styles/PageClientImpl.css';
import { CustomVideoLayout } from '@/lib/CustomVideoLayout';
import { RecordingIndicator } from '@/lib/RecordingIndicator';
import { useToast } from '@/app/custom/toast/use-toast';
const CONN_DETAILS_ENDPOINT =
process.env.NEXT_PUBLIC_CONN_DETAILS_ENDPOINT ?? '/api/connection-details';

View File

@ -3,16 +3,6 @@ import * as React from 'react';
export function RecordingIndicator() {
const isRecording = useIsRecording();
const [wasRecording, setWasRecording] = React.useState(false);
React.useEffect(() => {
if (isRecording !== wasRecording) {
setWasRecording(isRecording);
if (isRecording) {
window.alert('This meeting is being recorded');
}
}
}, [isRecording]);
return (
<div

View File

@ -7,7 +7,6 @@ import {
MediaDeviceMenu,
TrackToggle,
useRoomContext,
useIsRecording,
} from '@livekit/components-react';
import type { KrispNoiseFilterProcessor } from '@livekit/krisp-noise-filter';
@ -23,12 +22,9 @@ export interface SettingsMenuProps extends React.HTMLAttributes<HTMLDivElement>
*/
export function SettingsMenu(props: SettingsMenuProps) {
const layoutContext = useMaybeLayoutContext();
const room = useRoomContext();
const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT;
const settings = React.useMemo(() => {
return {
recording: recordingEndpoint ? { label: 'Recording' } : undefined,
media: { camera: true, microphone: true, label: 'Media Devices', speaker: true },
effects: { label: 'Effects' },
};
@ -73,45 +69,6 @@ export function SettingsMenu(props: SettingsMenuProps) {
}
}, [isNoiseFilterEnabled, microphoneTrack]);
const isRecording = useIsRecording();
const [initialRecStatus, setInitialRecStatus] = React.useState(isRecording);
const [processingRecRequest, setProcessingRecRequest] = React.useState(false);
React.useEffect(() => {
if (initialRecStatus !== isRecording) {
setProcessingRecRequest(false);
}
}, [isRecording, initialRecStatus]);
const toggleRoomRecording = async () => {
if (!recordingEndpoint) {
throw TypeError('No recording endpoint specified');
}
if (room.isE2EEEnabled) {
throw Error('Recording of encrypted meetings is currently not supported');
}
setProcessingRecRequest(true);
setInitialRecStatus(isRecording);
let response: Response;
const now = new Date(Date.now()).toISOString();
const fileName = `${now}-${room.name}.mp4`;
console.log('recoding to S3 file: ', fileName);
if (isRecording) {
response = await fetch(recordingEndpoint + `/stop?roomName=${room.name}`);
} else {
response = await fetch(recordingEndpoint + `/start?roomName=${room.name}&now=${now}`);
}
if (response.ok) {
} else {
console.error(
'Error handling recording request, check server logs:',
response.status,
response.statusText,
);
setProcessingRecRequest(false);
}
};
if (!props.showSettings) return null;
return (
@ -187,21 +144,6 @@ export function SettingsMenu(props: SettingsMenuProps) {
</section>
</>
)}
{activeTab === 'recording' && (
<>
<h3>Record Meeting</h3>
<section>
<p>
{isRecording
? 'Meeting is currently being recorded'
: 'No active recordings for this meeting'}
</p>
<button disabled={processingRecRequest} onClick={() => toggleRoomRecording()}>
{isRecording ? 'Stop' : 'Start'} Recording
</button>
</section>
</>
)}
</div>
<button
className={`lk-button settingsCloseButton`}

View File

@ -14,12 +14,17 @@
"@livekit/components-react": "2.6.0",
"@livekit/components-styles": "1.1.2",
"@livekit/krisp-noise-filter": "^0.2.8",
"@radix-ui/react-toast": "^1.2.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"livekit-client": "^2.8.1",
"livekit-server-sdk": "2.9.7",
"lucide-react": "^0.487.0",
"material-symbols": "^0.28.2",
"next": "14.2.12",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwind-merge": "^3.1.0",
"tinykeys": "^2.1.0"
},
"devDependencies": {

2948
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,6 @@
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 */