import { SpeakingExercise } from "@/interfaces/exam"; import { CommonProps } from "."; import { Fragment, useEffect, useState } from "react"; import { BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill } from "react-icons/bs"; import dynamic from "next/dynamic"; import Button from "../Low/Button"; import useExamStore from "@/stores/examStore"; import { downloadBlob } from "@/utils/evaluation"; import axios from "axios"; import Modal from "../Modal"; const Waveform = dynamic(() => import("../Waveform"), { ssr: false }); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { ssr: false, }); export default function Speaking({ id, title, text, video_url, type, prompts, suffix, userSolutions, onNext, onBack }: SpeakingExercise & CommonProps) { const [recordingDuration, setRecordingDuration] = useState(0); const [isRecording, setIsRecording] = useState(false); const [mediaBlob, setMediaBlob] = useState(); const [audioURL, setAudioURL] = useState(); const [isLoading, setIsLoading] = useState(false); const [isPromptsModalOpen, setIsPromptsModalOpen] = useState(false); const [inputText, setInputText] = useState(""); const hasExamEnded = useExamStore((state) => state.hasExamEnded); const saveToStorage = async () => { if (mediaBlob && mediaBlob.startsWith("blob")) { const blobBuffer = await downloadBlob(mediaBlob); const audioFile = new File([blobBuffer], "audio.wav", { type: "audio/wav" }); const seed = Math.random().toString().replace("0.", ""); const formData = new FormData(); formData.append("audio", audioFile, `${seed}.wav`); formData.append("root", "speaking_recordings"); const config = { headers: { "Content-Type": "audio/wav", }, }; const response = await axios.post<{ path: string }>("/api/storage/insert", formData, config); if (audioURL) await axios.post("/api/storage/delete", { path: audioURL }); return response.data.path; } return undefined; }; useEffect(() => { if (userSolutions.length > 0) { const { solution } = userSolutions[0] as { solution?: string }; if (solution && !mediaBlob) setMediaBlob(solution); if (solution && !solution.startsWith("blob")) setAudioURL(solution); } }, [userSolutions, mediaBlob]); useEffect(() => { if (hasExamEnded) next(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [hasExamEnded]); useEffect(() => { let recordingInterval: NodeJS.Timer | undefined = undefined; if (isRecording) { recordingInterval = setInterval(() => setRecordingDuration((prev) => prev + 1), 1000); } else if (recordingInterval) { clearInterval(recordingInterval); } return () => { if (recordingInterval) clearInterval(recordingInterval); }; }, [isRecording]); const next = async () => { onNext({ exercise: id, solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], score: { correct: 0, total: 100, missing: 0 }, type, }); }; const back = async () => { onBack({ exercise: id, solutions: mediaBlob ? [{ id, solution: mediaBlob }] : [], score: { correct: 0, total: 100, missing: 0 }, type, }); }; const handleNoteWriting = (e: React.ChangeEvent) => { const newText = e.target.value; const words = newText.match(/\S+/g); const wordCount = words ? words.length : 0; if (wordCount <= 100) { setInputText(newText); } else { let count = 0; let lastIndex = 0; const matches = newText.matchAll(/\S+/g); for (const match of matches) { count++; if (count > 100) break; lastIndex = match.index! + match[0].length; } setInputText(newText.slice(0, lastIndex)); } }; return (
setIsPromptsModalOpen(false)}>
{prompts.map((x, index) => (
  • {x}
  • ))}
    {!!suffix && {suffix}}
    {title} {prompts.length > 0 && ( You should talk for at least 1 minute and 30 seconds for your answer to be valid. )}
    {!video_url && ( {text.split("\\n").map((line, index) => ( {line}
    ))}
    )}
    {video_url && (
    )} {prompts && prompts.length > 0 && }
    {prompts && prompts.length > 0 && (