diff --git a/.gitignore b/.gitignore index 7be55e2..e672874 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,8 @@ /public/build /public/fonts-manifest.dev.json /public/hot +/public/uploads/candidate_recordings/* +/public/uploads/candidate_screenshots/* /public/storage /storage/*.key /storage/pail diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index c9c08fa..de5de06 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -31,6 +32,23 @@ public function index() return view('interview.index', compact('interviews', 'interviewers')); } + /** + * Display a specific Candidate Interview Live Review & Call Session. + */ + public function show($id) + { + $user = Auth::user(); + + if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) { + return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.'); + } + + $interview = Interview::findOrFail($id); + $interviewers = User::orderBy('name', 'asc')->get(); + + return view('interview.show', compact('interview', 'interviewers')); + } + /** * Store a new Candidate Interview session created by HR. */ @@ -156,41 +174,46 @@ public function executeCode(Request $request) $code = $request->code; $output = ''; - // 1. Ultra-Fast Piston Code Execution API (sub-second for C, C++, Java, JS, Python, PHP, Go, Rust) - $pistonLangMap = [ - 'python' => 'python', - 'py' => 'python', - 'javascript' => 'javascript', - 'js' => 'javascript', - 'c' => 'c', - 'cpp' => 'c++', - 'c++' => 'c++', - 'java' => 'java', - 'php' => 'php', - 'laravel' => 'php', - 'go' => 'go', - 'rust' => 'rust', + // 1. Ultra-Fast Public Judge0 CE Code Execution API (no auth needed) + $judge0LangMap = [ + 'python' => 71, // Python 3 + 'py' => 71, + 'javascript' => 63, // JavaScript (Node.js) + 'js' => 63, + 'c' => 50, // C (GCC) + 'cpp' => 54, // C++ (GCC) + 'c++' => 54, + 'java' => 62, // Java (OpenJDK) + 'php' => 68, // PHP + 'laravel' => 68, + 'go' => 60, // Go + 'rust' => 73, // Rust ]; - - if (isset($pistonLangMap[$langKey])) { + if (isset($judge0LangMap[$langKey])) { try { - $pistonLang = $pistonLangMap[$langKey]; - $response = Http::timeout(4)->post('https://emkc.org/api/v2/piston/execute', [ - 'language' => $pistonLang, - 'version' => '*', - 'files' => [ - ['content' => $code] - ] + $judge0LangId = $judge0LangMap[$langKey]; + $response = Http::timeout(6)->post('https://ce.judge0.com/submissions?base64_encoded=false&wait=true', [ + 'language_id' => $judge0LangId, + 'source_code' => $code, ]); if ($response->successful()) { $resData = $response->json(); - $output = $resData['run']['output'] ?? ($resData['run']['stdout'] ?? ''); - if (empty($output) && !empty($resData['run']['stderr'])) { - $output = $resData['run']['stderr']; + $compileOutput = $resData['compile_output'] ?? ''; + $stdout = $resData['stdout'] ?? ''; + $stderr = $resData['stderr'] ?? ''; + + if (!empty($compileOutput)) { + $output = $compileOutput; + } elseif (!empty($stdout)) { + $output = $stdout; + } elseif (!empty($stderr)) { + $output = $stderr; } } - } catch (\Exception $e) {} + } catch (\Exception $e) { + Log::warning("Judge0 Code execute failed.", [$e->getMessage()]); + } } // 2. Instant Local Process Runner Fallback @@ -271,29 +294,41 @@ public function submitCode(Request $request, $id) } /** - * Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, Gaze Anomaly). + * Log Anti-Cheat Proctoring Violations (Tab Switch, Focus Loss, MediaPipe ML Events, AI Detection). */ public function logViolation(Request $request, $id) { - $interview = Interview::findOrFail($id); + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); - $type = $request->input('type'); // tab_switch, focus_lost, paste_event, gaze_anomaly, question_repetition, external_ai_detected + $type = $request->input('type'); $details = $request->input('details', ''); + $confidence = $request->input('confidence', null); + $snapshot = $request->input('snapshot', null); + + $detailsStr = is_array($details) ? json_encode($details) : (string)$details; // Automatically run AI Speech/Transcript Inspection if violation is speech/question repetition/phone call $aiVerdict = null; if (in_array($type, ['question_repetition', 'question_repeat_lower', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'])) { $aiDetector = new \App\Services\AiCheatingDetectorService(); - $aiVerdict = $aiDetector->analyzeSpeechTranscript($details, $interview->candidate_notes ?? ''); + $aiVerdict = $aiDetector->analyzeSpeechTranscript($detailsStr, $interview->candidate_notes ?? ''); } $logs = $interview->proctor_logs ?? []; - $logs[] = [ + $logEntry = [ 'type' => $type, 'details' => $details, + 'confidence' => $confidence, + 'snapshot' => $snapshot, 'ai_verdict' => $aiVerdict, - 'timestamp' => now()->toIso8601String(), + 'timestamp' => $request->input('timestamp', now()->toIso8601String()), ]; + if ($request->has('event_id')) { + $logEntry['event_id'] = $request->input('event_id'); + } + $logs[] = $logEntry; $interview->update(['proctor_logs' => $logs]); @@ -634,11 +669,11 @@ public function getPollData($id) } } - // Clean up stale active_peers (> 15 seconds) + // Clean up stale active_peers (> 30 seconds) $activePeers = $interview->active_peers ?? []; $now = now()->timestamp; $validPeers = array_values(array_filter($activePeers, function ($p) use ($now) { - return ($now - ($p['last_seen'] ?? 0)) < 15; + return ($now - ($p['last_seen'] ?? 0)) < 30; })); if (count($validPeers) !== count($activePeers)) { $interview->update(['active_peers' => $validPeers]); @@ -697,12 +732,13 @@ public function registerPeerHeartbeat(Request $request, $id) $role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate'); $userId = $user ? $user->id : 0; + $interview->refresh(); $activePeers = $interview->active_peers ?? []; $now = now()->timestamp; $updatedPeers = []; foreach ($activePeers as $p) { - if (($now - ($p['last_seen'] ?? 0)) < 15) { + if (($now - ($p['last_seen'] ?? 0)) < 30) { if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) { continue; } @@ -712,12 +748,17 @@ public function registerPeerHeartbeat(Request $request, $id) } } - if ($action !== 'leave') { + if ($action === 'heartbeat') { + $micOn = $request->has('mic_on') ? filter_var($request->input('mic_on'), FILTER_VALIDATE_BOOLEAN) : true; + $camOn = $request->has('cam_on') ? filter_var($request->input('cam_on'), FILTER_VALIDATE_BOOLEAN) : true; + $updatedPeers[] = [ 'peer_id' => $peerId, 'user_id' => $userId, 'name' => $name, 'role' => $role, + 'mic_on' => $micOn, + 'cam_on' => $camOn, 'last_seen' => $now, ]; } @@ -821,9 +862,12 @@ public function uploadTabScreenshot(Request $request, $id) $relativePath = '/' . $subDir . '/' . $filename; $fullFilePath = $destinationPath . '/' . $filename; - // Automatically analyze candidate system screen capture using AI Vision Engine + // Automatically analyze candidate system screen capture using AI Vision Engine (disabled for now) + $aiVerdict = null; + /* $aiDetector = new \App\Services\AiCheatingDetectorService(); $aiVerdict = $aiDetector->analyzeScreenshot($fullFilePath); + */ $screenshotEntry = [ 'url' => $relativePath, diff --git a/resources/css/app.css b/resources/css/app.css index b9f73cf..213ff79 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -39,3 +39,18 @@ html, body, * { background: transparent; } +/* Mirror camera video feeds for candidate, interviewer, panelist, and admin */ +#webcam, +#interviewer-video-default, +#interviewer-cand-video, +#interviewer-self-video, +video[id^="panelist-video-"], +.camera-video { + transform: scaleX(-1); +} + +/* Ensure screen sharing is NEVER mirrored */ +#interviewer-screen-video { + transform: scaleX(1) !important; +} + diff --git a/resources/js/app.js b/resources/js/app.js index 5c60800..d3b3319 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,4 +1,7 @@ import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js'; +import './interview-call.js'; +import './candidate-room.js'; +import './candidate-proctor.js'; window.globalIceServers = globalIceServers; window.getMeteredIceServers = getMeteredIceServers; diff --git a/resources/js/candidate-proctor.js b/resources/js/candidate-proctor.js new file mode 100644 index 0000000..4791d6b --- /dev/null +++ b/resources/js/candidate-proctor.js @@ -0,0 +1,1459 @@ +/* + * Candidate-side proctoring pipeline. + * MediaPipe runs locally for ML face/gaze/object detection. + * Enforces Tab/Focus Loss, Clipboard Activity, AI-Tool/Hotkey Detection, + * Continuous Speech Analysis, Screen Share Enforcement & System Desktop Screenshots. + * All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint. + */ + +import { initMeteredIceServers } from './metered.js'; + +// MediaPipe Published Browser Bundle CDN URLs +const VISION_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs'; +const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs'; +const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/wasm'; +const FACE_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task'; +const OBJECT_MODEL_URL = 'https://storage.googleapis.com/mediapipe-models/object_detector/efficientdet_lite0/float32/1/efficientdet_lite0.tflite'; + +const SAMPLE_INTERVAL_MS = 200; +const NO_FACE_GRACE_MS = 1000; +const LOOKING_AWAY_GRACE_MS = 700; +const PROLONGED_LOOKING_AWAY_MS = 10000; +const PROLONGED_LOOKING_AWAY_RECHECK_MS = 120000; +const PHONE_EVENT_COOLDOWN_MS = 3000; +const SNAPSHOT_QUALITY = 0.72; +const HEAD_YAW_THRESHOLD = 0.34; +const HEAD_PITCH_UP_THRESHOLD = 0.2; +const HEAD_PITCH_DOWN_THRESHOLD = 0.74; +const GAZE_HORIZONTAL_LEFT_THRESHOLD = 0.28; +const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72; +const GAZE_DOWN_THRESHOLD = 0.62; +const CORNER_FIXATION_MS = 18000; +const CORNER_RECHECK_MS = 20000; +const CORNER_JITTER_GRACE_MS = 3000; + +// --- State Variables for Violation & Focus Detection --- +let isTabSwitchScreenshotEnabled = true; +let lastTabSwitchTime = 0; +let pendingNativePrompts = 0; +let suppressFocusViolationsUntil = 0; + +let screenShareStream = null; +let screenShareVideoElem = null; +let overlayCheckInterval = null; +let candidateSpeechHistory = []; + +// --- Native Prompt Suppression Window --- +export function beginNativePrompt() { + pendingNativePrompts++; +} + +export function endNativePrompt() { + pendingNativePrompts = Math.max(0, pendingNativePrompts - 1); +} + +export function armTrailingBuffer(ms) { + suppressFocusViolationsUntil = Math.max(suppressFocusViolationsUntil, Date.now() + ms); +} + +export function isFocusSuppressed() { + return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil; +} + +export function isCandidateAssessmentPage() { + if (typeof window === 'undefined' || typeof document === 'undefined') return false; + const isCandidateUrl = window.location.pathname.includes('/candidate/'); + const hasMonaco = Boolean(document.getElementById('monaco-editor-container')); + const isInterviewerPage = Boolean(document.getElementById('interviewer-cand-video') || document.getElementById('interviewer-self-video') || document.getElementById('panelist-mesh-grid')); + + return (isCandidateUrl || hasMonaco) && !isInterviewerPage; +} + +export function isCandidateCallActive() { + if (!isCandidateAssessmentPage()) return false; + if (typeof window !== 'undefined' && typeof window.isCallConnected === 'function') { + return window.isCallConnected(); + } + const callStatusBadge = document.getElementById('call-status-badge'); + if (callStatusBadge) { + return callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED'); + } + return false; +} + +// --- Structured Logging & Backend Reporting --- +class StructuredEventLogger { + constructor(sessionId) { + this.sessionId = sessionId; + this.events = []; + } + + record(type, confidence, details, snapshot = null) { + const event = { + schema: 'candidate-proctoring/v1', + session_id: this.sessionId, + event_id: `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + type, + timestamp: new Date().toISOString(), + confidence: Number(Math.max(0, Math.min(1, confidence || 0)).toFixed(4)), + details: details || {}, + snapshot: snapshot || null, + }; + this.events.push(event); + console.log('[proctoring:event]', event); + + this.sendToServer(event); + + return event; + } + + sendToServer(event) { + if (!isCandidateAssessmentPage()) return; + const interviewId = this.sessionId || document.body.dataset.interviewId; + if (!interviewId) return; + + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + fetch(`/candidate/log-violation/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + type: event.type, + details: typeof event.details === 'object' ? JSON.stringify(event.details) : event.details, + confidence: event.confidence, + snapshot: event.snapshot, + session_id: event.session_id, + event_id: event.event_id, + timestamp: event.timestamp + }) + }) + .then(r => r.json()) + .then(() => { + if (typeof BroadcastChannel !== 'undefined') { + const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); + const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); + callSigChannel.postMessage({ type: 'violation_occurred', violation_type: event.type, details: event.details }); + liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: event.type, details: event.details }); + } + if (typeof window.pollReviewData === 'function') { + window.pollReviewData(); + } + }) + .catch(err => console.log('[proctoring:sync-error]', err)); + } + + report(startedAt, endedAt, durations) { + const report = { + schema: 'candidate-proctoring/report-v1', + session_id: this.sessionId, + started_at: startedAt, + ended_at: endedAt, + generated_at: new Date().toISOString(), + review_required: true, + summary: { + looking_away_ms: Math.round(durations.lookingAwayMs), + no_face_ms: Math.round(durations.noFaceMs), + multiple_faces_ms: Math.round(durations.multipleFacesMs), + prolonged_looking_away_event_count: this.events.filter((event) => event.type === 'prolonged_looking_away').length, + phone_event_count: this.events.filter((event) => event.type === 'phone_detected').length, + total_events: this.events.length, + }, + events: this.events, + }; + console.log('[proctoring:report]', report); + return report; + } +} + +export function logViolation(type, details, confidence = 1.0, snapshot = null) { + if (!isCandidateAssessmentPage()) return; + if (window.candidateProctoring && window.candidateProctoring.logger) { + window.candidateProctoring.logger.record(type, confidence, details, snapshot); + } else { + const interviewId = document.body.dataset.interviewId; + if (!interviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + fetch(`/candidate/log-violation/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + type: type, + details: typeof details === 'object' ? JSON.stringify(details) : details, + confidence: confidence, + snapshot: snapshot + }) + }) + .then(r => r.json()) + .then(() => { + if (typeof BroadcastChannel !== 'undefined') { + const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); + const liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); + callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details }); + liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: type, details: details }); + } + if (typeof window.pollReviewData === 'function') { + window.pollReviewData(); + } + }) + .catch(err => console.log('Log violation fetch error:', err)); + } +} + +// --- System Desktop Screenshot Capture Engine --- +function getScreenShareVideoElement() { + if (!screenShareVideoElem) { + screenShareVideoElem = document.createElement('video'); + screenShareVideoElem.autoplay = true; + screenShareVideoElem.muted = true; + screenShareVideoElem.playsInline = true; + screenShareVideoElem.style.position = 'fixed'; + screenShareVideoElem.style.top = '0px'; + screenShareVideoElem.style.left = '0px'; + screenShareVideoElem.style.width = '320px'; + screenShareVideoElem.style.height = '180px'; + screenShareVideoElem.style.opacity = '0.001'; + screenShareVideoElem.style.pointerEvents = 'none'; + screenShareVideoElem.style.zIndex = '-9999'; + document.body.appendChild(screenShareVideoElem); + } + return screenShareVideoElem; +} + +export function captureAndUploadTabScreenshot(reason = 'tab_switch') { + if (!isTabSwitchScreenshotEnabled || !isCandidateAssessmentPage()) return; + if (reason !== 'tab_switch' && reason !== 'call_start' && reason !== 'external_ai_detected' && reason !== 'copy_event') return; + + try { + const tempCanvas = document.createElement('canvas'); + let drewScreenStream = false; + + const isCallStart = (reason === 'call_start'); + const bannerLabel = isCallStart + ? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString() + : '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString(); + + if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) { + const screenTrack = screenShareStream.getVideoTracks()[0]; + if (screenTrack && screenTrack.readyState === 'live') { + try { + const vidElem = getScreenShareVideoElement(); + if (vidElem.srcObject !== screenShareStream) { + vidElem.srcObject = screenShareStream; + vidElem.play().catch(() => {}); + } + + let rawW = vidElem.videoWidth || 1280; + let rawH = vidElem.videoHeight || 720; + + if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) { + const maxW = 1280; + let targetW = rawW; + let targetH = rawH; + if (targetW > maxW) { + targetH = Math.round(targetH * (maxW / targetW)); + targetW = maxW; + } + + tempCanvas.width = targetW; + tempCanvas.height = targetH; + const tempCtx = tempCanvas.getContext('2d'); + tempCtx.drawImage(vidElem, 0, 0, targetW, targetH); + + tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)'; + tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); + tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; + tempCtx.font = 'bold 14px sans-serif'; + tempCtx.fillText(bannerLabel, 16, tempCanvas.height - 15); + + uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.75), reason); + drewScreenStream = true; + } + } catch(e) { + console.log('Screen stream draw exception:', e); + } + } + } + + if (drewScreenStream) return; + + if (typeof window.html2canvas !== 'undefined' && !document.hidden) { + window.html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => { + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'rgba(15, 23, 42, 0.90)'; + ctx.fillRect(0, canvas.height - 42, canvas.width, 42); + ctx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; + ctx.font = 'bold 14px sans-serif'; + ctx.fillText(bannerLabel, 16, canvas.height - 15); + uploadTabScreenshotBlob(canvas.toDataURL('image/jpeg', 0.75), reason); + }).catch(() => { + fallbackCompositeSnapshot(reason); + }); + } else { + fallbackCompositeSnapshot(reason); + } + } catch(e) { + console.log('Candidate system screenshot capture exception:', e); + fallbackCompositeSnapshot(reason); + } +} + +export function fallbackCompositeSnapshot(reason = 'tab_switch') { + const interviewId = document.body.dataset.interviewId || ''; + const tempCanvas = document.createElement('canvas'); + const w = 1280; + const h = 720; + tempCanvas.width = w; + tempCanvas.height = h; + const tempCtx = tempCanvas.getContext('2d'); + + tempCtx.fillStyle = '#0b0f19'; + tempCtx.fillRect(0, 0, w, h); + tempCtx.fillStyle = '#1e293b'; + tempCtx.fillRect(0, 0, w, 50); + tempCtx.fillStyle = '#38bdf8'; + tempCtx.font = 'bold 16px sans-serif'; + tempCtx.fillText('💻 Candidate System Workspace • ID: ' + interviewId, 20, 32); + + const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor') || document.getElementById('monaco-editor-container'); + tempCtx.fillStyle = '#020617'; + tempCtx.fillRect(20, 70, w - 40, h - 130); + tempCtx.fillStyle = '#94a3b8'; + tempCtx.font = '13px monospace'; + const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : ''; + const lines = codeText.split('\n').slice(0, 30); + lines.forEach((line, i) => { + tempCtx.fillText(line.substring(0, 120), 35, 95 + (i * 18)); + }); + + const isCallStart = (reason === 'call_start'); + tempCtx.fillStyle = 'rgba(15, 23, 42, 0.90)'; + tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); + tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; + tempCtx.font = 'bold 14px sans-serif'; + const text = isCallStart + ? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString() + : '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString(); + tempCtx.fillText(text, 16, tempCanvas.height - 15); + + uploadTabScreenshotBlob(tempCanvas.toDataURL('image/jpeg', 0.85), reason); +} + +export function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') { + const interviewId = document.body.dataset.interviewId; + if (!interviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + fetch(`/candidate/upload-tab-screenshot/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ image: imageData, reason: reason }) + }) + .then(r => r.json()) + .then(data => { + console.log('Candidate system screenshot saved:', data); + }) + .catch(err => console.log('System screenshot fetch error:', err)); +} + +export function isEntireScreenShare(track) { + if (!track || !track.getSettings) return true; + const settings = track.getSettings(); + const surface = settings.displaySurface; + + if (surface) { + const allowedSurfaces = ['monitor', 'screen']; + if (!allowedSurfaces.includes(surface)) { + return false; + } + } + + if (window.screen && window.screen.width && settings.width) { + if (settings.width < (window.screen.width * 0.65) && settings.height < (window.screen.height * 0.65)) { + return false; + } + } + + return true; +} + +export function isScreenSharingActive() { + return Boolean(screenShareStream && screenShareStream.active && screenShareStream.getVideoTracks().some(t => t.readyState === 'live')); +} + +export function enforceBrowserFullscreen() { + if (!isScreenSharingActive() || document.fullscreenElement) return; + + beginNativePrompt(); + if (document.documentElement.requestFullscreen) { + document.documentElement.requestFullscreen() + .then(() => { + endNativePrompt(); + armTrailingBuffer(1000); + }) + .catch(() => { + endNativePrompt(); + showFullscreenRequiredModal(); + }); + } else { + endNativePrompt(); + showFullscreenRequiredModal(); + } +} + +export function showFullscreenRequiredModal() { + if (!isScreenSharingActive() || document.fullscreenElement) return; + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'warning', + title: 'Fullscreen Mode Required', + text: 'Your assessment must be conducted in Fullscreen mode while screen sharing. Please click below to enter Fullscreen.', + confirmButtonText: 'Enter Fullscreen Mode', + confirmButtonColor: '#6366f1', + allowOutsideClick: false, + allowEscapeKey: false + }).then(() => { + if (isScreenSharingActive()) { + enforceBrowserFullscreen(); + } + }); + } +} + +let candidateScreenPeerInstance = null; +const activeScreenCallTargets = new Set(); + +export function broadcastCandidateScreenStream(stream) { + if (!stream || !stream.active) return; + const videoTracks = stream.getVideoTracks(); + if (!videoTracks || videoTracks.length === 0 || videoTracks[0].readyState !== 'live') return; + + const interviewId = document.body?.dataset?.interviewId; + const submissionId = document.body?.dataset?.submissionId || interviewId; + if (!interviewId || typeof window.Peer === 'undefined') return; + + const subIdPart = (submissionId && submissionId !== String(interviewId)) ? ('_' + submissionId) : ''; + const legacyScreenPeerId = 'interviewer_screen_' + interviewId + subIdPart; + + const targetPeerIds = new Set(); + + // Collect dedicated screen receiver targets from active interviewers + if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) { + window.latestActivePeers.forEach(p => { + if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { + const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_'); + targetPeerIds.add(screenTargetId); + } + }); + } + + if (targetPeerIds.size === 0) { + targetPeerIds.add(legacyScreenPeerId); + } + + const makeCalls = (peer) => { + targetPeerIds.forEach(targetId => { + if (activeScreenCallTargets.has(targetId)) return; + + try { + console.log('[WebRTC Screen] Broadcasting screen to interviewer target:', targetId); + const outCall = peer.call(targetId, stream); + if (outCall) { + activeScreenCallTargets.add(targetId); + outCall.on('close', () => { + console.log('[WebRTC Screen] Screen call closed for target:', targetId); + activeScreenCallTargets.delete(targetId); + }); + outCall.on('error', (err) => { + console.warn('[WebRTC Screen] Screen call error for target:', targetId, err); + activeScreenCallTargets.delete(targetId); + }); + } + } catch(e) { + activeScreenCallTargets.delete(targetId); + } + }); + }; + + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed && candidateScreenPeerInstance.open) { + makeCalls(candidateScreenPeerInstance); + } else { + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { + try { candidateScreenPeerInstance.destroy(); } catch(e) {} + } + activeScreenCallTargets.clear(); + const screenPeerId = 'cand_screen_' + interviewId + subIdPart + '_' + Date.now(); + initMeteredIceServers().then(iceServers => { + candidateScreenPeerInstance = new window.Peer(screenPeerId, { + config: { iceServers: iceServers } + }); + candidateScreenPeerInstance.on('open', () => { + makeCalls(candidateScreenPeerInstance); + }); + candidateScreenPeerInstance.on('error', err => { + console.warn('[WebRTC Screen] Candidate screen peer error:', err); + }); + }).catch(() => { + candidateScreenPeerInstance = new window.Peer(screenPeerId); + candidateScreenPeerInstance.on('open', () => { + makeCalls(candidateScreenPeerInstance); + }); + }); + } +} + +// --- Screen Share Enforcement & Live Overlay Detector (Category 6) --- +export function startScreenShare() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { + alert('Screen sharing is not supported on your browser.'); + return; + } + + beginNativePrompt(); + + navigator.mediaDevices.getDisplayMedia({ + video: { + displaySurface: 'monitor' + }, + audio: false + }) + .then(stream => { + endNativePrompt(); + const track = stream.getVideoTracks()[0]; + + // Enforce ENTIRE SCREEN share across all browsers + if (!isEntireScreenShare(track)) { + track.stop(); + screenShareStream = null; + logViolation('tab_switch', 'Candidate attempted non-entire screen share (tab or window)'); + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'error', + title: 'Entire Screen Share Required', + html: 'You selected a single tab or window.

You MUST select "Entire Screen" (or whole monitor) to continue your interview.', + confirmButtonText: 'Reshare Entire Screen Now', + confirmButtonColor: '#ef4444', + allowOutsideClick: false, + allowEscapeKey: false + }).then(() => { + setTimeout(startScreenShare, 300); + }); + } else { + alert('Entire Screen share is required. Please select Entire Screen.'); + setTimeout(startScreenShare, 300); + } + return; + } + + screenShareStream = stream; + const vidElem = getScreenShareVideoElement(); + vidElem.srcObject = stream; + vidElem.play().catch(() => {}); + + updateScreenShareButton(true); + + enforceBrowserFullscreen(); + + broadcastCandidateScreenStream(stream); + + track.onended = () => { + screenShareStream = null; + activeScreenCallTargets.clear(); + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { + try { candidateScreenPeerInstance.destroy(); } catch(e) {} + candidateScreenPeerInstance = null; + } + updateScreenShareButton(false); + + if (document.fullscreenElement) { + try { document.exitFullscreen().catch(() => {}); } catch(e) {} + } + + logViolation('tab_switch', 'Candidate stopped screen sharing'); + }; + + initLiveOverlayDetector(); + }) + .catch(err => { + endNativePrompt(); + console.log('Screen sharing cancelled/denied:', err); + screenShareStream = null; + activeScreenCallTargets.clear(); + updateScreenShareButton(false); + }); +} + +export function stopScreenShare() { + if (screenShareStream) { + try { + screenShareStream.getTracks().forEach(track => track.stop()); + } catch(e) {} + screenShareStream = null; + } + activeScreenCallTargets.clear(); + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { + try { candidateScreenPeerInstance.destroy(); } catch(e) {} + candidateScreenPeerInstance = null; + } + updateScreenShareButton(false); + + if (document.fullscreenElement) { + try { document.exitFullscreen().catch(() => {}); } catch(e) {} + } + + logViolation('tab_switch', 'Candidate stopped screen sharing'); +} + +export function toggleScreenShare() { + if (screenShareStream && screenShareStream.active && screenShareStream.getVideoTracks().length > 0 && screenShareStream.getVideoTracks()[0].readyState === 'live') { + stopScreenShare(); + } else { + startScreenShare(); + } +} + +export function updateScreenShareButton(active) { + const btn = document.getElementById('share-screen-btn'); + if (!btn) return; + if (active) { + btn.innerHTML = '🛑 Stop Screen Sharing'; + btn.style.background = '#ef4444'; + btn.style.borderColor = '#dc2626'; + btn.onclick = stopScreenShare; + } else { + btn.innerHTML = '🖥️ Share Screen with Interviewer'; + btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)'; + btn.style.borderColor = '#6366f1'; + btn.onclick = startScreenShare; + } +} + +export function initLiveOverlayDetector() { + if (!isCandidateAssessmentPage()) return; + if (overlayCheckInterval) clearInterval(overlayCheckInterval); + + overlayCheckInterval = setInterval(() => { + if (!screenShareStream || screenShareStream.getVideoTracks().length === 0) return; + const track = screenShareStream.getVideoTracks()[0]; + if (track.readyState !== 'live') return; + + const vidElem = getScreenShareVideoElement(); + if (!vidElem || vidElem.videoWidth === 0) return; + + try { + if (!document.hasFocus()) { + const now = Date.now(); + if (now - lastTabSwitchTime > 3000) { + lastTabSwitchTime = now; + logViolation('tab_switch', 'Candidate window lost focus during active screen share'); + captureAndUploadTabScreenshot('tab_switch'); + } + } + } catch(e) {} + }, 2000); +} + +// --- Continuous Speech Recognition (Category 5) --- +export function initQuestionRepeatDetection() { + if (!isCandidateAssessmentPage()) return; + 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; + + const pageText = (document.body ? document.body.innerText : '').toLowerCase(); + + const words = transcript.split(/\s+/).filter(w => w.length > 3); + let matchedWordCount = 0; + words.forEach(w => { + if (pageText.includes(w)) matchedWordCount++; + }); + const isReadingQuestionOnScreen = words.length >= 3 && (matchedWordCount / words.length) >= 0.5; + + const isRepeatedSpeech = 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(); + + const phoneKeywords = ['hello', 'can you hear me', 'on phone', 'calling', 'call you back', 'hold on', 'on the line', 'speaker', 'phone', 'mobile', 'call me', 'speak later', 'hey call', 'on a call']; + const isPhoneTalk = phoneKeywords.some(kw => transcript.includes(kw)); + const isCallConnected = window.isCallConnected ? window.isCallConnected() : false; + + if (isPhoneTalk) { + logViolation('talking_on_phone', `Candidate detected talking on phone / phone call during assessment: "${transcript}"`); + } else if (isReadingQuestionOnScreen || isRepeatedSpeech) { + if (window.candidateProctoring?.lastStates?.lookingAway) { + logViolation('reading_external_device', `Candidate detected reading from external device or mobile screen: "${transcript}"`); + } else { + logViolation('question_repetition', `Candidate detected reading / repeating question out loud: "${transcript}" (suspected AI prompt ingestion)`); + } + } else { + if (!isCallConnected && transcript.length > 10) { + logViolation('talking_secondary_person', `Candidate speaking out loud to secondary person in room: "${transcript}"`); + } + } + } + } + }; + + speechRec.onerror = function() { + setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 4000); + }; + + speechRec.onend = function() { + setTimeout(() => { try { speechRec.start(); } catch(e) {} }, 1500); + }; + + speechRec.start(); + } catch(e) {} +} + +// --- Candidate Proctoring Event Listeners (Tab focus, Clipboard, Hotkeys, DOM Observer, PiP, Fullscreen) --- +export function initCandidateProctoringListeners() { + if (!isCandidateAssessmentPage()) return; + + // 1. Visibility Change (Tab Switched / Window Minimized) + document.addEventListener('visibilitychange', function () { + if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; + if (document.hidden) { + const now = Date.now(); + if (now - lastTabSwitchTime > 1000) { + lastTabSwitchTime = now; + logViolation('tab_switch', 'Candidate switched browser tab or minimized window'); + captureAndUploadTabScreenshot(); + } + } + }); + + // 2. Window Blur (Assessment Window Lost Focus) + window.addEventListener('blur', function () { + if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; + const now = Date.now(); + if (now - lastTabSwitchTime > 1000) { + lastTabSwitchTime = now; + logViolation('tab_switch', 'Candidate assessment window lost focus'); + captureAndUploadTabScreenshot('tab_switch'); + } + }); + + // 3. Mouse Viewport Departure + document.addEventListener('mouseleave', function () { + if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; + const now = Date.now(); + if (now - lastTabSwitchTime > 2500) { + lastTabSwitchTime = now; + logViolation('tab_switch', 'Candidate cursor departed assessment window viewport'); + captureAndUploadTabScreenshot('tab_switch'); + } + }); + + // 4. Clipboard Events (Paste into Editor / Copy from Assessment Window) + document.addEventListener('paste', function () { + if (!isCandidateAssessmentPage()) return; + logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)'); + }, true); + + document.addEventListener('copy', function () { + if (!isCandidateAssessmentPage()) return; + logViolation('copy_event', 'Candidate copied text from assessment window (possible Parakeet AI prompt ingestion)'); + captureAndUploadTabScreenshot(); + }); + + // 5. Global Keydown AI Hotkey Interception (Capture Phase) + window.addEventListener('keydown', function (e) { + if (!isCandidateAssessmentPage()) return; + if (e.code === 'Tab' || e.key === 'Tab') return; + + const key = (e.key || '').toLowerCase(); + const code = (e.code || '').toLowerCase(); + const isAlt = e.altKey; + const isCmd = e.metaKey; + const isCtrl = e.ctrlKey; + const isShift = e.shiftKey; + + const isAiHotkey = (isAlt && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'c' || key === 'v' || key === 'q' || key === 'w' || key === 'e' || key === 'x' || key === 'z')) || + (isCmd && (key === ' ' || code === 'space' || key === 'p' || key === 'a' || key === 's' || key === 'x')) || + (isCtrl && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'i' || key === 'c' || key === 'x')) || + (isCmd && isShift && (key === 'a' || key === 'p' || key === 's' || key === 'x' || key === '4' || key === '3')) || + (isCtrl && isAlt) || + (key === 'f12' || code === 'f12' || key === 'f11' || code === 'f11'); + + if (isAiHotkey) { + logViolation('external_ai_detected', `candidate might use external ai: Intercepted AI assistant shortcut hotkey (${e.code || e.key})`); + captureAndUploadTabScreenshot('external_ai_detected'); + } + }, true); + + // 6. Injected Extension DOM Observer + try { + const aiMutationObserver = new MutationObserver(mutations => { + if (!isCandidateAssessmentPage()) return; + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === 1) { + const nodeStr = ((node.id || '') + ' ' + (node.className || '') + ' ' + (node.getAttribute('src') || '')).toLowerCase(); + if (nodeStr.includes('parakeet') || nodeStr.includes('finalround') || nodeStr.includes('copilot') || nodeStr.includes('ai-assistant') || nodeStr.includes('otter')) { + logViolation('external_ai_detected', 'candidate might use external ai: Injected AI extension overlay element detected in DOM (' + node.tagName + ')'); + captureAndUploadTabScreenshot('external_ai_detected'); + } + } + } + } + }); + aiMutationObserver.observe(document.documentElement, { childList: true, subtree: true }); + } catch(e) {} + + // 7. Picture-in-Picture Floating AI Window Detector + setInterval(function() { + if (!isCandidateAssessmentPage()) return; + if (document.pictureInPictureElement) { + logViolation('external_ai_detected', 'candidate might use external ai: External Picture-in-Picture floating AI window active'); + } + }, 6000); + + // 8. Fullscreen Exit Listener (Only enforced when screen sharing is active) + document.addEventListener('fullscreenchange', function() { + if (!isCandidateAssessmentPage() || isFocusSuppressed() || !isScreenSharingActive()) return; + if (!document.fullscreenElement) { + logViolation('tab_switch', 'Candidate exited full-screen proctoring mode while screen sharing'); + captureAndUploadTabScreenshot(); + showFullscreenRequiredModal(); + } + }); +} + +// --- MediaPipe Proctoring Classes --- + +class WarningPresenter { + constructor() { + this.element = null; + } + + show(message) { + // Candidate-side violation notifications removed per directive. + // All violations are silently logged and sent to the server. + } +} + +class SnapshotService { + constructor(video, canvas) { + this.video = video; + this.canvas = canvas; + } + + capture() { + if (!this.video || this.video.readyState < 2 || !this.video.videoWidth) return null; + const width = Math.min(640, this.video.videoWidth); + const height = Math.round(width * this.video.videoHeight / this.video.videoWidth); + this.canvas.width = width; + this.canvas.height = height; + this.canvas.getContext('2d').drawImage(this.video, 0, 0, width, height); + return this.canvas.toDataURL('image/jpeg', SNAPSHOT_QUALITY); + } +} + +class FacePoseDetector { + constructor(landmarker) { + this.landmarker = landmarker; + } + + detect(video, timestamp) { + const result = this.landmarker.detectForVideo(video, timestamp); + const faces = result.faceLandmarks || []; + return { + count: faces.length, + pose: faces[0] ? this.estimatePose(faces[0]) : null, + gaze: faces[0] ? this.estimateGaze(faces[0]) : null, + }; + } + + estimatePose(landmarks) { + const point = (index) => landmarks[index]; + const leftEye = point(33); + const rightEye = point(263); + const nose = point(1); + const mouth = point(13); + const eyeMidX = (leftEye.x + rightEye.x) / 2; + const eyeMidY = (leftEye.y + rightEye.y) / 2; + const eyeDistance = Math.max(0.001, Math.abs(rightEye.x - leftEye.x)); + const yaw = (nose.x - eyeMidX) / eyeDistance; + const pitch = (nose.y - eyeMidY) / Math.max(0.001, Math.abs(mouth.y - eyeMidY)); + return { yaw, pitch, confidence: Math.min(1, 0.5 + Math.abs(yaw) * 0.4 + Math.abs(pitch - 0.5) * 0.2) }; + } + + estimateGaze(landmarks) { + const leftEye = this.estimateEyeGaze(landmarks, { + corners: [33, 133], + top: [159, 158], + bottom: [145, 153], + iris: [468, 469, 470, 471, 472], + }); + const rightEye = this.estimateEyeGaze(landmarks, { + corners: [362, 263], + top: [386, 385], + bottom: [374, 380], + iris: [473, 474, 475, 476, 477], + }); + const eyes = [leftEye, rightEye].filter(Boolean); + if (!eyes.length) return null; + + const horizontal = eyes.reduce((total, eye) => total + eye.horizontal, 0) / eyes.length; + const vertical = eyes.reduce((total, eye) => total + eye.vertical, 0) / eyes.length; + const confidence = Math.min(1, 0.45 + Math.max(Math.abs(horizontal - 0.5), Math.abs(vertical - 0.5)) * 1.4); + + return { horizontal, vertical, confidence }; + } + + estimateEyeGaze(landmarks, config) { + const points = [...config.corners, ...config.top, ...config.bottom, ...config.iris].map((index) => landmarks[index]); + if (points.some((point) => !point)) return null; + + const cornerA = landmarks[config.corners[0]]; + const cornerB = landmarks[config.corners[1]]; + const topY = this.averageCoordinate(landmarks, config.top, 'y'); + const bottomY = this.averageCoordinate(landmarks, config.bottom, 'y'); + const irisX = this.averageCoordinate(landmarks, config.iris, 'x'); + const irisY = this.averageCoordinate(landmarks, config.iris, 'y'); + const leftX = Math.min(cornerA.x, cornerB.x); + const rightX = Math.max(cornerA.x, cornerB.x); + + return { + horizontal: this.clamp01((irisX - leftX) / Math.max(0.001, rightX - leftX)), + vertical: this.clamp01((irisY - topY) / Math.max(0.001, bottomY - topY)), + }; + } + + averageCoordinate(landmarks, indexes, coordinate) { + return indexes.reduce((total, index) => total + landmarks[index][coordinate], 0) / indexes.length; + } + + clamp01(value) { + return Math.max(0, Math.min(1, value)); + } +} + +class LookingAwayClassifier { + classify(face) { + const reasons = []; + const pose = face.pose; + const gaze = face.gaze; + + if (pose && Math.abs(pose.yaw) > HEAD_YAW_THRESHOLD) reasons.push('head_yaw'); + if (pose && pose.pitch < HEAD_PITCH_UP_THRESHOLD) reasons.push('head_up'); + if (pose && pose.pitch > HEAD_PITCH_DOWN_THRESHOLD) reasons.push('head_down'); + if (gaze && gaze.horizontal < GAZE_HORIZONTAL_LEFT_THRESHOLD) reasons.push('eye_gaze_left'); + if (gaze && gaze.horizontal > GAZE_HORIZONTAL_RIGHT_THRESHOLD) reasons.push('eye_gaze_right'); + if (gaze && gaze.vertical > GAZE_DOWN_THRESHOLD) reasons.push('eye_gaze_down'); + + return { + active: reasons.length > 0, + confidence: this.confidence(reasons, pose, gaze), + details: { + reasons, + yaw: pose?.yaw ?? null, + pitch: pose?.pitch ?? null, + gaze_horizontal: gaze?.horizontal ?? null, + gaze_vertical: gaze?.vertical ?? null, + }, + }; + } + + confidence(reasons, pose, gaze) { + if (!reasons.length) return 0; + return Math.max( + reasons.some((reason) => reason.startsWith('head_')) ? pose?.confidence || 0 : 0, + reasons.some((reason) => reason.startsWith('eye_')) ? gaze?.confidence || 0 : 0, + ); + } +} + +class PhoneDetector { + constructor(detector) { + this.detector = detector; + } + + detect(video, timestamp) { + if (!this.detector || !video || video.readyState < 2 || !video.videoWidth) return null; + try { + const ts = Math.round(timestamp); + const result = this.detector.detectForVideo(video, ts); + const detections = result ? (result.detections || []) : []; + for (const detection of detections) { + const categories = detection.categories || []; + for (const category of categories) { + const name = (category.categoryName || category.displayName || '').toLowerCase(); + const score = category.score || 0; + if (/cell phone|mobile phone|phone|remote|handheld|device|calculator|tablet/i.test(name) && score >= 0.15) { + console.log('[proctoring:phone_detected_raw]', { name, score }); + return { confidence: score, label: category.categoryName || category.displayName || 'cell phone' }; + } + } + } + } catch (e) { + console.warn('[proctoring:phone_detect_error]', e.message); + } + return null; + } +} + +class DurationTracker { + constructor() { + this.activeSince = { lookingAway: null, noFace: null, multipleFaces: null }; + this.total = { lookingAwayMs: 0, noFaceMs: 0, multipleFacesMs: 0 }; + } + + update(name, active, now) { + const startedAt = this.activeSince[name]; + if (active && startedAt === null) this.activeSince[name] = now; + if (!active && startedAt !== null) { + this.total[`${name}Ms`] += now - startedAt; + this.activeSince[name] = null; + } + } + + finish(now) { + Object.keys(this.activeSince).forEach((name) => this.update(name, false, now)); + return { ...this.total }; + } +} + +class ProlongedLookingAwayTracker { + constructor({ thresholdMs, recheckMs }) { + this.thresholdMs = thresholdMs; + this.recheckMs = recheckMs; + this.startedAt = null; + this.nextCheckAt = null; + } + + update(active, now) { + if (!active) { + this.reset(); + return null; + } + + if (this.startedAt === null) this.startedAt = now; + + const durationMs = now - this.startedAt; + if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) return null; + + const recheck = this.nextCheckAt !== null; + this.nextCheckAt = now + this.recheckMs; + + return { + duration_ms: Math.round(durationMs), + threshold_ms: this.thresholdMs, + recheck, + recheck_interval_ms: this.recheckMs, + next_check_at: new Date(Date.now() + this.recheckMs).toISOString(), + }; + } + + reset() { + this.startedAt = null; + this.nextCheckAt = null; + } +} + +class CornerGazeClassifier { + classify(face) { + const pose = face.pose; + const gaze = face.gaze; + if (!pose || !gaze) return null; + + const horiz = gaze.horizontal; + const vert = gaze.vertical; + const yaw = pose.yaw; + const pitch = pose.pitch; + + const isLeft = (horiz <= 0.42 || yaw <= -0.16); + const isRight = (horiz >= 0.58 || yaw >= 0.16); + const isTop = (vert <= 0.44 || pitch <= 0.38); + const isBottom = (vert >= 0.56 || pitch >= 0.62); + + let corner = null; + let cornerLabel = null; + + if (isLeft && isTop) { + corner = 'top_left'; + cornerLabel = 'Top-Left Screen Corner'; + } else if (isRight && isTop) { + corner = 'top_right'; + cornerLabel = 'Top-Right Screen Corner'; + } else if (isLeft && isBottom) { + corner = 'bottom_left'; + cornerLabel = 'Bottom-Left Screen Corner'; + } else if (isRight && isBottom) { + corner = 'bottom_right'; + cornerLabel = 'Bottom-Right Screen Corner'; + } else if (isBottom) { + corner = 'bottom_center'; + cornerLabel = 'Screen Bottom / Downside'; + } else if (isLeft) { + corner = 'screen_left'; + cornerLabel = 'Screen Left Side'; + } else if (isRight) { + corner = 'screen_right'; + cornerLabel = 'Screen Right Side'; + } + + if (!corner) return null; + + const confidence = Math.min(1, 0.5 + Math.max(Math.abs(horiz - 0.5), Math.abs(vert - 0.5)) * 1.2); + + return { + corner, + corner_label: cornerLabel, + confidence, + details: { + corner, + corner_label: cornerLabel, + yaw, + pitch, + gaze_horizontal: horiz, + gaze_vertical: vert, + } + }; + } +} + +class CornerGazeTracker { + constructor({ thresholdMs, recheckMs, graceMs }) { + this.thresholdMs = thresholdMs; + this.recheckMs = recheckMs; + this.graceMs = graceMs; + + this.currentCorner = null; + this.startedAt = null; + this.lastActiveAt = null; + this.nextCheckAt = null; + } + + update(cornerResult, now) { + if (!cornerResult) { + if (this.lastActiveAt && (now - this.lastActiveAt) > this.graceMs) { + this.reset(); + } + return null; + } + + const activeCorner = cornerResult.corner; + + const isRelatedZone = (this.currentCorner && ( + (this.currentCorner.startsWith('bottom_') && activeCorner.startsWith('bottom_')) || + (this.currentCorner.startsWith('top_') && activeCorner.startsWith('top_')) || + (this.currentCorner === activeCorner) + )); + + if (!this.currentCorner || !isRelatedZone) { + this.currentCorner = activeCorner; + this.startedAt = now; + this.nextCheckAt = null; + } else { + this.currentCorner = activeCorner; + } + + this.lastActiveAt = now; + + const durationMs = now - this.startedAt; + if (durationMs < this.thresholdMs || (this.nextCheckAt !== null && now < this.nextCheckAt)) { + return null; + } + + this.nextCheckAt = now + this.recheckMs; + + return { + corner: activeCorner, + corner_label: cornerResult.corner_label, + duration_ms: Math.round(durationMs), + duration_s: Math.round(durationMs / 1000), + confidence: cornerResult.confidence, + details: { + ...cornerResult.details, + duration_ms: Math.round(durationMs), + duration_s: Math.round(durationMs / 1000), + } + }; + } + + reset() { + this.currentCorner = null; + this.startedAt = null; + this.lastActiveAt = null; + this.nextCheckAt = null; + } +} + +class CandidateProctoring { + constructor({ sessionId, video, canvas }) { + this.sessionId = sessionId; + this.video = video; + this.canvas = canvas; + this.logger = new StructuredEventLogger(sessionId); + this.warning = new WarningPresenter(); + this.snapshot = new SnapshotService(video, canvas); + this.durations = new DurationTracker(); + this.lookingAwayClassifier = new LookingAwayClassifier(); + this.prolongedLookingAway = new ProlongedLookingAwayTracker({ + thresholdMs: PROLONGED_LOOKING_AWAY_MS, + recheckMs: PROLONGED_LOOKING_AWAY_RECHECK_MS, + }); + this.cornerGazeClassifier = new CornerGazeClassifier(); + this.cornerGazeTracker = new CornerGazeTracker({ + thresholdMs: CORNER_FIXATION_MS, + recheckMs: CORNER_RECHECK_MS, + graceMs: CORNER_JITTER_GRACE_MS, + }); + this.startedAt = new Date().toISOString(); + this.lastSampleAt = 0; + this.lastPhoneEventAt = 0; + this.lastStates = { lookingAway: false, noFace: false, multipleFaces: false }; + this.running = false; + this.modelsReady = false; + this.reported = false; + } + + async start() { + if (this.running) return; + try { + const vision = await this.importVisionModule(); + const fileset = await vision.FilesetResolver.forVisionTasks(WASM_ROOT); + + const faceLandmarker = await this.withAmdLoaderDisabled(() => + this.createModel(vision.FaceLandmarker, fileset, { + modelAssetPath: FACE_MODEL_URL, + runningMode: 'VIDEO', numFaces: 3, minFaceDetectionConfidence: 0.5, + minFacePresenceConfidence: 0.5, minTrackingConfidence: 0.5, + }) + ); + this.faceDetector = new FacePoseDetector(faceLandmarker); + + try { + const objectDetector = await this.withAmdLoaderDisabled(() => + this.createModel(vision.ObjectDetector, fileset, { + modelAssetPath: OBJECT_MODEL_URL, + runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.35, + }) + ); + this.phoneDetector = new PhoneDetector(objectDetector); + console.log('[proctoring:object_detector_ready]'); + } catch (objErr) { + console.error('[proctoring:object_detector_failed]', objErr); + } + + this.modelsReady = true; + this.running = true; + this.scheduleNextSample(); + console.log('[proctoring:ready]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId }); + } catch (error) { + console.error('[proctoring:error]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'model_initialization_failed', message: error.message }); + } + } + + async importVisionModule() { + try { + return await import(/* @vite-ignore */ VISION_MODULE_URL); + } catch (primaryError) { + console.warn('[proctoring:warning]', { + schema: 'candidate-proctoring/v1', session_id: this.sessionId, + code: 'vision_cdn_fallback', message: primaryError.message, + }); + return import(/* @vite-ignore */ VISION_MODULE_FALLBACK_URL); + } + } + + async withAmdLoaderDisabled(callback) { + const amdGlobals = ['define', 'require', 'requirejs']; + const saved = amdGlobals.map((name) => ({ name, value: globalThis[name] })); + amdGlobals.forEach((name) => { + try { + delete globalThis[name]; + } catch (error) { + globalThis[name] = undefined; + } + }); + + try { + return await callback(); + } finally { + saved.forEach(({ name, value }) => { + if (value === undefined) delete globalThis[name]; + else globalThis[name] = value; + }); + } + } + + async createModel(Model, fileset, options) { + try { + return await Model.createFromOptions(fileset, { ...options, baseOptions: { modelAssetPath: options.modelAssetPath, delegate: 'GPU' } }); + } catch (gpuError) { + console.warn('[proctoring:warning]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'gpu_delegate_unavailable', message: gpuError.message }); + return Model.createFromOptions(fileset, { ...options, baseOptions: { modelAssetPath: options.modelAssetPath, delegate: 'CPU' } }); + } + } + + scheduleNextSample() { + if (!this.running) return; + setTimeout(() => this.sample(), SAMPLE_INTERVAL_MS); + } + + sample() { + if (!this.running) return; + if (this.video.readyState < 2 || !this.video.videoWidth) return this.scheduleNextSample(); + const timestamp = performance.now(); + try { + const face = this.faceDetector.detect(this.video, timestamp); + const phone = this.phoneDetector ? this.phoneDetector.detect(this.video, timestamp) : null; + this.processFace(face, timestamp); + this.processPhone(phone, timestamp); + } catch (error) { + console.error('[proctoring:error]', { schema: 'candidate-proctoring/v1', session_id: this.sessionId, code: 'frame_processing_failed', message: error.message }); + } + this.lastSampleAt = timestamp; + this.scheduleNextSample(); + } + + processFace(result, now) { + const noFace = result.count === 0; + const multipleFaces = result.count > 1; + const lookingAwayResult = this.lookingAwayClassifier.classify(result); + const cornerGazeResult = this.cornerGazeClassifier.classify(result); + + this.durations.update('noFace', noFace, now); + this.durations.update('multipleFaces', multipleFaces, now); + this.durations.update('lookingAway', lookingAwayResult.active, now); + + this.transition('noFace', noFace, noFace ? 0.95 : 0, { face_count: result.count }, NO_FACE_GRACE_MS, 'Candidate face is not visible'); + this.transition('multipleFaces', multipleFaces, multipleFaces ? 0.95 : 0, { face_count: result.count }, 0, 'Multiple faces detected'); + this.transition('lookingAway', lookingAwayResult.active, lookingAwayResult.confidence, lookingAwayResult.details, LOOKING_AWAY_GRACE_MS, 'Please look toward the camera'); + this.trackProlongedLookingAway(lookingAwayResult, now); + this.trackCornerGaze(cornerGazeResult, now); + } + + transition(name, active, confidence, details, graceMs, message) { + if (active === this.lastStates[name]) return; + if (active && graceMs > 0 && this.durations.activeSince[name] && performance.now() - this.durations.activeSince[name] < graceMs) return; + this.lastStates[name] = active; + if (active) { + this.warning.show(message); + const eventName = name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces'; + this.logger.record(eventName, confidence, details, this.snapshot.capture()); + } + } + + trackProlongedLookingAway(lookingAwayResult, now) { + const prolongedEvent = this.prolongedLookingAway.update(lookingAwayResult.active, now); + if (!prolongedEvent) return; + + this.warning.show(`Candidate has been looking away for over ${Math.round(PROLONGED_LOOKING_AWAY_MS / 1000)} seconds`); + this.logger.record('prolonged_looking_away', lookingAwayResult.confidence, { + ...prolongedEvent, + ...lookingAwayResult.details, + }, this.snapshot.capture()); + } + + trackCornerGaze(cornerResult, now) { + const cornerEvent = this.cornerGazeTracker.update(cornerResult, now); + if (!cornerEvent) return; + + this.logger.record('prolonged_corner_gaze', cornerEvent.confidence, { + ...cornerEvent.details, + }, this.snapshot.capture()); + } + + processPhone(phone, now) { + if (!phone || now - this.lastPhoneEventAt < PHONE_EVENT_COOLDOWN_MS) return; + this.lastPhoneEventAt = now; + this.warning.show('Possible phone detected in camera frame'); + this.logger.record('phone_detected', phone.confidence, { label: phone.label }, this.snapshot.capture()); + } + + stop() { + if (this.reported) return null; + this.running = false; + this.reported = true; + return this.logger.report(this.startedAt, new Date().toISOString(), this.durations.finish(performance.now())); + } +} + +// --- Candidate Proctoring Auto-Boot & Listener Registration --- +function bootCandidateProctoring() { + if (!isCandidateAssessmentPage()) return; + const sessionId = document.body.dataset.interviewId; + if (!sessionId) return; + + const video = document.getElementById('webcam'); + const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas'); + + // Boot MediaPipe proctoring if webcam element exists + if (video && navigator.mediaDevices) { + const proctor = new CandidateProctoring({ sessionId, video, canvas }); + window.candidateProctoring = proctor; + const startWhenReady = () => proctor.start(); + if (video.readyState >= 2) startWhenReady(); + video.addEventListener('loadeddata', startWhenReady, { once: true }); + const callStatus = document.getElementById('call-status-badge'); + if (callStatus) { + const statusObserver = new MutationObserver(() => { + if (/ended/i.test(callStatus.textContent || '')) proctor.stop(); + }); + statusObserver.observe(callStatus, { childList: true, characterData: true, subtree: true }); + } + window.addEventListener('pagehide', () => proctor.stop(), { once: true }); + } + + // Initialize all candidate cheat detection event listeners & speech analysis + initCandidateProctoringListeners(); + initQuestionRepeatDetection(); +} + +// --- Window Bindings for Proctoring --- +if (typeof window !== 'undefined') { + window.logViolation = logViolation; + window.captureAndUploadTabScreenshot = captureAndUploadTabScreenshot; + window.fallbackCompositeSnapshot = fallbackCompositeSnapshot; + window.uploadTabScreenshotBlob = uploadTabScreenshotBlob; + window.beginNativePrompt = beginNativePrompt; + window.endNativePrompt = endNativePrompt; + window.armTrailingBuffer = armTrailingBuffer; + window.isFocusSuppressed = isFocusSuppressed; + window.isScreenSharingActive = isScreenSharingActive; + window.startScreenShare = startScreenShare; + window.stopScreenShare = stopScreenShare; + window.toggleScreenShare = toggleScreenShare; + window.updateScreenShareButton = updateScreenShareButton; + window.initLiveOverlayDetector = initLiveOverlayDetector; + window.initQuestionRepeatDetection = initQuestionRepeatDetection; + window.initCandidateProctoringListeners = initCandidateProctoringListeners; + window.isCandidateAssessmentPage = isCandidateAssessmentPage; + window.isCandidateCallActive = isCandidateCallActive; + window.broadcastCandidateScreenStream = broadcastCandidateScreenStream; + window.getScreenShareStream = () => screenShareStream; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true }); + } else { + bootCandidateProctoring(); + } +} diff --git a/resources/js/candidate-room.js b/resources/js/candidate-room.js new file mode 100644 index 0000000..7731286 --- /dev/null +++ b/resources/js/candidate-room.js @@ -0,0 +1,1491 @@ +/** + * Candidate Assessment Room UI & Interactivity Engine + * Handles Monaco Editor setup & sync, Code Runner, Final Submission, + * Candidate Notepad, Live Drawing Board, Live Chat, and WebRTC Call Controls. + */ + +import { safePlayMediaStream, getInitials } from './interview-call.js'; +import { initMeteredIceServers } from './metered.js'; + +let editor = null; +let candidateLocalStream = null; +let candidatePeerInstance = null; +let candidatePeerConnection = null; +let liveSyncChannel = null; +let callSigChannel = null; +let globalCallChannel = null; + +let isCandidateMicEnabled = true; +let isCandidateCamEnabled = true; +let activeCandidateCallInstance = null; +let isCandidateCallConnected = false; +let candidateDeclinedOrLeftCall = false; +let pendingOffer = null; +let candRingTimer = null; +let latestCallStartedAt = null; +let hasCapturedCallStartScreenshot = false; +let candidateInterviewerTiles = new Map(); +let latestCallStatus = 'idle'; + +export class CandRingtoneEngine { + constructor() { + this.ctx = null; + this.interval = null; + this.isPlaying = false; + } + + init() { + try { + if (!this.ctx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioCtx(); + } + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } catch (e) {} + } + + start() { + this.init(); + if (this.isPlaying) return; + this.isPlaying = true; + + this.playTone(); + if (this.interval) clearInterval(this.interval); + this.interval = setInterval(() => { + if (this.isPlaying) this.playTone(); + }, 2400); + } + + playTone() { + this.init(); + if (!this.ctx || !this.isPlaying) return; + try { + const now = this.ctx.currentTime; + const osc1 = this.ctx.createOscillator(); + const osc2 = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc1.type = 'sine'; + osc2.type = 'sine'; + + osc1.frequency.setValueAtTime(523.25, now); + osc2.frequency.setValueAtTime(659.25, now); + + gain.gain.setValueAtTime(0.001, now); + gain.gain.linearRampToValueAtTime(0.35, now + 0.08); + gain.gain.setValueAtTime(0.35, now + 1.1); + gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3); + + osc1.connect(gain); + osc2.connect(gain); + gain.connect(this.ctx.destination); + + osc1.start(now); + osc2.start(now); + + osc1.stop(now + 1.35); + osc2.stop(now + 1.35); + } catch (e) {} + } + + stop() { + this.isPlaying = false; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +export const candRingtone = new CandRingtoneEngine(); + +if (typeof document !== 'undefined') { + ['click', 'keydown', 'touchstart'].forEach(evt => { + document.addEventListener(evt, () => candRingtone.init(), { passive: true }); + }); +} + +let candidateHeartbeatIntervalTimer = null; +let notesTimeout = null; + +let isDrawing = false; +let canvas, ctx; +let drawingSaveTimeout = null; +let lastDrawingSaveTime = 0; + +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: "<\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));" +}; + +function isTrueVal(val) { + if (val === undefined || val === null) return true; + if (val === false || val === 'false' || val === 0 || val === '0') return false; + return Boolean(val); +} + +export function getCandidateLocalStream() { + return candidateLocalStream; +} + +export function isCallConnected() { + return isCandidateCallConnected; +} + +// --- Candidate Code Execution & Submission --- +export function runCode() { + const interviewId = document.body.dataset.interviewId; + const runStatus = document.getElementById('run-status'); + const termOut = document.getElementById('terminal-output'); + const langSelect = document.getElementById('language-select'); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (runStatus) runStatus.innerText = 'Running...'; + if (termOut) termOut.innerText = 'Executing solution on code engine...'; + + const lang = langSelect ? langSelect.value : 'python'; + const code = editor ? editor.getValue() : ''; + + fetch('/candidate/execute-code', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ language: lang, code: code, interview_id: interviewId }) + }) + .then(r => r.json()) + .then(data => { + if (runStatus) runStatus.innerText = 'Ready'; + const outStr = data.success ? data.output : ('Error: ' + data.output); + if (termOut) termOut.innerText = outStr; + + fetch(`/candidate/sync-code/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ code: code, language: lang, output: outStr }) + }); + }) + .catch(() => { + if (runStatus) runStatus.innerText = 'Error'; + if (termOut) termOut.innerText = 'Failed to reach code runner service.'; + }); +} + +export function submitSolution() { + const interviewId = document.body.dataset.interviewId; + const langSelect = document.getElementById('language-select'); + const termOut = document.getElementById('terminal-output'); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + const code = editor ? editor.getValue() : ''; + const lang = langSelect ? langSelect.value : 'python'; + const output = termOut ? termOut.innerText : ''; + + fetch(`/candidate/submit-code/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ code: code, language: lang, output: output }) + }) + .then(r => r.json()) + .then(data => { + alert('✓ ' + data.message); + }); +} + +export function candidateLogoutAction() { + const loginUrl = document.body.dataset.loginUrl || '/candidate/login'; + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: 'Logout of Assessment?', + text: 'Are you sure you want to exit the Candidate Assessment Room?', + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, Logout' + }).then((result) => { + if (result.isConfirmed) { + window.location.href = loginUrl; + } + }); + } else { + if (confirm('Logout of Assessment?')) { + window.location.href = loginUrl; + } + } +} + +// --- Candidate Notepad & Canvas Drawing --- +export function toggleNotepadModal() { + const modal = document.getElementById('notepad-modal'); + if (modal) modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex'; +} + +export function saveNotepadContent() { + const interviewId = document.body.dataset.interviewId; + const textarea = document.getElementById('notepad-textarea'); + if (!textarea || !interviewId) return; + const notes = textarea.value; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'notes_sync', notes: notes }); + } + + clearTimeout(notesTimeout); + notesTimeout = setTimeout(() => { + fetch(`/candidate/notes/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ notes: notes }) + }); + }, 150); +} + +export function toggleDrawingModal() { + const modal = document.getElementById('drawing-modal'); + if (!modal) return; + modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex'; + if (modal.style.display === 'flex' && !canvas) { + initCanvasDrawing(); + } +} + +export function initCanvasDrawing() { + canvas = document.getElementById('drawing-canvas'); + if (!canvas) return; + ctx = canvas.getContext('2d'); + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + ctx.strokeStyle = '#38bdf8'; + + let savedDrawing = null; + const drawScript = document.getElementById('candidate-drawing-data'); + if (drawScript) { + try { savedDrawing = JSON.parse(drawScript.textContent); } catch(e) {} + } + if (savedDrawing) { + const img = new Image(); + img.onload = () => ctx.drawImage(img, 0, 0); + img.src = savedDrawing; + } + + canvas.addEventListener('mousedown', startDraw); + canvas.addEventListener('mousemove', draw); + canvas.addEventListener('mouseup', stopDraw); + canvas.addEventListener('mouseleave', stopDraw); +} + +function startDraw(e) { + isDrawing = true; + ctx.beginPath(); + ctx.moveTo(e.offsetX, e.offsetY); +} + +function draw(e) { + if (!isDrawing) return; + const colorInput = document.getElementById('draw-color'); + ctx.strokeStyle = colorInput ? colorInput.value : '#38bdf8'; + ctx.lineTo(e.offsetX, e.offsetY); + ctx.stroke(); + saveCanvasDrawing(false); +} + +function stopDraw() { + if (isDrawing) { + isDrawing = false; + ctx.closePath(); + saveCanvasDrawing(true); + } +} + +export function clearCanvasDraw() { + if (ctx && canvas) { + ctx.clearRect(0, 0, canvas.width, canvas.height); + saveCanvasDrawing(true); + } +} + +export function saveCanvasDrawing(force = false) { + const interviewId = document.body.dataset.interviewId; + if (!canvas || !interviewId) return; + const now = Date.now(); + const drawingData = canvas.toDataURL(); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'drawing_sync', drawing: drawingData }); + } + + if (force || (now - lastDrawingSaveTime >= 250)) { + clearTimeout(drawingSaveTimeout); + lastDrawingSaveTime = now; + fetch(`/candidate/drawing/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ drawing: drawingData }) + }); + } else { + clearTimeout(drawingSaveTimeout); + drawingSaveTimeout = setTimeout(() => { + saveCanvasDrawing(true); + }, 250); + } +} + +// --- Candidate Chat Polling & Sending --- +export function pollCandidateChat(data) { + if (!data) return; + const chatHistory = document.getElementById('candidate-chat-history'); + if (!chatHistory) return; + + if (data.status === 'blocked' || data.is_expired) { + return; + } + + if (data.warnings && Array.isArray(data.warnings)) { + if (data.warnings.length === 0) { + chatHistory.innerHTML = '
No messages yet. Send a message below.
'; + return; + } + + let html = ''; + data.warnings.forEach(w => { + const isInterviewer = w.sent_by !== null; + const senderName = isInterviewer ? (w.sender ? w.sender.name : 'Interviewer / Panelist') : 'You (Candidate)'; + const badgeColor = isInterviewer ? '#6366f1' : '#38bdf8'; + const bgColor = isInterviewer ? 'rgba(99,102,241,0.15)' : 'rgba(56,189,248,0.15)'; + + html += `
+
+ ${senderName} + ${new Date(w.created_at || Date.now()).toLocaleTimeString()} +
+
${w.message}
+
`; + }); + chatHistory.innerHTML = html; + chatHistory.scrollTop = chatHistory.scrollHeight; + } +} + +export function sendCandidateMessage() { + const interviewId = document.body.dataset.interviewId; + const input = document.getElementById('candidate-chat-input'); + if (!input || !interviewId) return; + const msg = input.value.trim(); + if (!msg) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + input.value = ''; + if (liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'candidate' }); + } + + fetch(`/candidate/send-message/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ message: msg }) + }) + .then(r => r.json()) + .then(() => { + fetch(`/candidate/poll/${interviewId}`, { + headers: { 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + if (data) pollCandidateChat(data); + }); + }); +} + +// --- Candidate WebRTC Calling Controls --- +export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interviewer Panelist', micOn = true, camOn = true) { + const micOnVal = isTrueVal(micOn); + const camOnVal = isTrueVal(camOn); + + const grid = document.getElementById('interviewer-mesh-grid'); + const placeholder = document.getElementById('interviewer-placeholder-box'); + if (placeholder) placeholder.style.display = 'none'; + + let tile = document.getElementById('cand-iv-tile-' + peerId); + let videoElem; + const initials = getInitials(labelName); + + if (!tile) { + tile = document.createElement('div'); + tile.id = 'cand-iv-tile-' + peerId; + tile.className = 'webcam-box'; + tile.style.position = 'relative'; + tile.style.borderColor = '#6366f1'; + + tile.innerHTML = ` + +
+
+ ${initials} +
+
${labelName}
+
Camera Turned Off
+
+
+ 🎙️ ${labelName} + | + + +
+ `; + + if (grid) grid.appendChild(tile); + videoElem = document.getElementById('cand-iv-video-' + peerId); + } else { + videoElem = document.getElementById('cand-iv-video-' + peerId); + const micIcon = document.getElementById('cand-iv-mic-' + peerId); + const camIcon = document.getElementById('cand-iv-cam-' + peerId); + const placeholderEl = document.getElementById('cand-iv-placeholder-' + peerId); + + if (micIcon) { + micIcon.className = micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; + micIcon.style.color = micOnVal ? '#34d399' : '#ef4444'; + micIcon.title = micOnVal ? 'Mic On' : 'Mic Muted'; + } + if (camIcon) { + camIcon.className = camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'; + camIcon.style.color = camOnVal ? '#34d399' : '#ef4444'; + camIcon.title = camOnVal ? 'Camera On' : 'Camera Off'; + } + if (placeholderEl) { + placeholderEl.style.display = camOnVal ? 'none' : 'flex'; + } + } + + if (videoElem && stream) { + safePlayMediaStream(videoElem, stream); + } + + candidateInterviewerTiles.set(peerId, stream); + + if (isCandidateCallConnected && stream) { + latestCallStatus = 'active'; + + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = '🟢 CALL CONNECTED (LIVE)'; + badge.style.background = 'rgba(16,185,129,0.3)'; + badge.style.color = '#34d399'; + } + } +} + +export function removeCandidateInterviewerTile(peerId) { + const stream = candidateInterviewerTiles.get(peerId); + if (stream && stream.active && latestCallStatus !== 'ended') { + return; + } + + const tile = document.getElementById('cand-iv-tile-' + peerId); + if (tile) tile.remove(); + candidateInterviewerTiles.delete(peerId); + + if (candidateInterviewerTiles.size === 0) { + const placeholder = document.getElementById('interviewer-placeholder-box'); + if (placeholder) placeholder.style.display = 'block'; + + const badge = document.getElementById('call-status-badge'); + if (badge) { + if (latestCallStatus === 'ended') { + badge.innerText = 'CALL ENDED BY HOST'; + badge.style.background = 'rgba(239,68,68,0.2)'; + badge.style.color = '#fca5a5'; + } else { + badge.innerText = 'READY (Waiting for Interviewer)'; + badge.style.background = 'rgba(16,185,129,0.2)'; + badge.style.color = '#34d399'; + } + } + } +} + +export function getCallSessionKey(timestamp = null) { + const interviewId = document.body?.dataset?.interviewId || ''; + return 'handled_call_' + interviewId + '_' + (timestamp || latestCallStartedAt || 'active'); +} + +export function triggerCandidateRing(offer = null, callStartedAt = null) { + if (isCandidateCallConnected) return; + if (latestCallStatus === 'ended' || latestCallStatus === 'idle') return; + if (callStartedAt) latestCallStartedAt = callStartedAt; + + const sessionKey = getCallSessionKey(callStartedAt); + const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled'; + + if (isHandled || candidateDeclinedOrLeftCall) { + showCandidateJoinOption(); + return; + } + + if (offer) pendingOffer = offer; + + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = '🔔 INCOMING CALL... RINGING'; + badge.style.background = 'rgba(234,179,8,0.3)'; + badge.style.color = '#fde047'; + } + + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '1'; + modal.style.pointerEvents = 'auto'; + } + candRingtone.start(); + + if (candRingTimer) clearTimeout(candRingTimer); + candRingTimer = setTimeout(() => { + stopCandidateRing(); + }, 15000); +} + +export function declineCandidateCall() { + candRingtone.stop(); + if (candRingTimer) { + clearTimeout(candRingTimer); + candRingTimer = null; + } + const sessionKey = getCallSessionKey(); + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(sessionKey, 'handled'); + } + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '0'; + modal.style.pointerEvents = 'none'; + } + candidateDeclinedOrLeftCall = true; + showCandidateJoinOption(); +} + +export function stopCandidateRing() { + candRingtone.stop(); + if (candRingTimer) { + clearTimeout(candRingTimer); + candRingTimer = null; + } + const sessionKey = getCallSessionKey(); + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(sessionKey, 'handled'); + } + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '0'; + modal.style.pointerEvents = 'none'; + } + candidateDeclinedOrLeftCall = true; + showCandidateJoinOption(); +} + +export function showCandidateJoinOption() { + if (isCandidateCallConnected) return; + if (latestCallStatus === 'ended' || latestCallStatus === 'idle') { + const joinBtn = document.getElementById('candidate-join-active-call-btn'); + if (joinBtn) { + joinBtn.style.display = 'none'; + joinBtn.disabled = true; + } + return; + } + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = '📞 CALL IN PROGRESS'; + badge.style.background = 'rgba(59,130,246,0.3)'; + badge.style.color = '#93c5fd'; + } + const joinBtn = document.getElementById('candidate-join-active-call-btn'); + if (joinBtn) { + joinBtn.style.display = 'block'; + joinBtn.disabled = false; + joinBtn.innerText = '🟢 📞 Call in Progress • Join Call Now'; + } +} + +export async function acceptCandidateCall() { + const interviewId = document.body.dataset.interviewId; + const sessionKey = getCallSessionKey(); + if (typeof sessionStorage !== 'undefined') { + sessionStorage.setItem(sessionKey, 'handled'); + } + + candidateDeclinedOrLeftCall = false; + candRingtone.stop(); + if (candRingTimer) { + clearTimeout(candRingTimer); + candRingTimer = null; + } + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '0'; + modal.style.pointerEvents = 'none'; + } + const joinBtn = document.getElementById('candidate-join-active-call-btn'); + + if (latestCallStatus === 'ended' || latestCallStatus === 'idle') { + if (joinBtn) { + joinBtn.style.display = 'none'; + joinBtn.disabled = true; + } + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'warning', + title: 'Call Ended', + text: 'The interview call has been ended by the host.', + toast: true, + position: 'top-end', + timer: 3000, + showConfirmButton: false + }); + } + return; + } + + if (joinBtn) joinBtn.style.display = 'none'; + + if (!candidateLocalStream || candidateLocalStream.getVideoTracks().length === 0) { + await startCandidateCameraStream(); + } + + isCandidateCallConnected = true; + + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = '🟢 CALL CONNECTED (LIVE)'; + badge.style.background = 'rgba(16,185,129,0.3)'; + badge.style.color = '#34d399'; + } + + const stream = candidateLocalStream || new MediaStream(); + + if (activeCandidateCallInstance) { + try { + activeCandidateCallInstance.answer(stream); + activeCandidateCallInstance.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(activeCandidateCallInstance.peer, remoteStream, 'Interviewer Panelist'); + }); + } catch (e) {} + } + + sendCandidatePeerHeartbeat('heartbeat'); + + if (callSigChannel && interviewId) { + callSigChannel.postMessage({ + type: 'peer_joined', + peer_id: getCandidatePeerId(), + role: 'candidate', + candidate_name: document.body.dataset.candidateName || 'Candidate' + }); + callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: 'cand_' + interviewId }); + } + + if (pendingOffer) { + await handleInterviewerOffer(pendingOffer); + pendingOffer = null; + } + + if (candidatePeerInstance && candidatePeerInstance.open) { + candidateInterviewerTiles.forEach((existingStream, peerId) => { + if (!existingStream && !peerId.startsWith('cand_')) { + try { + const outCall = candidatePeerInstance.call(peerId, stream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(peerId, remoteStream); + }); + } + } catch (e) {} + } + }); + } + + if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) { + hasCapturedCallStartScreenshot = true; + setTimeout(() => { + window.captureAndUploadTabScreenshot('call_start'); + }, 1200); + } +} + +export function candidateLeaveCall(isEndedByHost = false) { + isCandidateCallConnected = false; + candidateDeclinedOrLeftCall = true; + + candRingtone.stop(); + if (candRingTimer) { + clearTimeout(candRingTimer); + candRingTimer = null; + } + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '0'; + modal.style.pointerEvents = 'none'; + } + + if (activeCandidateCallInstance) { + try { activeCandidateCallInstance.close(); } catch(e) {} + activeCandidateCallInstance = null; + } + if (candidatePeerConnection) { + try { candidatePeerConnection.close(); } catch(e) {} + candidatePeerConnection = null; + } + + sendCandidatePeerHeartbeat('leave'); + + const interviewId = document.body.dataset.interviewId; + if (callSigChannel && interviewId) { + callSigChannel.postMessage({ + type: 'peer_left', + peer_id: getCandidatePeerId(), + role: 'candidate' + }); + } + + const remoteVideo = document.getElementById('interviewer-video-default'); + if (remoteVideo) remoteVideo.srcObject = null; + const placeholder = document.getElementById('interviewer-video-placeholder'); + if (placeholder) placeholder.style.display = 'flex'; + + const joinBtn = document.getElementById('candidate-join-active-call-btn'); + const badge = document.getElementById('call-status-badge'); + + if (isEndedByHost || latestCallStatus === 'ended' || latestCallStatus === 'idle') { + latestCallStatus = 'ended'; + if (joinBtn) { + joinBtn.style.display = 'none'; + joinBtn.disabled = true; + } + if (badge) { + badge.innerText = 'CALL ENDED BY HOST'; + badge.style.background = 'rgba(239,68,68,0.2)'; + badge.style.color = '#fca5a5'; + } + } else { + if (joinBtn) { + joinBtn.innerText = '🟢 📞 Rejoin Call Now'; + joinBtn.style.display = 'block'; + joinBtn.disabled = false; + } + if (badge) { + badge.innerText = 'CALL DISCONNECTED'; + badge.style.background = 'rgba(239,68,68,0.2)'; + badge.style.color = '#fca5a5'; + } + } + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'info', + title: isEndedByHost ? 'Call Ended' : 'Call Disconnected', + text: isEndedByHost ? 'The interviewer panel has ended the call session.' : 'You have left the call. Click "Rejoin Call Now" anytime to connect back while call is active.', + timer: 3000, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + } +} + +async function createCandidatePeerConnection() { + if (candidatePeerConnection) return candidatePeerConnection; + + const iceServers = await initMeteredIceServers(); + candidatePeerConnection = new RTCPeerConnection({ iceServers: iceServers }); + + if (candidateLocalStream) { + candidateLocalStream.getTracks().forEach(track => { + candidatePeerConnection.addTrack(track, candidateLocalStream); + }); + } + + candidatePeerConnection.ontrack = (event) => { + if (event.streams[0]) { + addOrUpdateCandidateInterviewerTile('default_pc', event.streams[0], 'Interviewer'); + } + }; + + candidatePeerConnection.onicecandidate = (event) => { + if (event.candidate && callSigChannel) { + callSigChannel.postMessage({ type: 'ice-candidate', candidate: event.candidate, sender: 'candidate' }); + } + }; + + return candidatePeerConnection; +} + +async function handleInterviewerOffer(offer) { + const pc = await createCandidatePeerConnection(); + await pc.setRemoteDescription(new RTCSessionDescription(offer)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + if (callSigChannel) { + callSigChannel.postMessage({ type: 'answer', answer: answer, sender: 'candidate' }); + } +} + +export function getCandidateSubmissionId() { + return document.body?.dataset?.submissionId || ''; +} + +export function getCandidatePeerId() { + const interviewId = document.body?.dataset?.interviewId || ''; + const subId = getCandidateSubmissionId(); + if (!interviewId) return ''; + + return (subId && subId !== String(interviewId)) + ? ('cand_' + interviewId + '_' + subId) + : ('cand_' + interviewId); +} + +export async function initCandidatePeer(force = false) { + const interviewId = document.body.dataset.interviewId; + const candName = document.body.dataset.candidateName || 'Candidate'; + if (!interviewId || typeof window.Peer === 'undefined') return; + + if (!force && candidatePeerInstance && !candidatePeerInstance.destroyed && candidatePeerInstance.open) { + return candidatePeerInstance; + } + + const iceServers = await initMeteredIceServers(); + const peerId = getCandidatePeerId(); + if (candidatePeerInstance && !candidatePeerInstance.destroyed) { + try { candidatePeerInstance.destroy(); } catch(e) {} + } + + candidatePeerInstance = new window.Peer(peerId, { + config: { iceServers: iceServers } + }); + + candidatePeerInstance.on('open', id => { + console.log('[WebRTC Candidate] Registered PeerJS ID:', id); + sendCandidatePeerHeartbeat(); + if (callSigChannel && isCandidateCallConnected) { + callSigChannel.postMessage({ + type: 'peer_joined', + peer_id: id, + role: 'candidate', + candidate_name: candName + }); + } + }); + + candidatePeerInstance.on('call', async call => { + console.log('[WebRTC Candidate] Incoming PeerJS call from:', call.peer); + activeCandidateCallInstance = call; + if (latestCallStatus !== 'ended') latestCallStatus = 'active'; + + call.on('stream', remoteStream => { + console.log('[WebRTC Candidate] Received remote stream from interviewer:', call.peer); + if (isCandidateCallConnected) { + addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist'); + } + }); + + call.on('close', () => { + console.log('[WebRTC Candidate] Call closed by peer:', call.peer); + removeCandidateInterviewerTile(call.peer); + }); + + if (isCandidateCallConnected) { + if (!candidateLocalStream || candidateLocalStream.getTracks().length === 0) { + await startCandidateCameraStream(); + } + const sendStream = candidateLocalStream || new MediaStream(); + try { call.answer(sendStream); } catch(e) { + console.error('[WebRTC Candidate] Error answering call:', e); + } + } else { + const sessionKey = getCallSessionKey(); + const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled'; + if (isHandled || candidateDeclinedOrLeftCall) { + showCandidateJoinOption(); + } else { + triggerCandidateRing(); + } + } + }); + + candidatePeerInstance.on('error', async err => { + console.warn('[WebRTC Candidate] PeerJS error:', err); + if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') { + if (candidatePeerInstance) { + try { candidatePeerInstance.destroy(); } catch(e) {} + } + const iceServers = await initMeteredIceServers(); + candidatePeerInstance = new window.Peer(peerId, { + config: { iceServers: iceServers } + }); + } + }); +} + +export function startCandidateCameraStream() { + const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder'); + const audioConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; + + if (window.beginNativePrompt) window.beginNativePrompt(); + + return navigator.mediaDevices.getUserMedia({ + video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, + audio: audioConstraints + }) + .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints })) + .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: true })) + .catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true })) + .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false })) + .then(stream => { + if (window.endNativePrompt) window.endNativePrompt(); + candidateLocalStream = stream; + const vid = document.getElementById('webcam'); + if (vid) { + vid.muted = true; + safePlayMediaStream(vid, stream, true); + } + if (avatarPlaceholder) avatarPlaceholder.style.display = 'none'; + + if (activeCandidateCallInstance && activeCandidateCallInstance.peerConnection && isCandidateCallConnected) { + stream.getTracks().forEach(track => { + const senders = activeCandidateCallInstance.peerConnection.getSenders(); + const sender = senders.find(s => s.track && s.track.kind === track.kind); + if (sender) { + sender.replaceTrack(track).catch(() => {}); + } else { + try { activeCandidateCallInstance.peerConnection.addTrack(track, stream); } catch(e) {} + } + }); + } + + if (callSigChannel && isCandidateCallConnected) { + callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: getCandidatePeerId() }); + } + initCandidatePeer(); + return stream; + }) + .catch(err => { + if (window.endNativePrompt) window.endNativePrompt(); + console.log('Candidate media access error:', err); + if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex'; + initCandidatePeer(); + }); +} + +export function sendCandidatePeerHeartbeat(action = 'heartbeat') { + const interviewId = document.body.dataset.interviewId; + const candName = document.body.dataset.candidateName || 'Candidate'; + if (!interviewId) return; + const peerId = getCandidatePeerId(); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + fetch(`/candidate/peer-heartbeat/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + peer_id: peerId, + name: candName, + role: 'candidate', + mic_on: Boolean(isCandidateMicEnabled), + cam_on: Boolean(isCandidateCamEnabled), + action: isCandidateCallConnected ? action : 'presence' + }) + }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (!data) return; + if (data.active_peers && Array.isArray(data.active_peers)) { + window.latestActivePeers = data.active_peers; + if (window.isScreenSharingActive && window.isScreenSharingActive() && window.broadcastCandidateScreenStream) { + const sStream = window.getScreenShareStream ? window.getScreenShareStream() : null; + if (sStream && sStream.active) { + window.broadcastCandidateScreenStream(sStream); + } + } + } + if (!isCandidateCallConnected || latestCallStatus !== 'active') return; + if (data.active_peers && Array.isArray(data.active_peers)) { + data.active_peers.forEach(p => { + if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { + const existingStream = candidateInterviewerTiles.get(p.peer_id) || null; + const nameLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name || 'Interviewer Panelist'); + const isMicOn = isTrueVal(p.mic_on); + const isCamOn = isTrueVal(p.cam_on); + + if (!existingStream && candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !p.peer_id.startsWith('cand_')) { + try { + const outCall = candidatePeerInstance.call(p.peer_id, candidateLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(p.peer_id, remoteStream, nameLabel, isMicOn, isCamOn); + }); + } + } catch(e) {} + } else { + addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, nameLabel, isMicOn, isCamOn); + } + } + }); + } + }) + .catch(() => {}); + + if (callSigChannel && isCandidateCallConnected) { + callSigChannel.postMessage({ + type: 'peer_state_changed', + peer_id: peerId, + role: 'candidate', + name: candName, + mic_on: Boolean(isCandidateMicEnabled), + cam_on: Boolean(isCandidateCamEnabled) + }); + } +} + +let candidateHeartbeatDebounceTimer = null; +function debouncedCandidatePeerHeartbeat() { + if (candidateHeartbeatDebounceTimer) clearTimeout(candidateHeartbeatDebounceTimer); + sendCandidatePeerHeartbeat(); + candidateHeartbeatDebounceTimer = setTimeout(() => { + sendCandidatePeerHeartbeat(); + }, 300); +} + +export function toggleMic() { + if (!candidateLocalStream) return; + const audioTracks = candidateLocalStream.getAudioTracks(); + if (audioTracks.length > 0) { + isCandidateMicEnabled = !isCandidateMicEnabled; + audioTracks[0].enabled = isCandidateMicEnabled; + const btn = document.getElementById('toggle-mic-btn'); + if (btn) { + btn.innerText = isCandidateMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted'; + btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; + btn.style.borderColor = isCandidateMicEnabled ? '#10b981' : '#ef4444'; + } + const icon = document.getElementById('cand-self-mic-icon'); + if (icon) { + icon.className = isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; + icon.style.color = isCandidateMicEnabled ? '#34d399' : '#ef4444'; + icon.title = isCandidateMicEnabled ? 'Mic On' : 'Mic Muted'; + } + debouncedCandidatePeerHeartbeat(); + } +} + +export function toggleCam() { + const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder'); + if (candidateLocalStream) { + const videoTracks = candidateLocalStream.getVideoTracks(); + if (videoTracks.length > 0) { + isCandidateCamEnabled = !isCandidateCamEnabled; + videoTracks[0].enabled = isCandidateCamEnabled; + const btn = document.getElementById('toggle-cam-btn'); + if (btn) { + btn.innerText = isCandidateCamEnabled ? '📷 Cam On' : '🚫 Cam Off'; + btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; + btn.style.borderColor = isCandidateCamEnabled ? '#10b981' : '#ef4444'; + } + if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex'; + } + } else { + isCandidateCamEnabled = !isCandidateCamEnabled; + if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex'; + } + const icon = document.getElementById('cand-self-cam-icon'); + if (icon) { + icon.className = isCandidateCamEnabled ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'; + icon.style.color = isCandidateCamEnabled ? '#34d399' : '#ef4444'; + icon.title = isCandidateCamEnabled ? 'Camera On' : 'Camera Off'; + } + debouncedCandidatePeerHeartbeat(); +} + +// --- Monaco Editor Initialization --- +export function initCandidateMonacoEditor(interviewId) { + const container = document.getElementById('monaco-editor-container'); + if (!container || typeof window.require === 'undefined') return; + + window.require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } }); + window.require(['vs/editor/editor.main'], function () { + const langSelect = document.getElementById('language-select'); + const initialLang = (langSelect ? langSelect.value : (document.body.dataset.language || 'python')).toLowerCase(); + + let initialCode = ''; + const codeScript = document.getElementById('candidate-submitted-code'); + if (codeScript) { + try { initialCode = JSON.parse(codeScript.textContent); } catch(e) {} + } + if (!initialCode) initialCode = defaultCode[initialLang] || defaultCode.python; + + editor = monaco.editor.create(container, { + value: initialCode, + language: initialLang === 'cpp' ? 'cpp' : (initialLang === 'c' ? 'c' : initialLang), + theme: 'vs-dark', + fontSize: 14, + fontFamily: 'Fira Code, monospace', + automaticLayout: true, + minimap: { enabled: true } + }); + + // Monaco Editor AI Hotkey Interception Hook + editor.onKeyDown(function (e) { + const isAlt = e.altKey; + const isCtrl = e.ctrlKey; + const isMeta = e.metaKey; + const isShift = e.shiftKey; + const code = e.browserEvent ? (e.browserEvent.code || '') : ''; + + if (isAlt || isMeta || (isCtrl && isShift) || code === 'F12') { + if (window.logViolation) { + window.logViolation('external_ai_detected', `candidate might use external ai: Intercepted AI hotkey shortcut in editor (${code || e.keyCode})`); + } + if (window.captureAndUploadTabScreenshot) { + window.captureAndUploadTabScreenshot('external_ai_detected'); + } + } + }); + + // Real-Time BroadcastChannel + Backend Code Sync + let codeSyncTimeout = null; + editor.onDidChangeModelContent(function () { + const code = editor.getValue(); + const lang = document.getElementById('language-select')?.value || 'python'; + + if (liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'code_sync', code: code, language: lang }); + } + + clearTimeout(codeSyncTimeout); + codeSyncTimeout = setTimeout(function () { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + fetch(`/candidate/sync-code/${interviewId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ code: code, language: lang }) + }); + }, 200); + }); + }); +} + +// --- Candidate Room Boot Function --- +export function initCandidateRoom() { + const interviewId = document.body.dataset.interviewId; + if (!interviewId) return; + + latestCallStatus = document.body.dataset.callStatus || 'idle'; + + // BroadcastChannels + callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); + liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); + + liveSyncChannel.onmessage = function(event) { + if (event.data && event.data.type === 'chat_message' && typeof pollCandidateChat === 'function') { + pollCandidateChat(); + } + }; + + callSigChannel.onmessage = async (event) => { + const msg = event.data; + if (!msg) return; + + if (msg.type === 'interviewer_joined' || msg.type === 'offer') { + latestCallStatus = 'active'; + if (!isCandidateCallConnected) { + const sessionKey = getCallSessionKey(); + const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled'; + if (isHandled || candidateDeclinedOrLeftCall) { + showCandidateJoinOption(); + } else { + triggerCandidateRing(msg.offer || null); + } + } + } + + if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') { + const peerIdToCall = msg.peer_id || msg.sender_peer_id; + if (peerIdToCall && peerIdToCall.startsWith('interviewer_')) { + const isMicOn = (msg.mic_on !== undefined) ? Boolean(msg.mic_on) : true; + const isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true; + + if (isCandidateCallConnected) { + const existingStream = candidateInterviewerTiles.get(peerIdToCall) || null; + addOrUpdateCandidateInterviewerTile(peerIdToCall, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn); + + if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !existingStream) { + try { + console.log('[WebRTC Candidate] Signal received, calling interviewer:', peerIdToCall); + const outCall = candidatePeerInstance.call(peerIdToCall, candidateLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + console.log('[WebRTC Candidate] Stream received via signal call:', peerIdToCall); + addOrUpdateCandidateInterviewerTile(peerIdToCall, remoteStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn); + }); + } + } catch(e) {} + } + } + } + } else if (msg.type === 'peer_left' || msg.type === 'interviewer_left') { + if (msg.peer_id) { + removeCandidateInterviewerTile(msg.peer_id); + } + } else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) { + await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); + } else if (msg.type === 'ice-candidate' && msg.sender !== 'candidate' && candidatePeerConnection && msg.candidate) { + try { + await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); + } catch(e) {} + } else if (msg.type === 'end_call_all') { + isCandidateCallConnected = false; + latestCallStatus = 'ended'; + candidateInterviewerTiles.forEach((_, pid) => removeCandidateInterviewerTile(pid)); + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = 'CALL ENDED BY HOST'; + badge.style.background = 'rgba(239,68,68,0.2)'; + badge.style.color = '#fca5a5'; + } + candidateLeaveCall(true); + } + }; + + globalCallChannel = new BroadcastChannel('global_call_alerts'); + globalCallChannel.onmessage = (event) => { + const data = event.data; + if (!data) return; + + if (data.type === 'incoming_call' && data.interview_id == interviewId) { + latestCallStatus = 'active'; + if (data.call_started_at) latestCallStartedAt = data.call_started_at; + const sessionKey = getCallSessionKey(data.call_started_at); + const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled'; + if (!isCandidateCallConnected) { + if (isHandled || candidateDeclinedOrLeftCall) { + showCandidateJoinOption(); + } else { + triggerCandidateRing(null, data.call_started_at); + } + } + } else if (data.type === 'call_ended' && data.interview_id == interviewId) { + isCandidateCallConnected = false; + latestCallStatus = 'ended'; + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = 'CALL ENDED BY HOST'; + badge.style.background = 'rgba(239,68,68,0.2)'; + badge.style.color = '#fca5a5'; + } + candidateLeaveCall(true); + } + }; + + // Monaco Editor + initCandidateMonacoEditor(interviewId); + + // Language Select Listener + const langSelect = document.getElementById('language-select'); + if (langSelect) { + langSelect.addEventListener('change', function () { + const lang = this.value; + const monacoLang = lang === 'cpp' ? 'cpp' : (lang === 'c' ? 'c' : lang); + if (editor) { + monaco.editor.setModelLanguage(editor.getModel(), monacoLang); + if (!editor.getValue() || editor.getValue() === defaultCode[lang]) { + editor.setValue(defaultCode[lang] || ''); + } + } + }); + } + + // Start Candidate Camera + startCandidateCameraStream(); + + // Heartbeat & Polling + if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer); + sendCandidatePeerHeartbeat(); + candidateHeartbeatIntervalTimer = setInterval(() => { + sendCandidatePeerHeartbeat(); + }, 6000); + + setInterval(() => { + if (!interviewId) return; + + fetch('/candidate/poll/' + interviewId, { + headers: { 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + if (!data) return; + + pollCandidateChat(data); + + if (data.call_status === 'active' && latestCallStatus !== 'ended') { + latestCallStatus = 'active'; + if (data.call_started_at) latestCallStartedAt = data.call_started_at; + + const sessionKey = getCallSessionKey(data.call_started_at); + const isHandledInSession = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled'; + + if (!isCandidateCallConnected) { + if (isHandledInSession || candidateDeclinedOrLeftCall) { + showCandidateJoinOption(); + } else { + triggerCandidateRing(null, data.call_started_at); + } + } + } else if (data.call_status === 'ended' || data.call_status === 'idle') { + latestCallStatus = data.call_status; + hasCapturedCallStartScreenshot = false; + candidateDeclinedOrLeftCall = false; + candRingtone.stop(); + if (candRingTimer) clearTimeout(candRingTimer); + isCandidateCallConnected = false; + + // Clear session storage handled keys for ended/idle call sessions + if (typeof sessionStorage !== 'undefined') { + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key && key.startsWith('handled_call_' + interviewId)) { + sessionStorage.removeItem(key); + } + } + } + + const modal = document.getElementById('candidate-incoming-modal'); + if (modal) { + modal.style.opacity = '0'; + modal.style.pointerEvents = 'none'; + } + const joinBtn = document.getElementById('candidate-join-active-call-btn'); + if (joinBtn) { + joinBtn.style.display = 'none'; + joinBtn.disabled = true; + } + const badge = document.getElementById('call-status-badge'); + if (badge && candidateInterviewerTiles.size === 0) { + badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY'; + badge.style.background = (data.call_status === 'ended') ? 'rgba(239,68,68,0.2)' : 'rgba(16,185,129,0.2)'; + badge.style.color = (data.call_status === 'ended') ? '#fca5a5' : '#34d399'; + } + } + + if (data.active_peers && Array.isArray(data.active_peers) && latestCallStatus !== 'ended') { + window.latestActivePeers = data.active_peers; + if (window.isScreenSharingActive && window.isScreenSharingActive() && window.broadcastCandidateScreenStream) { + const sStream = window.getScreenShareStream ? window.getScreenShareStream() : null; + if (sStream && sStream.active) { + window.broadcastCandidateScreenStream(sStream); + } + } + const activePeerIds = new Set(data.active_peers.map(p => p.peer_id)); + + if (isCandidateCallConnected) { + data.active_peers.forEach(p => { + if (p.peer_id && p.peer_id.startsWith('interviewer_')) { + let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Interviewer ' + (p.name || '')); + const isMicOn = isTrueVal(p.mic_on); + const isCamOn = isTrueVal(p.cam_on); + + if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open) { + if (!candidateInterviewerTiles.has(p.peer_id)) { + try { + const outCall = candidatePeerInstance.call(p.peer_id, candidateLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); + }); + } + } catch(e) {} + } else { + const existingStream = candidateInterviewerTiles.get(p.peer_id); + addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, roleLabel, isMicOn, isCamOn); + } + } + } + }); + + candidateInterviewerTiles.forEach((_, pid) => { + if (!activePeerIds.has(pid)) { + removeCandidateInterviewerTile(pid); + } + }); + } + } + }) + .catch(() => {}); + }, 6000); +} + +// Window Bindings +if (typeof window !== 'undefined') { + window.runCode = runCode; + window.submitSolution = submitSolution; + window.candidateLogoutAction = candidateLogoutAction; + window.toggleNotepadModal = toggleNotepadModal; + window.saveNotepadContent = saveNotepadContent; + window.toggleDrawingModal = toggleDrawingModal; + window.initCanvasDrawing = initCanvasDrawing; + window.clearCanvasDraw = clearCanvasDraw; + window.saveCanvasDrawing = saveCanvasDrawing; + window.acceptCandidateCall = acceptCandidateCall; + window.declineCandidateCall = declineCandidateCall; + window.candidateLeaveCall = candidateLeaveCall; + window.triggerCandidateRing = triggerCandidateRing; + window.stopCandidateRing = stopCandidateRing; + window.showCandidateJoinOption = showCandidateJoinOption; + window.getCallSessionKey = getCallSessionKey; + window.candRingtone = candRingtone; + window.toggleMic = toggleMic; + window.toggleCam = toggleCam; + window.sendCandidateMessage = sendCandidateMessage; + window.pollCandidateChat = pollCandidateChat; + window.initCandidateRoom = initCandidateRoom; + window.getCandidateLocalStream = getCandidateLocalStream; + window.isCallConnected = isCallConnected; + + const bootCandidate = () => { + if (document.body && document.body.dataset.interviewId && document.getElementById('monaco-editor-container')) { + initCandidateRoom(); + } + }; + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bootCandidate); + } else { + bootCandidate(); + } +} diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js new file mode 100644 index 0000000..a3baa1c --- /dev/null +++ b/resources/js/interview-call.js @@ -0,0 +1,2400 @@ +/** + * Interview Call & Live Assessment Review Engine + * Handles WebRTC 2-Way Calling, Candidate Screen Sharing, Real-Time Sync, + * Proctoring Alerts, SOS Audio Ringtone, Tab Switching, and Silent Call Recording. + */ + +import { initMeteredIceServers } from './metered.js'; + +// --- Global Audio Ringtone Synthesizer Engine --- +export class CallRingtoneEngine { + constructor() { + this.ctx = null; + this.interval = null; + this.isPlaying = false; + } + + init() { + try { + if (!this.ctx) { + const AudioCtx = window.AudioContext || window.webkitAudioContext; + this.ctx = new AudioCtx(); + } + if (this.ctx && this.ctx.state === 'suspended') { + this.ctx.resume(); + } + } catch (e) { + console.log('Audio init warning:', e); + } + } + + start() { + if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; + if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; + this.init(); + if (this.isPlaying) return; + this.isPlaying = true; + + this.playTone(); + if (this.interval) clearInterval(this.interval); + this.interval = setInterval(() => { + if (this.isPlaying) this.playTone(); + }, 2400); + } + + playTone() { + this.init(); + if (!this.ctx || !this.isPlaying) return; + try { + const now = this.ctx.currentTime; + const osc1 = this.ctx.createOscillator(); + const osc2 = this.ctx.createOscillator(); + const gain = this.ctx.createGain(); + + osc1.type = 'sine'; + osc2.type = 'sine'; + + osc1.frequency.setValueAtTime(523.25, now); + osc2.frequency.setValueAtTime(659.25, now); + + gain.gain.setValueAtTime(0.001, now); + gain.gain.linearRampToValueAtTime(0.35, now + 0.08); + gain.gain.setValueAtTime(0.35, now + 1.1); + gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3); + + osc1.connect(gain); + osc2.connect(gain); + gain.connect(this.ctx.destination); + + osc1.start(now); + osc2.start(now); + + osc1.stop(now + 1.35); + osc2.stop(now + 1.35); + } catch (e) {} + } + + stop() { + this.isPlaying = false; + if (this.interval) { + clearInterval(this.interval); + this.interval = null; + } + } +} + +export const callRingtone = new CallRingtoneEngine(); + +// State variables +let currentInterviewId = null; +let currentSubmissionUniqueId = null; +let interviewerPeer = null; +let screenReceiverPeer = null; + +export function getCandidatePeerId() { + if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { + currentInterviewId = document.body.dataset.interviewId; + } + if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) { + currentSubmissionUniqueId = document.body.dataset.submissionId; + } + if (!currentInterviewId) return ''; + + const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) + ? currentSubmissionUniqueId + : null; + + return subId ? ('cand_' + currentInterviewId + '_' + subId) : ('cand_' + currentInterviewId); +} + +export function getMyInterviewerPeerId() { + const currentUserId = window.currentUserId || 0; + if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { + currentInterviewId = document.body.dataset.interviewId; + } + if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) { + currentSubmissionUniqueId = document.body.dataset.submissionId; + } + if (!currentInterviewId) return ''; + + const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) + ? currentSubmissionUniqueId + : null; + + return subId + ? ('interviewer_' + currentInterviewId + '_' + subId + '_' + currentUserId) + : ('interviewer_' + currentInterviewId + '_' + currentUserId); +} + +export function getInterviewerScreenPeerId(userId = null) { + const currentUserId = userId !== null ? userId : (window.currentUserId || 0); + if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { + currentInterviewId = document.body.dataset.interviewId; + } + if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) { + currentSubmissionUniqueId = document.body.dataset.submissionId; + } + if (!currentInterviewId) return ''; + + const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) + ? currentSubmissionUniqueId + : null; + + return subId + ? ('interviewer_screen_' + currentInterviewId + '_' + subId + '_' + currentUserId) + : ('interviewer_screen_' + currentInterviewId + '_' + currentUserId); +} +let interviewerLocalStream = null; +let activeCall = null; +let isInterviewerMicOn = true; +let isInterviewerCamOn = true; +let screenZoomLevel = 1.0; +let candVidZoomLevel = 1.0; +let activeViolations = []; +let sosPollInterval = null; +let peerHeartbeatTimer = null; +let acknowledgedViolationCount = 0; +let sosDismissed = false; +let sosAutoTriggerDisabled = true; +let currentTabScreenshotEnabled = true; +let interviewerSigChannel = null; +let interviewerPeerConnection = null; +let liveSyncChannel = null; +let panelistMeshTiles = new Map(); +let latestActivePeers = []; + +// Ringing & Call state +let activeIncomingCall = null; +let iAmTheCaller = false; +let ringTimeoutTimer = null; +const dismissedCallIds = new Set(); + +export function formatProctoringLogDetails(l) { + let detailsObj = l.details; + if (typeof detailsObj === 'string') { + try { detailsObj = JSON.parse(detailsObj); } catch(e) {} + } + + const type = l.type || ''; + + if (type === 'multiple_faces') { + const count = (typeof detailsObj === 'object' && detailsObj && detailsObj.face_count) ? detailsObj.face_count : 2; + return `Multiple faces detected in camera frame (${count} faces)`; + } + + if (type === 'face_missing') { + return 'Candidate face is not visible in camera frame'; + } + + if (type === 'looking_away' || type === 'prolonged_looking_away') { + if (typeof detailsObj === 'object' && detailsObj && (detailsObj.reasons || typeof detailsObj.yaw === 'number')) { + const reasonLabels = { + 'head_yaw': 'Head Yaw', + 'head_up': 'Head Pitch Up', + 'head_down': 'Head Pitch Down', + 'eye_gaze_left': 'Eye Gaze Left', + 'eye_gaze_right': 'Eye Gaze Right', + 'eye_gaze_down': 'Eye Gaze Down' + }; + const reasonsStr = Array.isArray(detailsObj.reasons) + ? detailsObj.reasons.map(r => reasonLabels[r] || r.replace(/_/g, ' ')).join(', ') + : ''; + + const durationPrefix = (type === 'prolonged_looking_away') ? 'Looking away continuously (>10s)' : 'Looking away from camera'; + return reasonsStr ? `${durationPrefix} (${reasonsStr})` : durationPrefix; + } else if (typeof l.details === 'string' && l.details && !l.details.startsWith('{')) { + return l.details; + } + return type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera'; + } + + if (type === 'phone_detected') { + const label = (typeof detailsObj === 'object' && detailsObj && detailsObj.label) ? detailsObj.label : 'cell phone'; + return `Possible mobile phone detected in camera frame (${label})`; + } + + if (type === 'prolonged_corner_gaze') { + if (typeof detailsObj === 'object' && detailsObj && (detailsObj.corner_label || detailsObj.corner)) { + const cornerLabel = detailsObj.corner_label || (detailsObj.corner || 'screen corner').replace(/_/g, ' '); + const dur = detailsObj.duration_s || Math.round((detailsObj.duration_ms || 18000) / 1000); + return `Fixated on ${cornerLabel} for ${dur}s`; + } + return 'Candidate fixated on screen corner for prolonged period'; + } + + if (type === 'talking_secondary_person') { + return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a secondary person'; + } + + if (typeof l.details === 'object' && l.details !== null) { + try { return JSON.stringify(l.details); } catch(e) { return String(l.details); } + } + + return l.details || ''; +} +const endedCallIds = new Set(); +let globalCallChannel = null; +const adminPageLoadTimestamp = Date.now(); + +let globalIceServers = window.globalIceServers || [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' } +]; + +export function getInitials(name) { + if (!name) return 'CD'; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + const str = name.trim(); + if (str.length >= 2) { + return (str[0] + str[str.length - 1]).toUpperCase(); + } + return str.toUpperCase(); +} + +export function zoomScreen(delta) { + screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta)); + const screenVid = document.getElementById('interviewer-screen-video'); + if (screenVid) { + screenVid.style.transform = `scale(${screenZoomLevel})`; + } +} + +export function resetZoomScreen() { + screenZoomLevel = 1.0; + zoomScreen(0); +} + +export function toggleFullscreenScreen() { + const wrapper = document.getElementById('screen-wrapper'); + if (!wrapper) return; + if (!document.fullscreenElement) { + wrapper.requestFullscreen().catch(err => console.log(err)); + } else { + document.exitFullscreen(); + } +} + +export function zoomCandVideo(delta) { + if (delta === 0) candVidZoomLevel = 1.0; + else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta)); + const vid = document.getElementById('interviewer-cand-video'); + if (vid) { + vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`; + vid.style.transformOrigin = 'center center'; + vid.style.transition = 'transform 0.2s ease'; + } +} + +export function toggleSosAutoTrigger() { + sosAutoTriggerDisabled = !sosAutoTriggerDisabled; + const toggleSw = document.getElementById('sosToggle'); + const btn = document.getElementById('sos-toggle-btn'); + + if (toggleSw) { + if (sosAutoTriggerDisabled) { + toggleSw.classList.remove('on'); + } else { + toggleSw.classList.add('on'); + } + } + if (btn) { + if (sosAutoTriggerDisabled) { + btn.innerHTML = ' SOS Auto-Trigger: OFF'; + btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300'; + } else { + btn.innerHTML = ' SOS Auto-Trigger: ON'; + btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300'; + } + } +} + +export function openReportPage() { + if (!currentInterviewId) return; + window.open(`/interviews/${currentInterviewId}/report`, '_blank'); +} + +export function toggleTabScreenshotCapture() { + if (!currentInterviewId) return; + const newStatus = !currentTabScreenshotEnabled; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ enable: newStatus }) + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + currentTabScreenshotEnabled = res.enable_tab_switch_screenshot; + updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'info', + title: 'Screenshot Setting Updated', + text: res.message, + timer: 2500, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + } + } + }) + .catch(() => {}); +} + +export function updateScreenshotToggleBtnUI(enabled) { + const btn = document.getElementById('modal-toggle-screenshot-btn'); + if (btn) { + if (enabled) { + btn.innerHTML = ' ENABLED'; + btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none'; + } else { + btn.innerHTML = ' DISABLED'; + btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none'; + } + } +} + +export function switchIdeTab(tab) { + const tabs = ['code', 'notes', 'drawing', 'recordings', 'screenshots']; + tabs.forEach(t => { + const contentEl = document.getElementById(`tab-content-${t}`); + const btnEl = document.getElementById(`tab-btn-${t}`); + if (contentEl) { + contentEl.style.display = (t === tab) ? 'block' : 'none'; + } + if (btnEl) { + if (t === tab) { + btnEl.classList.add('bg-indigo-600', 'text-white', 'border-indigo-500'); + btnEl.classList.remove('bg-white/5', 'text-slate-400', 'border-slate-700/60'); + } else { + btnEl.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-500'); + btnEl.classList.add('bg-white/5', 'text-slate-400', 'border-slate-700/60'); + } + } + }); +} + +export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) { + if (!callData || !callData.id) return; + if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; + if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; + if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return; + if (iAmTheCaller && currentInterviewId == callData.id) return; + if (interviewerLocalStream && currentInterviewId == callData.id) return; + + if (callData.call_started_at) { + const startedTime = new Date(callData.call_started_at).getTime(); + const elapsedMs = Date.now() - startedTime; + if (elapsedMs > 8000 && !isRealtimeBroadcast) { + dismissedCallIds.add(callData.id); + return; + } + } else if (!isRealtimeBroadcast) { + dismissedCallIds.add(callData.id); + return; + } + + activeIncomingCall = callData; + const nameEl = document.getElementById('incoming-cand-name'); + const infoEl = document.getElementById('incoming-starter-info'); + const modalEl = document.getElementById('incoming-call-modal'); + if (nameEl) nameEl.innerText = callData.candidate_name; + if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist'); + if (modalEl) modalEl.classList.add('active'); + callRingtone.start(); + + if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer); + let remainingMs = 8000; + if (callData.call_started_at) { + const startedTime = new Date(callData.call_started_at).getTime(); + const elapsedMs = Date.now() - startedTime; + remainingMs = Math.max(1000, 8000 - elapsedMs); + } + + ringTimeoutTimer = setTimeout(() => { + declineIncomingCall(true); + }, remainingMs); +} + +export function pollGlobalActiveCalls() { + if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; + if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; + + fetch('/interviews/active-calls', { + headers: { 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + if (!data || !data.active_calls || data.active_calls.length === 0) { + if (activeIncomingCall) declineIncomingCall(true); + return; + } + + const incoming = data.active_calls.find(call => { + if (endedCallIds.has(call.id)) return false; + if (iAmTheCaller && currentInterviewId == call.id) return false; + if (interviewerLocalStream && currentInterviewId == call.id) return false; + if (dismissedCallIds.has(call.id)) return false; + return true; + }); + + if (incoming) { + const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0; + if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) { + dismissedCallIds.add(incoming.id); + return; + } + if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) { + triggerIncomingCallRing(incoming, false); + } + } else if (activeIncomingCall) { + declineIncomingCall(true); + } + }) + .catch(() => {}); +} + +export function acceptIncomingCall() { + callRingtone.stop(); + if (ringTimeoutTimer) { + clearTimeout(ringTimeoutTimer); + ringTimeoutTimer = null; + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + + if (activeIncomingCall) { + const targetCall = activeIncomingCall; + activeIncomingCall = null; + if (window.location.pathname.includes('/interviews/' + targetCall.id)) { + startInterviewerCall(true); + } else { + window.location.href = `/interviews/${targetCall.id}?join_call=1`; + } + } +} + +export function declineIncomingCall(silent = false) { + callRingtone.stop(); + if (ringTimeoutTimer) { + clearTimeout(ringTimeoutTimer); + ringTimeoutTimer = null; + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + + if (activeIncomingCall) { + dismissedCallIds.add(activeIncomingCall.id); + activeIncomingCall = null; + } + + if (!silent && typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'info', + title: 'Call Missed / Declined', + text: 'You can click "Join Active Call" from the list whenever you are ready.', + timer: 3000, + showConfirmButton: false, + toast: true, + position: 'top-end' + }); + } +} + +export function subscribeLiveSync(interviewId) { + if (liveSyncChannel) { + try { liveSyncChannel.close(); } catch(e) {} + } + liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); + liveSyncChannel.onmessage = function(event) { + const data = event.data; + if (!data) return; + + if (data.type === 'code_sync') { + if (data.language) { + const langEl = document.getElementById('modal-code-lang'); + if (langEl) langEl.innerText = data.language.toUpperCase(); + } + if (data.code !== undefined) { + const codeEl = document.getElementById('modal-code-display'); + if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.'; + } + } else if (data.type === 'notes_sync') { + if (data.notes !== undefined) { + const notesEl = document.getElementById('modal-notes-display'); + if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.'; + } + } else if (data.type === 'drawing_sync') { + const drawingImg = document.getElementById('modal-drawing-img'); + const noDrawing = document.getElementById('no-drawing-placeholder'); + if (data.drawing) { + if (drawingImg) { + drawingImg.src = data.drawing; + drawingImg.style.display = 'block'; + } + if (noDrawing) noDrawing.style.display = 'none'; + } else { + if (drawingImg) drawingImg.style.display = 'none'; + if (noDrawing) noDrawing.style.display = 'block'; + } + } else if (data.type === 'peer_state_changed') { + const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_')); + if (isCandPeer) { + const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true; + const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true; + updateCandidateStatusIcons(isMicOn, isCamOn); + } + pollReviewData(); + } else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') { + pollReviewData(); + } + }; + + if (interviewerSigChannel) { + try { interviewerSigChannel.close(); } catch(e) {} + } + interviewerSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); + interviewerSigChannel.onmessage = function(event) { + const data = event.data; + if (!data) return; + if (data.type === 'peer_state_changed') { + const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_')); + if (isCandPeer) { + const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true; + const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true; + updateCandidateStatusIcons(isMicOn, isCamOn); + } + setTimeout(pollReviewData, 500); + } else if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') { + setTimeout(pollReviewData, 500); + } + }; +} + +export function openReviewModal(id, name, uid, autoJoinCall = false) { + currentInterviewId = id; + currentSubmissionUniqueId = uid; + acknowledgedViolationCount = 0; + sosDismissed = false; + + const toggleSw = document.getElementById('sosToggle'); + if (toggleSw) { + if (sosAutoTriggerDisabled) { + toggleSw.classList.remove('on'); + } else { + toggleSw.classList.add('on'); + } + } + + const candNameEl = document.getElementById('modal-cand-name'); + const candUidEl = document.getElementById('modal-cand-uid'); + if (candNameEl) candNameEl.innerText = name; + if (candUidEl) candUidEl.innerText = 'Unique Submission ID: ' + uid; + + const candInitials = getInitials(name); + const avatarCircle = document.getElementById('modal-cand-avatar-circle'); + const avatarName = document.getElementById('cand-avatar-name'); + if (avatarCircle) avatarCircle.innerText = candInitials; + if (avatarName) avatarName.innerText = name; + + const reviewModal = document.getElementById('review-modal'); + if (reviewModal) reviewModal.classList.add('active'); + + resetZoomScreen(); + switchIdeTab('code'); + updateCandidateStatusIcons(false, false, false); + + initInterviewerSigChannel(); + subscribeLiveSync(id); + pollReviewData(); + if (sosPollInterval) clearInterval(sosPollInterval); + sosPollInterval = setInterval(pollReviewData, 6000); + + if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer); + peerHeartbeatTimer = setInterval(() => { + sendInterviewerPeerHeartbeat('heartbeat'); + }, 6000); + + if (autoJoinCall) { + setTimeout(() => { + startInterviewerCall(true); + }, 400); + } +} + +export function sendInterviewerPeerHeartbeat(action = 'heartbeat') { + if (!currentInterviewId) return; + const currentUserId = window.currentUserId || 0; + const currentUserName = window.currentUserName || 'Interviewer'; + const currentUserRole = window.currentUserRole || 'interviewer'; + const myPeerId = getMyInterviewerPeerId(); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ + peer_id: myPeerId, + name: currentUserName, + role: currentUserRole, + mic_on: isInterviewerMicOn, + cam_on: isInterviewerCamOn, + action: action + }) + }).catch(() => {}); +} + +function isTrueVal(val) { + if (val === undefined || val === null) return true; + if (val === false || val === 'false' || val === 0 || val === '0') return false; + return Boolean(val); +} + +export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { + const micOn = isTrueVal(isMicOn); + const camOn = isTrueVal(isCamOn); + + const candMicIcon = document.getElementById('cand-mic-status'); + const candCamIcon = document.getElementById('cand-cam-status'); + const candPlaceholder = document.getElementById('cand-video-placeholder'); + const candStatusText = document.getElementById('cand-status-text') || (candPlaceholder ? candPlaceholder.querySelector('.status') : null); + const candVid = document.getElementById('interviewer-cand-video'); + const screenVid = document.getElementById('interviewer-screen-video'); + const candVidWrapper = document.getElementById('cand-video-wrapper'); + const screenWrapper = document.getElementById('screen-wrapper'); + const screenPlaceholder = document.getElementById('screen-video-placeholder'); + const joinedPill = document.getElementById('candidate-joined-pill'); + + if (!isJoined) { + if (candVid) candVid.srcObject = null; + if (screenVid) screenVid.srcObject = null; + + if (joinedPill) { + joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60'; + joinedPill.innerHTML = ' Not Joined'; + } + if (candMicIcon) { + candMicIcon.className = 'fa-solid fa-microphone-slash text-slate-500 text-[10px]'; + candMicIcon.title = 'Candidate Not Joined'; + } + if (candCamIcon) { + candCamIcon.className = 'fa-solid fa-video-slash text-slate-500 text-[10px]'; + candCamIcon.title = 'Candidate Not Joined'; + } + if (candVidWrapper) { + candVidWrapper.style.opacity = '0.4'; + candVidWrapper.style.filter = 'grayscale(60%)'; + candVidWrapper.style.transition = 'all 0.3s ease'; + } + if (screenWrapper) { + screenWrapper.style.opacity = '0.4'; + screenWrapper.style.filter = 'grayscale(60%)'; + screenWrapper.style.transition = 'all 0.3s ease'; + } + if (candPlaceholder) { + candPlaceholder.style.display = 'flex'; + if (candStatusText) candStatusText.innerText = 'Candidate Not Joined'; + } + if (screenPlaceholder) { + screenPlaceholder.style.display = 'flex'; + } + return; + } + + const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); + + // Joined state: restore styles & active icons + if (joinedPill) { + joinedPill.className = 'pill green inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-emerald-500/30 text-emerald-400 bg-emerald-500/10'; + joinedPill.innerHTML = ' Joined'; + } + if (candVidWrapper) { + candVidWrapper.style.opacity = '1'; + candVidWrapper.style.filter = 'none'; + } + if (screenWrapper) { + screenWrapper.style.opacity = '1'; + screenWrapper.style.filter = 'none'; + } + + if (candMicIcon && isMicOn !== undefined) { + candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; + candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted'; + } + if (candCamIcon && isCamOn !== undefined) { + candCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]'; + candCamIcon.title = camOn ? 'Camera On' : 'Camera Off'; + } + if (candPlaceholder) { + if (!camOn) { + candPlaceholder.style.display = 'flex'; + if (candStatusText) candStatusText.innerText = 'Camera Turned Off'; + } else if (candidateStreamConnected) { + candPlaceholder.style.display = 'none'; + } else { + candPlaceholder.style.display = 'flex'; + if (candStatusText) candStatusText.innerText = 'Waiting for candidate camera stream...'; + } + } +} + +export function pollReviewData() { + if (!currentInterviewId) return; + const myPeerId = getMyInterviewerPeerId(); + const candPeerId = getCandidatePeerId(); + + if (interviewerLocalStream) { + sendInterviewerPeerHeartbeat('heartbeat'); + } + + fetch(`/interviews/${currentInterviewId}/poll`, { + headers: { 'Accept': 'application/json' } + }) + .then(r => { + if (!r.ok) return null; + const contentType = r.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) return null; + return r.json(); + }) + .then(data => { + if (!data) return; + + if (data.active_peers && Array.isArray(data.active_peers)) { + latestActivePeers = data.active_peers; + } + + const isCallEnded = (data.call_status === 'ended'); + + if (isCallEnded) { + // When call status is "Call Ended", do NOT check for candidate status + updateCandidateStatusIcons(false, false, false); + const screenVid = document.getElementById('interviewer-screen-video'); + const screenPlace = document.getElementById('screen-video-placeholder'); + if (screenVid) screenVid.srcObject = null; + if (screenPlace) screenPlace.style.display = 'flex'; + + panelistMeshTiles.forEach((_, pid) => { + removePanelistTile(pid); + }); + } else { + const candVid = document.getElementById('interviewer-cand-video'); + const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); + + const candidatePeer = (data.active_peers && Array.isArray(data.active_peers)) + ? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_'))))) + : null; + + const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected); + + if (isCandidateJoined) { + const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true; + const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true; + updateCandidateStatusIcons(isMicOn, isCamOn, true); + } else { + updateCandidateStatusIcons(false, false, false); + } + + if (data.active_peers && Array.isArray(data.active_peers)) { + const activeIds = new Set(data.active_peers.map(p => p.peer_id)); + + data.active_peers.forEach(p => { + if (p.peer_id && p.peer_id !== myPeerId) { + let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name ? ('Panelist ' + p.name) : 'Panelist'); + const isMicOn = isTrueVal(p.mic_on); + const isCamOn = isTrueVal(p.cam_on); + + const isCandPeer = p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_')))); + if (isCandPeer) { + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open && !candidateStreamConnected) { + try { + const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + if (candVid) safePlayMediaStream(candVid, remoteStream); + updateCandidateStatusIcons(isMicOn, isCamOn, true); + }); + } + } catch(e) {} + } + } else if (p.peer_id.startsWith('interviewer_') || p.role === 'interviewer' || p.role === 'admin') { + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { + if (!panelistMeshTiles.has(p.peer_id)) { + try { + const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); + }); + } + } catch(e) {} + } else { + addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn); + } + } else { + addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id) || null, roleLabel, isMicOn, isCamOn); + } + } + } + }); + + panelistMeshTiles.forEach((_, pid) => { + if (!activeIds.has(pid)) { + removePanelistTile(pid); + } + }); + } + } + + const codeLang = document.getElementById('modal-code-lang'); + const codeDisplay = document.getElementById('modal-code-display'); + const outputDisplay = document.getElementById('modal-output-display'); + const notesDisplay = document.getElementById('modal-notes-display'); + + if (codeLang) codeLang.innerText = (data.submitted_language || 'UNKNOWN').toUpperCase(); + if (codeDisplay) codeDisplay.innerText = data.submitted_code || '// Candidate has not typed any code yet.'; + if (outputDisplay) outputDisplay.innerText = data.code_output || 'No execution output.'; + if (notesDisplay) notesDisplay.innerText = data.candidate_notes || 'No candidate notes written yet.'; + + const drawingImg = document.getElementById('modal-drawing-img'); + const noDrawing = document.getElementById('no-drawing-placeholder'); + if (data.candidate_drawing) { + if (drawingImg) { + drawingImg.src = data.candidate_drawing; + drawingImg.style.display = 'block'; + } + if (noDrawing) noDrawing.style.display = 'none'; + } else { + if (drawingImg) drawingImg.style.display = 'none'; + if (noDrawing) noDrawing.style.display = 'block'; + } + + // Saved recordings + const recContainer = document.getElementById('modal-recordings-container'); + if (recContainer) { + const recPath = data.recording_path; + let recList = data.recordings || []; + if (recList.length === 0 && recPath) { + recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }]; + } + + if (recList.length > 0) { + recList.sort((a, b) => { + const timeA = a.created_at ? new Date(a.created_at).getTime() : 0; + const timeB = b.created_at ? new Date(b.created_at).getTime() : 0; + return timeB - timeA; + }); + + let recHtml = `
+ 📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''}) + 📜 Scroll to view older recordings +
`; + + recList.forEach((r, displayIdx) => { + let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath); + if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) { + videoUrl = '/' + videoUrl; + } + if (videoUrl && videoUrl.includes('/storage/')) { + videoUrl = videoUrl.replace('/storage/', '/media-stream/'); + } + const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx; + const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date(); + const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString(); + const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`); + const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm'); + + recHtml += `
+
+
+ 📹 Session Recording #${recList.length - displayIdx} + 📅 ${dateStr} +
+ + 📥 Download Video + +
+ +
`; + }); + recContainer.innerHTML = recHtml; + } else { + recContainer.innerHTML = '
No video recordings saved for this candidate yet.
'; + } + } + + // Live Chat history + const chatHistory = document.getElementById('interviewer-chat-history'); + const interviewerColors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316']; + + if (chatHistory && data.warnings) { + if (data.warnings.length > 0) { + let chatHtml = ''; + data.warnings.forEach(w => { + const isCand = (w.sent_by === null || w.sent_by === undefined); + const senderName = isCand + ? (data.candidate_name || 'Candidate') + : (w.sender ? w.sender.name : 'Interviewer'); + + const color = isCand + ? '#38bdf8' + : interviewerColors[(w.sent_by || 1) % interviewerColors.length]; + + const badgeLabel = isCand ? 'Candidate' : 'Panelist'; + const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30'; + + chatHtml += `
+
+
+ + ${senderName} + ${badgeLabel} +
+ ${new Date(w.created_at || Date.now()).toLocaleTimeString()} +
+
${w.message}
+
`; + }); + chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2 mb-2.5'; + chatHistory.innerHTML = chatHtml; + chatHistory.scrollTop = chatHistory.scrollHeight; + } else { + chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 mb-2.5'; + chatHistory.innerHTML = '
No chat history. Send a message below.
'; + } + } + + currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true; + updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); + + // Screenshots container + const shotContainer = document.getElementById('modal-screenshots-container'); + if (shotContainer) { + const shotList = data.tab_switch_screenshots || []; + if (shotList.length > 0) { + let shotHtml = `
+ 📸 Tab-Switch Screenshots Captured (${shotList.length}) + Click image to enlarge full size +
`; + + shotList.forEach((s) => { + let url = typeof s === 'string' ? s : (s.url || s.full_url); + if (url && !url.startsWith('http') && !url.startsWith('/')) { + url = '/' + url; + } + const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : ''; + shotHtml += `
+ +
📅 ${timeStr}
+ + 📥 View / Download + +
`; + }); + shotHtml += `
`; + shotContainer.innerHTML = shotHtml; + } else { + shotContainer.innerHTML = '
No tab-switch screenshots captured for this candidate yet.
'; + } + } + + // Header live pulse dot & buttons visibility + const livePulseDot = document.getElementById('live-call-pulse'); + const recStartBtn = document.getElementById('btn-start-recording'); + const recStopBtn = document.getElementById('btn-stop-recording'); + const startBtn = document.getElementById('btn-start-call'); + const leaveBtn = document.getElementById('btn-leave-call'); + const endAllBtn = document.getElementById('btn-end-all-call'); + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + + const timelinePill = document.getElementById('timeline-status-pill'); + + if (data.call_status) { + if (data.call_status === 'active') { + if (!interviewerLocalStream) { + if (livePulseDot) livePulseDot.style.display = 'none'; + if (startBtn) { + const icon = startBtn.querySelector('i'); + const span = startBtn.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = 'Join Call'; + startBtn.style.display = 'inline-flex'; + } + if (leaveBtn) leaveBtn.style.display = 'none'; + if (endAllBtn) endAllBtn.style.display = 'none'; + if (micBtn) micBtn.style.display = 'none'; + if (camBtn) camBtn.style.display = 'none'; + if (recStartBtn) recStartBtn.style.display = 'none'; + if (recStopBtn) recStopBtn.style.display = 'none'; + + if (timelinePill) { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; + if (span) span.innerText = 'Call in progress'; + timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; + } + } else { + if (livePulseDot) livePulseDot.style.display = 'inline-block'; + if (startBtn) startBtn.style.display = 'none'; + if (leaveBtn) leaveBtn.style.display = 'inline-flex'; + if (endAllBtn) endAllBtn.style.display = 'inline-flex'; + if (micBtn) micBtn.style.display = 'inline-flex'; + if (camBtn) camBtn.style.display = 'inline-flex'; + if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { + recStartBtn.style.display = 'inline-flex'; + } + + if (timelinePill) { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; + if (span) span.innerText = 'Connected (Live)'; + timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; + } + } + } else if (data.call_status === 'ended' || data.call_status === 'idle') { + if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall(); + if (livePulseDot) livePulseDot.style.display = 'none'; + if (startBtn) { + const icon = startBtn.querySelector('i'); + const span = startBtn.querySelector('span'); + if (data.call_status === 'ended') { + if (icon) icon.className = 'fa-solid fa-phone-slash'; + if (span) span.innerText = 'Call Ended'; + startBtn.disabled = true; + startBtn.style.opacity = '0.5'; + startBtn.style.cursor = 'not-allowed'; + startBtn.style.pointerEvents = 'none'; + startBtn.style.display = 'inline-flex'; + } else { + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = 'Start Call'; + startBtn.disabled = false; + startBtn.style.opacity = '1'; + startBtn.style.cursor = 'pointer'; + startBtn.style.pointerEvents = 'auto'; + startBtn.style.display = 'inline-flex'; + } + } + if (leaveBtn) leaveBtn.style.display = 'none'; + if (endAllBtn) endAllBtn.style.display = 'none'; + if (micBtn) micBtn.style.display = 'none'; + if (camBtn) camBtn.style.display = 'none'; + if (recStartBtn) recStartBtn.style.display = 'none'; + if (recStopBtn) recStopBtn.style.display = 'none'; + + if (timelinePill && data.call_status === 'ended') { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; + if (span) span.innerText = 'Call ended'; + timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; + } + } + } + + // Proctoring logs + const logsDisplay = document.getElementById('modal-logs-display'); + let logsHtml = ''; + let currentViolations = []; + + if (data.proctor_logs && data.proctor_logs.length > 0) { + data.proctor_logs.forEach(l => { + if (l.type === 'proctoring_summary_report') return; + + let icoClass = 'amber'; + let faIcon = 'fa-solid fa-window-restore'; + let isFlag = false; + + if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } + else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; } + else if (l.type === 'looking_away') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye-slash'; } + else if (l.type === 'face_missing') { icoClass = 'red'; faIcon = 'fa-solid fa-user-slash'; isFlag = true; } + else if (l.type === 'multiple_faces') { icoClass = 'red'; faIcon = 'fa-solid fa-users-viewfinder'; isFlag = true; } + else if (l.type === 'prolonged_looking_away') { icoClass = 'red'; faIcon = 'fa-solid fa-clock-rotate-left'; isFlag = true; } + else if (l.type === 'phone_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen-button'; isFlag = true; } + else if (l.type === 'prolonged_corner_gaze') { icoClass = 'amber'; faIcon = 'fa-solid fa-arrows-to-eye'; isFlag = true; } + else if (l.type === 'talking_secondary_person') { icoClass = 'red'; faIcon = 'fa-solid fa-user-group'; isFlag = true; } + else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; } + else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; } + else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; } + else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; } + else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } + else if (l.type === 'paste_event') { icoClass = 'red'; faIcon = 'fa-solid fa-paste'; isFlag = true; } + else if (l.type === 'copy_event') { icoClass = 'amber'; faIcon = 'fa-solid fa-copy'; } + + if (['paste_event', 'copy_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device', 'looking_away', 'face_missing', 'multiple_faces', 'prolonged_looking_away', 'prolonged_corner_gaze', 'phone_detected'].includes(l.type)) { + currentViolations.push(l); + } + + let screenshotLink = l.screenshot_url ? `[ Screenshot]` : ''; + let aiBadge = ''; + + if (l.ai_verdict) { + const confPct = Math.round((l.ai_verdict.confidence || 0.85) * 100); + const engineName = l.ai_verdict.engine || 'AI Engine'; + const toolName = l.ai_verdict.ai_tool_detected ? ` • Detected: ${l.ai_verdict.ai_tool_detected}` : ''; + aiBadge = `
AI inspector · ${confPct}% cheating confidence (${engineName}${toolName})
`; + isFlag = true; + } + + const formattedType = (l.type || 'violation').replace(/_/g, ' '); + const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1); + const humanDetails = formatProctoringLogDetails(l); + const timeString = new Date(l.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); + + logsHtml += `
+
+ +
+
+
${titleCap} — ${humanDetails}${screenshotLink}${aiBadge}
+
${timeString}
+
+
`; + }); + } else { + logsHtml = '
Zero proctoring violations recorded.
'; + } + if (logsDisplay) logsDisplay.innerHTML = logsHtml; + + activeViolations = currentViolations; + const totalViolations = currentViolations.length; + + // Trigger SOS Modal Popup and Ringtone Alarm ONLY if NEW unacknowledged violations occurred & SOS Auto-Trigger is active + if (totalViolations > 0 && totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) { + sosDismissed = false; + const newViolations = currentViolations.slice(acknowledgedViolationCount); + openSosModal(newViolations); + } + }) + .catch(() => {}); +} + +export function openSosModal(specificViolations = null) { + let violationsToDisplay = specificViolations; + if (!violationsToDisplay || violationsToDisplay.length === 0) { + if (acknowledgedViolationCount > 0 && activeViolations.length > acknowledgedViolationCount) { + violationsToDisplay = activeViolations.slice(acknowledgedViolationCount); + } else { + violationsToDisplay = activeViolations; + } + } + + let html = ''; + if (violationsToDisplay && violationsToDisplay.length > 0) { + violationsToDisplay.forEach(v => { + let typeTitle = (v.type || '').toUpperCase(); + if (v.type === 'external_ai_detected') { + typeTitle = 'candidate might use external ai'; + } else if (v.type === 'tab_switch') { + typeTitle = 'Candidate Switched Browser Tab'; + } else if (v.type === 'multiple_faces') { + typeTitle = 'Multiple Faces Detected'; + } else if (v.type === 'face_missing') { + typeTitle = 'Candidate Face Not Visible'; + } else if (v.type === 'looking_away') { + typeTitle = 'Candidate Looking Away'; + } else if (v.type === 'prolonged_looking_away') { + typeTitle = 'Prolonged Looking Away (>10s)'; + } else if (v.type === 'phone_detected') { + typeTitle = 'Mobile Phone Detected'; + } else if (v.type === 'talking_secondary_person') { + typeTitle = 'Candidate Talking to Secondary Person'; + } else if (v.type === 'talking_on_phone') { + typeTitle = 'Candidate Talking on Phone'; + } else if (v.type === 'reading_external_device') { + typeTitle = 'Candidate Reading from External Device'; + } else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') { + typeTitle = 'Candidate Reading Question Out Loud'; + } + const borderCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'border-red-500' : 'border-amber-500'; + const textCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'text-red-300' : 'text-amber-300'; + const vDetails = formatProctoringLogDetails(v); + const vTime = new Date(v.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); + + html += `
+ 🚨 ${typeTitle}: ${vDetails} +
Timestamp: ${vTime}
+
`; + }); + } else { + html = '
Potential proctoring alert flagged by engine.
'; + } + const listEl = document.getElementById('sos-violation-list'); + const modalEl = document.getElementById('sos-modal'); + if (listEl) listEl.innerHTML = html; + if (modalEl) modalEl.classList.add('active'); + + try { + callRingtone.start(); + } catch(e) {} +} + +export function stopSosAlarm() { + try { + callRingtone.stop(); + } catch(e) {} + + const modalEl = document.getElementById('sos-modal'); + if (modalEl) modalEl.classList.remove('active'); + + acknowledgedViolationCount = activeViolations.length; + sosDismissed = true; +} + +export function disableSosAndStopAlarm() { + sosAutoTriggerDisabled = true; + const toggleSw = document.getElementById('sosToggle'); + const btn = document.getElementById('sos-toggle-btn'); + if (toggleSw) toggleSw.classList.remove('on'); + if (btn) { + btn.innerHTML = ' SOS Auto-Trigger: OFF'; + btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300'; + } + stopSosAlarm(); +} + +export function interviewerLogoutAction() { + const form = document.getElementById('logout-form'); + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: 'Logout of Portal?', + text: 'Are you sure you want to log out of your session?', + icon: 'question', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, Logout' + }).then((result) => { + if (result.isConfirmed && form) { + form.submit(); + } + }); + } else if (form) { + form.submit(); + } +} + +export function regenerateCandidatePassword() { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: 'Regenerate Candidate Password?', + text: 'This will invalidate the candidate\'s current password and issue a new system password.', + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#eab308', + cancelButtonColor: '#475569', + confirmButtonText: 'Regenerate Now' + }).then((result) => { + if (result.isConfirmed) { + fetch(`/interviews/${currentInterviewId}/regenerate-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + } + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + window.Swal.fire({ + icon: 'success', + title: 'New Password Generated!', + text: 'New Candidate Passcode: ' + res.new_password, + confirmButtonColor: '#6366f1' + }).then(() => window.location.reload()); + } + }); + } + }); + } +} + +export function blockCandidateAction() { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: '⛔ Block Candidate Email & IP?', + text: 'This will immediately terminate the candidate session and block their email and client IP address.', + icon: 'error', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, Block Candidate' + }).then((result) => { + if (result.isConfirmed) { + fetch(`/interviews/${currentInterviewId}/block-candidate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + } + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + window.Swal.fire({ + icon: 'success', + title: 'Candidate Blocked', + text: res.message, + confirmButtonColor: '#6366f1' + }).then(() => { + closeReviewModal(); + window.location.reload(); + }); + } + }); + } + }); + } +} + +export { initMeteredIceServers }; + +export function safePlayMediaStream(videoElem, stream, isSelf = false) { + if (!videoElem || !stream) return; + const isScreenShare = (videoElem.id === 'interviewer-screen-video'); + if (isSelf || isScreenShare) { + videoElem.muted = true; + } + if (videoElem.srcObject !== stream) { + videoElem.srcObject = stream; + } + const playPromise = videoElem.play(); + if (playPromise !== undefined) { + playPromise.catch(err => { + if (err.name === 'AbortError' || err.name === 'NotAllowedError') { + videoElem.onloadedmetadata = () => { + videoElem.play().catch(() => {}); + }; + if (!isSelf && !isScreenShare) { + videoElem.muted = true; + videoElem.play().catch(() => {}); + const unmuteHandler = () => { + videoElem.muted = false; + videoElem.play().catch(() => {}); + }; + window.addEventListener('click', unmuteHandler, { once: true }); + window.addEventListener('keydown', unmuteHandler, { once: true }); + window.addEventListener('touchstart', unmuteHandler, { once: true }); + } + } + }); + } +} + +export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn = true, camOn = true) { + const currentUserId = window.currentUserId || 0; + const myPeerId = getMyInterviewerPeerId(); + if (!peerId || peerId === myPeerId) return; + const grid = document.getElementById('panelist-mesh-grid'); + if (!grid) return; + + let tile = document.getElementById('panelist-tile-' + peerId); + let videoElem; + const initials = getInitials(name); + + if (!tile) { + tile = document.createElement('div'); + tile.id = 'panelist-tile-' + peerId; + tile.className = 'video-tile relative aspect-[16/11] bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all'; + + tile.innerHTML = ` + +
+
+ ${initials} +
+
${name}
+
${camOn && stream ? 'Connecting video...' : 'Camera turned off'}
+
+ + ${name} + + + + + `; + + grid.appendChild(tile); + videoElem = document.getElementById('panelist-video-' + peerId); + } else { + videoElem = document.getElementById('panelist-video-' + peerId); + const meshMicIcon = document.getElementById('mesh-mic-' + peerId); + const meshCamIcon = document.getElementById('mesh-cam-' + peerId); + const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId); + + if (meshMicIcon) { + meshMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; + meshMicIcon.title = micOn ? 'Mic On' : 'Mic Muted'; + } + if (meshCamIcon) { + meshCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]'; + meshCamIcon.title = camOn ? 'Camera On' : 'Camera Off'; + } + if (meshPlaceholder) { + meshPlaceholder.style.display = (camOn && stream) ? 'none' : 'flex'; + } + } + + if (videoElem && stream) { + safePlayMediaStream(videoElem, stream); + stream.onaddtrack = () => { + safePlayMediaStream(videoElem, stream); + const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId); + if (meshPlaceholder && camOn) meshPlaceholder.style.display = 'none'; + }; + } + + if (stream) { + panelistMeshTiles.set(peerId, stream); + } +} + +export function removePanelistTile(peerId) { + const tile = document.getElementById('panelist-tile-' + peerId); + if (tile) tile.remove(); + panelistMeshTiles.delete(peerId); +} + +export async function initInterviewerPeer() { + if (!currentInterviewId || typeof window.Peer === 'undefined') return; + const iceServers = await initMeteredIceServers(); + + const currentUserId = window.currentUserId || 0; + const currentUserName = window.currentUserName || 'Interviewer'; + const myPeerId = getMyInterviewerPeerId(); + const candPeerId = getCandidatePeerId(); + + if (interviewerPeer && !interviewerPeer.destroyed) { + try { interviewerPeer.destroy(); } catch(e) {} + } + + interviewerPeer = new window.Peer(myPeerId, { + config: { iceServers: iceServers } + }); + + interviewerPeer.on('open', id => { + console.log('[WebRTC Interviewer] Registered PeerJS ID:', id); + if (interviewerSigChannel) { + interviewerSigChannel.postMessage({ + type: 'peer_joined', + peer_id: id, + user_id: currentUserId, + user_name: currentUserName, + role: 'interviewer' + }); + } + }); + + function handleIncomingScreenStream(screenStream) { + console.log('[WebRTC Screen] Attaching incoming screen stream to video element'); + const screenVid = document.getElementById('interviewer-screen-video'); + const placeholder = document.getElementById('screen-video-placeholder'); + if (screenVid && screenStream) { + screenVid.muted = true; + if (screenVid.srcObject !== screenStream) { + screenVid.srcObject = screenStream; + } + screenVid.play().catch(() => {}); + screenStream.onaddtrack = () => { + screenVid.muted = true; + if (screenVid.srcObject !== screenStream) { + screenVid.srcObject = screenStream; + } + screenVid.play().catch(() => {}); + if (placeholder) placeholder.style.display = 'none'; + }; + } + if (placeholder) placeholder.style.display = 'none'; + } + + interviewerPeer.on('call', async call => { + console.log('[WebRTC Interviewer] Incoming PeerJS call from:', call.peer); + if (call.peer && call.peer.startsWith('cand_screen_')) { + call.on('stream', screenStream => { + console.log('[WebRTC Screen] Screen stream received on main peer from:', call.peer); + handleIncomingScreenStream(screenStream); + }); + try { call.answer(); } catch(e) {} + return; + } + + const sendStream = interviewerLocalStream || new MediaStream(); + call.on('stream', remoteStream => { + console.log('[WebRTC Interviewer] Stream received from peer:', call.peer); + if (call.peer === candPeerId || (call.peer && call.peer.startsWith('cand_') && !call.peer.startsWith('cand_screen_'))) { + console.log('[WebRTC Interviewer] Candidate video stream connected! Candidate ID:', call.peer); + const candVid = document.getElementById('interviewer-cand-video'); + if (candVid) safePlayMediaStream(candVid, remoteStream); + const candCamIcon = document.getElementById('cand-cam-status'); + const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true; + updateCandidateStatusIcons(true, isCamOn, true); + } else { + const peerInfo = latestActivePeers.find(p => p.peer_id === call.peer); + const roleLabel = peerInfo ? (peerInfo.role === 'admin' ? ('Admin ' + (peerInfo.name || '')) : ('Panelist ' + (peerInfo.name || ''))) : 'Panelist'; + const isMicOn = peerInfo ? isTrueVal(peerInfo.mic_on) : true; + const isCamOn = peerInfo ? isTrueVal(peerInfo.cam_on) : true; + addOrUpdatePanelistTile(call.peer, remoteStream, roleLabel, isMicOn, isCamOn); + } + + const badge = document.getElementById('interviewer-call-status'); + if (badge) { + badge.innerText = 'CONNECTED (LIVE)'; + badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; + } + }); + + call.on('error', err => { + console.error('[WebRTC Interviewer] Call error from peer:', call.peer, err); + }); + + try { call.answer(sendStream); } catch(e) { + console.error('[WebRTC Interviewer] Error answering call:', e); + } + }); + + const screenPeerId = getInterviewerScreenPeerId(); + if (screenReceiverPeer && !screenReceiverPeer.destroyed) { + try { screenReceiverPeer.destroy(); } catch(e) {} + } + screenReceiverPeer = new window.Peer(screenPeerId, { + config: { iceServers: iceServers } + }); + screenReceiverPeer.on('call', call => { + console.log('[WebRTC Screen] Incoming screen call from:', call.peer); + call.on('stream', screenStream => { + console.log('[WebRTC Screen] Screen stream received from:', call.peer); + handleIncomingScreenStream(screenStream); + }); + try { call.answer(); } catch(e) { + console.error('[WebRTC Screen] Error answering screen call:', e); + } + }); + + // Also attempt legacy singleton screen peer if current user is admin / user 1 + const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) + ? currentSubmissionUniqueId + : null; + const legacyScreenPeerId = subId ? ('interviewer_screen_' + currentInterviewId + '_' + subId) : ('interviewer_screen_' + currentInterviewId); + + if (legacyScreenPeerId !== screenPeerId && (currentUserId === 1 || window.currentUserRole === 'admin')) { + try { + const legacyScreenPeer = new window.Peer(legacyScreenPeerId, { + config: { iceServers: iceServers } + }); + legacyScreenPeer.on('call', call => { + try { call.answer(); } catch(e) {} + call.on('stream', screenStream => { + handleIncomingScreenStream(screenStream); + }); + }); + legacyScreenPeer.on('error', () => {}); + } catch(e) {} + } +} + +export function initInterviewerSigChannel() { + if (!currentInterviewId) return; + if (interviewerSigChannel) interviewerSigChannel.close(); + + interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId); + const currentUserId = window.currentUserId || 0; + const currentUserName = window.currentUserName || 'Interviewer'; + const myPeerId = getMyInterviewerPeerId(); + const candPeerId = getCandidatePeerId(); + + interviewerSigChannel.onmessage = async (event) => { + const msg = event.data; + if (!msg) return; + + if (msg.type === 'violation_occurred') { + pollReviewData(); + } + + if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') { + if (msg.peer_id && msg.peer_id !== myPeerId) { + if (msg.type === 'peer_joined') { + interviewerSigChannel.postMessage({ + type: 'peer_presence_ack', + peer_id: myPeerId, + user_id: currentUserId, + user_name: currentUserName, + role: 'interviewer' + }); + } + + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { + try { + const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) { + const candVid = document.getElementById('interviewer-cand-video'); + if (candVid) safePlayMediaStream(candVid, remoteStream); + const candCamIcon = document.getElementById('cand-cam-status'); + const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true; + updateCandidateStatusIcons(undefined, isCamOn); + } else { + addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist'); + } + + const badge = document.getElementById('interviewer-call-status'); + if (badge) { + badge.innerText = 'CONNECTED (LIVE)'; + badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; + } + }); + } + } catch(e) {} + } + } + } else if (msg.type === 'peer_left') { + if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) { + const placeholder = document.getElementById('cand-video-placeholder'); + if (placeholder) placeholder.style.display = 'flex'; + } else if (msg.peer_id) { + removePanelistTile(msg.peer_id); + } + } else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) { + await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); + } else if (msg.type === 'ice-candidate' && msg.sender === 'candidate' && interviewerPeerConnection && msg.candidate) { + try { + await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); + } catch(e) {} + } else if (msg.type === 'end_call_all') { + leaveInterviewerCall(false); + } + }; +} + +export function startInterviewerCall(isRejoin = false) { + if (!currentInterviewId) return; + const startBtn = document.getElementById('btn-start-call'); + if (startBtn && startBtn.disabled) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + const currentUserName = window.currentUserName || 'Interviewer'; + + if (!isRejoin) { + iAmTheCaller = true; + const candNameEl = document.getElementById('modal-cand-name'); + const candName = candNameEl ? candNameEl.innerText : 'Candidate'; + if (!globalCallChannel) { + globalCallChannel = new BroadcastChannel('global_call_alerts'); + } + globalCallChannel.postMessage({ + type: 'incoming_call', + interview_id: currentInterviewId, + candidate_name: candName, + starter_name: currentUserName, + call_started_at: new Date().toISOString() + }); + } + + initInterviewerSigChannel(); + + fetch(`/interviews/${currentInterviewId}/call-status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin }) + }); + + const handleStream = (stream) => { + interviewerLocalStream = stream; + const selfVid = document.getElementById('interviewer-self-video'); + const selfPlace = document.getElementById('self-video-placeholder'); + if (selfVid) { + selfVid.muted = true; + safePlayMediaStream(selfVid, stream, true); + } + if (selfPlace) selfPlace.style.display = 'none'; + + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + const startBtn = document.getElementById('btn-start-call'); + const leaveBtn = document.getElementById('btn-leave-call'); + const endAllBtn = document.getElementById('btn-end-all-call'); + const recStartBtn = document.getElementById('btn-start-recording'); + + if (micBtn) micBtn.style.display = 'inline-flex'; + if (camBtn) camBtn.style.display = 'inline-flex'; + if (startBtn) startBtn.style.display = 'none'; + if (leaveBtn) leaveBtn.style.display = 'inline-flex'; + if (endAllBtn) endAllBtn.style.display = 'inline-flex'; + if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { + recStartBtn.style.display = 'inline-flex'; + } + + if (interviewerSigChannel) { + interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' }); + } + + if (!interviewerPeer || interviewerPeer.destroyed) { + initInterviewerPeer(); + } + + const makePeerJSCall = () => { + if (!interviewerPeer || interviewerPeer.destroyed) return; + const candPeerId = getCandidatePeerId(); + const myPeerId = getMyInterviewerPeerId(); + activeCall = interviewerPeer.call(candPeerId, stream); + + if (activeCall) { + activeCall.on('stream', candRemoteStream => { + const candVid = document.getElementById('interviewer-cand-video'); + const placeholder = document.getElementById('cand-video-placeholder'); + if (candVid) safePlayMediaStream(candVid, candRemoteStream); + if (placeholder) placeholder.style.display = 'none'; + + candRemoteStream.onaddtrack = () => { + if (placeholder) placeholder.style.display = 'none'; + if (candVid) safePlayMediaStream(candVid, candRemoteStream); + }; + + const badge = document.getElementById('interviewer-call-status'); + if (badge) { + badge.innerText = 'CONNECTED (LIVE)'; + badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; + } + }); + } + + // Also call any other already-active interviewers + if (Array.isArray(latestActivePeers)) { + latestActivePeers.forEach(p => { + if (p.peer_id && p.peer_id !== myPeerId && p.peer_id.startsWith('interviewer_')) { + try { + const outCall = interviewerPeer.call(p.peer_id, stream); + if (outCall) { + const roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); + const isMicOn = isTrueVal(p.mic_on); + const isCamOn = isTrueVal(p.cam_on); + outCall.on('stream', remoteStream => { + addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); + }); + } + } catch(e) {} + } + }); + } + }; + + if (interviewerPeer && interviewerPeer.open) { + makePeerJSCall(); + } else if (interviewerPeer) { + interviewerPeer.on('open', makePeerJSCall); + } + }; + + const audioConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }; + + navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints }) + .then(handleStream) + .catch(() => { + navigator.mediaDevices.getUserMedia({ video: true, audio: true }) + .then(handleStream) + .catch(() => { + navigator.mediaDevices.getUserMedia({ video: false, audio: true }) + .then(handleStream) + .catch(() => { + navigator.mediaDevices.getUserMedia({ video: true, audio: false }) + .then(handleStream) + .catch(err => console.log('WebRTC Stream init error:', err)); + }); + }); + }); +} + +export function leaveInterviewerCall(isEndAll = false) { + sendInterviewerPeerHeartbeat('leave'); + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + const endingId = currentInterviewId; + + if (interviewerSigChannel) { + try { + interviewerSigChannel.postMessage({ + type: 'peer_left', + peer_id: getMyInterviewerPeerId() + }); + } catch(e) {} + } + + if (endingId && isEndAll) { + endedCallIds.add(endingId); + dismissedCallIds.add(endingId); + + if (globalCallChannel) { + try { + globalCallChannel.postMessage({ + type: 'call_ended', + interview_id: endingId + }); + } catch(e) {} + } + + if (interviewerSigChannel) { + try { + interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId }); + } catch(e) {} + } + + fetch(`/interviews/${endingId}/call-status`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ call_status: 'ended' }) + }).catch(() => {}); + } + + if (interviewerLocalStream) { + try { + interviewerLocalStream.getTracks().forEach(t => t.stop()); + } catch(e) {} + interviewerLocalStream = null; + } + if (activeCall) { + try { activeCall.close(); } catch(e) {} + activeCall = null; + } + if (interviewerPeer) { + try { interviewerPeer.destroy(); } catch(e) {} + interviewerPeer = null; + } + if (screenReceiverPeer) { + try { screenReceiverPeer.destroy(); } catch(e) {} + screenReceiverPeer = null; + } + + updateCandidateStatusIcons(false, false, false); + + const candPlace = document.getElementById('cand-video-placeholder'); + const screenPlace = document.getElementById('screen-video-placeholder'); + const selfPlace = document.getElementById('self-video-placeholder'); + if (candPlace) candPlace.style.display = 'flex'; + if (screenPlace) screenPlace.style.display = 'flex'; + if (selfPlace) selfPlace.style.display = 'flex'; + + const candVid = document.getElementById('interviewer-cand-video'); + if (candVid) candVid.srcObject = null; + + const screenVid = document.getElementById('interviewer-screen-video'); + if (screenVid) screenVid.srcObject = null; + + const selfVid = document.getElementById('interviewer-self-video'); + if (selfVid) selfVid.srcObject = null; + + panelistMeshTiles.forEach((_, pid) => { + removePanelistTile(pid); + }); + + const livePulseDot = document.getElementById('live-call-pulse'); + if (livePulseDot) livePulseDot.style.display = 'none'; + + const startBtn = document.getElementById('btn-start-call'); + if (startBtn) { + const icon = startBtn.querySelector('i'); + const span = startBtn.querySelector('span'); + if (isEndAll) { + if (icon) icon.className = 'fa-solid fa-phone-slash'; + if (span) span.innerText = 'Call Ended'; + startBtn.disabled = true; + startBtn.style.opacity = '0.5'; + startBtn.style.cursor = 'not-allowed'; + startBtn.style.pointerEvents = 'none'; + startBtn.style.display = 'inline-flex'; + } else { + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = 'Join Call'; + startBtn.disabled = false; + startBtn.style.opacity = '1'; + startBtn.style.cursor = 'pointer'; + startBtn.style.pointerEvents = 'auto'; + startBtn.style.display = 'inline-flex'; + } + } + + const leaveBtn = document.getElementById('btn-leave-call'); + if (leaveBtn) leaveBtn.style.display = 'none'; + + const endAllBtn = document.getElementById('btn-end-all-call'); + if (endAllBtn) endAllBtn.style.display = 'none'; + + const micBtn = document.getElementById('btn-toggle-interviewer-mic'); + if (micBtn) micBtn.style.display = 'none'; + + const camBtn = document.getElementById('btn-toggle-interviewer-cam'); + if (camBtn) camBtn.style.display = 'none'; + + const recStartBtn = document.getElementById('btn-start-recording'); + if (recStartBtn) recStartBtn.style.display = 'none'; + + const recStopBtn = document.getElementById('btn-stop-recording'); + if (recStopBtn) recStopBtn.style.display = 'none'; + if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { + try { adminMediaRecorder.stop(); } catch(e) {} + } + + const timelinePill = document.getElementById('timeline-status-pill'); + if (timelinePill && isEndAll) { + const icon = timelinePill.querySelector('i'); + const span = timelinePill.querySelector('span'); + if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; + if (span) span.innerText = 'Call ended'; + timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; + } +} + +export function endCallForAll() { + const executeEndCall = () => { + callRingtone.stop(); + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + activeIncomingCall = null; + iAmTheCaller = false; + leaveInterviewerCall(true); + }; + + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + title: '🛑 End Call for All Participants?', + text: 'This will terminate the 2-way call session for candidate and all panelists.', + icon: 'warning', + showCancelButton: true, + confirmButtonColor: '#ef4444', + cancelButtonColor: '#475569', + confirmButtonText: 'Yes, End Call for All' + }).then((result) => { + if (result.isConfirmed) { + executeEndCall(); + } + }); + } else { + if (confirm('End Call for All Participants?')) { + executeEndCall(); + } + } +} + +// MediaRecorder recording +let adminMediaRecorder = null; +let adminRecordedChunks = []; +let recAudioContext = null; + +export function startCallRecording() { + if (!currentInterviewId) { + alert('Please open an active candidate interview session first.'); + return; + } + if (!interviewerLocalStream) { + alert('Please start or join the call before recording.'); + return; + } + + const candVid = document.getElementById('interviewer-cand-video'); + const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; + const streamToRecord = new MediaStream(); + + if (candidateStream) { + candidateStream.getVideoTracks().forEach(track => { + if (track.readyState === 'live') { + try { streamToRecord.addTrack(track); } catch(e) {} + } + }); + } + + if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) { + interviewerLocalStream.getVideoTracks().forEach(track => { + if (track.readyState === 'live') { + try { streamToRecord.addTrack(track); } catch(e) {} + } + }); + } + + const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : []; + const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : []; + + if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) { + try { + const AudioContextClass = window.AudioContext || window.webkitAudioContext; + if (recAudioContext) { + try { recAudioContext.close(); } catch(e) {} + } + recAudioContext = new AudioContextClass(); + const audioDestination = recAudioContext.createMediaStreamDestination(); + + if (candAudioTracks.length > 0) { + const candAudioStream = new MediaStream(candAudioTracks); + const candSource = recAudioContext.createMediaStreamSource(candAudioStream); + candSource.connect(audioDestination); + } + + if (adminAudioTracks.length > 0) { + const adminAudioStream = new MediaStream(adminAudioTracks); + const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream); + adminSource.connect(audioDestination); + } + + const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0]; + if (mixedAudioTrack) { + streamToRecord.addTrack(mixedAudioTrack); + } + } catch(e) { + const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0]; + if (fallbackAudio) { + try { streamToRecord.addTrack(fallbackAudio); } catch(err) {} + } + } + } + + const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live'); + if (activeTracks.length === 0) { + alert('No active video/audio stream available to record. Please start or join the call first.'); + return; + } + + adminRecordedChunks = []; + try { + let options = {}; + if (typeof MediaRecorder.isTypeSupported === 'function') { + if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) { + options = { mimeType: 'video/webm;codecs=vp8,opus' }; + } else if (MediaRecorder.isTypeSupported('video/webm')) { + options = { mimeType: 'video/webm' }; + } + } + + try { + adminMediaRecorder = new MediaRecorder(streamToRecord, options); + } catch(errOpt) { + adminMediaRecorder = new MediaRecorder(streamToRecord); + } + + adminMediaRecorder.ondataavailable = function(e) { + if (e.data && e.data.size > 0) { + adminRecordedChunks.push(e.data); + } + }; + + adminMediaRecorder.onstop = function() { + if (recAudioContext) { + try { recAudioContext.close(); } catch(e) {} + recAudioContext = null; + } + if (adminRecordedChunks.length === 0) return; + const recMime = adminMediaRecorder.mimeType || 'video/webm'; + const isMp4 = recMime.includes('mp4'); + const ext = isMp4 ? 'mp4' : 'webm'; + const blobType = isMp4 ? 'video/mp4' : 'video/webm'; + + const blob = new Blob(adminRecordedChunks, { type: blobType }); + uploadRecordedBlob(blob, ext); + }; + + adminMediaRecorder.start(1000); + + const recStartBtn = document.getElementById('btn-start-recording'); + const recStopBtn = document.getElementById('btn-stop-recording'); + if (recStartBtn) recStartBtn.style.display = 'none'; + if (recStopBtn) recStopBtn.style.display = 'inline-flex'; + + if (typeof window.Swal !== 'undefined') { + window.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); + } +} + +export function stopCallRecording() { + if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { + adminMediaRecorder.stop(); + } + const recStartBtn = document.getElementById('btn-start-recording'); + const recStopBtn = document.getElementById('btn-stop-recording'); + if (recStartBtn) recStartBtn.style.display = 'inline-flex'; + if (recStopBtn) recStopBtn.style.display = 'none'; +} + +export function uploadRecordedBlob(blob, ext = 'webm') { + if (!currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + const formData = new FormData(); + formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`); + formData.append('_token', csrfToken); + + fetch(`/candidate/upload-recording/${currentInterviewId}`, { + method: 'POST', + body: formData + }) + .then(r => r.json()) + .then(res => { + if (res.success) { + if (typeof window.Swal !== 'undefined') { + window.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)); +} + +export function toggleInterviewerMic() { + if (!interviewerLocalStream) return; + const audioTracks = interviewerLocalStream.getAudioTracks(); + if (audioTracks.length === 0) return; + + isInterviewerMicOn = !isInterviewerMicOn; + audioTracks[0].enabled = isInterviewerMicOn; + + const btn = document.getElementById('btn-toggle-interviewer-mic'); + if (btn) { + const icon = btn.querySelector('i'); + if (icon) { + icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; + } + + btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)'; + + if (isInterviewerMicOn) { + btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + } else { + btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + } + } + + const selfMicIcon = document.getElementById('self-mic-status'); + if (selfMicIcon) { + selfMicIcon.className = isInterviewerMicOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; + selfMicIcon.title = isInterviewerMicOn ? 'Mic On' : 'Mic Muted'; + } + + sendInterviewerPeerHeartbeat(); +} + +export function toggleInterviewerCam() { + const selfPlaceholder = document.getElementById('self-video-placeholder'); + const selfVid = document.getElementById('interviewer-self-video'); + + isInterviewerCamOn = !isInterviewerCamOn; + + const btn = document.getElementById('btn-toggle-interviewer-cam'); + if (btn) { + const icon = btn.querySelector('i'); + if (icon) { + icon.className = isInterviewerCamOn ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'; + } + btn.title = isInterviewerCamOn ? 'Cam On (Click to Turn Off)' : 'Cam Off (Click to Turn On)'; + + if (isInterviewerCamOn) { + btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + } else { + btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); + btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); + } + } + + const selfCamIcon = document.getElementById('self-cam-status'); + if (selfCamIcon) { + selfCamIcon.className = isInterviewerCamOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]'; + selfCamIcon.title = isInterviewerCamOn ? 'Camera On' : 'Camera Off'; + } + + if (interviewerLocalStream) { + const videoTracks = interviewerLocalStream.getVideoTracks(); + if (videoTracks.length > 0) { + videoTracks[0].enabled = isInterviewerCamOn; + } + } else if (isInterviewerCamOn) { + navigator.mediaDevices.getUserMedia({ video: true, audio: false }) + .then(stream => { + interviewerLocalStream = stream; + if (selfVid) { + selfVid.srcObject = stream; + selfVid.play().catch(() => {}); + } + if (selfPlaceholder) selfPlaceholder.style.display = 'none'; + }) + .catch(() => {}); + sendInterviewerPeerHeartbeat(); + return; + } + + if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex'; + sendInterviewerPeerHeartbeat(); +} + +export function closeReviewModal() { + if (sosPollInterval) clearInterval(sosPollInterval); + if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer); + if (liveSyncChannel) { + try { liveSyncChannel.close(); } catch(e) {} + liveSyncChannel = null; + } + leaveInterviewerCall(false); + const modal = document.getElementById('review-modal'); + if (modal) modal.classList.remove('active'); +} + +export function sendSilentWarning() { + const input = document.getElementById('warning-input'); + if (!input) return; + const msg = input.value.trim(); + if (!msg || !currentInterviewId) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; + + input.value = ''; + if (liveSyncChannel) { + liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' }); + } + + fetch(`/interviews/${currentInterviewId}/warning`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': csrfToken + }, + body: JSON.stringify({ message: msg }) + }) + .then(r => r.json()) + .then(() => { + pollReviewData(); + }); +} + +export function setCurrentInterviewId(id) { + currentInterviewId = id; +} + +// Bind functions onto window object for template inline onclick handlers +if (typeof window !== 'undefined') { + window.CallRingtoneEngine = CallRingtoneEngine; + window.callRingtone = callRingtone; + window.getInitials = getInitials; + window.zoomScreen = zoomScreen; + window.resetZoomScreen = resetZoomScreen; + window.toggleFullscreenScreen = toggleFullscreenScreen; + window.zoomCandVideo = zoomCandVideo; + window.toggleSosAutoTrigger = toggleSosAutoTrigger; + window.openReportPage = openReportPage; + window.toggleTabScreenshotCapture = toggleTabScreenshotCapture; + window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI; + window.switchIdeTab = switchIdeTab; + window.triggerIncomingCallRing = triggerIncomingCallRing; + window.pollGlobalActiveCalls = pollGlobalActiveCalls; + window.acceptIncomingCall = acceptIncomingCall; + window.declineIncomingCall = declineIncomingCall; + window.subscribeLiveSync = subscribeLiveSync; + window.openReviewModal = openReviewModal; + window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat; + window.pollReviewData = pollReviewData; + window.openSosModal = openSosModal; + window.stopSosAlarm = stopSosAlarm; + window.disableSosAndStopAlarm = disableSosAndStopAlarm; + window.toggleSosAutoTrigger = toggleSosAutoTrigger; + window.interviewerLogoutAction = interviewerLogoutAction; + window.regenerateCandidatePassword = regenerateCandidatePassword; + window.blockCandidateAction = blockCandidateAction; + window.startInterviewerCall = startInterviewerCall; + window.leaveInterviewerCall = leaveInterviewerCall; + window.endCallForAll = endCallForAll; + window.startCallRecording = startCallRecording; + window.stopCallRecording = stopCallRecording; + window.toggleInterviewerMic = toggleInterviewerMic; + window.toggleInterviewerCam = toggleInterviewerCam; + window.closeReviewModal = closeReviewModal; + window.sendSilentWarning = sendSilentWarning; + window.setCurrentInterviewId = setCurrentInterviewId; + + // Attach listeners + ['click', 'keydown', 'touchstart'].forEach(evt => { + document.addEventListener(evt, () => callRingtone.init(), { passive: true }); + }); + + globalCallChannel = new BroadcastChannel('global_call_alerts'); + globalCallChannel.onmessage = (event) => { + const data = event.data; + if (!data) return; + + if (data.type === 'incoming_call') { + if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; + if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; + triggerIncomingCallRing({ + id: data.interview_id, + candidate_name: data.candidate_name, + submission_unique_id: data.submission_unique_id, + starter_name: data.starter_name, + call_started_at: data.call_started_at || new Date().toISOString() + }, true); + } else if (data.type === 'call_ended') { + endedCallIds.add(data.interview_id); + dismissedCallIds.add(data.interview_id); + callRingtone.stop(); + if (ringTimeoutTimer) { + clearTimeout(ringTimeoutTimer); + ringTimeoutTimer = null; + } + const modal = document.getElementById('incoming-call-modal'); + if (modal) modal.classList.remove('active'); + if (activeIncomingCall && activeIncomingCall.id == data.interview_id) { + activeIncomingCall = null; + } + if (currentInterviewId && currentInterviewId == data.interview_id) { + updateCandidateStatusIcons(false, false, false); + leaveInterviewerCall(false); + } + } + }; + + setInterval(pollGlobalActiveCalls, 6000); +} diff --git a/resources/js/metered.js b/resources/js/metered.js index fd3b02a..29437ce 100644 --- a/resources/js/metered.js +++ b/resources/js/metered.js @@ -3,14 +3,15 @@ */ let globalIceServers = [ - { urls: 'stun:stun.l.google.com:19302' } + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun.cloudflare.com:3478' }, ]; let meteredIceFetchPromise = null; /** * Fetch ICE servers from /ice-servers endpoint and merge with default STUN server. * Handles network errors gracefully with fallback. - * + * * @returns {Promise} Resolves with the array of ICE server objects. */ export function getMeteredIceServers() { @@ -32,15 +33,20 @@ export function getMeteredIceServers() { if (Array.isArray(meteredIce) && meteredIce.length > 0) { globalIceServers = [ { urls: 'stun:stun.l.google.com:19302' }, - ...meteredIce.slice(0, 3) + { urls: 'stun:stun.cloudflare.com:3478' }, + ...meteredIce ]; } - window.globalIceServers = globalIceServers; + if (typeof window !== 'undefined') { + window.globalIceServers = globalIceServers; + } return globalIceServers; }) .catch(e => { console.warn('Metered TURN fetch warning:', e); - window.globalIceServers = globalIceServers; + if (typeof window !== 'undefined') { + window.globalIceServers = globalIceServers; + } return globalIceServers; }); diff --git a/resources/views/components/audit-log-item.blade.php b/resources/views/components/audit-log-item.blade.php new file mode 100644 index 0000000..6278c89 --- /dev/null +++ b/resources/views/components/audit-log-item.blade.php @@ -0,0 +1,115 @@ +@props([ + 'type' => 'violation', + 'details' => '', + 'timestamp' => null, + 'screenshotUrl' => null, + 'aiVerdict' => null, +]) + +@php + $icoClass = 'amber'; + $faIcon = 'fa-solid fa-window-restore'; + $isFlag = false; + + if (in_array($type, ['focus_lost', 'tab_switch'])) { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore'; + } elseif (in_array($type, ['gaze_anomaly', 'gaze_fixed_staring'])) { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-eye'; + } elseif ($type === 'looking_away') { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-eye-slash'; + } elseif ($type === 'face_missing') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-user-slash'; $isFlag = true; + } elseif ($type === 'multiple_faces') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-users-viewfinder'; $isFlag = true; + } elseif ($type === 'prolonged_looking_away') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-clock-rotate-left'; $isFlag = true; + } elseif ($type === 'phone_detected') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen-button'; $isFlag = true; + } elseif ($type === 'prolonged_corner_gaze') { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-arrows-to-eye'; $isFlag = true; + } elseif ($type === 'talking_secondary_person') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-user-group'; $isFlag = true; + } elseif (in_array($type, ['gaze_lower_device', 'reading_external_device'])) { + $icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen'; $isFlag = true; + } elseif (in_array($type, ['question_repeat_lower', 'question_repetition'])) { + $icoClass = 'red'; $faIcon = 'fa-solid fa-comments'; $isFlag = true; + } elseif ($type === 'talking_on_phone') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-phone'; $isFlag = true; + } elseif ($type === 'external_ai_detected') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-robot'; $isFlag = true; + } elseif ($type === 'paste_event') { + $icoClass = 'red'; $faIcon = 'fa-solid fa-paste'; $isFlag = true; + } elseif ($type === 'copy_event') { + $icoClass = 'amber'; $faIcon = 'fa-solid fa-copy'; + } + + $titleCap = ucfirst(str_replace('_', ' ', $type)); + $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('h:i:s a') : now()->format('h:i:s a'); + + // Human readable details parsing + $detailsObj = is_array($details) ? $details : (is_string($details) && str_starts_with(trim($details), '{') ? json_decode($details, true) : null); + $humanDetails = ''; + + if ($type === 'multiple_faces') { + $count = (is_array($detailsObj) && isset($detailsObj['face_count'])) ? $detailsObj['face_count'] : 2; + $humanDetails = "Multiple faces detected in camera frame ({$count} faces)"; + } elseif ($type === 'face_missing') { + $humanDetails = 'Candidate face is not visible in camera frame'; + } elseif (in_array($type, ['looking_away', 'prolonged_looking_away'])) { + if (is_array($detailsObj) && (isset($detailsObj['reasons']) || isset($detailsObj['yaw']))) { + $reasonLabels = [ + 'head_yaw' => 'Head Yaw', + 'head_up' => 'Head Pitch Up', + 'head_down' => 'Head Pitch Down', + 'eye_gaze_left' => 'Eye Gaze Left', + 'eye_gaze_right' => 'Eye Gaze Right', + 'eye_gaze_down' => 'Eye Gaze Down' + ]; + $reasonsArr = isset($detailsObj['reasons']) && is_array($detailsObj['reasons']) ? array_map(fn($r) => $reasonLabels[$r] ?? str_replace('_', ' ', $r), $detailsObj['reasons']) : []; + $reasonsStr = implode(', ', $reasonsArr); + + $prefix = ($type === 'prolonged_looking_away') ? 'Looking away continuously (>10s)' : 'Looking away from camera'; + $humanDetails = $reasonsStr ? "{$prefix} ({$reasonsStr})" : $prefix; + } else { + $humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : ($type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera'); + } + } elseif ($type === 'phone_detected') { + $label = (is_array($detailsObj) && isset($detailsObj['label'])) ? $detailsObj['label'] : 'cell phone'; + $humanDetails = "Possible mobile phone detected in camera frame ({$label})"; + } elseif ($type === 'prolonged_corner_gaze') { + if (is_array($detailsObj) && (isset($detailsObj['corner_label']) || isset($detailsObj['corner']))) { + $cornerLabel = $detailsObj['corner_label'] ?? str_replace('_', ' ', $detailsObj['corner'] ?? 'screen corner'); + $dur = $detailsObj['duration_s'] ?? round(($detailsObj['duration_ms'] ?? 18000) / 1000); + $humanDetails = "Fixated on {$cornerLabel} for {$dur}s"; + } else { + $humanDetails = 'Candidate fixated on screen corner for prolonged period'; + } + } elseif ($type === 'talking_secondary_person') { + $humanDetails = is_string($details) && !str_starts_with(trim($details), '{') ? $details : 'Candidate detected speaking out loud to a secondary person'; + } else { + $humanDetails = is_string($details) ? $details : (is_array($details) ? json_encode($details) : (string)$details); + } +@endphp + +
merge(['class' => 'log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ' . ($isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : '')]) }}> +
+ +
+
+
+ {{ $titleCap }} — {{ $humanDetails }} + @if($screenshotUrl) + [ Screenshot] + @endif + @if($aiVerdict) + @php + $confPct = round(($aiVerdict['confidence'] ?? 0.85) * 100); + $engineName = $aiVerdict['engine'] ?? 'AI Engine'; + $toolName = !empty($aiVerdict['ai_tool_detected']) ? ' • Detected: ' . e($aiVerdict['ai_tool_detected']) . '' : ''; + @endphp +
AI inspector · {{ $confPct }}% cheating confidence ({!! $engineName . $toolName !!})
+ @endif +
+
{{ $timeStr }}
+
+
diff --git a/resources/views/components/audit-log.blade.php b/resources/views/components/audit-log.blade.php new file mode 100644 index 0000000..21000a0 --- /dev/null +++ b/resources/views/components/audit-log.blade.php @@ -0,0 +1,21 @@ +@props([ + 'logs' => [], +]) + +
merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] max-h-[260px] overflow-y-auto']) }}> + @if(is_array($logs) && count($logs) > 0) + @foreach($logs as $log) + + @endforeach + @else +
+ Zero proctoring violations recorded. +
+ @endif +
diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php new file mode 100644 index 0000000..a025502 --- /dev/null +++ b/resources/views/components/button.blade.php @@ -0,0 +1,41 @@ +@props([ + 'variant' => 'primary', // primary, secondary, danger, warning, success, ghost + 'size' => 'md', // sm, md, lg + 'type' => 'button', + 'icon' => null, +]) + +@php + $baseClasses = 'inline-flex items-center gap-1.5 rounded-lg border font-medium text-xs transition-all duration-150 cursor-pointer select-none whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed'; + + $variants = [ + 'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]', + 'secondary' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]', + 'primary' => 'bg-[#4b82f7] text-white border-[#4b82f7] hover:bg-[#3d6fe0] shadow-sm', + 'success' => 'bg-[#21c274] text-white border-[#21c274] hover:bg-[#1da865] shadow-sm', + 'danger' => 'bg-[#ef4a5f] text-white border-[#ef4a5f] hover:bg-[#d63d51] shadow-sm', + 'warning' => 'bg-[#f0a939] text-white border-[#f0a939] hover:bg-[#d8952d] shadow-sm', + 'ghost' => 'bg-transparent text-slate-400 border-transparent hover:bg-white/5 hover:text-slate-200', + ]; + + $sizes = [ + 'sm' => 'px-2.5 py-1.5 text-xs', + 'md' => 'px-3 py-1.5 text-[12.5px]', + 'lg' => 'px-4 py-2 text-sm', + ]; + + $classes = implode(' ', [ + $baseClasses, + $variants[$variant] ?? $variants['secondary'], + $sizes[$size] ?? $sizes['md'], + ]); +@endphp + + diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php new file mode 100644 index 0000000..00f9082 --- /dev/null +++ b/resources/views/components/card.blade.php @@ -0,0 +1,28 @@ +@props([ + 'title' => null, + 'icon' => null, +]) + +
merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}> + @if($title || $icon || isset($headerExtra)) +
+
+ @if($icon) + + @endif + @if($title) + {{ $title }} + @endif +
+ @if(isset($headerExtra)) +
+ {{ $headerExtra }} +
+ @endif +
+ @endif + +
+ {{ $slot }} +
+
diff --git a/resources/views/components/chat-message.blade.php b/resources/views/components/chat-message.blade.php new file mode 100644 index 0000000..b940239 --- /dev/null +++ b/resources/views/components/chat-message.blade.php @@ -0,0 +1,42 @@ +@props([ + 'senderName' => 'Interviewer', + 'isCandidate' => false, + 'color' => '#6366f1', + 'message' => '', + 'timestamp' => null, +]) + +@php + $resolvedColor = $isCandidate ? '#38bdf8' : ($color ?? '#6366f1'); + $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s'); + + // Hex to rgba helper for background opacity + $hex = ltrim($resolvedColor, '#'); + if (strlen($hex) == 3) { + $r = hexdec(substr($hex,0,1).substr($hex,0,1)); + $g = hexdec(substr($hex,1,1).substr($hex,1,1)); + $b = hexdec(substr($hex,2,1).substr($hex,2,1)); + } else { + $r = hexdec(substr($hex,0,2)); + $g = hexdec(substr($hex,2,2)); + $b = hexdec(substr($hex,4,2)); + } + $bgRgba = "rgba({$r}, {$g}, {$b}, 0.1)"; + $borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)"; +@endphp + +
merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};"> +
+
+ + {{ $senderName }} + @if($isCandidate) + Candidate + @else + Panelist + @endif +
+ {{ $timeStr }} +
+
{{ $message }}
+
diff --git a/resources/views/components/chat-panel.blade.php b/resources/views/components/chat-panel.blade.php new file mode 100644 index 0000000..c50b5b1 --- /dev/null +++ b/resources/views/components/chat-panel.blade.php @@ -0,0 +1,44 @@ +@props([ + 'warnings' => [], + 'candidateName' => 'Candidate', +]) + +@php + $interviewerColors = [ + '#6366f1', // Indigo + '#10b981', // Emerald + '#f59e0b', // Amber + '#ec4899', // Pink + '#8b5cf6', // Purple + '#06b6d4', // Cyan + '#f97316', // Orange + ]; +@endphp + +
merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2']) }}> + @if(count($warnings) > 0) + @foreach($warnings as $w) + @php + $isCand = is_null($w->sent_by); + $senderName = $isCand + ? ($candidateName ?: 'Candidate') + : ($w->sender ? $w->sender->name : 'Interviewer'); + + $color = $isCand + ? '#38bdf8' + : $interviewerColors[($w->sent_by ?? 1) % count($interviewerColors)]; + @endphp + + @endforeach + @else +
+ No chat history. Send a message below. +
+ @endif +
diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php new file mode 100644 index 0000000..acdabde --- /dev/null +++ b/resources/views/components/modal.blade.php @@ -0,0 +1,43 @@ +@props([ + 'id', + 'title' => null, + 'maxWidth' => '2xl', + 'onClose' => null, +]) + +@php + $maxWidths = [ + 'sm' => 'max-w-sm', + 'md' => 'max-w-md', + 'lg' => 'max-w-lg', + 'xl' => 'max-w-xl', + '2xl' => 'max-w-2xl', + '5xl' => 'max-w-5xl', + '7xl' => 'max-w-7xl', + 'full' => 'max-w-full mx-4', + ]; + + $maxWidthClass = $maxWidths[$maxWidth] ?? $maxWidths['2xl']; +@endphp + +
merge(['class' => 'admin-modal fixed inset-0 w-screen h-screen bg-slate-950/85 backdrop-blur-md flex items-center justify-center z-[1000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto']) }}> +
+ @if($title || isset($headerActions)) +
+ @if($title) +

