"use client"; import Link from "next/link"; import { useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { MAX_AUDIO_FILE_BYTES, formatAllowedMimeTypes, isAllowedAudioMimeType, } from "@/lib/upload-validation"; type ConsentInfo = { audioAllowed: boolean; signedAt: string; }; type CaptureStudent = { id: string; code: string; displayName: string; consent: ConsentInfo | null; }; type CaptureClientProps = { initialStudents: CaptureStudent[]; }; type RecordingState = "idle" | "recording" | "uploading"; const MAX_AUDIO_SIZE_MB = Math.round(MAX_AUDIO_FILE_BYTES / (1024 * 1024)); type ConsentMap = Record; export default function CaptureClient({ initialStudents }: CaptureClientProps) { const [students, setStudents] = useState(initialStudents); const [selectedStudentId, setSelectedStudentId] = useState(""); const [consentByStudent, setConsentByStudent] = useState(() => { return initialStudents.reduce((map, student) => { map[student.id] = student.consent; return map; }, {}); }); const [codeQuery, setCodeQuery] = useState(""); const [recordingState, setRecordingState] = useState("idle"); const [isLoadingConsent, setIsLoadingConsent] = useState(false); const [previewUrl, setPreviewUrl] = useState(null); const [recordingDuration, setRecordingDuration] = useState(null); const mediaRecorderRef = useRef(null); const streamRef = useRef(null); const chunksRef = useRef([]); const startedAtRef = useRef(null); const selectedStudent = useMemo( () => students.find((student) => student.id === selectedStudentId), [selectedStudentId, students] ); const selectedConsent = selectedStudentId ? consentByStudent[selectedStudentId] ?? null : null; const canRecord = Boolean(selectedStudent && selectedConsent?.audioAllowed); useEffect(() => { return () => { if (previewUrl) { URL.revokeObjectURL(previewUrl); } if (mediaRecorderRef.current?.state === "recording") { mediaRecorderRef.current.stop(); } if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); } }; }, [previewUrl]); const refreshConsent = async (studentId: string) => { setIsLoadingConsent(true); try { const response = await fetch(`/api/students/${studentId}/consent`); if (!response.ok) { throw new Error("Falha ao consultar consentimento"); } const payload = (await response.json()) as { consent: ConsentInfo | null }; setConsentByStudent((prev) => ({ ...prev, [studentId]: payload.consent })); } catch (error) { console.error(error); toast.error("Não foi possível atualizar o consentimento agora."); } finally { setIsLoadingConsent(false); } }; const handleStudentChange = (studentId: string) => { setSelectedStudentId(studentId); setRecordingDuration(null); setPreviewUrl((prev) => { if (prev) { URL.revokeObjectURL(prev); } return null; }); void refreshConsent(studentId); }; const handleCodeSearch = async (event: React.FormEvent) => { event.preventDefault(); const trimmed = codeQuery.trim(); if (!trimmed) { return; } const normalized = trimmed.toUpperCase(); const localMatch = students.find((student) => student.code === normalized); if (localMatch) { setSelectedStudentId(localMatch.id); void refreshConsent(localMatch.id); toast.success("Estudante selecionado pelo código."); return; } try { const response = await fetch(`/api/students?search=${encodeURIComponent(trimmed)}`); if (!response.ok) { throw new Error("Erro ao buscar estudante"); } const payload = (await response.json()) as { students: CaptureStudent[] }; const found = payload.students.find((student) => student.code === normalized); if (!found) { toast.error("Nenhum estudante encontrado com esse código."); return; } setStudents((prev) => { const exists = prev.some((student) => student.id === found.id); return exists ? prev : [...prev, { ...found, consent: null }]; }); setSelectedStudentId(found.id); setConsentByStudent((prev) => ({ ...prev, [found.id]: null })); void refreshConsent(found.id); toast.success("Estudante carregado."); } catch (error) { console.error(error); toast.error("Não foi possível localizar o estudante."); } }; const stopStream = () => { if (streamRef.current) { streamRef.current.getTracks().forEach((track) => track.stop()); streamRef.current = null; } }; const handleStartRecording = async () => { if (!selectedStudent) { toast.error("Selecione um estudante antes de gravar."); return; } if (!selectedConsent?.audioAllowed) { toast.error("Gravação bloqueada sem consentimento de áudio."); return; } if (typeof window === "undefined" || typeof MediaRecorder === "undefined") { toast.error("Seu navegador não suporta gravação de áudio."); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); streamRef.current = stream; const recorder = new MediaRecorder(stream, { mimeType: "audio/webm" }); chunksRef.current = []; recorder.ondataavailable = (event) => { if (event.data.size > 0) { chunksRef.current.push(event.data); } }; recorder.onstop = async () => { stopStream(); const blob = new Blob(chunksRef.current, { type: recorder.mimeType }); const duration = startedAtRef.current ? Math.max(0, Date.now() - startedAtRef.current) : null; startedAtRef.current = null; setRecordingDuration(duration); setPreviewUrl((prev) => { if (prev) { URL.revokeObjectURL(prev); } return URL.createObjectURL(blob); }); setRecordingState("uploading"); await uploadRecording(blob, duration ?? undefined); setRecordingState("idle"); }; recorder.start(); startedAtRef.current = Date.now(); mediaRecorderRef.current = recorder; setRecordingState("recording"); toast.message("Gravação iniciada", { description: "Toque em 'Parar' para enviar o áudio.", }); } catch (error) { console.error(error); stopStream(); setRecordingState("idle"); toast.error("Não foi possível iniciar a gravação."); } }; const handleStopRecording = () => { if (mediaRecorderRef.current && mediaRecorderRef.current.state === "recording") { mediaRecorderRef.current.stop(); } }; const uploadRecording = async (blob: Blob, durationMs?: number) => { if (!selectedStudent) { toast.error("Selecione um estudante antes de enviar."); return; } const mimeType = blob.type || "audio/webm"; if (!isAllowedAudioMimeType(mimeType)) { toast.error( `Formato de áudio não permitido. Use apenas: ${formatAllowedMimeTypes()}.` ); setRecordingState("idle"); return; } if (blob.size > MAX_AUDIO_FILE_BYTES) { toast.error( `Arquivo muito grande. Limite máximo: ${MAX_AUDIO_SIZE_MB}MB.` ); setRecordingState("idle"); return; } try { const signResponse = await fetch("/api/storage/sign-upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mimeType, studentId: selectedStudent.id, sizeBytes: blob.size, }), }); if (!signResponse.ok) { const errorPayload = await signResponse.json().catch(() => null); const message = errorPayload?.error ?? "Erro ao preparar o upload."; throw new Error(message); } const { key, uploadUrl, headers } = (await signResponse.json()) as { key: string; uploadUrl: string; headers?: Record; }; const uploadHeaders = new Headers(headers ?? {}); if (!uploadHeaders.has("Content-Type")) { uploadHeaders.set("Content-Type", mimeType); } const uploadResult = await fetch(uploadUrl, { method: "PUT", headers: uploadHeaders, body: blob, }); if (!uploadResult.ok) { throw new Error("Falha ao enviar o áudio para o armazenamento."); } const payload: Record = { storageKey: key, mimeType, studentId: selectedStudent.id, }; if (durationMs && durationMs > 0) { payload.durationMs = Math.round(durationMs); } const recordingResponse = await fetch("/api/recordings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); if (!recordingResponse.ok) { const errorPayload = await recordingResponse.json().catch(() => null); const message = errorPayload?.error ?? "Não foi possível registrar a gravação."; throw new Error(message); } toast.success("Gravação enviada com sucesso."); } catch (error) { console.error(error); toast.error( error instanceof Error ? error.message : "Não foi possível salvar a gravação." ); } }; const consentStatus = (() => { if (!selectedStudentId) { return "Selecione um estudante para verificar o consentimento."; } if (isLoadingConsent) { return "Consultando consentimento..."; } if (!selectedConsent) { return "Nenhum consentimento registrado para este estudante."; } return selectedConsent.audioAllowed ? "Consentimento para áudio ativo." : "Consentimento de áudio negado."; })(); const formattedDuration = useMemo(() => { if (!recordingDuration) return null; const seconds = Math.round(recordingDuration / 1000); const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${String(minutes).padStart(2, "0")}:${String(remainingSeconds).padStart(2, "0")}`; }, [recordingDuration]); return (

Coleta móvel

Capturar áudio do estudante

Registre gravações breves diretamente no navegador. Certifique-se de que o consentimento de áudio está ativo antes de iniciar.

Selecionar estudante Busque pelo código compartilhado ou escolha na lista da organização.
setCodeQuery(event.target.value.toUpperCase())} className="sm:max-w-xs" />
{students.length > 0 ? (
) : (

Nenhum estudante cadastrado ainda. Cadastre alunos no painel antes de gravar.

)} {selectedStudent ? (
Selecionado: {selectedStudent.code} {selectedStudent.displayName || "Sem nome cadastrado"}

{consentStatus}

) : null}
Gravar áudio Permita o acesso ao microfone do dispositivo. O arquivo será enviado diretamente para o armazenamento seguro. {!selectedConsent?.audioAllowed ? (

Gravação bloqueada

É necessário registrar consentimento de áudio antes de gravar. Confirme com o responsável e registre no painel.

) : null}

Status

  • Estudante: {selectedStudent ? selectedStudent.code : "não selecionado"}
  • Consentimento: {selectedConsent?.audioAllowed ? "autorizado" : "bloqueado"}
  • Microfone: {recordingState === "recording" ? "gravando" : "pronto"}
  • Upload: {recordingState === "uploading" ? "enviando" : "aguardando"}
  • {formattedDuration ?
  • Duração: {formattedDuration}
  • : null}
{previewUrl ? (

Prévia do áudio

) : null}
); }