frontend; backend: adiciona sistema novo de gravacao de provas
Signed-off-by: Rodrigo B. de Sousa Martins <rodrigo.sousa.577@gmail.com>
This commit is contained in:
1 parent
0750b26208
commit
b5ae8c4f24
13 files changed
+1475
-10
No files matched your search
@@ -0,0 +1,397 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
interface SessionRecordingProps {
|
||||
onSessionStart?: (sessionId: string) => void;
|
||||
onSessionEnd?: (sessionId: string, reportPath: string) => void;
|
||||
}
|
||||
|
||||
export const SessionRecording: React.FC<SessionRecordingProps> = ({
|
||||
onSessionStart,
|
||||
onSessionEnd
|
||||
}) => {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const recordedChunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
// Coletar informações de contexto
|
||||
const metadata = await collectContextMetadata();
|
||||
|
||||
// Criar sessão no backend
|
||||
const response = await fetch('/api/sessions/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(metadata)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao criar sessão');
|
||||
}
|
||||
|
||||
const { sessionId: newSessionId } = await response.json();
|
||||
setSessionId(newSessionId);
|
||||
|
||||
// Solicitar captura de tela
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { mediaSource: 'screen' },
|
||||
audio: true
|
||||
});
|
||||
|
||||
streamRef.current = stream;
|
||||
|
||||
// Configurar MediaRecorder
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: 'video/webm;codecs=vp9'
|
||||
});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
recordedChunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
recordedChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = () => {
|
||||
handleRecordingStop(newSessionId);
|
||||
};
|
||||
|
||||
// Iniciar gravação
|
||||
mediaRecorder.start(1000); // Gravar em chunks de 1 segundo
|
||||
setIsRecording(true);
|
||||
setRecordingTime(0);
|
||||
|
||||
// Iniciar timer
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime(prev => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// Overlay de informações na tela
|
||||
createScreenOverlay(metadata);
|
||||
|
||||
onSessionStart?.(newSessionId);
|
||||
toast.success('Gravação de sessão iniciada!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação:', error);
|
||||
toast.error('Erro ao iniciar gravação: ' + error.message);
|
||||
}
|
||||
}, [onSessionStart]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
|
||||
removeScreenOverlay();
|
||||
}
|
||||
}, [isRecording]);
|
||||
|
||||
const takeScreenshot = useCallback(async () => {
|
||||
if (!sessionId || !streamRef.current) {
|
||||
toast.error('Sessão não ativa');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Capturar screenshot usando Canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
const video = document.createElement('video');
|
||||
|
||||
video.srcObject = streamRef.current;
|
||||
video.play();
|
||||
|
||||
video.onloadedmetadata = async () => {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx?.drawImage(video, 0, 0);
|
||||
|
||||
// Converter para base64
|
||||
const screenshotData = canvas.toDataURL('image/png');
|
||||
|
||||
// Enviar para o backend
|
||||
const response = await fetch(`/api/sessions/${sessionId}/screenshot`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ screenshot: screenshotData })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { filename } = await response.json();
|
||||
setScreenshots(prev => [...prev, filename]);
|
||||
toast.success('Screenshot capturado!');
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao capturar screenshot:', error);
|
||||
toast.error('Erro ao capturar screenshot');
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const handleRecordingStop = async (sessionId: string) => {
|
||||
try {
|
||||
setIsProcessing(true);
|
||||
|
||||
// Combinar chunks do vídeo
|
||||
const blob = new Blob(recordedChunksRef.current, { type: 'video/webm' });
|
||||
|
||||
// Enviar vídeo para o backend
|
||||
const formData = new FormData();
|
||||
formData.append('video', blob, `session_${sessionId}.webm`);
|
||||
|
||||
const uploadResponse = await fetch(`/api/sessions/${sessionId}/video`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error('Erro ao enviar vídeo');
|
||||
}
|
||||
|
||||
// Finalizar sessão e gerar relatório
|
||||
const finalizeResponse = await fetch(`/api/sessions/${sessionId}/finalize`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!finalizeResponse.ok) {
|
||||
throw new Error('Erro ao finalizar sessão');
|
||||
}
|
||||
|
||||
const { reportPath } = await finalizeResponse.json();
|
||||
|
||||
onSessionEnd?.(sessionId, reportPath);
|
||||
toast.success('Sessão finalizada e relatório gerado!');
|
||||
|
||||
// Navegar para página de resultados
|
||||
navigate(`/sessions/report/${sessionId}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro ao processar gravação:', error);
|
||||
toast.error('Erro ao processar gravação: ' + error.message);
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
Gravação de Sessão de Coleta de Prova
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Status da gravação */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${isRecording ? 'bg-red-500 animate-pulse' : 'bg-gray-300'}`} />
|
||||
<span className="font-medium">
|
||||
{isRecording ? 'Gravando' : isProcessing ? 'Processando...' : 'Pronto para gravar'}
|
||||
</span>
|
||||
{isRecording && (
|
||||
<span className="font-mono text-lg text-red-600">
|
||||
{formatTime(recordingTime)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<span className="text-sm text-gray-500">
|
||||
Sessão: {sessionId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controles */}
|
||||
<div className="flex justify-center space-x-4">
|
||||
{!isRecording && !isProcessing && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="px-6 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium"
|
||||
>
|
||||
Iniciar Gravação
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isRecording && (
|
||||
<>
|
||||
<button
|
||||
onClick={takeScreenshot}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
|
||||
>
|
||||
Capturar Tela
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 font-medium"
|
||||
>
|
||||
Parar Gravação
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Informações da sessão */}
|
||||
{(isRecording || screenshots.length > 0) && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-medium text-gray-900 mb-2">Informações da Sessão</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Screenshots capturados:</span>
|
||||
<span className="ml-2 font-medium">{screenshots.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Tempo de gravação:</span>
|
||||
<span className="ml-2 font-medium">{formatTime(recordingTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Lista de screenshots */}
|
||||
{screenshots.length > 0 && (
|
||||
<div className="border-t pt-4">
|
||||
<h3 className="font-medium text-gray-900 mb-2">Screenshots Capturados</h3>
|
||||
<div className="space-y-1">
|
||||
{screenshots.map((screenshot, index) => (
|
||||
<div key={index} className="text-sm text-gray-600">
|
||||
{index + 1}. {screenshot}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProcessing && (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Processando gravação e gerando relatório...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Função para coletar metadados de contexto
|
||||
async function collectContextMetadata() {
|
||||
const getLocation = (): Promise<GeolocationPosition | null> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve(position),
|
||||
() => resolve(null),
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const location = await getLocation();
|
||||
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
platform: navigator.platform,
|
||||
cookieEnabled: navigator.cookieEnabled,
|
||||
onlineStatus: navigator.onLine,
|
||||
screenResolution: `${screen.width}x${screen.height}`,
|
||||
viewportSize: `${window.innerWidth}x${window.innerHeight}`,
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
geolocation: location ? {
|
||||
latitude: location.coords.latitude,
|
||||
longitude: location.coords.longitude,
|
||||
accuracy: location.coords.accuracy
|
||||
} : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
referrer: document.referrer
|
||||
};
|
||||
}
|
||||
|
||||
// Função para criar overlay na tela
|
||||
function createScreenOverlay(metadata: any) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'recording-overlay';
|
||||
overlay.style.cssText = `
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
z-index: 10000;
|
||||
max-width: 300px;
|
||||
`;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div>🔴 GRAVAÇÃO ATIVA</div>
|
||||
<div>IP: ${metadata.ipAddress || 'Coletando...'}</div>
|
||||
<div>Timestamp: ${new Date().toLocaleString()}</div>
|
||||
<div>User-Agent: ${metadata.userAgent.substring(0, 50)}...</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
// Função para remover overlay
|
||||
function removeScreenOverlay() {
|
||||
const overlay = document.getElementById('recording-overlay');
|
||||
if (overlay) {
|
||||
overlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionRecording;
|
||||
Reference in new issue
Block a user