diff --git a/.env.example b/.env.example deleted file mode 100644 index f961fa04..00000000 --- a/.env.example +++ /dev/null @@ -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 - diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 0967ef42..00000000 --- a/.eslintrc.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/app/api/record/start/route.ts b/app/api/record/start/route.ts index 4d75896e..07129acb 100644 --- a/app/api/record/start/route.ts +++ b/app/api/record/start/route.ts @@ -1,10 +1,11 @@ -import { EgressClient, EncodedFileOutput, S3Upload } from 'livekit-server-sdk'; +import { EgressClient, EncodedFileOutput, RoomServiceClient, S3Upload } from 'livekit-server-sdk'; import { NextRequest, NextResponse } from 'next/server'; export async function GET(req: NextRequest) { try { const roomName = req.nextUrl.searchParams.get('roomName'); const now = req.nextUrl.searchParams.get('now'); + const identity = req.nextUrl.searchParams.get('identity'); // new Date(Date.now()).toISOString(); /** @@ -68,6 +69,11 @@ export async function GET(req: NextRequest) { layout: 'speaker', }, ); + const roomClient = new RoomServiceClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + await roomClient.updateRoomMetadata( + roomName, + JSON.stringify({ recording: { isRecording: true, recorder: identity } }), + ); if (RUNNER_URL && RUNNER_SECRET) { post_runner(RUNNER_URL, RUNNER_SECRET, filepath); @@ -76,6 +82,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/api/record/stop/route.ts b/app/api/record/stop/route.ts index e2630ac9..c9bfd7fd 100644 --- a/app/api/record/stop/route.ts +++ b/app/api/record/stop/route.ts @@ -1,9 +1,10 @@ -import { EgressClient } from 'livekit-server-sdk'; +import { EgressClient, RoomServiceClient } from 'livekit-server-sdk'; import { NextRequest, NextResponse } from 'next/server'; export async function GET(req: NextRequest) { try { const roomName = req.nextUrl.searchParams.get('roomName'); + const identity = req.nextUrl.searchParams.get('identity'); /** * CAUTION: @@ -22,6 +23,7 @@ export async function GET(req: NextRequest) { hostURL.protocol = 'https:'; const egressClient = new EgressClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); + const roomClient = new RoomServiceClient(hostURL.origin, LIVEKIT_API_KEY, LIVEKIT_API_SECRET); const activeEgresses = (await egressClient.listEgress({ roomName })).filter( (info) => info.status < 2, ); @@ -29,6 +31,10 @@ export async function GET(req: NextRequest) { return new NextResponse('No active recording found', { status: 404 }); } await Promise.all(activeEgresses.map((info) => egressClient.stopEgress(info.egressId))); + await roomClient.updateRoomMetadata( + roomName, + JSON.stringify({ recording: { isRecording: false, recorder: identity } }), + ); return new NextResponse(null, { status: 200 }); } catch (error) { diff --git a/app/contexts/layout-context.tsx b/app/contexts/layout-context.tsx new file mode 100644 index 00000000..b9ea6da0 --- /dev/null +++ b/app/contexts/layout-context.tsx @@ -0,0 +1,61 @@ +import { createContext, useContext } from 'react'; + +type LayoutContextType = { + // isSettingsOpen: SettingsContextType, + isChatOpen: ChatContextType; + isParticipantsListOpen: ParticipantsListContextType; +}; + +export const CustomLayoutContext = createContext(undefined); + +export function useCustomLayoutContext(): LayoutContextType { + const customLayoutContext = useContext(CustomLayoutContext); + if (!customLayoutContext) { + throw Error('Tried to access LayoutContext context outside a LayoutContextProvider provider.'); + } + return customLayoutContext; +} + +interface CustomLayoutContextProviderProps { + layoutContextValue: LayoutContextType; + children: React.ReactNode; +} + +export function CustomLayoutContextProvider({ + layoutContextValue, + children, +}: CustomLayoutContextProviderProps) { + return ( + + {' '} + {children}{' '} + + ); +} + +export type SettingsAction = { + msg: 'toggle_settings'; +}; + +export type SettingsContextType = { + dispatch?: React.Dispatch; + state?: boolean; +}; + +export type ChatAction = { + msg: 'toggle_chat'; +}; + +export type ChatContextType = { + dispatch?: React.Dispatch; + state?: boolean; +}; + +export type ParticipantsListAction = { + msg: 'toggle_participants_list'; +}; + +export type ParticipantsListContextType = { + dispatch?: React.Dispatch; + state?: boolean; +}; diff --git a/app/custom/CustomControlBar.tsx b/app/custom/CustomControlBar.tsx new file mode 100644 index 00000000..5fd7de62 --- /dev/null +++ b/app/custom/CustomControlBar.tsx @@ -0,0 +1,287 @@ +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { + DisconnectButton, + useLayoutContext, + useLocalParticipant, + useRoomContext, +} from '@livekit/components-react'; +import { Room, RoomEvent, Track } from 'livekit-client'; +import { mergeClasses } from '@/lib/client-utils'; +import { ToggleSource } from '@livekit/components-core'; +import '../../styles/CustomControlBar.css'; +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'; + +export function CustomControlBar() { + const room = useRoomContext(); + const recordingEndpoint = process.env.NEXT_PUBLIC_LK_RECORD_ENDPOINT; + const { localParticipant } = useLocalParticipant(); + const [isRecordingRequestPending, setIsRecordingRequestPending] = useState(false); + const [participantCount, setParticipantCount] = useState(room.numParticipants); + const { dispatch } = useLayoutContext().widget; + const { isParticipantsListOpen, isChatOpen } = useCustomLayoutContext(); + const { toast } = useToast(); + const [recordingState, setRecordingState] = useState({ + recording: { isRecording: false, recorder: '' }, + }); + const isRecording = useMemo(() => { + return recordingState.recording.isRecording; + }, [recordingState]); + const isSelfRecord = useMemo(() => { + return recordingState.recording.recorder === localParticipant.identity; + }, [recordingState]); + + const [isFirstMount, setIsFirstMount] = useState(true); + + useEffect(() => { + setIsFirstMount(false); + }, []); + + useEffect(() => { + if (isRecording) { + toast({ + title: 'Recording in progress. Please be aware this call is being recorded.', + }); + } else { + if (isFirstMount) return; + toast({ + title: 'Recorded ended. This call is no longer being recorded.', + }); + } + }, [isRecording]); + + function ToggleParticipantsList() { + if (isParticipantsListOpen.dispatch) + isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' }); + } + + const toggleChat = () => { + if (isChatOpen.dispatch) isChatOpen.dispatch({ msg: 'toggle_chat' }); + }; + + const toggleRoomRecording = async () => { + if (isRecordingRequestPending || (isRecording && !isSelfRecord)) 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}&identity=${localParticipant.identity}`, + ); + } else { + response = await fetch( + recordingEndpoint + + `/start?roomName=${room.name}&now=${now}&identity=${localParticipant.identity}`, + ); + } + if (response.ok) { + } else { + console.error( + 'Error handling recording request, check server logs:', + response.status, + response.statusText, + ); + } + }; + + const updateRoomMetadata = (metadata: string) => { + const parsedMetadata = JSON.parse(metadata === '' ? '{}' : metadata); + setIsRecordingRequestPending(false); + setRecordingState({ + recording: { + isRecording: parsedMetadata.recording.isRecording, + recorder: parsedMetadata.recording.recorder, + }, + }); + }; + + useEffect(() => { + if (room) { + const updateParticipantCount = () => { + setParticipantCount(room.numParticipants); + }; + + room.on(RoomEvent.Connected, updateParticipantCount); + room.on(RoomEvent.ParticipantConnected, updateParticipantCount); + room.on(RoomEvent.ParticipantDisconnected, updateParticipantCount); + room.on(RoomEvent.RoomMetadataChanged, updateRoomMetadata); + + return () => { + room.off(RoomEvent.Connected, updateParticipantCount); + room.off(RoomEvent.ParticipantConnected, updateParticipantCount); + room.off(RoomEvent.ParticipantDisconnected, updateParticipantCount); + room.off(RoomEvent.RoomMetadataChanged, updateRoomMetadata); + }; + } + }, [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 ( +
+
+ {room.name} + +
+ + {/* Center: Control Buttons */} +
+ + +
+ {isRecording && isSelfRecord ? ( + stop_circle + ) : ( + radio_button_checked + )} +
+ + + + call_end + +
+ + {/* Participants, Settings btn */} +
+
+ people + {participantCount} +
+
+ chat +
+
{ + if (dispatch) dispatch({ msg: 'toggle_settings' }); + }} + > + settings +
+
+
+ ); +} + +interface ControlButtonProps { + enabled?: boolean; + icon: React.ReactNode; + className?: string; + onClick?: () => void; +} + +function ControlButton({ enabled = true, icon, className, onClick }: ControlButtonProps) { + return ( + + ); +} + +function TrackToggle({ source }: { source: ToggleSource }) { + const { enabled, toggle } = useTrackToggle({ source }); + const isScreenShare = source === Track.Source.ScreenShare; + + return ( + } + /> + ); +} + +interface TrackIconProps { + trackSource: ToggleSource; + enabled: boolean; +} + +function TrackIcon({ trackSource, enabled }: TrackIconProps) { + switch (trackSource) { + case Track.Source.Camera: + return enabled ? : ; + case Track.Source.Microphone: + return enabled ? : ; + case Track.Source.ScreenShare: + return enabled ? ( + + ) : ( + + stop_screen_share + + ); + } +} + +// Custom hook for track toggle +function useTrackToggle({ source }: { source: ToggleSource }) { + const { localParticipant } = useRoomContext(); + + const toggle = () => { + switch (source) { + case Track.Source.Camera: + return localParticipant.setCameraEnabled(!enabled); + case Track.Source.Microphone: + return localParticipant.setMicrophoneEnabled(!enabled); + case Track.Source.ScreenShare: + return localParticipant.setScreenShareEnabled(!enabled); + } + }; + + const enabled = (() => { + switch (source) { + case Track.Source.Camera: + return localParticipant.isCameraEnabled; + case Track.Source.Microphone: + return localParticipant.isMicrophoneEnabled; + case Track.Source.ScreenShare: + return localParticipant.isScreenShareEnabled; + } + })(); + + return { enabled, toggle }; +} diff --git a/app/custom/ParticipantList.tsx b/app/custom/ParticipantList.tsx new file mode 100644 index 00000000..0ea0e20d --- /dev/null +++ b/app/custom/ParticipantList.tsx @@ -0,0 +1,116 @@ +import { useRoomContext } from '@livekit/components-react'; +import { Participant, RemoteParticipant } from 'livekit-client'; +import { MicOffSVG, MicOnSVG } from '../svg/mic'; +import { CameraOffSVG, CameraOnSVG } from '../svg/camera'; +import { ScreenShareOnSVG } from '../svg/screen-share'; +import { getAvatarColor, getInitials } from '@/lib/client-utils'; +import { useCustomLayoutContext } from '../contexts/layout-context'; + +const ParticipantList = () => { + const room = useRoomContext(); + const { localParticipant, remoteParticipants } = room; + const { isParticipantsListOpen } = useCustomLayoutContext(); + + function ToggleParticipantList() { + if (isParticipantsListOpen.dispatch) + isParticipantsListOpen.dispatch({ msg: 'toggle_participants_list' }); + } + + return ( +
+
+
+ {room.numParticipants} + Participants +
+
+ close +
+
+ + {[...remoteParticipants.entries()].map((participant) => { + return ; + })} +
+ ); +}; + +export default ParticipantList; + +interface ParticipantItemProps { + participant: Participant; +} + +const ParticipantItem: React.FC = ({ participant }) => { + const profilePictureUrl = participant.metadata + ? JSON.parse(participant.metadata).profilePictureUrl + : null; + return ( +
+
+
+ {profilePictureUrl ? ( + {participant.name} + ) : ( +
+ {getInitials(participant.name || participant.identity)} +
+ )} +
+
{participant.name}
+
+
+ {participant.isScreenShareEnabled ? : <>} + {participant.isCameraEnabled ? : } + {participant.isMicrophoneEnabled ? : } +
+
+ ); +}; diff --git a/app/custom/VideoConferenceClientImpl.tsx b/app/custom/VideoConferenceClientImpl.tsx index 391ddb61..2ec69b77 100644 --- a/app/custom/VideoConferenceClientImpl.tsx +++ b/app/custom/VideoConferenceClientImpl.tsx @@ -15,7 +15,6 @@ import { import { DebugMode } from '@/lib/Debug'; import { useMemo, useEffect, useState } from 'react'; import { decodePassphrase } from '@/lib/client-utils'; -import { SettingsMenu } from '@/lib/SettingsMenu'; export function VideoConferenceClientImpl(props: { liveKitUrl: string; @@ -65,11 +64,7 @@ export function VideoConferenceClientImpl(props: { return; } console.log('ROOM!!!'); - const updateTranscriptions = ( - segments: TranscriptionSegment[], - participant: any, - publication: any, - ) => { + const updateTranscriptions = (segments: TranscriptionSegment[]) => { console.log('received transcriptions', segments); setTranscriptions((prev) => { const newTranscriptions = { ...prev }; @@ -95,12 +90,7 @@ export function VideoConferenceClientImpl(props: { audio={true} video={true} > - + ); diff --git a/app/custom/VideoTrack.tsx b/app/custom/VideoTrack.tsx new file mode 100644 index 00000000..338d83e8 --- /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