diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php
index c9c08fa..af1db53 100644
--- a/app/Http/Controllers/InterviewController.php
+++ b/app/Http/Controllers/InterviewController.php
@@ -31,6 +31,23 @@ public function index()
return view('interview.index', compact('interviews', 'interviewers'));
}
+ /**
+ * Display a specific Candidate Interview Live Review & Call Session.
+ */
+ public function show($id)
+ {
+ $user = Auth::user();
+
+ if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) {
+ return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
+ }
+
+ $interview = Interview::findOrFail($id);
+ $interviewers = User::orderBy('name', 'asc')->get();
+
+ return view('interview.show', compact('interview', 'interviewers'));
+ }
+
/**
* Store a new Candidate Interview session created by HR.
*/
diff --git a/resources/js/app.js b/resources/js/app.js
index 5c60800..6df38d5 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,4 +1,5 @@
import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js';
+import './interview-call.js';
window.globalIceServers = globalIceServers;
window.getMeteredIceServers = getMeteredIceServers;
@@ -8,3 +9,4 @@ window.initMeteredIceServers = initMeteredIceServers;
if (typeof window !== 'undefined') {
window.getMeteredIceServers();
}
+
diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js
new file mode 100644
index 0000000..898c5b8
--- /dev/null
+++ b/resources/js/interview-call.js
@@ -0,0 +1,1833 @@
+/**
+ * Interview Call & Live Assessment Review Engine
+ * Handles WebRTC 2-Way Calling, Candidate Screen Sharing, Real-Time Sync,
+ * Proctoring Alerts, SOS Audio Ringtone, Tab Switching, and Silent Call Recording.
+ */
+
+// --- Global Audio Ringtone Synthesizer Engine ---
+export class CallRingtoneEngine {
+ constructor() {
+ this.ctx = null;
+ this.interval = null;
+ this.isPlaying = false;
+ }
+
+ init() {
+ try {
+ if (!this.ctx) {
+ const AudioCtx = window.AudioContext || window.webkitAudioContext;
+ this.ctx = new AudioCtx();
+ }
+ if (this.ctx && this.ctx.state === 'suspended') {
+ this.ctx.resume();
+ }
+ } catch (e) {
+ console.log('Audio init warning:', e);
+ }
+ }
+
+ start() {
+ this.init();
+ if (this.isPlaying) return;
+ this.isPlaying = true;
+
+ this.playTone();
+ if (this.interval) clearInterval(this.interval);
+ this.interval = setInterval(() => {
+ if (this.isPlaying) this.playTone();
+ }, 2400);
+ }
+
+ playTone() {
+ this.init();
+ if (!this.ctx || !this.isPlaying) return;
+ try {
+ const now = this.ctx.currentTime;
+ const osc1 = this.ctx.createOscillator();
+ const osc2 = this.ctx.createOscillator();
+ const gain = this.ctx.createGain();
+
+ osc1.type = 'sine';
+ osc2.type = 'sine';
+
+ osc1.frequency.setValueAtTime(523.25, now);
+ osc2.frequency.setValueAtTime(659.25, now);
+
+ gain.gain.setValueAtTime(0.001, now);
+ gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
+ gain.gain.setValueAtTime(0.35, now + 1.1);
+ gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
+
+ osc1.connect(gain);
+ osc2.connect(gain);
+ gain.connect(this.ctx.destination);
+
+ osc1.start(now);
+ osc2.start(now);
+
+ osc1.stop(now + 1.35);
+ osc2.stop(now + 1.35);
+ } catch (e) {}
+ }
+
+ stop() {
+ this.isPlaying = false;
+ if (this.interval) {
+ clearInterval(this.interval);
+ this.interval = null;
+ }
+ }
+}
+
+export const callRingtone = new CallRingtoneEngine();
+
+// State variables
+let currentInterviewId = null;
+let interviewerPeer = null;
+let screenReceiverPeer = null;
+let interviewerLocalStream = null;
+let activeCall = null;
+let isInterviewerMicOn = true;
+let isInterviewerCamOn = true;
+let screenZoomLevel = 1.0;
+let candVidZoomLevel = 1.0;
+let activeViolations = [];
+let sosPollInterval = null;
+let acknowledgedViolationCount = 0;
+let sosDismissed = false;
+let sosAutoTriggerDisabled = false;
+let currentTabScreenshotEnabled = true;
+let interviewerSigChannel = null;
+let interviewerPeerConnection = null;
+let liveSyncChannel = null;
+let panelistMeshTiles = new Map();
+
+// Ringing & Call state
+let activeIncomingCall = null;
+let iAmTheCaller = false;
+let ringTimeoutTimer = null;
+const dismissedCallIds = new Set();
+const endedCallIds = new Set();
+let globalCallChannel = null;
+const adminPageLoadTimestamp = Date.now();
+
+let globalIceServers = window.globalIceServers || [
+ { urls: 'stun:stun.l.google.com:19302' }
+];
+
+export function getInitials(name) {
+ if (!name) return 'CD';
+ const parts = name.trim().split(/\s+/);
+ if (parts.length >= 2) {
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+ }
+ const str = name.trim();
+ if (str.length >= 2) {
+ return (str[0] + str[str.length - 1]).toUpperCase();
+ }
+ return str.toUpperCase();
+}
+
+export function zoomScreen(delta) {
+ screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta));
+ const screenVid = document.getElementById('interviewer-screen-video');
+ if (screenVid) {
+ screenVid.style.transform = `scale(${screenZoomLevel})`;
+ }
+}
+
+export function resetZoomScreen() {
+ screenZoomLevel = 1.0;
+ zoomScreen(0);
+}
+
+export function toggleFullscreenScreen() {
+ const wrapper = document.getElementById('screen-wrapper');
+ if (!wrapper) return;
+ if (!document.fullscreenElement) {
+ wrapper.requestFullscreen().catch(err => console.log(err));
+ } else {
+ document.exitFullscreen();
+ }
+}
+
+export function zoomCandVideo(delta) {
+ if (delta === 0) candVidZoomLevel = 1.0;
+ else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta));
+ const vid = document.getElementById('interviewer-cand-video');
+ if (vid) {
+ vid.style.transform = `scale(${candVidZoomLevel})`;
+ vid.style.transformOrigin = 'center center';
+ vid.style.transition = 'transform 0.2s ease';
+ }
+}
+
+export function toggleSosAutoTrigger() {
+ sosAutoTriggerDisabled = !sosAutoTriggerDisabled;
+ const toggleSw = document.getElementById('sosToggle');
+ const btn = document.getElementById('sos-toggle-btn');
+
+ if (toggleSw) {
+ if (sosAutoTriggerDisabled) {
+ toggleSw.classList.remove('on');
+ } else {
+ toggleSw.classList.add('on');
+ }
+ }
+ if (btn) {
+ if (sosAutoTriggerDisabled) {
+ btn.innerHTML = ' SOS Auto-Trigger: OFF';
+ btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300';
+ } else {
+ btn.innerHTML = ' SOS Auto-Trigger: ON';
+ btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300';
+ }
+ }
+}
+
+export function openReportPage() {
+ if (!currentInterviewId) return;
+ window.open(`/interviews/${currentInterviewId}/report`, '_blank');
+}
+
+export function toggleTabScreenshotCapture() {
+ if (!currentInterviewId) return;
+ const newStatus = !currentTabScreenshotEnabled;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+ fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ },
+ body: JSON.stringify({ enable: newStatus })
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) {
+ currentTabScreenshotEnabled = res.enable_tab_switch_screenshot;
+ updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ icon: 'info',
+ title: 'Screenshot Setting Updated',
+ text: res.message,
+ timer: 2500,
+ showConfirmButton: false,
+ toast: true,
+ position: 'top-end'
+ });
+ }
+ }
+ })
+ .catch(() => {});
+}
+
+export function updateScreenshotToggleBtnUI(enabled) {
+ const btn = document.getElementById('modal-toggle-screenshot-btn');
+ if (btn) {
+ if (enabled) {
+ btn.innerHTML = ' ENABLED';
+ btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none';
+ } else {
+ btn.innerHTML = ' DISABLED';
+ btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none';
+ }
+ }
+}
+
+export function switchIdeTab(tab) {
+ const tabs = ['code', 'notes', 'drawing', 'recordings', 'screenshots'];
+ tabs.forEach(t => {
+ const contentEl = document.getElementById(`tab-content-${t}`);
+ const btnEl = document.getElementById(`tab-btn-${t}`);
+ if (contentEl) {
+ contentEl.style.display = (t === tab) ? 'block' : 'none';
+ }
+ if (btnEl) {
+ if (t === tab) {
+ btnEl.classList.add('bg-indigo-600', 'text-white', 'border-indigo-500');
+ btnEl.classList.remove('bg-white/5', 'text-slate-400', 'border-slate-700/60');
+ } else {
+ btnEl.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-500');
+ btnEl.classList.add('bg-white/5', 'text-slate-400', 'border-slate-700/60');
+ }
+ }
+ });
+}
+
+export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
+ if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return;
+ if (iAmTheCaller && currentInterviewId == callData.id) return;
+ if (interviewerLocalStream && currentInterviewId == callData.id) return;
+
+ if (callData.call_started_at) {
+ const startedTime = new Date(callData.call_started_at).getTime();
+ const elapsedMs = Date.now() - startedTime;
+ if (elapsedMs > 8000 && !isRealtimeBroadcast) {
+ dismissedCallIds.add(callData.id);
+ return;
+ }
+ } else if (!isRealtimeBroadcast) {
+ dismissedCallIds.add(callData.id);
+ return;
+ }
+
+ activeIncomingCall = callData;
+ const nameEl = document.getElementById('incoming-cand-name');
+ const infoEl = document.getElementById('incoming-starter-info');
+ const modalEl = document.getElementById('incoming-call-modal');
+ if (nameEl) nameEl.innerText = callData.candidate_name;
+ if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
+ if (modalEl) modalEl.classList.add('active');
+ callRingtone.start();
+
+ if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer);
+ let remainingMs = 8000;
+ if (callData.call_started_at) {
+ const startedTime = new Date(callData.call_started_at).getTime();
+ const elapsedMs = Date.now() - startedTime;
+ remainingMs = Math.max(1000, 8000 - elapsedMs);
+ }
+
+ ringTimeoutTimer = setTimeout(() => {
+ declineIncomingCall(true);
+ }, remainingMs);
+}
+
+export function pollGlobalActiveCalls() {
+ fetch('/interviews/active-calls', {
+ headers: { 'Accept': 'application/json' }
+ })
+ .then(r => r.json())
+ .then(data => {
+ if (!data || !data.active_calls || data.active_calls.length === 0) {
+ if (activeIncomingCall) declineIncomingCall(true);
+ return;
+ }
+
+ const incoming = data.active_calls.find(call => {
+ if (endedCallIds.has(call.id)) return false;
+ if (iAmTheCaller && currentInterviewId == call.id) return false;
+ if (interviewerLocalStream && currentInterviewId == call.id) return false;
+ if (dismissedCallIds.has(call.id)) return false;
+ return true;
+ });
+
+ if (incoming) {
+ const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0;
+ if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) {
+ dismissedCallIds.add(incoming.id);
+ return;
+ }
+ if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
+ triggerIncomingCallRing(incoming, false);
+ }
+ } else if (activeIncomingCall) {
+ declineIncomingCall(true);
+ }
+ })
+ .catch(() => {});
+}
+
+export function acceptIncomingCall() {
+ callRingtone.stop();
+ if (ringTimeoutTimer) {
+ clearTimeout(ringTimeoutTimer);
+ ringTimeoutTimer = null;
+ }
+ const modal = document.getElementById('incoming-call-modal');
+ if (modal) modal.classList.remove('active');
+
+ if (activeIncomingCall) {
+ const targetCall = activeIncomingCall;
+ activeIncomingCall = null;
+ if (window.location.pathname.includes('/interviews/' + targetCall.id)) {
+ startInterviewerCall(true);
+ } else {
+ window.location.href = `/interviews/${targetCall.id}?join_call=1`;
+ }
+ }
+}
+
+export function declineIncomingCall(silent = false) {
+ callRingtone.stop();
+ if (ringTimeoutTimer) {
+ clearTimeout(ringTimeoutTimer);
+ ringTimeoutTimer = null;
+ }
+ const modal = document.getElementById('incoming-call-modal');
+ if (modal) modal.classList.remove('active');
+
+ if (activeIncomingCall) {
+ dismissedCallIds.add(activeIncomingCall.id);
+ activeIncomingCall = null;
+ }
+
+ if (!silent && typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ icon: 'info',
+ title: 'Call Missed / Declined',
+ text: 'You can click "Join Active Call" from the list whenever you are ready.',
+ timer: 3000,
+ showConfirmButton: false,
+ toast: true,
+ position: 'top-end'
+ });
+ }
+}
+
+export function subscribeLiveSync(interviewId) {
+ if (liveSyncChannel) {
+ try { liveSyncChannel.close(); } catch(e) {}
+ }
+ liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
+ liveSyncChannel.onmessage = function(event) {
+ const data = event.data;
+ if (!data) return;
+
+ if (data.type === 'code_sync') {
+ if (data.language) {
+ const langEl = document.getElementById('modal-code-lang');
+ if (langEl) langEl.innerText = data.language.toUpperCase();
+ }
+ if (data.code !== undefined) {
+ const codeEl = document.getElementById('modal-code-display');
+ if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.';
+ }
+ } else if (data.type === 'notes_sync') {
+ if (data.notes !== undefined) {
+ const notesEl = document.getElementById('modal-notes-display');
+ if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.';
+ }
+ } else if (data.type === 'drawing_sync') {
+ const drawingImg = document.getElementById('modal-drawing-img');
+ const noDrawing = document.getElementById('no-drawing-placeholder');
+ if (data.drawing) {
+ if (drawingImg) {
+ drawingImg.src = data.drawing;
+ drawingImg.style.display = 'block';
+ }
+ if (noDrawing) noDrawing.style.display = 'none';
+ } else {
+ if (drawingImg) drawingImg.style.display = 'none';
+ if (noDrawing) noDrawing.style.display = 'block';
+ }
+ } else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') {
+ pollReviewData();
+ }
+ };
+
+ if (interviewerSigChannel) {
+ try { interviewerSigChannel.close(); } catch(e) {}
+ }
+ interviewerSigChannel = new BroadcastChannel('interview_call_' + interviewId);
+ interviewerSigChannel.onmessage = function(event) {
+ const data = event.data;
+ if (!data) return;
+ if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') {
+ setTimeout(pollReviewData, 200);
+ }
+ };
+}
+
+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');
+
+ const candNameEl = document.getElementById('modal-cand-name');
+ const candUidEl = document.getElementById('modal-cand-uid');
+ if (candNameEl) candNameEl.innerText = name;
+ if (candUidEl) candUidEl.innerText = 'Unique Submission ID: ' + uid;
+
+ const candInitials = getInitials(name);
+ const avatarCircle = document.getElementById('modal-cand-avatar-circle');
+ const avatarName = document.getElementById('cand-avatar-name');
+ if (avatarCircle) avatarCircle.innerText = candInitials;
+ if (avatarName) avatarName.innerText = name;
+
+ const reviewModal = document.getElementById('review-modal');
+ if (reviewModal) reviewModal.classList.add('active');
+
+ resetZoomScreen();
+ switchIdeTab('code');
+
+ subscribeLiveSync(id);
+ pollReviewData();
+ if (sosPollInterval) clearInterval(sosPollInterval);
+ sosPollInterval = setInterval(pollReviewData, 2500);
+
+ if (autoJoinCall) {
+ setTimeout(() => {
+ startInterviewerCall(true);
+ }, 400);
+ }
+}
+
+export function sendInterviewerPeerHeartbeat(action = 'heartbeat') {
+ if (!currentInterviewId) return;
+ const currentUserId = window.currentUserId || 0;
+ const currentUserName = window.currentUserName || 'Interviewer';
+ const currentUserRole = window.currentUserRole || 'interviewer';
+ const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+
+ fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ },
+ body: JSON.stringify({
+ peer_id: myPeerId,
+ name: currentUserName,
+ role: currentUserRole,
+ action: action
+ })
+ }).catch(() => {});
+}
+
+export function pollReviewData() {
+ if (!currentInterviewId) return;
+ if (interviewerLocalStream) {
+ sendInterviewerPeerHeartbeat('heartbeat');
+ }
+
+ fetch(`/interviews/${currentInterviewId}/poll`, {
+ headers: { 'Accept': 'application/json' }
+ })
+ .then(r => {
+ if (!r.ok) return null;
+ const contentType = r.headers.get('content-type');
+ if (!contentType || !contentType.includes('application/json')) return null;
+ return r.json();
+ })
+ .then(data => {
+ if (!data) return;
+
+ const currentUserId = window.currentUserId || 0;
+ const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
+
+ if (data.active_peers && Array.isArray(data.active_peers)) {
+ const activeIds = new Set(data.active_peers.map(p => p.peer_id));
+
+ data.active_peers.forEach(p => {
+ if (p.peer_id && p.peer_id !== myPeerId) {
+ let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
+ if (p.peer_id.startsWith('interviewer_')) {
+ if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
+ if (!panelistMeshTiles.has(p.peer_id)) {
+ try {
+ const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream);
+ if (outCall) {
+ outCall.on('stream', remoteStream => {
+ addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel);
+ });
+ }
+ } catch(e) {}
+ }
+ }
+ }
+ }
+ });
+
+ panelistMeshTiles.forEach((_, pid) => {
+ if (!activeIds.has(pid)) {
+ removePanelistTile(pid);
+ }
+ });
+ }
+
+ const codeLang = document.getElementById('modal-code-lang');
+ const codeDisplay = document.getElementById('modal-code-display');
+ const outputDisplay = document.getElementById('modal-output-display');
+ const notesDisplay = document.getElementById('modal-notes-display');
+
+ if (codeLang) codeLang.innerText = (data.submitted_language || 'UNKNOWN').toUpperCase();
+ if (codeDisplay) codeDisplay.innerText = data.submitted_code || '// Candidate has not typed any code yet.';
+ if (outputDisplay) outputDisplay.innerText = data.code_output || 'No execution output.';
+ if (notesDisplay) notesDisplay.innerText = data.candidate_notes || 'No candidate notes written yet.';
+
+ const drawingImg = document.getElementById('modal-drawing-img');
+ const noDrawing = document.getElementById('no-drawing-placeholder');
+ if (data.candidate_drawing) {
+ if (drawingImg) {
+ drawingImg.src = data.candidate_drawing;
+ drawingImg.style.display = 'block';
+ }
+ if (noDrawing) noDrawing.style.display = 'none';
+ } else {
+ if (drawingImg) drawingImg.style.display = 'none';
+ if (noDrawing) noDrawing.style.display = 'block';
+ }
+
+ // Saved recordings
+ const recContainer = document.getElementById('modal-recordings-container');
+ if (recContainer) {
+ const recPath = data.recording_path;
+ let recList = data.recordings || [];
+ if (recList.length === 0 && recPath) {
+ recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }];
+ }
+
+ if (recList.length > 0) {
+ recList.sort((a, b) => {
+ const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
+ const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
+ return timeB - timeA;
+ });
+
+ let recHtml = `
+ 📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''})
+ 📜 Scroll to view older recordings
+
`;
+
+ recList.forEach((r, displayIdx) => {
+ let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
+ if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
+ videoUrl = '/' + videoUrl;
+ }
+ if (videoUrl && videoUrl.includes('/storage/')) {
+ videoUrl = videoUrl.replace('/storage/', '/media-stream/');
+ }
+ const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
+ const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
+ const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
+ const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`);
+ const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm');
+
+ recHtml += `
+
+
+
`;
+ });
+ recContainer.innerHTML = recHtml;
+ } else {
+ recContainer.innerHTML = 'No video recordings saved for this candidate yet.
';
+ }
+ }
+
+ // Live Chat history
+ const chatHistory = document.getElementById('interviewer-chat-history');
+ const interviewerColors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316'];
+
+ if (chatHistory && data.warnings) {
+ if (data.warnings.length > 0) {
+ let chatHtml = '';
+ data.warnings.forEach(w => {
+ const isCand = (w.sent_by === null || w.sent_by === undefined);
+ const senderName = isCand
+ ? (data.candidate_name || 'Candidate')
+ : (w.sender ? w.sender.name : 'Interviewer');
+
+ const color = isCand
+ ? '#38bdf8'
+ : interviewerColors[(w.sent_by || 1) % interviewerColors.length];
+
+ const badgeLabel = isCand ? 'Candidate' : 'Panelist';
+ const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30';
+
+ chatHtml += `
+
+
+
+ ${senderName}
+ ${badgeLabel}
+
+
${new Date(w.created_at || Date.now()).toLocaleTimeString()}
+
+
${w.message}
+
`;
+ });
+ chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2 mb-2.5';
+ chatHistory.innerHTML = chatHtml;
+ chatHistory.scrollTop = chatHistory.scrollHeight;
+ } else {
+ chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 mb-2.5';
+ chatHistory.innerHTML = 'No chat history. Send a message below.
';
+ }
+ }
+
+ currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true;
+ updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
+
+ // Screenshots container
+ const shotContainer = document.getElementById('modal-screenshots-container');
+ if (shotContainer) {
+ const shotList = data.tab_switch_screenshots || [];
+ if (shotList.length > 0) {
+ let shotHtml = `
+ 📸 Tab-Switch Screenshots Captured (${shotList.length})
+ Click image to enlarge full size
+
`;
+
+ shotList.forEach((s) => {
+ let url = typeof s === 'string' ? s : (s.url || s.full_url);
+ if (url && !url.startsWith('http') && !url.startsWith('/')) {
+ url = '/' + url;
+ }
+ const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : '';
+ shotHtml += `
+

+
📅 ${timeStr}
+
+ 📥 View / Download
+
+
`;
+ });
+ shotHtml += `
`;
+ shotContainer.innerHTML = shotHtml;
+ } else {
+ shotContainer.innerHTML = 'No tab-switch screenshots captured for this candidate yet.
';
+ }
+ }
+
+ // Header live pulse dot & buttons visibility
+ const livePulseDot = document.getElementById('live-call-pulse');
+ const recStartBtn = document.getElementById('btn-start-recording');
+ const recStopBtn = document.getElementById('btn-stop-recording');
+ const startBtn = document.getElementById('btn-start-call');
+ const leaveBtn = document.getElementById('btn-leave-call');
+ const endAllBtn = document.getElementById('btn-end-all-call');
+ const micBtn = document.getElementById('btn-toggle-interviewer-mic');
+ const camBtn = document.getElementById('btn-toggle-interviewer-cam');
+
+ const timelinePill = document.getElementById('timeline-status-pill');
+
+ if (data.call_status) {
+ if (data.call_status === 'active') {
+ if (!interviewerLocalStream) {
+ if (livePulseDot) livePulseDot.style.display = 'none';
+ if (startBtn) {
+ const icon = startBtn.querySelector('i');
+ const span = startBtn.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-phone';
+ if (span) span.innerText = 'Join Call';
+ startBtn.style.display = 'inline-flex';
+ }
+ if (leaveBtn) leaveBtn.style.display = 'none';
+ if (endAllBtn) endAllBtn.style.display = 'none';
+ if (micBtn) micBtn.style.display = 'none';
+ if (camBtn) camBtn.style.display = 'none';
+
+ if (timelinePill) {
+ const icon = timelinePill.querySelector('i');
+ const span = timelinePill.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse';
+ if (span) span.innerText = 'Call in progress';
+ timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]';
+ }
+ } else {
+ if (livePulseDot) livePulseDot.style.display = 'inline-block';
+ if (startBtn) startBtn.style.display = 'none';
+ if (leaveBtn) leaveBtn.style.display = 'inline-flex';
+ if (endAllBtn) endAllBtn.style.display = 'inline-flex';
+ if (micBtn) micBtn.style.display = 'inline-flex';
+ if (camBtn) camBtn.style.display = 'inline-flex';
+
+ if (timelinePill) {
+ const icon = timelinePill.querySelector('i');
+ const span = timelinePill.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse';
+ if (span) span.innerText = 'Connected (Live)';
+ timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]';
+ }
+ }
+ if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
+ recStartBtn.style.display = 'inline-flex';
+ }
+ } else if (data.call_status === 'ended' || data.call_status === 'idle') {
+ if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall();
+ if (livePulseDot) livePulseDot.style.display = 'none';
+ if (startBtn) {
+ const icon = startBtn.querySelector('i');
+ const span = startBtn.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-phone';
+ if (span) span.innerText = (data.call_status === 'ended') ? 'Restart call' : 'Start / Join Call';
+ startBtn.style.display = 'inline-flex';
+ }
+ if (leaveBtn) leaveBtn.style.display = 'none';
+ if (endAllBtn) endAllBtn.style.display = 'none';
+ if (micBtn) micBtn.style.display = 'none';
+ if (camBtn) camBtn.style.display = 'none';
+ if (recStartBtn) recStartBtn.style.display = 'none';
+ if (recStopBtn) recStopBtn.style.display = 'none';
+
+ if (timelinePill && data.call_status === 'ended') {
+ const icon = timelinePill.querySelector('i');
+ const span = timelinePill.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
+ if (span) span.innerText = 'Call ended';
+ timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]';
+ }
+ }
+ }
+
+ // Proctoring logs
+ const logsDisplay = document.getElementById('modal-logs-display');
+ let logsHtml = '';
+ let currentViolations = [];
+
+ if (data.proctor_logs && data.proctor_logs.length > 0) {
+ data.proctor_logs.forEach(l => {
+ 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 === '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'; }
+
+ 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)) {
+ currentViolations.push(l);
+ }
+
+ let screenshotLink = l.screenshot_url ? `[ Screenshot]` : '';
+ let aiBadge = '';
+
+ if (l.ai_verdict) {
+ const confPct = Math.round((l.ai_verdict.confidence || 0.85) * 100);
+ const engineName = l.ai_verdict.engine || 'AI Engine';
+ const toolName = l.ai_verdict.ai_tool_detected ? ` • Detected: ${l.ai_verdict.ai_tool_detected}` : '';
+ aiBadge = `AI inspector · ${confPct}% cheating confidence (${engineName}${toolName})
`;
+ isFlag = true;
+ }
+
+ const formattedType = (l.type || 'violation').replace(/_/g, ' ');
+ const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1);
+
+ logsHtml += `
+
+
+
+
+
${titleCap} — ${l.details || ''}${screenshotLink}${aiBadge}
+
${new Date(l.timestamp).toLocaleTimeString()}
+
+
`;
+ });
+ } else {
+ logsHtml = ' Zero proctoring violations recorded.
';
+ }
+ if (logsDisplay) logsDisplay.innerHTML = logsHtml;
+
+ activeViolations = currentViolations;
+ const totalViolations = currentViolations.length;
+
+ // Trigger SOS Modal Popup and Ringtone Alarm ONLY if NEW unacknowledged violations occurred & SOS Auto-Trigger is active
+ if (totalViolations > 0 && totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) {
+ sosDismissed = false;
+ const newViolations = currentViolations.slice(acknowledgedViolationCount);
+ openSosModal(newViolations);
+ }
+ })
+ .catch(() => {});
+}
+
+export function openSosModal(specificViolations = null) {
+ let violationsToDisplay = specificViolations;
+ if (!violationsToDisplay || violationsToDisplay.length === 0) {
+ if (acknowledgedViolationCount > 0 && activeViolations.length > acknowledgedViolationCount) {
+ violationsToDisplay = activeViolations.slice(acknowledgedViolationCount);
+ } else {
+ violationsToDisplay = activeViolations;
+ }
+ }
+
+ let html = '';
+ if (violationsToDisplay && violationsToDisplay.length > 0) {
+ violationsToDisplay.forEach(v => {
+ let typeTitle = (v.type || '').toUpperCase();
+ if (v.type === 'external_ai_detected') {
+ typeTitle = 'candidate might use external ai';
+ } else if (v.type === 'tab_switch') {
+ typeTitle = 'Candidate Switched Browser Tab';
+ } else if (v.type === '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';
+
+ html += `
+
🚨 ${typeTitle}: ${v.details || 'Suspicious activity detected.'}
+
Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}
+
`;
+ });
+ } else {
+ html = 'Potential proctoring alert flagged by engine.
';
+ }
+ const listEl = document.getElementById('sos-violation-list');
+ const modalEl = document.getElementById('sos-modal');
+ if (listEl) listEl.innerHTML = html;
+ if (modalEl) modalEl.classList.add('active');
+
+ try {
+ callRingtone.start();
+ } catch(e) {}
+}
+
+export function stopSosAlarm() {
+ try {
+ callRingtone.stop();
+ } catch(e) {}
+
+ const modalEl = document.getElementById('sos-modal');
+ if (modalEl) modalEl.classList.remove('active');
+
+ acknowledgedViolationCount = activeViolations.length;
+ sosDismissed = true;
+}
+
+export function interviewerLogoutAction() {
+ const form = document.getElementById('logout-form');
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ title: 'Logout of Portal?',
+ text: 'Are you sure you want to log out of your session?',
+ icon: 'question',
+ showCancelButton: true,
+ confirmButtonColor: '#ef4444',
+ cancelButtonColor: '#475569',
+ confirmButtonText: 'Yes, Logout'
+ }).then((result) => {
+ if (result.isConfirmed && form) {
+ form.submit();
+ }
+ });
+ } else if (form) {
+ form.submit();
+ }
+}
+
+export function regenerateCandidatePassword() {
+ if (!currentInterviewId) return;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ title: 'Regenerate Candidate Password?',
+ text: 'This will invalidate the candidate\'s current password and issue a new system password.',
+ icon: 'warning',
+ showCancelButton: true,
+ confirmButtonColor: '#eab308',
+ cancelButtonColor: '#475569',
+ confirmButtonText: 'Regenerate Now'
+ }).then((result) => {
+ if (result.isConfirmed) {
+ fetch(`/interviews/${currentInterviewId}/regenerate-password`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ }
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) {
+ window.Swal.fire({
+ icon: 'success',
+ title: 'New Password Generated!',
+ text: 'New Candidate Passcode: ' + res.new_password,
+ confirmButtonColor: '#6366f1'
+ }).then(() => window.location.reload());
+ }
+ });
+ }
+ });
+ }
+}
+
+export function blockCandidateAction() {
+ if (!currentInterviewId) return;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ title: '⛔ Block Candidate Email & IP?',
+ text: 'This will immediately terminate the candidate session and block their email and client IP address.',
+ icon: 'error',
+ showCancelButton: true,
+ confirmButtonColor: '#ef4444',
+ cancelButtonColor: '#475569',
+ confirmButtonText: 'Yes, Block Candidate'
+ }).then((result) => {
+ if (result.isConfirmed) {
+ fetch(`/interviews/${currentInterviewId}/block-candidate`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ }
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) {
+ window.Swal.fire({
+ icon: 'success',
+ title: 'Candidate Blocked',
+ text: res.message,
+ confirmButtonColor: '#6366f1'
+ }).then(() => {
+ closeReviewModal();
+ window.location.reload();
+ });
+ }
+ });
+ }
+ });
+ }
+}
+
+export function initMeteredIceServers() {
+ if (typeof window.getMeteredIceServers === 'function') {
+ return window.getMeteredIceServers().then(servers => {
+ if (Array.isArray(servers) && servers.length > 0) {
+ globalIceServers = servers;
+ }
+ return globalIceServers;
+ });
+ }
+ return Promise.resolve(globalIceServers);
+}
+
+export function safePlayMediaStream(videoElem, stream, isSelf = false) {
+ if (!videoElem || !stream) return;
+ if (isSelf) videoElem.muted = true;
+ if (videoElem.srcObject !== stream) {
+ videoElem.srcObject = stream;
+ }
+ const playPromise = videoElem.play();
+ if (playPromise !== undefined) {
+ playPromise.catch(err => {
+ if (err.name === 'AbortError' || err.name === 'NotAllowedError') {
+ videoElem.onloadedmetadata = () => {
+ videoElem.play().catch(() => {});
+ };
+ }
+ });
+ }
+}
+
+export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist') {
+ const currentUserId = window.currentUserId || 0;
+ if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return;
+ const grid = document.getElementById('panelist-mesh-grid');
+ if (!grid) return;
+
+ let tile = document.getElementById('panelist-tile-' + peerId);
+ let videoElem;
+
+ if (!tile) {
+ tile = document.createElement('div');
+ tile.id = 'panelist-tile-' + peerId;
+ tile.className = 'relative bg-[#050811] rounded-xl border-2 border-indigo-500 overflow-hidden h-[220px]';
+
+ videoElem = document.createElement('video');
+ videoElem.id = 'panelist-video-' + peerId;
+ videoElem.autoplay = true;
+ videoElem.playsInline = true;
+ videoElem.className = 'w-full h-full object-cover';
+
+ const labelDiv = document.createElement('div');
+ labelDiv.className = 'absolute bottom-1.5 left-1.5 bg-indigo-600/85 px-2 py-0.5 rounded text-[0.65rem] text-white font-semibold z-10';
+ labelDiv.innerText = '🎙️ ' + name;
+
+ tile.appendChild(videoElem);
+ tile.appendChild(labelDiv);
+ grid.appendChild(tile);
+ } else {
+ videoElem = document.getElementById('panelist-video-' + peerId);
+ }
+
+ if (videoElem && stream) {
+ safePlayMediaStream(videoElem, stream);
+ }
+
+ panelistMeshTiles.set(peerId, stream);
+}
+
+export function removePanelistTile(peerId) {
+ const tile = document.getElementById('panelist-tile-' + peerId);
+ if (tile) tile.remove();
+ panelistMeshTiles.delete(peerId);
+}
+
+export async function initInterviewerPeer() {
+ if (!currentInterviewId || typeof window.Peer === 'undefined') return;
+ await initMeteredIceServers();
+
+ const currentUserId = window.currentUserId || 0;
+ const currentUserName = window.currentUserName || 'Interviewer';
+ const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
+
+ if (interviewerPeer && !interviewerPeer.destroyed) {
+ try { interviewerPeer.destroy(); } catch(e) {}
+ }
+
+ interviewerPeer = new window.Peer(myPeerId, {
+ config: { iceServers: globalIceServers }
+ });
+
+ interviewerPeer.on('open', id => {
+ if (interviewerSigChannel) {
+ interviewerSigChannel.postMessage({
+ type: 'peer_joined',
+ peer_id: id,
+ user_id: currentUserId,
+ user_name: currentUserName,
+ role: 'interviewer'
+ });
+ }
+ });
+
+ interviewerPeer.on('call', call => {
+ const sendStream = interviewerLocalStream || new MediaStream();
+ try { call.answer(sendStream); } catch(e) {}
+
+ call.on('stream', remoteStream => {
+ if (call.peer === 'cand_' + currentInterviewId) {
+ const candVid = document.getElementById('interviewer-cand-video');
+ const placeholder = document.getElementById('cand-video-placeholder');
+ if (candVid) safePlayMediaStream(candVid, remoteStream);
+ if (placeholder) placeholder.style.display = 'none';
+ } else {
+ addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist');
+ }
+
+ const badge = document.getElementById('interviewer-call-status');
+ if (badge) {
+ badge.innerText = 'CONNECTED (LIVE)';
+ badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
+ }
+ });
+ });
+
+ const screenPeerId = 'interviewer_screen_' + currentInterviewId;
+ if (screenReceiverPeer && !screenReceiverPeer.destroyed) {
+ try { screenReceiverPeer.destroy(); } catch(e) {}
+ }
+ screenReceiverPeer = new window.Peer(screenPeerId, {
+ config: { iceServers: globalIceServers }
+ });
+ screenReceiverPeer.on('call', call => {
+ call.answer();
+ call.on('stream', screenStream => {
+ const screenVid = document.getElementById('interviewer-screen-video');
+ const placeholder = document.getElementById('screen-video-placeholder');
+ if (screenVid) safePlayMediaStream(screenVid, screenStream);
+ if (placeholder) placeholder.style.display = 'none';
+ });
+ });
+}
+
+export function initInterviewerSigChannel() {
+ if (!currentInterviewId) return;
+ if (interviewerSigChannel) interviewerSigChannel.close();
+
+ interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId);
+ const currentUserId = window.currentUserId || 0;
+ const currentUserName = window.currentUserName || 'Interviewer';
+ const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
+
+ interviewerSigChannel.onmessage = async (event) => {
+ const msg = event.data;
+ if (!msg) return;
+
+ if (msg.type === 'violation_occurred') {
+ pollReviewData();
+ }
+
+ if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') {
+ if (msg.peer_id && msg.peer_id !== myPeerId) {
+ if (msg.type === 'peer_joined') {
+ interviewerSigChannel.postMessage({
+ type: 'peer_presence_ack',
+ peer_id: myPeerId,
+ user_id: currentUserId,
+ user_name: currentUserName,
+ role: 'interviewer'
+ });
+ }
+
+ if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
+ try {
+ const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream);
+ if (outCall) {
+ outCall.on('stream', remoteStream => {
+ if (msg.peer_id === 'cand_' + currentInterviewId) {
+ const candVid = document.getElementById('interviewer-cand-video');
+ const placeholder = document.getElementById('cand-video-placeholder');
+ if (candVid) safePlayMediaStream(candVid, remoteStream);
+ if (placeholder) placeholder.style.display = 'none';
+ } else {
+ addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist');
+ }
+
+ const badge = document.getElementById('interviewer-call-status');
+ if (badge) {
+ badge.innerText = 'CONNECTED (LIVE)';
+ badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
+ }
+ });
+ }
+ } catch(e) {}
+ }
+ }
+ } else if (msg.type === 'peer_left') {
+ if (msg.peer_id === 'cand_' + currentInterviewId) {
+ const placeholder = document.getElementById('cand-video-placeholder');
+ if (placeholder) placeholder.style.display = 'flex';
+ } else if (msg.peer_id) {
+ removePanelistTile(msg.peer_id);
+ }
+ } else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) {
+ await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
+ } else if (msg.type === 'ice-candidate' && msg.sender === 'candidate' && interviewerPeerConnection && msg.candidate) {
+ try {
+ await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
+ } catch(e) {}
+ } else if (msg.type === 'end_call_all') {
+ leaveInterviewerCall(false);
+ }
+ };
+}
+
+export function startInterviewerCall(isRejoin = false) {
+ if (!currentInterviewId) return;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+ const currentUserName = window.currentUserName || 'Interviewer';
+
+ if (!isRejoin) {
+ iAmTheCaller = true;
+ const candNameEl = document.getElementById('modal-cand-name');
+ const candName = candNameEl ? candNameEl.innerText : 'Candidate';
+ if (!globalCallChannel) {
+ globalCallChannel = new BroadcastChannel('global_call_alerts');
+ }
+ globalCallChannel.postMessage({
+ type: 'incoming_call',
+ interview_id: currentInterviewId,
+ candidate_name: candName,
+ starter_name: currentUserName,
+ call_started_at: new Date().toISOString()
+ });
+ }
+
+ initInterviewerSigChannel();
+
+ fetch(`/interviews/${currentInterviewId}/call-status`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ },
+ body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin })
+ });
+
+ const handleStream = (stream) => {
+ interviewerLocalStream = stream;
+ const selfVid = document.getElementById('interviewer-self-video');
+ const selfPlace = document.getElementById('self-video-placeholder');
+ if (selfVid) {
+ selfVid.muted = true;
+ safePlayMediaStream(selfVid, stream, true);
+ }
+ if (selfPlace) selfPlace.style.display = 'none';
+
+ const micBtn = document.getElementById('btn-toggle-interviewer-mic');
+ const camBtn = document.getElementById('btn-toggle-interviewer-cam');
+ const startBtn = document.getElementById('btn-start-call');
+ const leaveBtn = document.getElementById('btn-leave-call');
+ const endAllBtn = document.getElementById('btn-end-all-call');
+
+ if (micBtn) micBtn.style.display = 'inline-flex';
+ if (camBtn) camBtn.style.display = 'inline-flex';
+ if (startBtn) startBtn.style.display = 'none';
+ if (leaveBtn) leaveBtn.style.display = 'inline-flex';
+ if (endAllBtn) endAllBtn.style.display = 'inline-flex';
+
+ if (interviewerSigChannel) {
+ interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' });
+ }
+
+ if (!interviewerPeer || interviewerPeer.destroyed) {
+ initInterviewerPeer();
+ }
+
+ const makePeerJSCall = () => {
+ if (!interviewerPeer || interviewerPeer.destroyed) return;
+ const candPeerId = 'cand_' + currentInterviewId;
+ activeCall = interviewerPeer.call(candPeerId, stream);
+
+ if (activeCall) {
+ activeCall.on('stream', candRemoteStream => {
+ const candVid = document.getElementById('interviewer-cand-video');
+ const placeholder = document.getElementById('cand-video-placeholder');
+ if (candVid) safePlayMediaStream(candVid, candRemoteStream);
+ if (placeholder) placeholder.style.display = 'none';
+
+ candRemoteStream.onaddtrack = () => {
+ if (placeholder) placeholder.style.display = 'none';
+ if (candVid) safePlayMediaStream(candVid, candRemoteStream);
+ };
+
+ const badge = document.getElementById('interviewer-call-status');
+ if (badge) {
+ badge.innerText = 'CONNECTED (LIVE)';
+ badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
+ }
+ });
+ }
+ };
+
+ if (interviewerPeer && interviewerPeer.open) {
+ makePeerJSCall();
+ } else if (interviewerPeer) {
+ interviewerPeer.on('open', makePeerJSCall);
+ }
+ };
+
+ const audioConstraints = {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true
+ };
+
+ navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints })
+ .then(handleStream)
+ .catch(() => {
+ navigator.mediaDevices.getUserMedia({ video: true, audio: true })
+ .then(handleStream)
+ .catch(() => {
+ navigator.mediaDevices.getUserMedia({ video: false, audio: true })
+ .then(handleStream)
+ .catch(() => {
+ navigator.mediaDevices.getUserMedia({ video: true, audio: false })
+ .then(handleStream)
+ .catch(err => console.log('WebRTC Stream init error:', err));
+ });
+ });
+ });
+}
+
+export function leaveInterviewerCall(isExplicitEnd = true) {
+ sendInterviewerPeerHeartbeat('leave');
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+
+ if (currentInterviewId && isExplicitEnd) {
+ const endingId = currentInterviewId;
+ endedCallIds.add(endingId);
+ dismissedCallIds.add(endingId);
+
+ if (globalCallChannel) {
+ try {
+ globalCallChannel.postMessage({
+ type: 'call_ended',
+ interview_id: endingId
+ });
+ } catch(e) {}
+ }
+
+ if (interviewerSigChannel) {
+ try {
+ interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
+ } catch(e) {}
+ }
+
+ fetch(`/interviews/${endingId}/call-status`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ },
+ body: JSON.stringify({ call_status: 'ended' })
+ }).catch(() => {});
+ }
+
+ if (interviewerLocalStream) {
+ try {
+ interviewerLocalStream.getTracks().forEach(t => t.stop());
+ } catch(e) {}
+ interviewerLocalStream = null;
+ }
+ if (activeCall) {
+ try { activeCall.close(); } catch(e) {}
+ activeCall = null;
+ }
+ if (interviewerPeer) {
+ try { interviewerPeer.destroy(); } catch(e) {}
+ interviewerPeer = null;
+ }
+ if (screenReceiverPeer) {
+ try { screenReceiverPeer.destroy(); } catch(e) {}
+ screenReceiverPeer = null;
+ }
+
+ const candPlace = document.getElementById('cand-video-placeholder');
+ const screenPlace = document.getElementById('screen-video-placeholder');
+ const selfPlace = document.getElementById('self-video-placeholder');
+ if (candPlace) candPlace.style.display = 'flex';
+ if (screenPlace) screenPlace.style.display = 'flex';
+ if (selfPlace) selfPlace.style.display = 'flex';
+
+ const candVid = document.getElementById('interviewer-cand-video');
+ if (candVid) candVid.srcObject = null;
+
+ const selfVid = document.getElementById('interviewer-self-video');
+ if (selfVid) selfVid.srcObject = null;
+
+ const livePulseDot = document.getElementById('live-call-pulse');
+ if (livePulseDot) livePulseDot.style.display = 'none';
+
+ const startBtn = document.getElementById('btn-start-call');
+ if (startBtn) {
+ const icon = startBtn.querySelector('i');
+ const span = startBtn.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-phone';
+ if (span) span.innerText = 'Restart Call';
+ startBtn.style.display = 'inline-flex';
+ }
+
+ const leaveBtn = document.getElementById('btn-leave-call');
+ if (leaveBtn) leaveBtn.style.display = 'none';
+
+ const endAllBtn = document.getElementById('btn-end-all-call');
+ if (endAllBtn) endAllBtn.style.display = 'none';
+
+ const micBtn = document.getElementById('btn-toggle-interviewer-mic');
+ if (micBtn) micBtn.style.display = 'none';
+
+ const camBtn = document.getElementById('btn-toggle-interviewer-cam');
+ if (camBtn) camBtn.style.display = 'none';
+
+ const timelinePill = document.getElementById('timeline-status-pill');
+ if (timelinePill) {
+ const icon = timelinePill.querySelector('i');
+ const span = timelinePill.querySelector('span');
+ if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
+ if (span) span.innerText = 'Call ended';
+ timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]';
+ }
+}
+
+export function endCallForAll() {
+ const executeEndCall = () => {
+ callRingtone.stop();
+ const modal = document.getElementById('incoming-call-modal');
+ if (modal) modal.classList.remove('active');
+ activeIncomingCall = null;
+ iAmTheCaller = false;
+ leaveInterviewerCall(true);
+ };
+
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ title: '🛑 End Call for All Participants?',
+ text: 'This will terminate the 2-way call session for candidate and all panelists.',
+ icon: 'warning',
+ showCancelButton: true,
+ confirmButtonColor: '#ef4444',
+ cancelButtonColor: '#475569',
+ confirmButtonText: 'Yes, End Call for All'
+ }).then((result) => {
+ if (result.isConfirmed) {
+ executeEndCall();
+ }
+ });
+ } else {
+ if (confirm('End Call for All Participants?')) {
+ executeEndCall();
+ }
+ }
+}
+
+// MediaRecorder recording
+let adminMediaRecorder = null;
+let adminRecordedChunks = [];
+let recAudioContext = null;
+
+export function startCallRecording() {
+ if (!currentInterviewId) {
+ alert('Please open an active candidate interview session first.');
+ return;
+ }
+
+ const candVid = document.getElementById('interviewer-cand-video');
+ const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
+ const streamToRecord = new MediaStream();
+
+ if (candidateStream) {
+ candidateStream.getVideoTracks().forEach(track => {
+ if (track.readyState === 'live') {
+ try { streamToRecord.addTrack(track); } catch(e) {}
+ }
+ });
+ }
+
+ if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) {
+ interviewerLocalStream.getVideoTracks().forEach(track => {
+ if (track.readyState === 'live') {
+ try { streamToRecord.addTrack(track); } catch(e) {}
+ }
+ });
+ }
+
+ const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
+ const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
+
+ if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) {
+ try {
+ const AudioContextClass = window.AudioContext || window.webkitAudioContext;
+ if (recAudioContext) {
+ try { recAudioContext.close(); } catch(e) {}
+ }
+ recAudioContext = new AudioContextClass();
+ const audioDestination = recAudioContext.createMediaStreamDestination();
+
+ if (candAudioTracks.length > 0) {
+ const candAudioStream = new MediaStream(candAudioTracks);
+ const candSource = recAudioContext.createMediaStreamSource(candAudioStream);
+ candSource.connect(audioDestination);
+ }
+
+ if (adminAudioTracks.length > 0) {
+ const adminAudioStream = new MediaStream(adminAudioTracks);
+ const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream);
+ adminSource.connect(audioDestination);
+ }
+
+ const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0];
+ if (mixedAudioTrack) {
+ streamToRecord.addTrack(mixedAudioTrack);
+ }
+ } catch(e) {
+ const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0];
+ if (fallbackAudio) {
+ try { streamToRecord.addTrack(fallbackAudio); } catch(err) {}
+ }
+ }
+ }
+
+ const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live');
+ if (activeTracks.length === 0) {
+ alert('No active video/audio stream available to record. Please start or join the call first.');
+ return;
+ }
+
+ adminRecordedChunks = [];
+ try {
+ let options = {};
+ if (typeof MediaRecorder.isTypeSupported === 'function') {
+ if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
+ options = { mimeType: 'video/webm;codecs=vp8,opus' };
+ } else if (MediaRecorder.isTypeSupported('video/webm')) {
+ options = { mimeType: 'video/webm' };
+ }
+ }
+
+ try {
+ adminMediaRecorder = new MediaRecorder(streamToRecord, options);
+ } catch(errOpt) {
+ adminMediaRecorder = new MediaRecorder(streamToRecord);
+ }
+
+ adminMediaRecorder.ondataavailable = function(e) {
+ if (e.data && e.data.size > 0) {
+ adminRecordedChunks.push(e.data);
+ }
+ };
+
+ adminMediaRecorder.onstop = function() {
+ if (recAudioContext) {
+ try { recAudioContext.close(); } catch(e) {}
+ recAudioContext = null;
+ }
+ if (adminRecordedChunks.length === 0) return;
+ const recMime = adminMediaRecorder.mimeType || 'video/webm';
+ const isMp4 = recMime.includes('mp4');
+ const ext = isMp4 ? 'mp4' : 'webm';
+ const blobType = isMp4 ? 'video/mp4' : 'video/webm';
+
+ const blob = new Blob(adminRecordedChunks, { type: blobType });
+ uploadRecordedBlob(blob, ext);
+ };
+
+ adminMediaRecorder.start(1000);
+
+ const recStartBtn = document.getElementById('btn-start-recording');
+ const recStopBtn = document.getElementById('btn-stop-recording');
+ if (recStartBtn) recStartBtn.style.display = 'none';
+ if (recStopBtn) recStopBtn.style.display = 'inline-flex';
+
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ icon: 'info',
+ title: 'Recording Started',
+ text: 'Interview call recording is active (silent mode - candidate is not notified).',
+ toast: true,
+ position: 'top-end',
+ timer: 3000,
+ showConfirmButton: false
+ });
+ }
+ } catch(e) {
+ alert('Recording failed to initialize: ' + e.message);
+ }
+}
+
+export function stopCallRecording() {
+ if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
+ adminMediaRecorder.stop();
+ }
+ const recStartBtn = document.getElementById('btn-start-recording');
+ const recStopBtn = document.getElementById('btn-stop-recording');
+ if (recStartBtn) recStartBtn.style.display = 'inline-flex';
+ if (recStopBtn) recStopBtn.style.display = 'none';
+}
+
+export function uploadRecordedBlob(blob, ext = 'webm') {
+ if (!currentInterviewId) return;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+
+ const formData = new FormData();
+ formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`);
+ formData.append('_token', csrfToken);
+
+ fetch(`/candidate/upload-recording/${currentInterviewId}`, {
+ method: 'POST',
+ body: formData
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (res.success) {
+ if (typeof window.Swal !== 'undefined') {
+ window.Swal.fire({
+ icon: 'success',
+ title: 'Recording Saved!',
+ text: 'Call recording saved successfully under Candidate Profile and Report.',
+ toast: true,
+ position: 'top-end',
+ timer: 3500,
+ showConfirmButton: false
+ });
+ }
+ pollReviewData();
+ }
+ })
+ .catch(err => console.log('Recording upload error:', err));
+}
+
+export function toggleInterviewerMic() {
+ if (!interviewerLocalStream) return;
+ const audioTracks = interviewerLocalStream.getAudioTracks();
+ if (audioTracks.length === 0) return;
+
+ isInterviewerMicOn = !isInterviewerMicOn;
+ audioTracks[0].enabled = isInterviewerMicOn;
+
+ const btn = document.getElementById('btn-toggle-interviewer-mic');
+ if (!btn) return;
+
+ const icon = btn.querySelector('i');
+ if (icon) {
+ icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
+ }
+
+ btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)';
+
+ if (isInterviewerMicOn) {
+ btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
+ btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
+ } else {
+ btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
+ btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
+ }
+}
+
+export function toggleInterviewerCam() {
+ const selfPlaceholder = document.getElementById('self-video-placeholder');
+ const selfVid = document.getElementById('interviewer-self-video');
+
+ isInterviewerCamOn = !isInterviewerCamOn;
+
+ const btn = document.getElementById('btn-toggle-interviewer-cam');
+ if (btn) {
+ const icon = btn.querySelector('i');
+ if (icon) {
+ icon.className = isInterviewerCamOn ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
+ }
+ btn.title = isInterviewerCamOn ? 'Cam On (Click to Turn Off)' : 'Cam Off (Click to Turn On)';
+
+ if (isInterviewerCamOn) {
+ btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
+ btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
+ } else {
+ btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
+ btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
+ }
+ }
+
+ if (interviewerLocalStream) {
+ const videoTracks = interviewerLocalStream.getVideoTracks();
+ if (videoTracks.length > 0) {
+ videoTracks[0].enabled = isInterviewerCamOn;
+ }
+ } else if (isInterviewerCamOn) {
+ navigator.mediaDevices.getUserMedia({ video: true, audio: false })
+ .then(stream => {
+ interviewerLocalStream = stream;
+ if (selfVid) {
+ selfVid.srcObject = stream;
+ selfVid.play().catch(() => {});
+ }
+ if (selfPlaceholder) selfPlaceholder.style.display = 'none';
+ })
+ .catch(() => {});
+ return;
+ }
+
+ if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
+}
+
+export function closeReviewModal() {
+ if (sosPollInterval) clearInterval(sosPollInterval);
+ if (liveSyncChannel) {
+ try { liveSyncChannel.close(); } catch(e) {}
+ liveSyncChannel = null;
+ }
+ leaveInterviewerCall(true);
+ const modal = document.getElementById('review-modal');
+ if (modal) modal.classList.remove('active');
+}
+
+export function sendSilentWarning() {
+ const input = document.getElementById('warning-input');
+ if (!input) return;
+ const msg = input.value.trim();
+ if (!msg || !currentInterviewId) return;
+ const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
+
+ input.value = '';
+ if (liveSyncChannel) {
+ liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' });
+ }
+
+ fetch(`/interviews/${currentInterviewId}/warning`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-TOKEN': csrfToken
+ },
+ body: JSON.stringify({ message: msg })
+ })
+ .then(r => r.json())
+ .then(() => {
+ pollReviewData();
+ });
+}
+
+export function setCurrentInterviewId(id) {
+ currentInterviewId = id;
+}
+
+// Bind functions onto window object for template inline onclick handlers
+if (typeof window !== 'undefined') {
+ window.CallRingtoneEngine = CallRingtoneEngine;
+ window.callRingtone = callRingtone;
+ window.getInitials = getInitials;
+ window.zoomScreen = zoomScreen;
+ window.resetZoomScreen = resetZoomScreen;
+ window.toggleFullscreenScreen = toggleFullscreenScreen;
+ window.zoomCandVideo = zoomCandVideo;
+ window.toggleSosAutoTrigger = toggleSosAutoTrigger;
+ window.openReportPage = openReportPage;
+ window.toggleTabScreenshotCapture = toggleTabScreenshotCapture;
+ window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI;
+ window.switchIdeTab = switchIdeTab;
+ window.triggerIncomingCallRing = triggerIncomingCallRing;
+ window.pollGlobalActiveCalls = pollGlobalActiveCalls;
+ window.acceptIncomingCall = acceptIncomingCall;
+ window.declineIncomingCall = declineIncomingCall;
+ window.subscribeLiveSync = subscribeLiveSync;
+ window.openReviewModal = openReviewModal;
+ window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat;
+ window.pollReviewData = pollReviewData;
+ window.openSosModal = openSosModal;
+ window.stopSosAlarm = stopSosAlarm;
+ window.interviewerLogoutAction = interviewerLogoutAction;
+ window.regenerateCandidatePassword = regenerateCandidatePassword;
+ window.blockCandidateAction = blockCandidateAction;
+ window.startInterviewerCall = startInterviewerCall;
+ window.leaveInterviewerCall = leaveInterviewerCall;
+ window.endCallForAll = endCallForAll;
+ window.startCallRecording = startCallRecording;
+ window.stopCallRecording = stopCallRecording;
+ window.toggleInterviewerMic = toggleInterviewerMic;
+ window.toggleInterviewerCam = toggleInterviewerCam;
+ window.closeReviewModal = closeReviewModal;
+ window.sendSilentWarning = sendSilentWarning;
+ window.setCurrentInterviewId = setCurrentInterviewId;
+
+ // Attach listeners
+ ['click', 'keydown', 'touchstart'].forEach(evt => {
+ document.addEventListener(evt, () => callRingtone.init(), { passive: true });
+ });
+
+ globalCallChannel = new BroadcastChannel('global_call_alerts');
+ globalCallChannel.onmessage = (event) => {
+ const data = event.data;
+ if (!data) return;
+
+ if (data.type === 'incoming_call') {
+ triggerIncomingCallRing({
+ id: data.interview_id,
+ candidate_name: data.candidate_name,
+ submission_unique_id: data.submission_unique_id,
+ starter_name: data.starter_name,
+ call_started_at: data.call_started_at || new Date().toISOString()
+ }, true);
+ } else if (data.type === 'call_ended') {
+ endedCallIds.add(data.interview_id);
+ dismissedCallIds.add(data.interview_id);
+ callRingtone.stop();
+ if (ringTimeoutTimer) {
+ clearTimeout(ringTimeoutTimer);
+ ringTimeoutTimer = null;
+ }
+ const modal = document.getElementById('incoming-call-modal');
+ if (modal) modal.classList.remove('active');
+ if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
+ activeIncomingCall = null;
+ }
+ }
+ };
+
+ setInterval(pollGlobalActiveCalls, 1500);
+}
diff --git a/resources/views/components/audit-log-item.blade.php b/resources/views/components/audit-log-item.blade.php
new file mode 100644
index 0000000..f86ee70
--- /dev/null
+++ b/resources/views/components/audit-log-item.blade.php
@@ -0,0 +1,55 @@
+@props([
+ 'type' => 'violation',
+ 'details' => '',
+ 'timestamp' => null,
+ 'screenshotUrl' => null,
+ 'aiVerdict' => null,
+])
+
+@php
+ $icoClass = 'amber';
+ $faIcon = 'fa-solid fa-window-restore';
+ $isFlag = false;
+
+ if ($type === 'focus_lost') {
+ $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 (in_array($type, ['gaze_lower_device', 'reading_external_device'])) {
+ $icoClass = 'red'; $faIcon = 'fa-solid fa-mobile-screen'; $isFlag = true;
+ } elseif (in_array($type, ['question_repeat_lower', 'question_repetition'])) {
+ $icoClass = 'red'; $faIcon = 'fa-solid fa-comments'; $isFlag = true;
+ } elseif ($type === 'talking_on_phone') {
+ $icoClass = 'red'; $faIcon = 'fa-solid fa-phone'; $isFlag = true;
+ } elseif ($type === 'external_ai_detected') {
+ $icoClass = 'red'; $faIcon = 'fa-solid fa-robot'; $isFlag = true;
+ } elseif ($type === 'tab_switch') {
+ $icoClass = 'amber'; $faIcon = 'fa-solid fa-window-restore';
+ }
+
+ $titleCap = ucfirst(str_replace('_', ' ', $type));
+ $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s');
+@endphp
+
+merge(['class' => 'log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ' . ($isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : '')]) }}>
+
+
+
+
+
+
{{ $titleCap }} — {{ $details }}
+ @if($screenshotUrl)
+
[ Screenshot]
+ @endif
+ @if($aiVerdict)
+ @php
+ $confPct = round(($aiVerdict['confidence'] ?? 0.85) * 100);
+ $engineName = $aiVerdict['engine'] ?? 'AI Engine';
+ $toolName = !empty($aiVerdict['ai_tool_detected']) ? ' • Detected:
' . e($aiVerdict['ai_tool_detected']) . '' : '';
+ @endphp
+
AI inspector · {{ $confPct }}% cheating confidence ({!! $engineName . $toolName !!})
+ @endif
+
+
{{ $timeStr }}
+
+
diff --git a/resources/views/components/audit-log.blade.php b/resources/views/components/audit-log.blade.php
new file mode 100644
index 0000000..21000a0
--- /dev/null
+++ b/resources/views/components/audit-log.blade.php
@@ -0,0 +1,21 @@
+@props([
+ 'logs' => [],
+])
+
+merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] max-h-[260px] overflow-y-auto']) }}>
+ @if(is_array($logs) && count($logs) > 0)
+ @foreach($logs as $log)
+
+ @endforeach
+ @else
+
+ Zero proctoring violations recorded.
+
+ @endif
+
diff --git a/resources/views/components/button.blade.php b/resources/views/components/button.blade.php
new file mode 100644
index 0000000..a025502
--- /dev/null
+++ b/resources/views/components/button.blade.php
@@ -0,0 +1,41 @@
+@props([
+ 'variant' => 'primary', // primary, secondary, danger, warning, success, ghost
+ 'size' => 'md', // sm, md, lg
+ 'type' => 'button',
+ 'icon' => null,
+])
+
+@php
+ $baseClasses = 'inline-flex items-center gap-1.5 rounded-lg border font-medium text-xs transition-all duration-150 cursor-pointer select-none whitespace-nowrap disabled:opacity-50 disabled:cursor-not-allowed';
+
+ $variants = [
+ 'default' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]',
+ 'secondary' => 'bg-[#0d1220] text-slate-300 border-[#232b3d] hover:bg-[#161d2d] hover:text-white hover:border-[#3a4257]',
+ 'primary' => 'bg-[#4b82f7] text-white border-[#4b82f7] hover:bg-[#3d6fe0] shadow-sm',
+ 'success' => 'bg-[#21c274] text-white border-[#21c274] hover:bg-[#1da865] shadow-sm',
+ 'danger' => 'bg-[#ef4a5f] text-white border-[#ef4a5f] hover:bg-[#d63d51] shadow-sm',
+ 'warning' => 'bg-[#f0a939] text-white border-[#f0a939] hover:bg-[#d8952d] shadow-sm',
+ 'ghost' => 'bg-transparent text-slate-400 border-transparent hover:bg-white/5 hover:text-slate-200',
+ ];
+
+ $sizes = [
+ 'sm' => 'px-2.5 py-1.5 text-xs',
+ 'md' => 'px-3 py-1.5 text-[12.5px]',
+ 'lg' => 'px-4 py-2 text-sm',
+ ];
+
+ $classes = implode(' ', [
+ $baseClasses,
+ $variants[$variant] ?? $variants['secondary'],
+ $sizes[$size] ?? $sizes['md'],
+ ]);
+@endphp
+
+
diff --git a/resources/views/components/card.blade.php b/resources/views/components/card.blade.php
new file mode 100644
index 0000000..00f9082
--- /dev/null
+++ b/resources/views/components/card.blade.php
@@ -0,0 +1,28 @@
+@props([
+ 'title' => null,
+ 'icon' => null,
+])
+
+merge(['class' => 'bg-[#121826] border border-[#1b2233] rounded-[12px] mb-4 overflow-hidden']) }}>
+ @if($title || $icon || isset($headerExtra))
+
+
+ @if($icon)
+
+ @endif
+ @if($title)
+ {{ $title }}
+ @endif
+
+ @if(isset($headerExtra))
+
+ {{ $headerExtra }}
+
+ @endif
+
+ @endif
+
+
+ {{ $slot }}
+
+
diff --git a/resources/views/components/chat-message.blade.php b/resources/views/components/chat-message.blade.php
new file mode 100644
index 0000000..b940239
--- /dev/null
+++ b/resources/views/components/chat-message.blade.php
@@ -0,0 +1,42 @@
+@props([
+ 'senderName' => 'Interviewer',
+ 'isCandidate' => false,
+ 'color' => '#6366f1',
+ 'message' => '',
+ 'timestamp' => null,
+])
+
+@php
+ $resolvedColor = $isCandidate ? '#38bdf8' : ($color ?? '#6366f1');
+ $timeStr = $timestamp ? \Carbon\Carbon::parse($timestamp)->format('H:i:s') : now()->format('H:i:s');
+
+ // Hex to rgba helper for background opacity
+ $hex = ltrim($resolvedColor, '#');
+ if (strlen($hex) == 3) {
+ $r = hexdec(substr($hex,0,1).substr($hex,0,1));
+ $g = hexdec(substr($hex,1,1).substr($hex,1,1));
+ $b = hexdec(substr($hex,2,1).substr($hex,2,1));
+ } else {
+ $r = hexdec(substr($hex,0,2));
+ $g = hexdec(substr($hex,2,2));
+ $b = hexdec(substr($hex,4,2));
+ }
+ $bgRgba = "rgba({$r}, {$g}, {$b}, 0.1)";
+ $borderRgba = "rgba({$r}, {$g}, {$b}, 0.35)";
+@endphp
+
+merge(['class' => 'chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all']) }} style="background: {{ $bgRgba }}; border-color: {{ $resolvedColor }};">
+
+
+
+ {{ $senderName }}
+ @if($isCandidate)
+ Candidate
+ @else
+ Panelist
+ @endif
+
+
{{ $timeStr }}
+
+
{{ $message }}
+
diff --git a/resources/views/components/chat-panel.blade.php b/resources/views/components/chat-panel.blade.php
new file mode 100644
index 0000000..c50b5b1
--- /dev/null
+++ b/resources/views/components/chat-panel.blade.php
@@ -0,0 +1,44 @@
+@props([
+ 'warnings' => [],
+ 'candidateName' => 'Candidate',
+])
+
+@php
+ $interviewerColors = [
+ '#6366f1', // Indigo
+ '#10b981', // Emerald
+ '#f59e0b', // Amber
+ '#ec4899', // Pink
+ '#8b5cf6', // Purple
+ '#06b6d4', // Cyan
+ '#f97316', // Orange
+ ];
+@endphp
+
+merge(['class' => 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2']) }}>
+ @if(count($warnings) > 0)
+ @foreach($warnings as $w)
+ @php
+ $isCand = is_null($w->sent_by);
+ $senderName = $isCand
+ ? ($candidateName ?: 'Candidate')
+ : ($w->sender ? $w->sender->name : 'Interviewer');
+
+ $color = $isCand
+ ? '#38bdf8'
+ : $interviewerColors[($w->sent_by ?? 1) % count($interviewerColors)];
+ @endphp
+
+ @endforeach
+ @else
+
+ No chat history. Send a message below.
+
+ @endif
+
diff --git a/resources/views/components/modal.blade.php b/resources/views/components/modal.blade.php
new file mode 100644
index 0000000..acdabde
--- /dev/null
+++ b/resources/views/components/modal.blade.php
@@ -0,0 +1,43 @@
+@props([
+ 'id',
+ 'title' => null,
+ 'maxWidth' => '2xl',
+ 'onClose' => null,
+])
+
+@php
+ $maxWidths = [
+ 'sm' => 'max-w-sm',
+ 'md' => 'max-w-md',
+ 'lg' => 'max-w-lg',
+ 'xl' => 'max-w-xl',
+ '2xl' => 'max-w-2xl',
+ '5xl' => 'max-w-5xl',
+ '7xl' => 'max-w-7xl',
+ 'full' => 'max-w-full mx-4',
+ ];
+
+ $maxWidthClass = $maxWidths[$maxWidth] ?? $maxWidths['2xl'];
+@endphp
+
+merge(['class' => 'admin-modal fixed inset-0 w-screen h-screen bg-slate-950/85 backdrop-blur-md flex items-center justify-center z-[1000] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto']) }}>
+
+ @if($title || isset($headerActions))
+
+ @if($title)
+
{{ $title }}
+ @endif
+
+ @if(isset($headerActions))
+ {{ $headerActions }}
+ @endif
+
+
+
+ @endif
+
+
+ {{ $slot }}
+
+
+
diff --git a/resources/views/components/pill.blade.php b/resources/views/components/pill.blade.php
new file mode 100644
index 0000000..865b722
--- /dev/null
+++ b/resources/views/components/pill.blade.php
@@ -0,0 +1,41 @@
+@props([
+ 'variant' => 'info', // primary, secondary, success, danger, warning, info, ghost
+ 'size' => 'md', // sm, md
+ 'icon' => null,
+])
+
+@php
+ $baseClasses = 'inline-flex items-center gap-1.5 font-semibold text-[11px] tracking-wide px-2.5 py-1 rounded-full border transition-colors duration-150 whitespace-nowrap';
+
+ $variants = [
+ 'blue' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]',
+ 'primary' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]',
+ 'info' => 'text-[#4b82f7] bg-[rgba(75,130,247,0.12)] border-[rgba(75,130,247,0.35)]',
+ 'red' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]',
+ 'danger' => 'text-[#ef4a5f] bg-[rgba(239,74,95,0.1)] border-[rgba(239,74,95,0.32)]',
+ 'success' => 'text-[#21c274] bg-[rgba(33,194,116,0.12)] border-[rgba(33,194,116,0.35)]',
+ 'warning' => 'text-[#f0a939] bg-[rgba(240,169,57,0.12)] border-[rgba(240,169,57,0.35)]',
+ 'secondary' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]',
+ 'ghost' => 'text-slate-400 bg-[#0d1220] border-[#232b3d]',
+ ];
+
+ $sizes = [
+ 'sm' => 'px-2 py-0.5 text-[10.5px]',
+ 'md' => 'px-2.5 py-1 text-[11px]',
+ ];
+
+ $classes = implode(' ', [
+ $baseClasses,
+ $variants[$variant] ?? $variants['ghost'],
+ $sizes[$size] ?? $sizes['md'],
+ ]);
+@endphp
+
+merge(['class' => $classes]) }}>
+ @if($icon)
+
+ @endif
+ @if(trim($slot))
+ {{ $slot }}
+ @endif
+
diff --git a/resources/views/components/tab-button.blade.php b/resources/views/components/tab-button.blade.php
new file mode 100644
index 0000000..f9a6a31
--- /dev/null
+++ b/resources/views/components/tab-button.blade.php
@@ -0,0 +1,26 @@
+@props([
+ 'id' => null,
+ 'active' => false,
+ 'icon' => null,
+ 'tabKey' => null,
+])
+
+@php
+ $baseClasses = 'inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold rounded-md transition-colors duration-150 cursor-pointer select-none border-none';
+ $activeClasses = $active
+ ? 'bg-[#4b82f7] text-white shadow-sm active'
+ : 'bg-transparent text-slate-400 hover:text-slate-200';
+@endphp
+
+
diff --git a/resources/views/components/video-tile.blade.php b/resources/views/components/video-tile.blade.php
new file mode 100644
index 0000000..5b1177a
--- /dev/null
+++ b/resources/views/components/video-tile.blade.php
@@ -0,0 +1,57 @@
+@props([
+ 'id',
+ 'videoId',
+ 'placeholderId',
+ 'badgeText' => '',
+ 'badgeId' => null,
+ 'borderColor' => 'border-[#1b2233]',
+ 'showZoom' => false,
+ 'showFullscreen' => false,
+ 'initials' => 'CD',
+ 'avatarCircleId' => null,
+ 'avatarNameId' => null,
+ 'avatarTitle' => 'Video Stream',
+ 'zoomInFn' => null,
+ 'zoomOutFn' => null,
+ 'zoomResetFn' => null,
+ 'fullscreenFn' => null,
+ 'containerId' => null,
+ 'focused' => false,
+])
+
+merge(['class' => 'video-tile relative aspect-[16/11] bg-[#0d1220] border rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all ' . ($focused ? 'border-[rgba(75,130,247,0.35)] shadow-[0_0_0_1px_rgba(75,130,247,0.35)]' : 'border-[#1b2233]')]) }}>
+ @if($showZoom || $showFullscreen)
+
+ @if($showZoom)
+
+
+
+ @endif
+ @if($showFullscreen)
+
+ @endif
+
+ @endif
+
+ @if($containerId)
+
+
+
+ @else
+
+ @endif
+
+
+
+ {{ $initials }}
+
+
{{ $avatarTitle }}
+
+ {{ $slot->isNotEmpty() ? $slot : "Waiting for candidate…" }}
+
+
+
+
+ {{ $badgeText }}
+
+
diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php
index cd2ef7d..e92db91 100644
--- a/resources/views/interview/candidate_room.blade.php
+++ b/resources/views/interview/candidate_room.blade.php
@@ -1022,12 +1022,19 @@ function logViolation(type, details) {
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ type: type, details: details })
+ })
+ .then(r => r.json())
+ .then(res => {
+ if (typeof callSigChannel !== 'undefined' && callSigChannel) {
+ callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details });
+ }
+ if (typeof liveSyncChannel !== 'undefined' && liveSyncChannel) {
+ liveSyncChannel.postMessage({ type: 'violation_logged', violation_type: type, details: details });
+ }
+ if (typeof pollReviewData === 'function') {
+ pollReviewData();
+ }
});
-
- // Post message over BroadcastChannel to alert interviewer dashboard real-time
- if (typeof callSigChannel !== 'undefined' && callSigChannel) {
- callSigChannel.postMessage({ type: 'violation_occurred', violation_type: type, details: details });
- }
}
// --- CANDIDATE REAL-TIME CALL RINGTONE SYNTHESIZER ---
diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php
index 4497b69..73e21d5 100644
--- a/resources/views/interview/index.blade.php
+++ b/resources/views/interview/index.blade.php
@@ -8,254 +8,87 @@
+
-
-
-
-
-
+
+
-
-
-
- ⚡
+
+
+
+
-
Candidate Live Assessment Portal
-
Proctored IDE & Code Submissions
+
Candidate Live Assessment Portal
+
Proctored IDE & Code Submissions
-
+
-
+
@if(session('success'))
-
+
{{ session('success') }}
@endif
+
-
-
+
+
-
- ⚙️ Global Admin Proctoring & System Settings
+
+ Global Admin Proctoring & System Settings
-
Master control panel for candidate assessment rules, system screen capturing, and module toggles
+ Master control panel for candidate assessment rules, system screen capturing, and module toggles
-
- 🛡️ ADMIN CONTROL PANEL
-
+
+ ADMIN CONTROL PANEL
+
-