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}
+