subhajit_work_22_07 #11
@ -411,6 +411,8 @@ public function getPollData($id)
|
|||||||
'temp_password' => $interview->temp_password,
|
'temp_password' => $interview->temp_password,
|
||||||
'blocked_ip' => $interview->blocked_ip,
|
'blocked_ip' => $interview->blocked_ip,
|
||||||
'proctor_logs' => $interview->proctor_logs ?? [],
|
'proctor_logs' => $interview->proctor_logs ?? [],
|
||||||
|
'recording_path' => $interview->recording_path,
|
||||||
|
'recordings' => $interview->recordings ?? [],
|
||||||
'warnings' => $interview->warnings,
|
'warnings' => $interview->warnings,
|
||||||
'is_expired' => $interview->isExpired(),
|
'is_expired' => $interview->isExpired(),
|
||||||
]);
|
]);
|
||||||
@ -436,16 +438,65 @@ public function uploadRecording(Request $request, $id)
|
|||||||
|
|
||||||
if ($request->hasFile('video')) {
|
if ($request->hasFile('video')) {
|
||||||
$file = $request->file('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);
|
$path = $file->storeAs('public/recordings', $filename);
|
||||||
|
$url = Storage::url($path);
|
||||||
|
|
||||||
$interview->update(['recording_path' => Storage::url($path)]);
|
$recordings = $interview->recordings ?? [];
|
||||||
return response()->json(['success' => true, 'path' => Storage::url($path)]);
|
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']);
|
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).
|
* Generate Comprehensive Executive Candidate Evaluation & Behavioral Report (PDF View).
|
||||||
*/
|
*/
|
||||||
@ -453,18 +504,22 @@ public function generateReport($id)
|
|||||||
{
|
{
|
||||||
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
|
||||||
|
|
||||||
$logs = $interview->proctor_logs ?? [];
|
$logs = is_array($interview->proctor_logs) ? $interview->proctor_logs : [];
|
||||||
$tabSwitches = 0;
|
$tabSwitches = 0;
|
||||||
$focusLosses = 0;
|
$focusLosses = 0;
|
||||||
$gazeAnomalies = 0;
|
$gazeAnomalies = 0;
|
||||||
$pasteEvents = 0;
|
$pasteEvents = 0;
|
||||||
|
$lowerGazeViolations = 0;
|
||||||
|
$questionRepeatViolations = 0;
|
||||||
|
|
||||||
foreach ($logs as $l) {
|
foreach ($logs as $l) {
|
||||||
$type = $l['type'] ?? '';
|
$type = $l['type'] ?? '';
|
||||||
if ($type === 'tab_switch') $tabSwitches++;
|
if ($type === 'tab_switch') $tabSwitches++;
|
||||||
if ($type === 'focus_lost') $focusLosses++;
|
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 === 'paste_event') $pasteEvents++;
|
||||||
|
if ($type === 'gaze_lower_device') $lowerGazeViolations++;
|
||||||
|
if (in_array($type, ['question_repeat_lower', 'question_repetition'])) $questionRepeatViolations++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$totalViolations = count($logs);
|
$totalViolations = count($logs);
|
||||||
@ -474,7 +529,7 @@ public function generateReport($id)
|
|||||||
$integrityScore = 100;
|
$integrityScore = 100;
|
||||||
$integrityLabel = 'Exceptional Integrity';
|
$integrityLabel = 'Exceptional Integrity';
|
||||||
$integrityColor = '#10b981';
|
$integrityColor = '#10b981';
|
||||||
} elseif ($totalViolations <= 2) {
|
} elseif ($totalViolations <= 2 && $lowerGazeViolations === 0 && $questionRepeatViolations === 0) {
|
||||||
$integrityScore = 85;
|
$integrityScore = 85;
|
||||||
$integrityLabel = 'Low Behavioral Risk';
|
$integrityLabel = 'Low Behavioral Risk';
|
||||||
$integrityColor = '#06b6d4';
|
$integrityColor = '#06b6d4';
|
||||||
@ -488,7 +543,7 @@ public function generateReport($id)
|
|||||||
$integrityColor = '#ef4444';
|
$integrityColor = '#ef4444';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate Technical Code Score
|
// Technical Code Score
|
||||||
$codeScore = 0;
|
$codeScore = 0;
|
||||||
if (!empty($interview->submitted_code)) {
|
if (!empty($interview->submitted_code)) {
|
||||||
$codeScore += 50;
|
$codeScore += 50;
|
||||||
@ -510,6 +565,8 @@ public function generateReport($id)
|
|||||||
'focusLosses',
|
'focusLosses',
|
||||||
'gazeAnomalies',
|
'gazeAnomalies',
|
||||||
'pasteEvents',
|
'pasteEvents',
|
||||||
|
'lowerGazeViolations',
|
||||||
|
'questionRepeatViolations',
|
||||||
'integrityScore',
|
'integrityScore',
|
||||||
'integrityLabel',
|
'integrityLabel',
|
||||||
'integrityColor',
|
'integrityColor',
|
||||||
|
|||||||
@ -24,6 +24,7 @@ class Interview extends Model
|
|||||||
'submitted_language',
|
'submitted_language',
|
||||||
'code_output',
|
'code_output',
|
||||||
'recording_path',
|
'recording_path',
|
||||||
|
'recordings',
|
||||||
'submission_unique_id',
|
'submission_unique_id',
|
||||||
'candidate_notes',
|
'candidate_notes',
|
||||||
'candidate_drawing',
|
'candidate_drawing',
|
||||||
@ -37,6 +38,7 @@ class Interview extends Model
|
|||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'assigned_interviewers' => 'array',
|
'assigned_interviewers' => 'array',
|
||||||
'proctor_logs' => 'array',
|
'proctor_logs' => 'array',
|
||||||
|
'recordings' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function creator()
|
public function creator()
|
||||||
|
|||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('interviews', function (Blueprint $table) {
|
||||||
|
if (!Schema::hasColumn('interviews', 'interviewer_notes')) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('interviews', function (Blueprint $table) {
|
||||||
|
$table->json('recordings')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('interviews', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('recordings');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
BIN
public/Screenshot_13.png
Normal file
BIN
public/Screenshot_13.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
BIN
public/Screenshot_14.png
Normal file
BIN
public/Screenshot_14.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
@ -188,11 +188,6 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<!-- Silent Warning Banner -->
|
|
||||||
<div id="warning-banner">
|
|
||||||
🚨 ATTENTION: <span id="warning-text">Please keep your eyes on the screen during the assessment.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- IDE Header Bar -->
|
<!-- IDE Header Bar -->
|
||||||
<header class="ide-header">
|
<header class="ide-header">
|
||||||
<div style="display: flex; align-items: center; gap: 12px;">
|
<div style="display: flex; align-items: center; gap: 12px;">
|
||||||
@ -384,17 +379,17 @@
|
|||||||
let recordedChunks = [];
|
let recordedChunks = [];
|
||||||
let previousFrameData = null;
|
let previousFrameData = null;
|
||||||
|
|
||||||
// Default Code Snippets for Languages
|
|
||||||
const defaultCode = {
|
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()`,
|
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 <iostream>\nusing namespace std;\n\nint main() {\n cout << "Hello from SingleLogin Assessment!" << endl;\n return 0;\n}`,
|
cpp: "// C++ Assessment Solution\n#include <iostream>\nusing namespace std;\n\nint main() {\n cout << \"Hello from SingleLogin Assessment!\" << endl;\n return 0;\n}",
|
||||||
c: `// C Assessment Solution\n#include <stdio.h>\n\nint main() {\n printf("Hello from SingleLogin Assessment!\\n");\n return 0;\n}`,
|
c: "// C Assessment Solution\n#include <stdio.h>\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}`,
|
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: `<?php\n// PHP / Laravel Assessment Solution\necho "Hello from SingleLogin Assessment!\\n";\n$data = [1, 2, 3, 4, 5];\necho "Sum: " . array_sum($data);`,
|
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));`
|
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.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } });
|
||||||
require(['vs/editor/editor.main'], function () {
|
require(['vs/editor/editor.main'], function () {
|
||||||
const initialLang = '{{ strtolower($interview->language) }}';
|
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 () {
|
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) {
|
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) {
|
function logViolation(type, details) {
|
||||||
fetch(`{{ route('interview.violation', $interview->id) }}`, {
|
fetch(`{{ route('interview.violation', $interview->id) }}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -518,6 +663,11 @@ function logViolation(type, details) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({ type: type, details: 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)
|
// 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS)
|
||||||
|
|||||||
@ -246,7 +246,10 @@
|
|||||||
$logs = $inv->proctor_logs ?? [];
|
$logs = $inv->proctor_logs ?? [];
|
||||||
$tabSwitches = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch'));
|
$tabSwitches = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'tab_switch'));
|
||||||
$focusLost = count(array_filter($logs, fn($l) => ($l['type'] ?? '') === 'focus_lost'));
|
$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
|
@endphp
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
@ -306,6 +309,21 @@
|
|||||||
<span style="color: {{ $gazeAlerts > 0 ? '#f43f5e' : 'var(--text-muted)' }};">
|
<span style="color: {{ $gazeAlerts > 0 ? '#f43f5e' : 'var(--text-muted)' }};">
|
||||||
👁️ Eye Gaze Anomaly: {{ $gazeAlerts }}
|
👁️ Eye Gaze Anomaly: {{ $gazeAlerts }}
|
||||||
</span>
|
</span>
|
||||||
|
@if($lowerGaze > 0)
|
||||||
|
<span style="color: #ef4444; font-weight: 700;">
|
||||||
|
📱 Lower Gaze (>10s): {{ $lowerGaze }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if($questionRepeat > 0)
|
||||||
|
<span style="color: #f43f5e; font-weight: 700;">
|
||||||
|
🗣️ Question Repetition: {{ $questionRepeat }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
@if($externalAi > 0)
|
||||||
|
<span style="color: #ef4444; font-weight: 800; background: rgba(239,68,68,0.2); padding: 2px 6px; border-radius: 4px;">
|
||||||
|
🤖 Parakeet / External AI: {{ $externalAi }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: right; white-space: nowrap;">
|
<td style="text-align: right; white-space: nowrap;">
|
||||||
@ -431,7 +449,7 @@
|
|||||||
|
|
||||||
<!-- SOS CHEATING ALERT BUTTON (Flashing Red) -->
|
<!-- SOS CHEATING ALERT BUTTON (Flashing Red) -->
|
||||||
<button id="sos-alert-btn" onclick="openSosModal()" class="btn-nav" style="display: none; background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; box-shadow: 0 0 15px rgba(239,68,68,0.7); cursor: pointer; animation: sosPulse 1.2s infinite alternate;">
|
<button id="sos-alert-btn" onclick="openSosModal()" class="btn-nav" style="display: none; background: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; box-shadow: 0 0 15px rgba(239,68,68,0.7); cursor: pointer; animation: sosPulse 1.2s infinite alternate;">
|
||||||
🚨 SOS CHEATING DETECTED!
|
🚨 candidate might cheating
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- REGENERATE CANDIDATE PASSWORD -->
|
<!-- REGENERATE CANDIDATE PASSWORD -->
|
||||||
@ -461,6 +479,12 @@
|
|||||||
<button id="btn-start-call" onclick="startInterviewerCall()" class="btn-nav" style="background: #10b981; color: white; border: none; font-weight: 700; padding: 6px 14px; cursor: pointer;">
|
<button id="btn-start-call" onclick="startInterviewerCall()" class="btn-nav" style="background: #10b981; color: white; border: none; font-weight: 700; padding: 6px 14px; cursor: pointer;">
|
||||||
📞 Start / Join Call
|
📞 Start / Join Call
|
||||||
</button>
|
</button>
|
||||||
|
<button id="btn-start-recording" onclick="startCallRecording()" class="btn-nav" style="background: rgba(239,68,68,0.2); border-color: #ef4444; color: #fca5a5; font-weight: 700; padding: 6px 14px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px;">
|
||||||
|
⏺️ Record Call (Silent)
|
||||||
|
</button>
|
||||||
|
<button id="btn-stop-recording" onclick="stopCallRecording()" class="btn-nav" style="display: none; background: #ef4444; color: white; border: none; font-weight: 700; padding: 6px 14px; cursor: pointer; animation: pulse 1s infinite alternate;">
|
||||||
|
⏹️ Stop & Save Recording
|
||||||
|
</button>
|
||||||
<button id="btn-leave-call" onclick="leaveInterviewerCall()" class="btn-nav" style="display: none; background: rgba(234,179,8,0.2); border-color: #eab308; color: #fde047; font-size: 0.75rem; font-weight: 600;" title="Leave your individual connection without ending call for others">
|
<button id="btn-leave-call" onclick="leaveInterviewerCall()" class="btn-nav" style="display: none; background: rgba(234,179,8,0.2); border-color: #eab308; color: #fde047; font-size: 0.75rem; font-weight: 600;" title="Leave your individual connection without ending call for others">
|
||||||
🚪 Leave Call
|
🚪 Leave Call
|
||||||
</button>
|
</button>
|
||||||
@ -559,16 +583,17 @@
|
|||||||
<span style="font-size: 0.7rem; color: #34d399;">● Live Synced</span>
|
<span style="font-size: 0.7rem; color: #34d399;">● Live Synced</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Switcher for Code Solution, Notepad, Drawing -->
|
<!-- Tab Switcher for Code Solution, Notepad, Drawing, Saved Profile Video Recordings -->
|
||||||
<div style="display: flex; gap: 6px;">
|
<div style="display: flex; gap: 6px;">
|
||||||
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
|
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
|
||||||
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
|
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
|
||||||
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
|
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
|
||||||
|
<button id="tab-btn-recordings" onclick="switchIdeTab('recordings')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📹 Saved Recordings (.MP4)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
|
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
|
||||||
<!-- Left Column: Tab Content (Code Solution, Notepad, or Canvas Drawing) -->
|
<!-- Left Column: Tab Content (Code Solution, Notepad, Canvas Drawing, or Saved Video Recordings) -->
|
||||||
<div>
|
<div>
|
||||||
<!-- Tab 1: Live Code Solution & Terminal Output -->
|
<!-- Tab 1: Live Code Solution & Terminal Output -->
|
||||||
<div id="tab-content-code" style="display: block;">
|
<div id="tab-content-code" style="display: block;">
|
||||||
@ -593,6 +618,14 @@
|
|||||||
<div id="no-drawing-placeholder" style="color: #94a3b8; font-size: 0.8rem;">No drawing data submitted by candidate yet.</div>
|
<div id="no-drawing-placeholder" style="color: #94a3b8; font-size: 0.8rem;">No drawing data submitted by candidate yet.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab 4: Saved Candidate Profile Video Recordings & MP4 Downloads -->
|
||||||
|
<div id="tab-content-recordings" style="display: none;">
|
||||||
|
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600;">Saved Candidate Video Sessions (Downloadable in MP4 format):</div>
|
||||||
|
<div id="modal-recordings-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 350px; overflow-y: auto;">
|
||||||
|
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
|
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
|
||||||
@ -633,7 +666,7 @@
|
|||||||
<div style="display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(239,68,68,0.3); padding-bottom: 12px; margin-bottom: 14px;">
|
<div style="display: flex; align-items: center; gap: 12px; border-bottom: 1px solid rgba(239,68,68,0.3); padding-bottom: 12px; margin-bottom: 14px;">
|
||||||
<div style="font-size: 2.2rem;">🚨</div>
|
<div style="font-size: 2.2rem;">🚨</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 style="color: #fca5a5; font-size: 1.25rem; font-weight: 800; margin: 0;">SOS CHEATING DETECTED</h3>
|
<h3 style="color: #fca5a5; font-size: 1.25rem; font-weight: 800; margin: 0;">candidate might cheating</h3>
|
||||||
<div style="font-size: 0.72rem; color: #94a3b8;">Proctoring engine flagged candidate cheating activity!</div>
|
<div style="font-size: 0.72rem; color: #94a3b8;">Proctoring engine flagged candidate cheating activity!</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -739,6 +772,7 @@ function switchIdeTab(tab) {
|
|||||||
document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none';
|
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-notes').style.display = (tab === 'notes') ? 'block' : 'none';
|
||||||
document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? '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.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)';
|
||||||
document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8';
|
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.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-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) {
|
function openReviewModal(id, name, uid) {
|
||||||
@ -795,6 +832,35 @@ function pollReviewData() {
|
|||||||
noDrawing.style.display = 'block';
|
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 += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 10px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
|
<strong style="color: white; font-size: 0.8rem;">📹 Video Session Recording #${idx + 1} (${dateStr})</strong>
|
||||||
|
<a href="${downloadUrl}" target="_blank" download class="btn-nav" style="background: #10b981; color: white; border: none; font-size: 0.75rem; font-weight: 700; padding: 4px 12px; text-decoration: none;">
|
||||||
|
📥 Download MP4 Video
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<video src="${videoUrl}" controls style="width: 100%; max-height: 200px; border-radius: 8px; background: #000; outline: none;"></video>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
recContainer.innerHTML = recHtml;
|
||||||
|
} else {
|
||||||
|
recContainer.innerHTML = '<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const chatHistory = document.getElementById('interviewer-chat-history');
|
const chatHistory = document.getElementById('interviewer-chat-history');
|
||||||
if (chatHistory && data.warnings && data.warnings.length > 0) {
|
if (chatHistory && data.warnings && data.warnings.length > 0) {
|
||||||
let chatHtml = '';
|
let chatHtml = '';
|
||||||
@ -844,8 +910,12 @@ function pollReviewData() {
|
|||||||
data.proctor_logs.forEach(l => {
|
data.proctor_logs.forEach(l => {
|
||||||
let icon = '🔴';
|
let icon = '🔴';
|
||||||
if (l.type === 'focus_lost') icon = '🟡';
|
if (l.type === 'focus_lost') icon = '🟡';
|
||||||
if (l.type === 'gaze_anomaly') icon = '👁️';
|
if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') icon = '👁️';
|
||||||
if (l.type === 'paste_event' || l.type === 'tab_switch' || l.type === 'focus_lost' || l.type === 'gaze_anomaly') {
|
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);
|
currentViolations.push(l);
|
||||||
}
|
}
|
||||||
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
|
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
|
||||||
@ -869,7 +939,7 @@ function pollReviewData() {
|
|||||||
if (totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) {
|
if (totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) {
|
||||||
sosDismissed = false;
|
sosDismissed = false;
|
||||||
sosBtn.style.animation = 'sosPulse 1.2s infinite alternate';
|
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%)';
|
sosBtn.style.background = 'linear-gradient(135deg, #ef4444 0%, #b91c1c 100%)';
|
||||||
openSosModal();
|
openSosModal();
|
||||||
}
|
}
|
||||||
@ -1064,6 +1134,10 @@ function initInterviewerSigChannel() {
|
|||||||
const msg = event.data;
|
const msg = event.data;
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
|
|
||||||
|
if (msg.type === 'violation_occurred') {
|
||||||
|
pollReviewData();
|
||||||
|
}
|
||||||
|
|
||||||
if ((msg.type === 'offer' || msg.type === 'candidate_ready') && msg.sender === 'candidate') {
|
if ((msg.type === 'offer' || msg.type === 'candidate_ready') && msg.sender === 'candidate') {
|
||||||
if (msg.offer) {
|
if (msg.offer) {
|
||||||
await handleCandidateOffer(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() {
|
function toggleInterviewerMic() {
|
||||||
if (!interviewerLocalStream) return;
|
if (!interviewerLocalStream) return;
|
||||||
const audioTracks = interviewerLocalStream.getAudioTracks();
|
const audioTracks = interviewerLocalStream.getAudioTracks();
|
||||||
|
|||||||
@ -206,8 +206,8 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.print-btn {
|
.print-btn, .top-download-pdf-btn {
|
||||||
display: none;
|
display: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@ -219,6 +219,16 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="report-container">
|
<div class="report-container">
|
||||||
|
<!-- Top Bar with Download PDF Button -->
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border);">
|
||||||
|
<div style="font-size: 0.85rem; font-weight: 700; color: var(--primary); display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span>📄 Executive Assessment Performance Report</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="window.print()" class="top-download-pdf-btn" style="background: #6366f1; color: white; border: none; font-weight: 700; font-size: 0.85rem; padding: 10px 20px; border-radius: 10px; cursor: pointer; display: flex; align-items: center; gap: 8px; box-shadow: 0 4px 14px rgba(99,102,241,0.3);">
|
||||||
|
📥 Download PDF Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="header-bar">
|
<div class="header-bar">
|
||||||
<div>
|
<div>
|
||||||
@ -302,26 +312,34 @@
|
|||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
🔍 Behavioral Audit & Proctoring Incident Breakdown
|
🔍 Behavioral Audit & Proctoring Incident Breakdown
|
||||||
</div>
|
</div>
|
||||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px;">
|
<div style="display: grid; grid-template-columns: repeat(6, 1fr); gap: 10px; margin-bottom: 20px;">
|
||||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
<div style="font-size: 1.2rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
|
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $tabSwitches }}</div>
|
||||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Tab Switches</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
<div style="font-size: 1.2rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
|
<div style="font-size: 1.1rem; font-weight: 800; color: #f59e0b;">{{ $focusLosses }}</div>
|
||||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Focus Lost</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
<div style="font-size: 1.2rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
|
<div style="font-size: 1.1rem; font-weight: 800; color: #38bdf8;">{{ $gazeAnomalies }}</div>
|
||||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Gaze Anomalies</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="background: var(--card-bg); padding: 12px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
<div style="font-size: 1.2rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
|
<div style="font-size: 1.1rem; font-weight: 800; color: #818cf8;">{{ $pasteEvents }}</div>
|
||||||
<div style="font-size: 0.65rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">Code Pastes</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 800; color: #ef4444;">{{ $lowerGazeViolations }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">📱 Lower Gaze</div>
|
||||||
|
</div>
|
||||||
|
<div style="background: var(--card-bg); padding: 10px; border-radius: 10px; border: 1px solid var(--border); text-align: center;">
|
||||||
|
<div style="font-size: 1.1rem; font-weight: 800; color: #f43f5e;">{{ $questionRepeatViolations }}</div>
|
||||||
|
<div style="font-size: 0.6rem; color: var(--text-muted); font-weight: 700;">🗣️ Question Repeat</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(count($logs) > 0)
|
@if(is_array($logs) && count($logs) > 0)
|
||||||
<table class="audit-table">
|
<table class="audit-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@ -362,20 +380,38 @@
|
|||||||
{{ $interview->code_output ?? 'No execution output recorded.' }}
|
{{ $interview->code_output ?? 'No execution output recorded.' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Candidate Notes & Drawing Section -->
|
<!-- Candidate Notes, Interviewer Evaluation Notes, Drawing & Video Recordings Section -->
|
||||||
@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))
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
📝 Workspace Artifacts (Scratchpad Notes & Architecture Drawing)
|
📝 Workspace Artifacts, Evaluation Notes & Call Session Recordings
|
||||||
</div>
|
</div>
|
||||||
<div style="background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 28px;">
|
<div style="background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 16px; margin-bottom: 28px;">
|
||||||
@if(!empty($interview->candidate_notes))
|
@if(!empty($interview->candidate_notes))
|
||||||
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTES:</div>
|
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE NOTEPAD NOTES (UNIQUE ID SYNCED):</div>
|
||||||
<pre style="font-family: monospace; font-size: 0.8rem; background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;">{{ $interview->candidate_notes }}</pre>
|
<pre style="font-family: monospace; font-size: 0.8rem; background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;">{{ $interview->candidate_notes }}</pre>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if(!empty($interview->interviewer_notes))
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: #10b981; margin-bottom: 6px;">INTERVIEWER EVALUATION NOTES (STORED BY UNIQUE ID):</div>
|
||||||
|
<pre style="font-family: sans-serif; font-size: 0.8rem; background: #f0fdf4; color: #166534; padding: 10px; border-radius: 8px; border: 1px solid #bbf7d0; margin-bottom: 12px;">{{ $interview->interviewer_notes }}</pre>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if(!empty($interview->candidate_drawing))
|
@if(!empty($interview->candidate_drawing))
|
||||||
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE DRAWING DIAGRAM:</div>
|
<div style="font-size: 0.75rem; font-weight: 700; color: var(--text-muted); margin-bottom: 6px;">CANDIDATE DRAWING DIAGRAM:</div>
|
||||||
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border);" />
|
<img src="{{ $interview->candidate_drawing }}" alt="Candidate Drawing" style="max-width: 100%; max-height: 250px; border-radius: 8px; border: 1px solid var(--border); margin-bottom: 12px;" />
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(is_array($interview->formatted_recordings) && count($interview->formatted_recordings) > 0)
|
||||||
|
<div style="font-size: 0.75rem; font-weight: 700; color: #6366f1; margin-bottom: 8px;">📹 STORED CALL SESSION RECORDINGS ({{ count($interview->formatted_recordings) }} RECORDINGS):</div>
|
||||||
|
@foreach($interview->formatted_recordings as $idx => $rec)
|
||||||
|
<div style="margin-bottom: 12px; background: white; border: 1px solid var(--border); border-radius: 8px; padding: 10px;">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
|
||||||
|
<span style="font-size: 0.75rem; font-weight: 700; color: var(--primary);">Recording #{{ $idx + 1 }} ({{ $rec['created_at'] }})</span>
|
||||||
|
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none;">📥 Download MP4 Video</a>
|
||||||
|
</div>
|
||||||
|
<video src="{{ $rec['stream_url'] }}" controls style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;"></video>
|
||||||
|
</div>
|
||||||
|
@endforeach
|
||||||
@endif
|
@endif
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|||||||
@ -69,6 +69,7 @@
|
|||||||
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
|
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::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}/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');
|
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user