diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 335bb50..64ee6c5 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -411,6 +411,8 @@ public function getPollData($id) 'temp_password' => $interview->temp_password, 'blocked_ip' => $interview->blocked_ip, 'proctor_logs' => $interview->proctor_logs ?? [], + 'recording_path' => $interview->recording_path, + 'recordings' => $interview->recordings ?? [], 'warnings' => $interview->warnings, 'is_expired' => $interview->isExpired(), ]); @@ -436,16 +438,65 @@ public function uploadRecording(Request $request, $id) if ($request->hasFile('video')) { $file = $request->file('video'); - $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.webm'; + $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.mp4'; $path = $file->storeAs('public/recordings', $filename); + $url = Storage::url($path); - $interview->update(['recording_path' => Storage::url($path)]); - return response()->json(['success' => true, 'path' => Storage::url($path)]); + $recordings = $interview->recordings ?? []; + if (!is_array($recordings)) { + $recordings = $interview->recording_path ? [$interview->recording_path] : []; + } + $recordings[] = [ + 'url' => $url, + 'filename' => $filename, + 'created_at' => now()->toIso8601String(), + ]; + + $interview->update([ + 'recording_path' => $url, + 'recordings' => $recordings, + ]); + + return response()->json(['success' => true, 'path' => $url, 'recordings' => $recordings]); } return response()->json(['success' => false, 'message' => 'No video payload received']); } + /** + * Download Candidate Video Recording as MP4 File. + */ + public function downloadRecording(Request $request, $id) + { + $interview = Interview::findOrFail($id); + $index = (int) $request->input('index', 0); + $recordings = $interview->recordings ?? []; + + $targetUrl = null; + if (!empty($recordings) && isset($recordings[$index])) { + $rec = $recordings[$index]; + $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; + } + + if (!$targetUrl) { + $targetUrl = $interview->recording_path; + } + + if (!$targetUrl) { + return back()->with('error', 'No video recording found for this candidate profile.'); + } + + $relativePath = str_replace('/storage/', 'public/', $targetUrl); + if (Storage::exists($relativePath)) { + $downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.mp4'; + return Storage::download($relativePath, $downloadFilename, [ + 'Content-Type' => 'video/mp4', + ]); + } + + return redirect($targetUrl); + } + /** * Generate Comprehensive Executive Candidate Evaluation & Behavioral Report (PDF View). */ @@ -453,18 +504,22 @@ public function generateReport($id) { $interview = Interview::with(['warnings.sender'])->findOrFail($id); - $logs = $interview->proctor_logs ?? []; + $logs = is_array($interview->proctor_logs) ? $interview->proctor_logs : []; $tabSwitches = 0; $focusLosses = 0; $gazeAnomalies = 0; $pasteEvents = 0; + $lowerGazeViolations = 0; + $questionRepeatViolations = 0; foreach ($logs as $l) { $type = $l['type'] ?? ''; if ($type === 'tab_switch') $tabSwitches++; if ($type === 'focus_lost') $focusLosses++; - if ($type === 'gaze_anomaly') $gazeAnomalies++; + if (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) $gazeAnomalies++; if ($type === 'paste_event') $pasteEvents++; + if ($type === 'gaze_lower_device') $lowerGazeViolations++; + if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++; } $totalViolations = count($logs); @@ -474,7 +529,7 @@ public function generateReport($id) $integrityScore = 100; $integrityLabel = 'Exceptional Integrity'; $integrityColor = '#10b981'; - } elseif ($totalViolations <= 2) { + } elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) { $integrityScore = 85; $integrityLabel = 'Low Behavioral Risk'; $integrityColor = '#06b6d4'; @@ -488,7 +543,7 @@ public function generateReport($id) $integrityColor = '#ef4444'; } - // Calculate Technical Code Score + // Technical Code Score $codeScore = 0; if (!empty($interview->submitted_code)) { $codeScore += 50; @@ -510,6 +565,8 @@ public function generateReport($id) 'focusLosses', 'gazeAnomalies', 'pasteEvents', + 'lowerGazeViolations', + 'questionRepeatViolations', 'integrityScore', 'integrityLabel', 'integrityColor', diff --git a/app/Models/Interview.php b/app/Models/Interview.php index 1fc5d82..36d6eb5 100644 --- a/app/Models/Interview.php +++ b/app/Models/Interview.php @@ -24,6 +24,7 @@ class Interview extends Model 'submitted_language', 'code_output', 'recording_path', + 'recordings', 'submission_unique_id', 'candidate_notes', 'candidate_drawing', @@ -37,6 +38,7 @@ class Interview extends Model 'expires_at' => 'datetime', 'assigned_interviewers' => 'array', 'proctor_logs' => 'array', + 'recordings' => 'array', ]; public function creator() diff --git a/database/migrations/2026_07_22_122000_add_interviewer_notes_to_interviews_table.php b/database/migrations/2026_07_22_122000_add_interviewer_notes_to_interviews_table.php new file mode 100644 index 0000000..bad9a1b --- /dev/null +++ b/database/migrations/2026_07_22_122000_add_interviewer_notes_to_interviews_table.php @@ -0,0 +1,32 @@ +text('interviewer_notes')->nullable()->after('candidate_notes'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + if (Schema::hasColumn('interviews', 'interviewer_notes')) { + $table->dropColumn('interviewer_notes'); + } + }); + } +}; diff --git a/database/migrations/2026_07_23_105435_add_recordings_to_interviews_table.php b/database/migrations/2026_07_23_105435_add_recordings_to_interviews_table.php new file mode 100644 index 0000000..7e138b3 --- /dev/null +++ b/database/migrations/2026_07_23_105435_add_recordings_to_interviews_table.php @@ -0,0 +1,28 @@ +json('recordings')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + $table->dropColumn('recordings'); + }); + } +}; diff --git a/public/Screenshot_13.png b/public/Screenshot_13.png new file mode 100644 index 0000000..4230ee5 Binary files /dev/null and b/public/Screenshot_13.png differ diff --git a/public/Screenshot_14.png b/public/Screenshot_14.png new file mode 100644 index 0000000..7dcd1c9 Binary files /dev/null and b/public/Screenshot_14.png differ diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 15e4a3b..57110bb 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -188,11 +188,6 @@ - -
- 🚨 ATTENTION: Please keep your eyes on the screen during the assessment. -
-
@@ -384,17 +379,17 @@ let recordedChunks = []; let previousFrameData = null; - // Default Code Snippets for Languages + const defaultCode = { - python: `# Python 3 Assessment Solution\ndef main():\n print("Hello from SingleLogin Assessment!")\n num = 42\n print(f"Computed Result: {num * 2}")\n\nif __name__ == "__main__":\n main()`, - cpp: `// C++ Assessment Solution\n#include \nusing namespace std;\n\nint main() {\n cout << "Hello from SingleLogin Assessment!" << endl;\n return 0;\n}`, - c: `// C Assessment Solution\n#include \n\nint main() {\n printf("Hello from SingleLogin Assessment!\\n");\n return 0;\n}`, - java: `// Java Assessment Solution\npublic class Solution {\n public static void main(String[] args) {\n System.out.println("Hello from SingleLogin Assessment!");\n }\n}`, - php: ` a + b, 0));` + python: "# Python 3 Assessment Solution\ndef main():\n print(\"Hello from SingleLogin Assessment!\")\n num = 42\n print(f\"Computed Result: {num * 2}\")\n\nif __name__ == \"__main__\":\n main()", + cpp: "// C++ Assessment Solution\n#include \nusing namespace std;\n\nint main() {\n cout << \"Hello from SingleLogin Assessment!\" << endl;\n return 0;\n}", + c: "// C Assessment Solution\n#include \n\nint main() {\n printf(\"Hello from SingleLogin Assessment!\\n\");\n return 0;\n}", + java: "// Java Assessment Solution\npublic class Solution {\n public static void main(String[] args) {\n System.out.println(\"Hello from SingleLogin Assessment!\");\n }\n}", + php: "<\x3fphp\n// PHP / Laravel Assessment Solution\necho \"Hello from SingleLogin Assessment!\\n\";\n$data = [1, 2, 3, 4, 5];\necho \"Sum: \" . array_sum($data);", + javascript: "// JavaScript (Node.js) Solution\nconsole.log(\"Hello from SingleLogin Assessment!\");\nconst nums = [10, 20, 30];\nconsole.log(\"Total:\", nums.reduce((a, b) => a + b, 0));" }; - // Initialize Monaco Editor + require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } }); require(['vs/editor/editor.main'], function () { const initialLang = '{{ strtolower($interview->language) }}'; @@ -499,16 +494,166 @@ function submitSolution() { } }); - // 2. Window Focus Loss / External Screen Overlap Detection + // 2. Window Focus Loss / External AI Overlay Window Switch Detection (Parakeet AI) window.addEventListener('blur', function () { - logViolation('focus_lost', 'Window lost focus (possible external AI overlay or screen overlap)'); + logViolation('external_ai_detected', 'External AI Assistant Suspicion: Candidate window lost focus (interacting with floating overlay window or Parakeet AI widget)'); }); - // 3. Code Copy-Paste Detection + // 3. Code Copy-Paste & Ingestion Detection document.addEventListener('paste', function (e) { - logViolation('paste_event', 'Candidate pasted content into editor window'); + logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)'); }); + // 4. Parakeet AI Global Hotkey Interception (Alt+Space, Cmd+Space, Ctrl+Shift+A/C/X, Win Key) + document.addEventListener('keydown', function (e) { + const isAltSpace = e.altKey && (e.code === 'Space' || e.keyCode === 32); + const isCmdSpace = e.metaKey && (e.code === 'Space' || e.keyCode === 32); + const isCtrlShift = e.ctrlKey && e.shiftKey && ['KeyA', 'KeyC', 'KeyX', 'KeyV'].includes(e.code); + const isWinKey = e.key === 'Meta' || e.key === 'Win'; + + if (isAltSpace || isCmdSpace || isCtrlShift || isWinKey) { + logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay (Parakeet AI / Hotkey shortcut)`); + } + }); + + // 5. Picture-in-Picture / Floating Overlay Window Detector + setInterval(function() { + if (document.pictureInPictureElement) { + logViolation('external_ai_detected', 'External Picture-in-Picture Floating Window Active: Suspected AI assistant overlay window'); + } + }, 6000); + + // --- ACCURATE EYE GAZE, 10-SECOND LOWER SCREEN STARE & QUESTION REPETITION DETECTION --- + let gazeDownwardCounter = 0; + let gazeDownwardDurationMs = 0; + let hasLogged10sLowerGaze = false; + let gazeFixedStaringCounter = 0; + let previousFramePixels = null; + + function analyzeEyeGazeAndStaring() { + const video = document.getElementById('webcam'); + const canvas = document.getElementById('proctor-canvas'); + if (!video || !canvas || video.readyState !== 4) return; + + const ctx = canvas.getContext('2d'); + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = imgData.data; + + let totalDarkPixels = 0; + let lowerQuarterDarkPixels = 0; + const quarterY = Math.floor(canvas.height * 0.70); + + for (let y = 0; y < canvas.height; y++) { + for (let x = 0; x < canvas.width; x++) { + const idx = (y * canvas.width + x) * 4; + const gray = (pixels[idx] + pixels[idx+1] + pixels[idx+2]) / 3; + + if (gray < 45) { + totalDarkPixels++; + if (y > quarterY) lowerQuarterDarkPixels++; + } + } + } + + const lowerRatio = totalDarkPixels > 0 ? (lowerQuarterDarkPixels / totalDarkPixels) : 0; + + // Lowered Gaze Check (looking down at desk/screen bottom) + if (lowerRatio > 0.40) { + gazeDownwardCounter++; + gazeDownwardDurationMs += 400; + + if (gazeDownwardCounter === 6) { + logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom or desk'); + } + + // Fix: 10.0 seconds of accumulated/continuous lower screen gaze (triggers log + SOS alert) + if (gazeDownwardDurationMs >= 10000 && !hasLogged10sLowerGaze) { + hasLogged10sLowerGaze = true; + logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for more than 10s (suspected reading from mobile or external device)'); + } + } else { + gazeDownwardCounter = Math.max(0, gazeDownwardCounter - 1); + if (gazeDownwardCounter === 0) { + gazeDownwardDurationMs = 0; + hasLogged10sLowerGaze = false; + } + } + + // Fixed Screen Focus / Staring (10 seconds = 25 cycles * 400ms) Check + if (previousFramePixels) { + let frameDeltaSum = 0; + for (let i = 0; i < pixels.length; i += 16) { + frameDeltaSum += Math.abs(pixels[i] - previousFramePixels[i]); + } + const frameDelta = frameDeltaSum / (pixels.length / 16); + + if (frameDelta >= 0.1 && frameDelta < 3.8) { + gazeFixedStaringCounter++; + if (gazeFixedStaringCounter === 25) { // 10.0 seconds of continuous fixed stare + logViolation('gaze_fixed_staring', 'candidate might see other screen'); + } + } else { + gazeFixedStaringCounter = Math.max(0, gazeFixedStaringCounter - 1); + } + } + previousFramePixels = new Uint8Array(pixels); + } + + setInterval(analyzeEyeGazeAndStaring, 400); + + // --- WEB SPEECH RECOGNITION FOR QUESTION REPETITION & AI SPEECH PROMPTING --- + let candidateSpeechHistory = []; + + function initQuestionRepeatDetection() { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + if (!SpeechRecognition) return; + + try { + const speechRec = new SpeechRecognition(); + speechRec.continuous = true; + speechRec.interimResults = false; + speechRec.lang = 'en-US'; + + speechRec.onresult = function(event) { + for (let i = event.resultIndex; i < event.results.length; ++i) { + if (event.results[i].isFinal) { + const transcript = event.results[i][0].transcript.trim().toLowerCase(); + if (transcript.length < 4) continue; + + // Check if candidate is repeating recent spoken text + const isRepeated = candidateSpeechHistory.some(prev => { + return prev === transcript || (transcript.length > 8 && prev.includes(transcript)) || (prev.length > 8 && transcript.includes(prev)); + }); + + candidateSpeechHistory.push(transcript); + if (candidateSpeechHistory.length > 15) candidateSpeechHistory.shift(); + + if (isRepeated) { + if (gazeDownwardCounter > 3) { + logViolation('question_repeat_lower', `Candidate repeating question ("${transcript}") while looking down at lower portion/mobile device`); + } else { + logViolation('question_repetition', `Candidate continuously repeating question out loud: "${transcript}" (suspected Parakeet AI speech prompt)`); + } + } + } + } + }; + + speechRec.onerror = function() { + setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 4000); + }; + + speechRec.onend = function() { + setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 1500); + }; + + speechRec.start(); + } catch(e) {} + } + + initQuestionRepeatDetection(); + function logViolation(type, details) { fetch(`{{ route('interview.violation', $interview->id) }}`, { method: 'POST', @@ -518,6 +663,11 @@ function logViolation(type, details) { }, body: JSON.stringify({ type: type, details: details }) }); + + // Post message over BroadcastChannel to alert interviewer dashboard real-time + if (typeof callSigChannel !== 'undefined' && callSigChannel) { + callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details }); + } } // 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS) diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 4bb503b..6315011 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -246,7 +246,10 @@ $logs = $inv->proctor_logs ?? []; $tabSwitches = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch')); $focusLost = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'focus_lost')); - $gazeAlerts = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_anomaly')); + $gazeAlerts = count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['gaze_anomaly', 'gaze_fixed_staring']))); + $lowerGaze = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'gaze_lower_device')); + $questionRepeat = count(array_filter($logs, fn($l) => in_array($l['type'] ?? '', ['question_repeat_lower', 'question_repetition']))); + $externalAi = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'external_ai_detected')); @endphp @@ -306,6 +309,21 @@ πŸ‘οΈ Eye Gaze Anomaly: {{ $gazeAlerts }} + @if($lowerGaze > 0) + + πŸ“± Lower Gaze (>10s): {{ $lowerGaze }} + + @endif + @if($questionRepeat > 0) + + πŸ—£οΈ Question Repetition: {{ $questionRepeat }} + + @endif + @if($externalAi > 0) + + πŸ€– Parakeet / External AI: {{ $externalAi }} + + @endif
@@ -431,7 +449,7 @@ @@ -461,6 +479,12 @@ + + @@ -559,16 +583,17 @@ ● Live Synced - +
+
- +
@@ -593,6 +618,14 @@
No drawing data submitted by candidate yet.
+ + +
@@ -633,7 +666,7 @@
🚨
-

SOS CHEATING DETECTED

+

candidate might cheating

Proctoring engine flagged candidate cheating activity!
@@ -739,6 +772,7 @@ function switchIdeTab(tab) { document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none'; document.getElementById('tab-content-notes').style.display = (tab === 'notes') ? 'block' : 'none'; document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? 'block' : 'none'; + document.getElementById('tab-content-recordings').style.display = (tab === 'recordings') ? 'block' : 'none'; document.getElementById('tab-btn-code').style.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)'; document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8'; @@ -748,6 +782,9 @@ function switchIdeTab(tab) { document.getElementById('tab-btn-drawing').style.background = (tab === 'drawing') ? '#06b6d4' : 'rgba(255,255,255,0.05)'; document.getElementById('tab-btn-drawing').style.color = (tab === 'drawing') ? 'white' : '#94a3b8'; + + document.getElementById('tab-btn-recordings').style.background = (tab === 'recordings') ? '#10b981' : 'rgba(255,255,255,0.05)'; + document.getElementById('tab-btn-recordings').style.color = (tab === 'recordings') ? 'white' : '#94a3b8'; } function openReviewModal(id, name, uid) { @@ -795,6 +832,35 @@ function pollReviewData() { noDrawing.style.display = 'block'; } + // Render Candidate Saved Video Recordings in Profile Modal + const recContainer = document.getElementById('modal-recordings-container'); + if (recContainer) { + const recPath = data.recording_path; + const recList = data.recordings || []; + if (recPath || recList.length > 0) { + let recHtml = ''; + let allRecs = recList.length > 0 ? recList : [{ url: recPath, created_at: new Date().toISOString() }]; + allRecs.forEach((r, idx) => { + const videoUrl = typeof r === 'string' ? r : (r.url || recPath); + const dateStr = r.created_at ? new Date(r.created_at).toLocaleTimeString() : 'Session Recording'; + const downloadUrl = `{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${idx}`; + + recHtml += `
+
+ πŸ“Ή Video Session Recording #${idx + 1} (${dateStr}) + + πŸ“₯ Download MP4 Video + +
+ +
`; + }); + recContainer.innerHTML = recHtml; + } else { + recContainer.innerHTML = '
No video recordings saved for this candidate yet.
'; + } + } + const chatHistory = document.getElementById('interviewer-chat-history'); if (chatHistory && data.warnings && data.warnings.length > 0) { let chatHtml = ''; @@ -844,8 +910,12 @@ function pollReviewData() { data.proctor_logs.forEach(l => { let icon = 'πŸ”΄'; if (l.type === 'focus_lost') icon = '🟑'; - if (l.type === 'gaze_anomaly') icon = 'πŸ‘οΈ'; - if (l.type === 'paste_event' || l.type === 'tab_switch' || l.type === 'focus_lost' || l.type === 'gaze_anomaly') { + if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') icon = 'πŸ‘οΈ'; + if (l.type === 'gaze_lower_device') icon = 'πŸ“±'; + if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') icon = 'πŸ—£οΈ'; + if (l.type === 'external_ai_detected') icon = 'πŸ€–'; + + if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected'].includes(l.type)) { currentViolations.push(l); } logsHtml += `
@@ -869,7 +939,7 @@ function pollReviewData() { if (totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) { sosDismissed = false; sosBtn.style.animation = 'sosPulse 1.2s infinite alternate'; - sosBtn.innerText = '🚨 SOS CHEATING DETECTED!'; + sosBtn.innerText = '🚨 candidate might cheating'; sosBtn.style.background = 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)'; openSosModal(); } @@ -1064,6 +1134,10 @@ function initInterviewerSigChannel() { const msg = event.data; if (!msg) return; + if (msg.type === 'violation_occurred') { + pollReviewData(); + } + if ((msg.type === 'offer' || msg.type === 'candidate_ready') && msg.sender === 'candidate') { if (msg.offer) { await handleCandidateOffer(msg.offer); @@ -1285,6 +1359,111 @@ function endCallForAll() { } } + // --- SILENT CALL RECORDING ENGINE FOR INTERVIEWERS & ADMINS --- + let adminMediaRecorder = null; + let adminRecordedChunks = []; + + function startCallRecording() { + if (!currentInterviewId) { + alert('Please open an active candidate interview session first.'); + return; + } + + const candVid = document.getElementById('interviewer-cand-video'); + let streamToRecord = null; + + if (candVid && candVid.srcObject) { + streamToRecord = candVid.srcObject; + } else if (interviewerLocalStream) { + streamToRecord = interviewerLocalStream; + } + + if (!streamToRecord) { + alert('No active video/audio stream available to record. Please click "Start / Join Call" first.'); + return; + } + + adminRecordedChunks = []; + try { + const options = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') + ? { mimeType: 'video/webm;codecs=vp9' } + : (MediaRecorder.isTypeSupported('video/webm') ? { mimeType: 'video/webm' } : {}); + + adminMediaRecorder = new MediaRecorder(streamToRecord, options); + + adminMediaRecorder.ondataavailable = function(e) { + if (e.data && e.data.size > 0) { + adminRecordedChunks.push(e.data); + } + }; + + adminMediaRecorder.onstop = function() { + if (adminRecordedChunks.length === 0) return; + const blob = new Blob(adminRecordedChunks, { type: 'video/webm' }); + uploadRecordedBlob(blob); + }; + + adminMediaRecorder.start(1000); + + document.getElementById('btn-start-recording').style.display = 'none'; + document.getElementById('btn-stop-recording').style.display = 'inline-flex'; + + if (typeof Swal !== 'undefined') { + Swal.fire({ + icon: 'info', + title: 'Recording Started', + text: 'Interview call recording is active (silent mode - candidate is not notified).', + toast: true, + position: 'top-end', + timer: 3000, + showConfirmButton: false + }); + } + } catch(e) { + alert('Recording failed to initialize: ' + e.message); + } + } + + function stopCallRecording() { + if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { + adminMediaRecorder.stop(); + } + + document.getElementById('btn-start-recording').style.display = 'inline-flex'; + document.getElementById('btn-stop-recording').style.display = 'none'; + } + + function uploadRecordedBlob(blob) { + if (!currentInterviewId) return; + + const formData = new FormData(); + formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.mp4`); + formData.append('_token', '{{ csrf_token() }}'); + + fetch(`{{ route('interview.upload-recording', ':id') }}`.replace(':id', currentInterviewId), { + method: 'POST', + body: formData + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + if (typeof Swal !== 'undefined') { + Swal.fire({ + icon: 'success', + title: 'Recording Saved!', + text: 'Call recording saved successfully under Candidate Profile and Report.', + toast: true, + position: 'top-end', + timer: 3500, + showConfirmButton: false + }); + } + pollReviewData(); + } + }) + .catch(err => console.log('Recording upload error:', err)); + } + function toggleInterviewerMic() { if (!interviewerLocalStream) return; const audioTracks = interviewerLocalStream.getAudioTracks(); diff --git a/resources/views/interview/report.blade.php b/resources/views/interview/report.blade.php index 518ded8..466439e 100644 --- a/resources/views/interview/report.blade.php +++ b/resources/views/interview/report.blade.php @@ -206,8 +206,8 @@ max-width: 100%; padding: 0; } - .print-btn { - display: none; + .print-btn, .top-download-pdf-btn { + display: none !important; } } @@ -219,6 +219,16 @@
+ +
+
+ πŸ“„ Executive Assessment Performance Report +
+ +
+
@@ -302,26 +312,34 @@
πŸ” Behavioral Audit & Proctoring Incident Breakdown
-
-
-
{{ $tabSwitches }}
-
Tab Switches
+
+
+
{{ $tabSwitches }}
+
Tab Switches
-
-
{{ $focusLosses }}
-
Focus Lost
+
+
{{ $focusLosses }}
+
Focus Lost
-
-
{{ $gazeAnomalies }}
-
Gaze Anomalies
+
+
{{ $gazeAnomalies }}
+
Gaze Anomalies
-
-
{{ $pasteEvents }}
-
Code Pastes
+
+
{{ $pasteEvents }}
+
Code Pastes
+
+
+
{{ $lowerGazeViolations }}
+
πŸ“± Lower Gaze
+
+
+
{{ $questionRepeatViolations }}
+
πŸ—£οΈ Question Repeat
- @if(count($logs) > 0) + @if(is_array($logs) && count($logs) > 0) @@ -362,20 +380,38 @@ {{ $interview->code_output ?? 'No execution output recorded.' }} - - @if(!empty($interview->candidate_notes) || !empty($interview->candidate_drawing)) + + @if(!empty($interview->candidate_notes) || !empty($interview->interviewer_notes) || !empty($interview->candidate_drawing) || (is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0))
- πŸ“ Workspace Artifacts (Scratchpad Notes & Architecture Drawing) + πŸ“ Workspace Artifacts, Evaluation Notes & Call Session Recordings
@if(!empty($interview->candidate_notes)) -
CANDIDATE NOTES:
+
CANDIDATE NOTEPAD NOTES (UNIQUE ID SYNCED):
{{ $interview->candidate_notes }}
@endif + @if(!empty($interview->interviewer_notes)) +
INTERVIEWER EVALUATION NOTES (STORED BY UNIQUE ID):
+
{{ $interview->interviewer_notes }}
+ @endif + @if(!empty($interview->candidate_drawing))
CANDIDATE DRAWING DIAGRAM:
- Candidate Drawing + Candidate Drawing + @endif + + @if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0) +
πŸ“Ή STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS):
+ @foreach($interview->formatted_recordings as $idx => $rec) +
+
+ Recording #{{ $idx + 1 }} ({{ $rec['created_at'] }}) + πŸ“₯ Download MP4 Video +
+ +
+ @endforeach @endif
@endif diff --git a/routes/web.php b/routes/web.php index b9e6cef..8391d54 100644 --- a/routes/web.php +++ b/routes/web.php @@ -69,6 +69,7 @@ Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password'); Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate'); Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report'); + Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording'); Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status'); });