diff --git a/.gitignore b/.gitignore index 21bda7d..b7dc12f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,4 @@ next-env.d.ts .idea package-lock.json /*.yaml +/*.env diff --git a/package.json b/package.json index 6146f26..013b3a1 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "lint": "next lint" }, "dependencies": { + "@heygen/streaming-avatar": "^1.0.16", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", diff --git a/src/app/(routes)/interview/video-call/dialog.tsx b/src/app/(routes)/interview/video-call/dialog.tsx new file mode 100644 index 0000000..f2cb18e --- /dev/null +++ b/src/app/(routes)/interview/video-call/dialog.tsx @@ -0,0 +1,180 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +interface DeviceInfo { + deviceId: string; + label: string; +} + +interface OptionsDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + selectedAvatar: any; + onStartInterview: (cameraId: string, microphoneId: string) => void; +} + +export const OptionsDialog: React.FC = ({ + isOpen, + onOpenChange, + selectedAvatar, + onStartInterview +}) => { + const [cameraDevices, setCameraDevices] = useState([]); + const [microphoneDevices, setMicrophoneDevices] = useState([]); + const [selectedCamera, setSelectedCamera] = useState(''); + const [selectedMicrophone, setSelectedMicrophone] = useState(''); + const [isCameraReady, setIsCameraReady] = useState(false); + const [cameraError, setCameraError] = useState(null); + + useEffect(() => { + if (isOpen) { + requestPermissions(); + } + }, [isOpen]); + + const requestPermissions = async () => { + try { + await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); + await getDevices(); + } catch (err) { + console.error('Error requesting permissions:', err); + setCameraError('Failed to get camera and microphone permissions. Please allow access and try again.'); + } + }; + + const getDevices = async () => { + try { + const devices = await navigator.mediaDevices.enumerateDevices(); + const cameras = devices.filter(device => device.kind === 'videoinput'); + const microphones = devices.filter(device => device.kind === 'audioinput'); + setCameraDevices(cameras.map(camera => ({ deviceId: camera.deviceId, label: camera.label }))); + setMicrophoneDevices(microphones.map(mic => ({ deviceId: mic.deviceId, label: mic.label }))); + if (cameras.length > 0) setSelectedCamera(cameras[0].deviceId); + if (microphones.length > 0) setSelectedMicrophone(microphones[0].deviceId); + } catch (err) { + console.error('Error getting devices:', err); + setCameraError('Failed to get camera and microphone devices. Please check your permissions.'); + } + }; + + const initializeCamera = async () => { + if (!selectedCamera) { + console.error('No camera selected'); + setCameraError('No camera selected. Please choose a camera from the list.'); + return; + } + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: selectedCamera } }, + audio: false + }); + + // We're just testing if we can get the stream, then stopping it immediately + stream.getTracks().forEach(track => track.stop()); + setIsCameraReady(true); + setCameraError(null); + } catch (err) { + console.error('Error initializing camera:', err); + let errorMessage = 'Failed to initialize camera. '; + if (err instanceof DOMException) { + switch (err.name) { + case 'NotFoundError': + errorMessage += 'Camera not found. Please ensure your camera is connected and not in use by another application.'; + break; + case 'NotAllowedError': + errorMessage += 'Camera access denied. Please grant permission to use the camera.'; + break; + case 'NotReadableError': + errorMessage += 'Could not start video source. Please try closing other applications that might be using the camera.'; + break; + default: + errorMessage += 'Please check your camera permissions and try again.'; + } + } + setCameraError(errorMessage); + setIsCameraReady(false); + } + }; + + useEffect(() => { + if (selectedCamera) { + initializeCamera(); + } + }, [selectedCamera]); + + const handleStartInterview = () => { + onStartInterview(selectedCamera, selectedMicrophone); + }; + + return ( + + + + Start Interview with {selectedAvatar?.avatar_name}? + +

Are you ready to begin your AI-powered interview experience?

+ {selectedAvatar && ( + + )} +
+ {cameraDevices.length > 0 && ( +
+ + +
+ )} + {microphoneDevices.length > 0 && ( +
+ + +
+ )} + + {cameraError && ( +

{cameraError}

+ )} +
+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/(routes)/interview/video-call/interviewerListProps .tsx b/src/app/(routes)/interview/video-call/interviewerListProps .tsx new file mode 100644 index 0000000..bc56576 --- /dev/null +++ b/src/app/(routes)/interview/video-call/interviewerListProps .tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Card, CardContent } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar" +import { ScrollArea } from "@/components/ui/scroll-area" + +interface Avatar { + avatar_id: string; + avatar_name: string; + gender: string; + preview_image_url: string; + preview_video_url: string; +} + +interface InterviewerListProps { + avatars: Avatar[]; + onSelectAvatar: (avatar: Avatar) => void; +} + +export const InterviewerList: React.FC = ({ avatars, onSelectAvatar }) => { + return ( + + + +
+ {avatars.map((avatar) => ( + + +
+ + + {avatar.avatar_name[0]} + +

{avatar.avatar_name}

+
+ +
+
+ ))} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/src/app/(routes)/interview/video-call/page.tsx b/src/app/(routes)/interview/video-call/page.tsx new file mode 100644 index 0000000..6dabf09 --- /dev/null +++ b/src/app/(routes)/interview/video-call/page.tsx @@ -0,0 +1,147 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { createVideoChatManagerService } from '@/modules/interview_manager/application/service/videoChatManagerService' +import { createVideoChatManagerRepositoryAdapter } from '@/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapter' +import { Skeleton } from "@/components/ui/skeleton" +import { Button } from "@/components/ui/button" +import { OptionsDialog } from './dialog' +import { VideoCall } from './videoCall' +import { InterviewerList } from './interviewerListProps ' + +const api_token = "MDdlYzkyNjljY2M2NDQyZjg1ZTAwYjQxMDQ2OWZkMGYtMTcyMjM5NzAxMA==" + +interface Avatar { + avatar_id: string; + avatar_name: string; + gender: string; + preview_image_url: string; + preview_video_url: string; +} + +export default function Page() { + const [avatars, setAvatars] = useState([]) + const [selectedAvatar, setSelectedAvatar] = useState(null) + const [isInterviewStarted, setIsInterviewStarted] = useState(false) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [streamingToken, setStreamingToken] = useState(null) + const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false) + const [selectedCameraId, setSelectedCameraId] = useState('') + const [selectedMicrophoneId, setSelectedMicrophoneId] = useState('') + + const videoChatRepositoryPort = createVideoChatManagerRepositoryAdapter(); + const videoChatService = createVideoChatManagerService(videoChatRepositoryPort); + + useEffect(() => { + const fetchAvatars = async () => { +if (!api_token) { + throw new Error("API token is not defined") + } + try { + const response = await fetch('https://api.heygen.com/v2/avatars', { + headers: { + 'x-api-key': api_token, + }, + }) + if (!response.ok) throw new Error('Failed to fetch avatars') + const data = await response.json() + const filteredAvatars = data.data.avatars + .filter((avatar: Avatar) => + !avatar.avatar_name.includes('(Left)') && !avatar.avatar_name.includes('(Right)') + ) + .map((avatar: Avatar) => ({ + ...avatar, + avatar_name: avatar.avatar_name.replace(' (Front)', '') + })) + setAvatars(filteredAvatars) + } catch (err) { + setError('Failed to load avatars. Please try again later.') + } finally { + setIsLoading(false) + } + } + + fetchAvatars() + }, []) + + const getStreamingToken = async () => { + try { + const response = await videoChatService.getToken() + if (!response) throw new Error('Failed to get streaming token') + setStreamingToken(response.token) + } catch (err) { + setError('Failed to get streaming token. Please try again.') + } + } + + const handleSelectAvatar = (avatar: Avatar) => { + setSelectedAvatar(avatar) + getStreamingToken() + setIsConfirmDialogOpen(true) + } + + const handleStartInterview = (cameraId: string, microphoneId: string) => { + setSelectedCameraId(cameraId) + setSelectedMicrophoneId(microphoneId) + setIsInterviewStarted(true) + setIsConfirmDialogOpen(false) + } + + const handleEndInterview = () => { + setIsInterviewStarted(false) + setSelectedAvatar(null) + setStreamingToken(null) + setSelectedCameraId('') + setSelectedMicrophoneId('') + } + + if (isLoading) { + return ( +
+ + + +
+ ) + } + + if (error) { + return ( +
+

{error}

+ +
+ ) + } + + return ( +
+

AI Interview Experience

+ + {!isInterviewStarted ? ( + + ) : ( + + )} + + +
+ ) +} \ No newline at end of file diff --git a/src/app/(routes)/interview/video-call/videoCall.tsx b/src/app/(routes)/interview/video-call/videoCall.tsx new file mode 100644 index 0000000..8e34c6a --- /dev/null +++ b/src/app/(routes)/interview/video-call/videoCall.tsx @@ -0,0 +1,148 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { Button } from "@/components/ui/button" +import { SquareIcon, RefreshCcw } from 'lucide-react' +import { Configuration, NewSessionData, StreamingAvatarApi } from "@heygen/streaming-avatar" + +interface VideoCallProps { + selectedAvatar: any; + streamingToken: string; + cameraId: string; + microphoneId: string; + onEndInterview: () => void; +} + +export const VideoCall: React.FC = ({ + selectedAvatar, + streamingToken, + cameraId, + microphoneId, + onEndInterview +}) => { + const userVideoRef = useRef(null); + const avatarVideoRef = useRef(null); + const [userStream, setUserStream] = useState(null); + const [avatarStream, setAvatarStream] = useState(null); + const [cameraError, setCameraError] = useState(null); + const [sessionData, setSessionData] = useState(null); + const avatarApiRef = useRef(null); + + useEffect(() => { + initializeStreams(); + return () => { + stopStreams(); + }; + }, []); + + const initializeStreams = async () => { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { deviceId: { exact: cameraId } }, + audio: { deviceId: { exact: microphoneId } } + }); + setUserStream(stream); + + avatarApiRef.current = new StreamingAvatarApi( + new Configuration({ + accessToken: streamingToken, + }) + ); + + const res = await avatarApiRef.current.createStartAvatar({ + newSessionRequest: { + quality: 'high', + avatarName: selectedAvatar.avatar_id, + voice: { voiceId: '077ab11b14f04ce0b49b5f6e5cc20979' }, + }, + }); + + setSessionData(res); + setAvatarStream(avatarApiRef.current.mediaStream); + } catch (err) { + console.error('Error initializing streams:', err); + setCameraError('Failed to start video call. Please try again.'); + } + }; + + const stopStreams = () => { + if (userStream) { + userStream.getTracks().forEach(track => track.stop()); + } + if (avatarApiRef.current && sessionData) { + avatarApiRef.current.stopAvatar({ + stopSessionRequest: { sessionId: sessionData.sessionId }, + }); + } + }; + + const retryCamera = () => { + setCameraError(null); + initializeStreams(); + }; + + useEffect(() => { + if (userStream && userVideoRef.current) { + userVideoRef.current.srcObject = userStream; + } + }, [userStream]); + + useEffect(() => { + if (avatarStream && avatarVideoRef.current) { + avatarVideoRef.current.srcObject = avatarStream; + } + }, [avatarStream]); + + const handleEndInterview = () => { + stopStreams(); + onEndInterview(); + }; + + return ( +
+
+ {cameraError ? ( +
+ Camera Error: + {cameraError} + +
+ ) : ( +
+
+ {avatarStream ? ( + + ) : ( +

Waiting for avatar stream...

+ )} +
+
+ +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..01ff19c --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..01b8b6d --- /dev/null +++ b/src/components/ui/skeleton.tsx @@ -0,0 +1,15 @@ +import { cn } from "@/lib/utils" + +function Skeleton({ + className, + ...props +}: React.HTMLAttributes) { + return ( +
+ ) +} + +export { Skeleton } diff --git a/src/lib/constants.ts b/src/lib/constants.ts new file mode 100644 index 0000000..6937b64 --- /dev/null +++ b/src/lib/constants.ts @@ -0,0 +1,135 @@ +export const AVATARS = [ + { + avatar_id: "Eric_public_pro2_20230608", + name: "Edward in Blue Shirt", + }, + { + avatar_id: "Tyler-incasualsuit-20220721", + name: "Tyler in Casual Suit", + }, + { + avatar_id: "Anna_public_3_20240108", + name: "Anna in Brown T-shirt", + }, + { + avatar_id: "Susan_public_2_20240328", + name: "Susan in Black Shirt", + }, + { + avatar_id: "josh_lite3_20230714", + name: "Joshua Heygen CEO", + }, +]; + +export const VOICES = [ + { + voice_id: "077ab11b14f04ce0b49b5f6e5cc20979", + language: "English", + gender: "Male", + name: "Paul - Natural", + preview_audio: + "https://static.heygen.ai/voice_preview/k6dKrFe85PisZ3FMLeppUM.mp3", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "131a436c47064f708210df6628ef8f32", + language: "English", + gender: "Female", + name: "Amber - Friendly", + preview_audio: + "https://static.heygen.ai/voice_preview/5HHGT48B6g6aSg2buYcBvw.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "0ebe70d83b2349529e56492c002c9572", + language: "English", + gender: "Male", + name: "Antoni - Friendly", + preview_audio: + "https://static.heygen.ai/voice_preview/TwupgZ2az5RiTnmAifPmmS.mp3", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "1bd001e7e50f421d891986aad5158bc8", + language: "English", + gender: "Female", + name: "Sara - Cheerful", + preview_audio: + "https://static.heygen.ai/voice_preview/func8CFnfVLKF2VzGDCDCR.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "001cc6d54eae4ca2b5fb16ca8e8eb9bb", + language: "Spanish", + gender: "Male", + name: "Elias - Natural", + preview_audio: + "https://static.heygen.ai/voice_preview/JmCb3rgMZnCjCAA9aacnGj.wav", + support_pause: false, + emotion_support: false, + }, + { + voice_id: "00988b7d451d0722635ff7b2b9540a7b", + language: "Portuguese", + gender: "Female", + name: "Brenda - Professional", + preview_audio: + "https://static.heygen.ai/voice_preview/fec6396adb73461c9997b2c0d7759b7b.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "00c8fd447ad7480ab1785825978a2215", + language: "Chinese", + gender: "Female", + name: "Xiaoxuan - Serious", + preview_audio: + "https://static.heygen.ai/voice_preview/909633f8d34e408a9aaa4e1b60586865.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "00ed77fac8b84ffcb2ab52739b9dccd3", + language: "Latvian", + gender: "Male", + name: "Nils - Affinity", + preview_audio: + "https://static.heygen.ai/voice_preview/KwTwAz3R4aBFN69fEYQFdX.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "02bec3b4cb514722a84e4e18d596fddf", + language: "Arabic", + gender: "Female", + name: "Fatima - Professional", + preview_audio: + "https://static.heygen.ai/voice_preview/930a245487fe42158c810ac76b8ddbab.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "04e95f5bcb8b4620a2c4ef45b8a4481a", + language: "Ukrainian", + gender: "Female", + name: "Polina - Professional", + preview_audio: + "https://static.heygen.ai/voice_preview/ntekV94yFpvv4RgBVPqW7c.wav", + support_pause: true, + emotion_support: false, + }, + { + voice_id: "071d6bea6a7f455b82b6364dab9104a2", + language: "German", + gender: "Male", + name: "Jan - Natural", + preview_audio: + "https://static.heygen.ai/voice_preview/fa3728bed81a4d11b8ccef10506af5f4.wav", + support_pause: true, + emotion_support: false, + }, +]; diff --git a/src/modules/interview_manager/application/service/videoChatManagerService.ts b/src/modules/interview_manager/application/service/videoChatManagerService.ts new file mode 100644 index 0000000..eee3fa8 --- /dev/null +++ b/src/modules/interview_manager/application/service/videoChatManagerService.ts @@ -0,0 +1,9 @@ +import { VideoChatManagerRepositoryPort } from '../../domain/port/videoChatManagerRepositoryPort'; + +export const createVideoChatManagerService = (videoChatManagerRepositoryPort: VideoChatManagerRepositoryPort) => { + return { + getToken: async () => { + return videoChatManagerRepositoryPort.getToken(); + } + } +} \ No newline at end of file diff --git a/src/modules/interview_manager/domain/model/__mocks__/videoChatManagerMock.ts b/src/modules/interview_manager/domain/model/__mocks__/videoChatManagerMock.ts new file mode 100644 index 0000000..f954e12 --- /dev/null +++ b/src/modules/interview_manager/domain/model/__mocks__/videoChatManagerMock.ts @@ -0,0 +1,5 @@ +import { TokenVideoChatManager } from "../videoChatManager"; + +export const createVideoChatManagerMock: TokenVideoChatManager = { + token: "1" +} \ No newline at end of file diff --git a/src/modules/interview_manager/domain/model/videoChatManager.ts b/src/modules/interview_manager/domain/model/videoChatManager.ts new file mode 100644 index 0000000..0e1fe26 --- /dev/null +++ b/src/modules/interview_manager/domain/model/videoChatManager.ts @@ -0,0 +1,3 @@ +export type TokenVideoChatManager = { + token: string; +} \ No newline at end of file diff --git a/src/modules/interview_manager/domain/port/videoChatManagerRepositoryPort.ts b/src/modules/interview_manager/domain/port/videoChatManagerRepositoryPort.ts new file mode 100644 index 0000000..1bef096 --- /dev/null +++ b/src/modules/interview_manager/domain/port/videoChatManagerRepositoryPort.ts @@ -0,0 +1,5 @@ +import { TokenVideoChatManager } from "../model/videoChatManager"; + +export interface VideoChatManagerRepositoryPort { + getToken: () => Promise; +} \ No newline at end of file diff --git a/src/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapter.ts b/src/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapter.ts new file mode 100644 index 0000000..8105c07 --- /dev/null +++ b/src/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapter.ts @@ -0,0 +1,37 @@ +import { TokenVideoChatManager } from '../../domain/model/videoChatManager'; +import { VideoChatManagerRepositoryPort } from '../../domain/port/videoChatManagerRepositoryPort'; +const api_token = "MDdlYzkyNjljY2M2NDQyZjg1ZTAwYjQxMDQ2OWZkMGYtMTcyMjM5NzAxMA==" + +export const createVideoChatManagerRepositoryAdapter = (): VideoChatManagerRepositoryPort => { + return { + getToken: async (): Promise => { + if (!api_token) { + throw new Error("API token is not defined") + } + + try { + const response = await fetch( + "https://api.heygen.com/v1/streaming.create_token", + { + method: "POST", + headers: { + "x-api-key": api_token, + "content-type": "application/json", + }, + body: JSON.stringify({}), + } + ) + + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`) + } + const data = await response.json() + + return {token: data.data.token} + } catch (error: any) { + console.error(error) + return {token: ""} + } + } + } +} \ No newline at end of file diff --git a/src/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapterMock.ts b/src/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapterMock.ts new file mode 100644 index 0000000..83d5479 --- /dev/null +++ b/src/modules/interview_manager/infrastructure/adapter/videoChatManagerRepositoryAdapterMock.ts @@ -0,0 +1,11 @@ +import { createVideoChatManagerMock } from '../../domain/model/__mocks__/videoChatManagerMock'; +import { TokenVideoChatManager } from '../../domain/model/videoChatManager'; +import { VideoChatManagerRepositoryPort } from '../../domain/port/videoChatManagerRepositoryPort'; + +export const createVideoChatManagerRepositoryAdapterMock = (): VideoChatManagerRepositoryPort => { + return { + getToken: async (): Promise => { + return createVideoChatManagerMock; + } + } +} \ No newline at end of file