{{ $title }}

+ @endif +
+ @if(isset($headerActions)) + {{ $headerActions }} + @endif + +
+
+ @endif + +
+ {{ $slot }} +
+
+
diff --git a/resources/views/components/pill.blade.php b/resources/views/components/pill.blade.php new file mode 100644 index 0000000..865b722 --- /dev/null +++ b/resources/views/components/pill.blade.php @@ -0,0 +1,41 @@ +@props([ + 'variant' => 'info', // primary, secondary, success, danger, warning, info, ghost + 'size' => 'md', // sm, md + 'icon' => null, +]) + +@php + $baseClasses = 'inline-flex items-center gap-1.5 font-semibold text-[11px] tracking-wide px-2.5 py-1 rounded-full border transition-colors duration-150 whitespace-nowrap'; + + $variants = [ + 'blue' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]', + 'primary' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]', + 'info' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]', + 'red' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]', + 'danger' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]', + 'success' => 'text-[#21c274] bg-[rgba(33,194,116,0.12)] border-[rgba(33,194,116,0.35)]', + 'warning' => 'text-[#f0a939] bg-[rgba(240,169,57,0.12)] border-[rgba(240,169,57,0.35)]', + 'secondary' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]', + 'ghost' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]', + ]; + + $sizes = [ + 'sm' => 'px-2 py-0.5 text-[10.5px]', + 'md' => 'px-2.5 py-1 text-[11px]', + ]; + + $classes = implode(' ', [ + $baseClasses, + $variants[$variant] ?? $variants['ghost'], + $sizes[$size] ?? $sizes['md'], + ]); +@endphp + +merge(['class' => $classes]) }}> + @if($icon) + + @endif + @if(trim($slot)) + {{ $slot }} + @endif + diff --git a/resources/views/components/tab-button.blade.php b/resources/views/components/tab-button.blade.php new file mode 100644 index 0000000..f9a6a31 --- /dev/null +++ b/resources/views/components/tab-button.blade.php @@ -0,0 +1,26 @@ +@props([ + 'id' => null, + 'active' => false, + 'icon' => null, + 'tabKey' => null, +]) + +@php + $baseClasses = 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md transition-colors duration-150 cursor-pointer select-none border-none'; + $activeClasses = $active + ? 'bg-[#4b82f7] text-white shadow-sm active' + : 'bg-transparent text-slate-400 hover:text-slate-200'; +@endphp + + diff --git a/resources/views/components/video-tile.blade.php b/resources/views/components/video-tile.blade.php new file mode 100644 index 0000000..38c3375 --- /dev/null +++ b/resources/views/components/video-tile.blade.php @@ -0,0 +1,68 @@ +@props([ + 'id', + 'videoId', + 'placeholderId', + 'badgeText' => '', + 'badgeId' => null, + 'micStatusId' => null, + 'camStatusId' => null, + 'borderColor' => 'border-[#1b2233]', + 'showZoom' => false, + 'showFullscreen' => false, + 'initials' => 'CD', + 'avatarCircleId' => null, + 'avatarNameId' => null, + 'avatarTitle' => 'Video Stream', + 'zoomInFn' => null, + 'zoomOutFn' => null, + 'zoomResetFn' => null, + 'fullscreenFn' => null, + 'containerId' => null, + 'focused' => false, +]) + +
merge(['class' => 'video-tile relative aspect-[16/11] bg-[#0d1220] border rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all ' . ($focused ? 'border-[rgba(75,130,247,0.35)] shadow-[0_0_0_1px_rgba(75,130,247,0.35)]' : 'border-[#1b2233]')]) }}> + @if($showZoom || $showFullscreen) +
+ @if($showZoom) + + + + @endif + @if($showFullscreen) + + @endif +
+ @endif + + @if($containerId) +
+ +
+ @else + + @endif + +
+
+ {{ $initials }} +
+
{{ $avatarTitle }}
+
+ {{ $slot->isNotEmpty() ? $slot : "Waiting for candidate…" }} +
+
+ + + {{ $badgeText }} + @if($micStatusId || $camStatusId) + + @if($micStatusId) + + @endif + @if($camStatusId) + + @endif + @endif + +
diff --git a/resources/views/dashboard.blade.php b/resources/views/dashboard.blade.php index b7f266e..e0132cf 100644 --- a/resources/views/dashboard.blade.php +++ b/resources/views/dashboard.blade.php @@ -1567,7 +1567,7 @@ function acceptIncomingCall() { if (activeIncomingCall) { const targetId = activeIncomingCall.id; activeIncomingCall = null; - window.location.href = "{{ route('interview.index') }}?open_interview=" + targetId + "&join_call=1"; + window.location.href = "/interviews/" + targetId + "?join_call=1"; } } diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index cd2ef7d..6f739a5 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -3,12 +3,14 @@ + Live Assessment Room - {{ $interview->candidate_name }} | SingleLogin @vite(['resources/css/app.css', 'resources/js/app.js']) + @@ -191,7 +193,10 @@ } - + + + +
@@ -298,22 +303,25 @@
- + -
- 📷 Candidate Camera +
+ Candidate (You) + | + +
- +
IV @@ -384,8 +392,8 @@
- -
+ +
@@ -418,1744 +426,5 @@
- - diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 4497b69..4d46feb 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -8,254 +8,87 @@ + - - - - - + + -