From 71b5c301137b679e8b7ad522cf7c7c7b845170af Mon Sep 17 00:00:00 2001 From: "kushal.saha" Date: Wed, 12 Aug 2026 10:56:49 +0000 Subject: [PATCH] fix: add candidate joining status, reverse video, refactor the candiate_room to have js bundled --- .gitignore | 2 + app/Http/Controllers/InterviewController.php | 29 +- resources/css/app.css | 15 + resources/js/app.js | 1 + resources/js/candidate-proctor.js | 652 ++++++- resources/js/candidate-room.js | 1090 +++++++++++ resources/js/interview-call.js | 216 +- .../views/components/audit-log-item.blade.php | 67 +- .../views/components/video-tile.blade.php | 4 +- .../views/interview/candidate_room.blade.php | 1732 +---------------- resources/views/interview/index.blade.php | 8 +- resources/views/interview/show.blade.php | 17 +- 12 files changed, 2020 insertions(+), 1813 deletions(-) create mode 100644 resources/js/candidate-room.js 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 af1db53..9dc2460 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -288,29 +288,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]); @@ -730,11 +742,16 @@ public function registerPeerHeartbeat(Request $request, $id) } if ($action !== 'leave') { + $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, ]; } 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 e7f6124..d3b3319 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -1,5 +1,6 @@ import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js'; import './interview-call.js'; +import './candidate-room.js'; import './candidate-proctor.js'; window.globalIceServers = globalIceServers; diff --git a/resources/js/candidate-proctor.js b/resources/js/candidate-proctor.js index 78faee5..ac648f2 100644 --- a/resources/js/candidate-proctor.js +++ b/resources/js/candidate-proctor.js @@ -1,17 +1,18 @@ /* * Candidate-side proctoring pipeline. - * MediaPipe runs locally; no camera frame is sent to the application server. - * Events are deliberately emitted as structured objects so the logger can be - * replaced by an API adapter later without changing detection code. + * 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. */ -// Load MediaPipe's published browser bundle directly. This avoids CDN -// transform endpoints that may return text/plain or a 404 through proxies. +// 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; @@ -26,13 +27,42 @@ const GAZE_HORIZONTAL_LEFT_THRESHOLD = 0.28; const GAZE_HORIZONTAL_RIGHT_THRESHOLD = 0.72; const GAZE_DOWN_THRESHOLD = 0.62; +// --- 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; +} + +// --- Structured Logging & Backend Reporting --- class StructuredEventLogger { constructor(sessionId) { this.sessionId = sessionId; this.events = []; } - record(type, confidence, details, snapshot) { + record(type, confidence, details, snapshot = null) { const event = { schema: 'candidate-proctoring/v1', session_id: this.sessionId, @@ -45,9 +75,49 @@ class StructuredEventLogger { }; this.events.push(event); console.log('[proctoring:event]', event); + + this.sendToServer(event); + return event; } + sendToServer(event) { + 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', @@ -62,6 +132,7 @@ class StructuredEventLogger { 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, }; @@ -70,22 +141,506 @@ class StructuredEventLogger { } } +export function logViolation(type, details, confidence = 1.0, snapshot = null) { + 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) return; + const callStatusBadge = document.getElementById('call-status-badge'); + const isCallActive = callStatusBadge ? callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED') : true; + if (!isCallActive) 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)); +} + +// --- 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]; + const settings = track.getSettings ? track.getSettings() : {}; + + // Enforce ENTIRE SCREEN share + if (settings.displaySurface && settings.displaySurface !== 'monitor') { + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'warning', + title: 'Entire Screen Share Required', + text: 'You selected a single tab or window. You MUST select "Entire Screen".', + confirmButtonText: 'Reshare Entire Screen', + confirmButtonColor: '#6366f1', + allowOutsideClick: false, + allowEscapeKey: false + }).then(() => { + track.stop(); + setTimeout(startScreenShare, 300); + }); + } else { + alert('Entire Screen share is required. Please select Entire Screen.'); + track.stop(); + setTimeout(startScreenShare, 300); + } + return; + } + + screenShareStream = stream; + const vidElem = getScreenShareVideoElement(); + vidElem.srcObject = stream; + vidElem.play().catch(() => {}); + + const btn = document.getElementById('share-screen-btn'); + if (btn) { + btn.innerText = '🟢 Entire Screen Sharing Active'; + btn.style.background = '#10b981'; + } + + beginNativePrompt(); + if (document.documentElement.requestFullscreen) { + document.documentElement.requestFullscreen() + .catch(() => {}) + .finally(() => { + endNativePrompt(); + armTrailingBuffer(1000); + }); + } + + const interviewId = document.body.dataset.interviewId; + if (typeof window.Peer !== 'undefined' && interviewId) { + const screenPeer = new window.Peer('cand_screen_' + interviewId + '_' + Date.now()); + screenPeer.on('open', () => { + screenPeer.call('interviewer_screen_' + interviewId, stream); + }); + } + + track.onended = () => { + screenShareStream = null; + if (btn) { + btn.innerText = '🖥️ Share Screen with Interviewer'; + btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)'; + } + logViolation('tab_switch', 'Candidate stopped screen sharing'); + }; + + initLiveOverlayDetector(); + }) + .catch(err => { + endNativePrompt(); + console.log('Screen sharing cancelled/denied:', err); + screenShareStream = null; + const btn = document.getElementById('share-screen-btn'); + if (btn) { + btn.innerText = '🖥️ Share Screen with Interviewer'; + btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)'; + } + }); +} + +export function initLiveOverlayDetector() { + 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() { + 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() { + // 1. Visibility Change (Tab Switched / Window Minimized) + document.addEventListener('visibilitychange', function () { + if (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 (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 (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 () { + logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)'); + }, true); + + document.addEventListener('copy', function () { + 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 (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 => { + 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 (document.pictureInPictureElement) { + logViolation('external_ai_detected', 'candidate might use external ai: External Picture-in-Picture floating AI window active'); + } + }, 6000); + + // 8. Fullscreen Exit Listener + document.addEventListener('fullscreenchange', function() { + if (isFocusSuppressed()) return; + if (!document.fullscreenElement) { + logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)'); + captureAndUploadTabScreenshot(); + } + }); +} + +// --- MediaPipe Proctoring Classes --- + class WarningPresenter { constructor() { this.element = null; } show(message) { - if (!this.element) { - this.element = document.createElement('div'); - this.element.setAttribute('role', 'status'); - this.element.style.cssText = 'position:fixed;top:70px;left:50%;transform:translateX(-50%);z-index:9999;padding:12px 24px;border-radius:12px;background:rgba(239,68,68,.95);color:#fff;font:700 14px Instrument Sans,sans-serif;box-shadow:0 10px 30px rgba(239,68,68,.45);pointer-events:none;'; - document.body.appendChild(this.element); - } - this.element.textContent = `Proctoring notice: ${message}`; - this.element.style.display = 'block'; - clearTimeout(this.hideTimer); - this.hideTimer = setTimeout(() => { this.element.style.display = 'none'; }, 2500); + // Candidate-side violation notifications removed per directive. + // All violations are silently logged and sent to the server. } } @@ -356,10 +911,6 @@ class CandidateProctoring { } async withAmdLoaderDisabled(callback) { - // Monaco's RequireJS loader sees MediaPipe's internally injected - // vision_wasm_internal.js and reports "one anonymous define". Keep - // the two loaders isolated only for model creation, then restore all - // globals so the editor continues to work normally. const amdGlobals = ['define', 'require', 'requirejs']; const saved = amdGlobals.map((name) => ({ name, value: globalThis[name] })); amdGlobals.forEach((name) => { @@ -429,7 +980,8 @@ class CandidateProctoring { this.lastStates[name] = active; if (active) { this.warning.show(message); - this.logger.record(name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces', confidence, details, this.snapshot.capture()); + const eventName = name === 'lookingAway' ? 'looking_away' : name === 'noFace' ? 'face_missing' : 'multiple_faces'; + this.logger.record(eventName, confidence, details, this.snapshot.capture()); } } @@ -459,25 +1011,53 @@ class CandidateProctoring { } } +// --- Candidate Proctoring Auto-Boot & Listener Registration --- function bootCandidateProctoring() { const video = document.getElementById('webcam'); const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas'); const sessionId = document.body.dataset.interviewId; - if (!video || !sessionId || !navigator.mediaDevices) return; - 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 }); + if (!sessionId) return; + + // 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 }); } - window.addEventListener('pagehide', () => proctor.stop(), { once: true }); + + // Initialize all candidate cheat detection event listeners & speech analysis + initCandidateProctoringListeners(); + initQuestionRepeatDetection(); } -if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true }); -else bootCandidateProctoring(); +// --- 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.startScreenShare = startScreenShare; + window.initLiveOverlayDetector = initLiveOverlayDetector; + window.initQuestionRepeatDetection = initQuestionRepeatDetection; + window.initCandidateProctoringListeners = initCandidateProctoringListeners; + + 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..e5991be --- /dev/null +++ b/resources/js/candidate-room.js @@ -0,0 +1,1090 @@ +/** + * 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'; + +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 latestCallStartedAt = null; +let hasCapturedCallStartScreenshot = false; +let candidateInterviewerTiles = new Map(); +let latestCallStatus = 'idle'; + +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() { + const interviewId = document.body.dataset.interviewId; + if (!interviewId) return; + + fetch(`/candidate/poll/${interviewId}`, { + 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; + const chatHistory = document.getElementById('candidate-chat-history'); + if (!chatHistory) return; + + if (data.status === 'blocked' || data.is_expired) { + window.location.reload(); + 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; + } + }) + .catch(() => {}); +} + +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(() => { + pollCandidateChat(); + }); +} + +// --- 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); + 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'; + } +} + +export function removeCandidateInterviewerTile(peerId) { + 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) { + badge.innerText = 'READY (Waiting for Interviewer)'; + badge.style.background = 'rgba(16,185,129,0.2)'; + badge.style.color = '#34d399'; + } + } +} + +export async function acceptCandidateCall() { + const interviewId = document.body.dataset.interviewId; + candidateDeclinedOrLeftCall = false; + + 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); } catch(e) {} + } + + if (callSigChannel && interviewId) { + callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: 'cand_' + interviewId }); + } + + if (pendingOffer) { + await handleInterviewerOffer(pendingOffer); + pendingOffer = null; + } + + if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) { + hasCapturedCallStartScreenshot = true; + setTimeout(() => { + window.captureAndUploadTabScreenshot('call_start'); + }, 1200); + } +} + +export function declineCandidateCall() {} + +export function candidateLeaveCall(isEndedByHost = false) { + isCandidateCallConnected = false; + candidateDeclinedOrLeftCall = true; + + if (window.callRingtone) window.callRingtone.stop(); + + if (activeCandidateCallInstance) { + try { activeCandidateCallInstance.close(); } catch(e) {} + activeCandidateCallInstance = null; + } + if (candidatePeerConnection) { + try { candidatePeerConnection.close(); } catch(e) {} + candidatePeerConnection = null; + } + + 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 globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }]; + candidatePeerConnection = new RTCPeerConnection({ iceServers: globalIceServers }); + + 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 async function initCandidatePeer() { + const interviewId = document.body.dataset.interviewId; + const candName = document.body.dataset.candidateName || 'Candidate'; + if (!interviewId || typeof window.Peer === 'undefined') return; + + const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }]; + const peerId = 'cand_' + interviewId; + if (candidatePeerInstance) { + try { candidatePeerInstance.destroy(); } catch(e) {} + } + + candidatePeerInstance = new window.Peer(peerId, { + config: { iceServers: globalIceServers } + }); + + candidatePeerInstance.on('open', id => { + console.log('Candidate WebRTC Peer Registered:', id); + if (callSigChannel) { + callSigChannel.postMessage({ + type: 'peer_joined', + peer_id: id, + role: 'candidate', + candidate_name: candName + }); + } + }); + + candidatePeerInstance.on('call', call => { + activeCandidateCallInstance = call; + latestCallStatus = 'active'; + + const sendStream = candidateLocalStream || new MediaStream(); + try { call.answer(sendStream); } catch(e) {} + + call.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist'); + }); + + call.on('close', () => { + removeCandidateInterviewerTile(call.peer); + }); + }); + + candidatePeerInstance.on('error', async err => { + console.warn('Candidate PeerJS error:', err); + if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') { + if (candidatePeerInstance) { + try { candidatePeerInstance.destroy(); } catch(e) {} + } + candidatePeerInstance = new window.Peer(peerId, { + config: { iceServers: globalIceServers } + }); + } + }); +} + +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) { + 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) callSigChannel.postMessage({ type: 'candidate_ready' }); + 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 = 'cand_' + interviewId; + 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: action + }) + }) + .then(r => r.ok ? r.json() : null) + .then(data => { + if (data && 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); + + addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, nameLabel, isMicOn, isCamOn); + } + }); + } + }) + .catch(() => {}); + + if (callSigChannel) { + 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 === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') { + latestCallStatus = 'active'; + if (msg.peer_id && msg.peer_id.startsWith('interviewer_')) { + const isMicOn = (msg.mic_on !== undefined) ? Boolean(msg.mic_on) : true; + const isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true; + const existingStream = candidateInterviewerTiles.get(msg.peer_id) || null; + addOrUpdateCandidateInterviewerTile(msg.peer_id, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn); + + if (candidateLocalStream && candidatePeerInstance && !existingStream) { + try { + const outCall = candidatePeerInstance.call(msg.peer_id, candidateLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(msg.peer_id, 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 (!isCandidateCallConnected) acceptCandidateCall(); + } 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(); + + // Chat polling + setInterval(pollCandidateChat, 600); + pollCandidateChat(); + + // Heartbeat & Polling + if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer); + sendCandidatePeerHeartbeat(); + candidateHeartbeatIntervalTimer = setInterval(() => { + sendCandidatePeerHeartbeat(); + }, 6000); + + setInterval(() => { + if (!interviewId) return; + sendCandidatePeerHeartbeat('heartbeat'); + + fetch('/candidate/poll/' + interviewId, { + headers: { 'Accept': 'application/json' } + }) + .then(r => r.json()) + .then(data => { + if (!data) return; + + if (data.active_peers && Array.isArray(data.active_peers)) { + const activePeerIds = new Set(data.active_peers.map(p => p.peer_id)); + + 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 || '')); + 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); + }); + } + } catch(e) {} + } + } + } + }); + + candidateInterviewerTiles.forEach((_, pid) => { + if (!activePeerIds.has(pid)) { + removeCandidateInterviewerTile(pid); + } + }); + } + + if (data.call_status === 'active') { + latestCallStatus = 'active'; + if (data.call_started_at) latestCallStartedAt = data.call_started_at; + + if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) { + hasCapturedCallStartScreenshot = true; + setTimeout(() => { + window.captureAndUploadTabScreenshot('call_start'); + }, 1200); + } + + if (!isCandidateCallConnected && candidateLocalStream) { + acceptCandidateCall(); + } + } else if (data.call_status === 'ended' || data.call_status === 'idle') { + latestCallStatus = data.call_status; + hasCapturedCallStartScreenshot = false; + candidateDeclinedOrLeftCall = false; + isCandidateCallConnected = false; + + const badge = document.getElementById('call-status-badge'); + if (badge) { + 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'; + } + } + }) + .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.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 index b8a7014..8d6faa9 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -96,7 +96,7 @@ let sosPollInterval = null; let peerHeartbeatTimer = null; let acknowledgedViolationCount = 0; let sosDismissed = false; -let sosAutoTriggerDisabled = false; +let sosAutoTriggerDisabled = true; let currentTabScreenshotEnabled = true; let interviewerSigChannel = null; let interviewerPeerConnection = null; @@ -108,6 +108,68 @@ 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 parts = []; + if (reasonsStr) parts.push(reasonsStr); + if (typeof detailsObj.yaw === 'number') parts.push(`Yaw: ${detailsObj.yaw > 0 ? '+' : ''}${detailsObj.yaw.toFixed(2)}`); + if (typeof detailsObj.pitch === 'number') parts.push(`Pitch: ${detailsObj.pitch > 0 ? '+' : ''}${detailsObj.pitch.toFixed(2)}`); + if (typeof detailsObj.gaze_horizontal === 'number') parts.push(`Gaze H: ${detailsObj.gaze_horizontal.toFixed(2)}`); + if (typeof detailsObj.gaze_vertical === 'number') parts.push(`Gaze V: ${detailsObj.gaze_vertical.toFixed(2)}`); + + const durationPrefix = (type === 'prolonged_looking_away') ? 'Looking away continuously (>10s) — ' : ''; + return `${durationPrefix}${parts.join(' | ')}`; + } 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 === '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(); @@ -157,7 +219,7 @@ export function zoomCandVideo(delta) { 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})`; + vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`; vid.style.transformOrigin = 'center center'; vid.style.transition = 'transform 0.2s ease'; } @@ -452,10 +514,15 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) { currentInterviewId = id; acknowledgedViolationCount = 0; sosDismissed = false; - sosAutoTriggerDisabled = false; const toggleSw = document.getElementById('sosToggle'); - if (toggleSw) toggleSw.classList.add('on'); + 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'); @@ -522,7 +589,7 @@ function isTrueVal(val) { return Boolean(val); } -export function updateCandidateStatusIcons(isMicOn, isCamOn) { +export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { const micOn = isTrueVal(isMicOn); const camOn = isTrueVal(isCamOn); @@ -533,6 +600,54 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn) { const candVid = document.getElementById('interviewer-cand-video'); const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); + const candVidWrapper = document.getElementById('cand-video-wrapper'); + const screenWrapper = document.getElementById('screen-wrapper'); + const joinedPill = document.getElementById('candidate-joined-pill'); + + if (!isJoined) { + 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'; + } + return; + } + + // 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'; @@ -549,7 +664,7 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn) { candPlaceholder.style.display = 'none'; } else { candPlaceholder.style.display = 'flex'; - if (candStatusText) candStatusText.innerHTML = 'Waiting for candidate…'; + if (candStatusText) candStatusText.innerText = 'Waiting for candidate camera stream...'; } } } @@ -575,17 +690,22 @@ export function pollReviewData() { const currentUserId = window.currentUserId || 0; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; + const candidatePeer = (data.active_peers && Array.isArray(data.active_peers)) + ? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_'))) + : null; + + if (candidatePeer) { + const isMicOn = isTrueVal(candidatePeer.mic_on); + const isCamOn = isTrueVal(candidatePeer.cam_on); + 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 => { - const isCandPeer = p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_')); - if (isCandPeer) { - const isMicOn = isTrueVal(p.mic_on); - const isCamOn = isTrueVal(p.cam_on); - updateCandidateStatusIcons(isMicOn, isCamOn); - } - if (p.peer_id && p.peer_id !== myPeerId) { let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); const isMicOn = isTrueVal(p.mic_on); @@ -707,12 +827,12 @@ export function pollReviewData() { let chatHtml = ''; data.warnings.forEach(w => { const isCand = (w.sent_by === null || w.sent_by === undefined); - const senderName = isCand - ? (data.candidate_name || 'Candidate') + const senderName = isCand + ? (data.candidate_name || 'Candidate') : (w.sender ? w.sender.name : 'Interviewer'); - - const color = isCand - ? '#38bdf8' + + const color = isCand + ? '#38bdf8' : interviewerColors[(w.sent_by || 1) % interviewerColors.length]; const badgeLabel = isCand ? 'Candidate' : 'Panelist'; @@ -861,19 +981,29 @@ export function pollReviewData() { 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 === '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', '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'].includes(l.type)) { + 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', 'phone_detected'].includes(l.type)) { currentViolations.push(l); } @@ -890,14 +1020,16 @@ export function pollReviewData() { 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} — ${l.details || ''}${screenshotLink}${aiBadge}
-
${new Date(l.timestamp).toLocaleTimeString()}
+
${titleCap} — ${humanDetails}${screenshotLink}${aiBadge}
+
${timeString}
`; }); @@ -937,21 +1069,33 @@ export function openSosModal(specificViolations = null) { 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'; - } else if (v.type === 'gaze_anomaly' || v.type === 'gaze_lower_device') { - typeTitle = 'Candidate Lower Eye Gaze Detected'; } - const borderCol = v.type === 'external_ai_detected' ? 'border-red-500' : 'border-amber-500'; - const textCol = v.type === 'external_ai_detected' ? 'text-red-300' : 'text-amber-300'; + 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}: ${v.details || 'Suspicious activity detected.'} -
Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}
+ 🚨 ${typeTitle}: ${vDetails} +
Timestamp: ${vTime}
`; }); } else { @@ -974,11 +1118,23 @@ export function stopSosAlarm() { 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') { @@ -1125,7 +1281,7 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn 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} @@ -1896,6 +2052,8 @@ if (typeof window !== 'undefined') { window.pollReviewData = pollReviewData; window.openSosModal = openSosModal; window.stopSosAlarm = stopSosAlarm; + window.disableSosAndStopAlarm = disableSosAndStopAlarm; + window.toggleSosAutoTrigger = toggleSosAutoTrigger; window.interviewerLogoutAction = interviewerLogoutAction; window.regenerateCandidatePassword = regenerateCandidatePassword; window.blockCandidateAction = blockCandidateAction; diff --git a/resources/views/components/audit-log-item.blade.php b/resources/views/components/audit-log-item.blade.php index f86ee70..ac5bdfa 100644 --- a/resources/views/components/audit-log-item.blade.php +++ b/resources/views/components/audit-log-item.blade.php @@ -11,10 +11,22 @@ $faIcon = 'fa-solid fa-window-restore'; $isFlag = false; - if ($type === 'focus_lost') { + 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 === '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'])) { @@ -23,12 +35,57 @@ $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 === 'tab_switch') { - $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore'; + } 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') : now()->format('H:i:s'); + $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); + + $parts = []; + if ($reasonsStr) $parts[] = $reasonsStr; + if (isset($detailsObj['yaw']) && is_numeric($detailsObj['yaw'])) $parts[] = 'Yaw: ' . ($detailsObj['yaw'] > 0 ? '+' : '') . number_format((float)$detailsObj['yaw'], 2); + if (isset($detailsObj['pitch']) && is_numeric($detailsObj['pitch'])) $parts[] = 'Pitch: ' . ($detailsObj['pitch'] > 0 ? '+' : '') . number_format((float)$detailsObj['pitch'], 2); + if (isset($detailsObj['gaze_horizontal']) && is_numeric($detailsObj['gaze_horizontal'])) $parts[] = 'Gaze H: ' . number_format((float)$detailsObj['gaze_horizontal'], 2); + if (isset($detailsObj['gaze_vertical']) && is_numeric($detailsObj['gaze_vertical'])) $parts[] = 'Gaze V: ' . number_format((float)$detailsObj['gaze_vertical'], 2); + + $prefix = ($type === 'prolonged_looking_away') ? 'Looking away continuously (>10s) — ' : ''; + $humanDetails = $prefix . implode(' | ', $parts); + } 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 === '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' : '')]) }}> @@ -37,7 +94,7 @@
- {{ $titleCap }} — {{ $details }} + {{ $titleCap }} — {{ $humanDetails }} @if($screenshotUrl) [ Screenshot] @endif diff --git a/resources/views/components/video-tile.blade.php b/resources/views/components/video-tile.blade.php index bf7b7b5..e420c61 100644 --- a/resources/views/components/video-tile.blade.php +++ b/resources/views/components/video-tile.blade.php @@ -37,10 +37,10 @@ @if($containerId)
- +
@else - + @endif
diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index c5c0e81..40d5330 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -3,6 +3,7 @@ + Live Assessment Room - {{ $interview->candidate_name }} | SingleLogin @vite(['resources/css/app.css', 'resources/js/app.js']) @@ -192,7 +193,10 @@ } - + + + +
@@ -299,7 +303,7 @@
- +