From 1947856c08b30d194062514fec0b62fd83e82ec9 Mon Sep 17 00:00:00 2001 From: SujithThirumalaisamy Date: Sat, 5 Apr 2025 22:03:34 +0530 Subject: [PATCH] Added Recording function to the control bar --- app/api/record/start/route.ts | 1 + app/custom/CustomControlBar.tsx | 85 +- app/custom/toast/toast.tsx | 130 + app/custom/toast/toaster.tsx | 37 + app/custom/toast/use-toast.tsx | 190 ++ app/custom/toast/utils.ts | 6 + app/layout.tsx | 6 +- app/providers/providers.tsx | 11 + app/rooms/[roomName]/PageClientImpl.tsx | 1 + lib/RecordingIndicator.tsx | 10 - lib/SettingsMenu.tsx | 58 - package.json | 5 + pnpm-lock.yaml | 2948 +++++++++++++++++------ styles/CustomControlBar.css | 1 - 14 files changed, 2728 insertions(+), 761 deletions(-) create mode 100644 app/custom/toast/toast.tsx create mode 100644 app/custom/toast/toaster.tsx create mode 100644 app/custom/toast/use-toast.tsx create mode 100644 app/custom/toast/utils.ts create mode 100644 app/providers/providers.tsx diff --git a/app/api/record/start/route.ts b/app/api/record/start/route.ts index 4d75896..1cda0c4 100644 --- a/app/api/record/start/route.ts +++ b/app/api/record/start/route.ts @@ -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 }); } } diff --git a/app/custom/CustomControlBar.tsx b/app/custom/CustomControlBar.tsx index 87055bf..82e18b7 100644 --- a/app/custom/CustomControlBar.tsx +++ b/app/custom/CustomControlBar.tsx @@ -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) { -
- radio_button_checked +
+ {isRecording ? ( + + stop_circle + + ) : ( + radio_button_checked + )}
diff --git a/app/custom/toast/toast.tsx b/app/custom/toast/toast.tsx new file mode 100644 index 0000000..fd0d84a --- /dev/null +++ b/app/custom/toast/toast.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +}; diff --git a/app/custom/toast/toaster.tsx b/app/custom/toast/toaster.tsx new file mode 100644 index 0000000..56fe23d --- /dev/null +++ b/app/custom/toast/toaster.tsx @@ -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 ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + + {description} + + )} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/app/custom/toast/use-toast.tsx b/app/custom/toast/use-toast.tsx new file mode 100644 index 0000000..0a9a4ab --- /dev/null +++ b/app/custom/toast/use-toast.tsx @@ -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; + } + | { + type: ActionType['DISMISS_TOAST']; + toastId?: ToasterToast['id']; + } + | { + type: ActionType['REMOVE_TOAST']; + toastId?: ToasterToast['id']; + }; + +interface State { + toasts: ToasterToast[]; +} + +const toastTimeouts = new Map>(); + +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; + +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(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 }; diff --git a/app/custom/toast/utils.ts b/app/custom/toast/utils.ts new file mode 100644 index 0000000..2819a83 --- /dev/null +++ b/app/custom/toast/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/app/layout.tsx b/app/layout.tsx index 0c93c4f..65698a0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -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" /> + - {children} + + {children} + ); } diff --git a/app/providers/providers.tsx b/app/providers/providers.tsx new file mode 100644 index 0000000..dd616e7 --- /dev/null +++ b/app/providers/providers.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { Toaster } from '../custom/toast/toaster'; + +export default function Providers({ children }: { children: React.ReactNode }) { + return ( + <> + + {children} + + ); +} diff --git a/app/rooms/[roomName]/PageClientImpl.tsx b/app/rooms/[roomName]/PageClientImpl.tsx index 46a519f..e6ce971 100644 --- a/app/rooms/[roomName]/PageClientImpl.tsx +++ b/app/rooms/[roomName]/PageClientImpl.tsx @@ -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'; diff --git a/lib/RecordingIndicator.tsx b/lib/RecordingIndicator.tsx index 877ed9a..5463303 100644 --- a/lib/RecordingIndicator.tsx +++ b/lib/RecordingIndicator.tsx @@ -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 (
*/ 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) { )} - {activeTab === 'recording' && ( - <> -

Record Meeting

-
-

- {isRecording - ? 'Meeting is currently being recorded' - : 'No active recordings for this meeting'} -

- -
- - )}