/** * Interview Call & Live Assessment Review Engine * Handles WebRTC 2-Way Calling, Candidate Screen Sharing, Real-Time Sync, * Proctoring Alerts, SOS Audio Ringtone, Tab Switching, and Silent Call Recording. */ import { initMeteredIceServers } from './metered.js'; import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js'; import { RecordingCompositor } from './recording-compositor.js'; import { createNoiseSuppressedStream, getRNNoisePreference, setRNNoisePreference } from './noise-suppressor.js'; // --- Global Audio Ringtone Synthesizer Engine --- export class CallRingtoneEngine { constructor() { this.ctx = null; this.interval = null; this.isPlaying = false; } init() { try { if (!this.ctx) { const AudioCtx = window.AudioContext || window.webkitAudioContext; this.ctx = new AudioCtx(); } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } } catch (e) { console.log('Audio init warning:', e); } } start() { if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; this.init(); if (this.isPlaying) return; this.isPlaying = true; this.playTone(); if (this.interval) clearInterval(this.interval); this.interval = setInterval(() => { if (this.isPlaying) this.playTone(); }, 2400); } playTone() { this.init(); if (!this.ctx || !this.isPlaying) return; try { const now = this.ctx.currentTime; const osc1 = this.ctx.createOscillator(); const osc2 = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc1.type = 'sine'; osc2.type = 'sine'; osc1.frequency.setValueAtTime(523.25, now); osc2.frequency.setValueAtTime(659.25, now); gain.gain.setValueAtTime(0.001, now); gain.gain.linearRampToValueAtTime(0.35, now + 0.08); gain.gain.setValueAtTime(0.35, now + 1.1); gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3); osc1.connect(gain); osc2.connect(gain); gain.connect(this.ctx.destination); osc1.start(now); osc2.start(now); osc1.stop(now + 1.35); osc2.stop(now + 1.35); } catch (e) {} } stop() { this.isPlaying = false; if (this.interval) { clearInterval(this.interval); this.interval = null; } } } export const callRingtone = new CallRingtoneEngine(); // State variables let currentInterviewId = null; let currentSubmissionUniqueId = null; let interviewerPeer = null; let screenReceiverPeer = null; export function getCandidatePeerId() { if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { currentInterviewId = document.body.dataset.interviewId; } if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) { currentSubmissionUniqueId = document.body.dataset.submissionId; } if (!currentInterviewId) return ''; const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) ? currentSubmissionUniqueId : null; return subId ? ('cand_' + currentInterviewId + '_' + subId) : ('cand_' + currentInterviewId); } export function getMyInterviewerPeerId() { const currentUserId = window.currentUserId || 0; if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { currentInterviewId = document.body.dataset.interviewId; } if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) { currentSubmissionUniqueId = document.body.dataset.submissionId; } if (!currentInterviewId) return ''; const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) ? currentSubmissionUniqueId : null; return subId ? ('interviewer_' + currentInterviewId + '_' + subId + '_' + currentUserId) : ('interviewer_' + currentInterviewId + '_' + currentUserId); } export function getInterviewerScreenPeerId(userId = null) { const currentUserId = userId !== null ? userId : (window.currentUserId || 0); if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { currentInterviewId = document.body.dataset.interviewId; } if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) { currentSubmissionUniqueId = document.body.dataset.submissionId; } if (!currentInterviewId) return ''; const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) ? currentSubmissionUniqueId : null; return subId ? ('interviewer_screen_' + currentInterviewId + '_' + subId + '_' + currentUserId) : ('interviewer_screen_' + currentInterviewId + '_' + currentUserId); } let interviewerLocalStream = null; let interviewerNoiseSuppressor = null; let activeCall = null; let isInterviewerMicOn = true; let isInterviewerCamOn = true; let screenZoomLevel = 1.0; let candVidZoomLevel = 1.0; let activeViolations = []; let sosPollInterval = null; let peerHeartbeatTimer = null; let acknowledgedViolationCount = 0; let sosDismissed = false; let sosAutoTriggerDisabled = true; let currentTabScreenshotEnabled = true; let interviewerSigChannel = null; let interviewerPeerConnection = null; let liveSyncChannel = null; let panelistMeshTiles = new Map(); let latestActivePeers = []; // Ringing & Call state let activeIncomingCall = null; let iAmTheCaller = false; let ringTimeoutTimer = null; const dismissedCallIds = new Set(); export function formatProctoringLogDetails(l) { let detailsObj = l.details; if (typeof detailsObj === 'string') { try { detailsObj = JSON.parse(detailsObj); } catch(e) {} } const type = l.type || ''; if (type === 'multiple_faces') { const count = (typeof detailsObj === 'object' && detailsObj && detailsObj.face_count) ? detailsObj.face_count : 2; return `Multiple faces detected in camera frame (${count} faces)`; } if (type === 'face_missing') { return 'Candidate face is not visible in camera frame'; } if (type === 'looking_away' || type === 'prolonged_looking_away') { if (typeof detailsObj === 'object' && detailsObj && (detailsObj.reasons || typeof detailsObj.yaw === 'number')) { const reasonLabels = { 'head_yaw': 'Head Yaw', 'head_up': 'Head Pitch Up', 'head_down': 'Head Pitch Down', 'eye_gaze_left': 'Eye Gaze Left', 'eye_gaze_right': 'Eye Gaze Right', 'eye_gaze_down': 'Eye Gaze Down' }; const reasonsStr = Array.isArray(detailsObj.reasons) ? detailsObj.reasons.map(r => reasonLabels[r] || r.replace(/_/g, ' ')).join(', ') : ''; const durationPrefix = (type === 'prolonged_looking_away') ? 'Looking away continuously (>10s)' : 'Looking away from camera'; return reasonsStr ? `${durationPrefix} (${reasonsStr})` : durationPrefix; } else if (typeof l.details === 'string' && l.details && !l.details.startsWith('{')) { return l.details; } return type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera'; } if (type === 'phone_detected') { const label = (typeof detailsObj === 'object' && detailsObj && detailsObj.label) ? detailsObj.label : 'cell phone'; return `Possible mobile phone detected in camera frame (${label})`; } if (type === 'prolonged_corner_gaze') { if (typeof detailsObj === 'object' && detailsObj && (detailsObj.corner_label || detailsObj.corner)) { const cornerLabel = detailsObj.corner_label || (detailsObj.corner || 'screen corner').replace(/_/g, ' '); const dur = detailsObj.duration_s || Math.round((detailsObj.duration_ms || 18000) / 1000); return `Fixated on ${cornerLabel} for ${dur}s`; } return 'Candidate fixated on screen corner for prolonged period'; } if (type === 'talking_secondary_person') { return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a secondary person'; } if (typeof l.details === 'object' && l.details !== null) { try { return JSON.stringify(l.details); } catch(e) { return String(l.details); } } return l.details || ''; } const endedCallIds = new Set(); let globalCallChannel = null; const adminPageLoadTimestamp = Date.now(); let globalIceServers = window.globalIceServers || [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun.cloudflare.com:3478' } ]; export function getInitials(name) { if (!name) return 'CD'; const parts = name.trim().split(/\s+/); if (parts.length >= 2) { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } const str = name.trim(); if (str.length >= 2) { return (str[0] + str[str.length - 1]).toUpperCase(); } return str.toUpperCase(); } export function zoomScreen(delta) { screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta)); const screenVid = document.getElementById('interviewer-screen-video'); if (screenVid) { screenVid.style.transform = `scale(${screenZoomLevel})`; } } export function resetZoomScreen() { screenZoomLevel = 1.0; zoomScreen(0); } export function toggleFullscreenScreen() { const wrapper = document.getElementById('screen-wrapper'); if (!wrapper) return; if (!document.fullscreenElement) { wrapper.requestFullscreen().catch(err => console.log(err)); } else { document.exitFullscreen(); } } export function zoomCandVideo(delta) { if (delta === 0) candVidZoomLevel = 1.0; else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta)); const vid = document.getElementById('interviewer-cand-video'); if (vid) { vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`; vid.style.transformOrigin = 'center center'; vid.style.transition = 'transform 0.2s ease'; } } export function toggleSosAutoTrigger() { sosAutoTriggerDisabled = !sosAutoTriggerDisabled; const toggleSw = document.getElementById('sosToggle'); const btn = document.getElementById('sos-toggle-btn'); if (toggleSw) { if (sosAutoTriggerDisabled) { toggleSw.classList.remove('on'); } else { toggleSw.classList.add('on'); } } if (btn) { if (sosAutoTriggerDisabled) { btn.innerHTML = ' SOS Auto-Trigger: OFF'; btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300'; } else { btn.innerHTML = ' SOS Auto-Trigger: ON'; btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300'; } } } export function openReportPage() { if (!currentInterviewId) return; window.open(`/interviews/${currentInterviewId}/report`, '_blank'); } export function toggleTabScreenshotCapture() { if (!currentInterviewId) return; const newStatus = !currentTabScreenshotEnabled; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ enable: newStatus }) }) .then(r => r.json()) .then(res => { if (res.success) { currentTabScreenshotEnabled = res.enable_tab_switch_screenshot; updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'info', title: 'Screenshot Setting Updated', text: res.message, timer: 2500, showConfirmButton: false, toast: true, position: 'top-end' }); } } }) .catch(() => {}); } export function updateScreenshotToggleBtnUI(enabled) { const btn = document.getElementById('modal-toggle-screenshot-btn'); if (btn) { if (enabled) { btn.innerHTML = ' ENABLED'; btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none'; } else { btn.innerHTML = ' DISABLED'; btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none'; } } } let currentInspectorTab = null; export function closeInspectorPanel() { currentInspectorTab = null; try { if (currentInterviewId) { sessionStorage.removeItem('interview_inspector_tab_' + currentInterviewId); } } catch(e) {} const details = document.getElementById('inspector-details'); if (details) details.open = false; const tabs = ['logs', '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 = 'none'; if (btnEl) { btnEl.classList.remove('bg-indigo-600', 'text-white'); btnEl.classList.add('text-slate-400', 'hover:bg-white/5'); } }); } export function switchIdeTab(tab, event = null) { if (event) { event.stopPropagation(); } const details = document.getElementById('inspector-details'); // If user clicks the currently active open tab button, toggle it collapsed if (currentInspectorTab === tab && details && details.open) { closeInspectorPanel(); return; } currentInspectorTab = tab; try { if (currentInterviewId) { sessionStorage.setItem('interview_inspector_tab_' + currentInterviewId, tab); } } catch(e) {} if (details) details.open = true; const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots']; const tabMeta = { logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' }, notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' }, drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' }, recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' }, screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' }, code: { title: 'Candidate Code', icon: 'fa-solid fa-code' } }; const floatingTitle = document.getElementById('inspector-floating-text'); const floatingIcon = document.getElementById('inspector-floating-icon'); if (tabMeta[tab]) { if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title; if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`; } 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'); btnEl.classList.remove('text-slate-400', 'hover:bg-white/5'); } else { btnEl.classList.remove('bg-indigo-600', 'text-white'); btnEl.classList.add('text-slate-400', 'hover:bg-white/5'); } } }); } // Click outside to dismiss floating inspector details if (typeof document !== 'undefined') { document.addEventListener('click', function(e) { const details = document.getElementById('inspector-details'); if (details && details.open && !details.contains(e.target)) { closeInspectorPanel(); } }); } export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) { if (!callData || !callData.id) return; if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return; if (iAmTheCaller && currentInterviewId == callData.id) return; if (interviewerLocalStream && currentInterviewId == callData.id) return; if (callData.call_started_at) { const startedTime = new Date(callData.call_started_at).getTime(); const elapsedMs = Date.now() - startedTime; if (elapsedMs > 8000 && !isRealtimeBroadcast) { dismissedCallIds.add(callData.id); return; } } else if (!isRealtimeBroadcast) { dismissedCallIds.add(callData.id); return; } activeIncomingCall = callData; const nameEl = document.getElementById('incoming-cand-name'); const infoEl = document.getElementById('incoming-starter-info'); const modalEl = document.getElementById('incoming-call-modal'); if (nameEl) nameEl.innerText = callData.candidate_name; if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist'); if (modalEl) modalEl.classList.add('active'); callRingtone.start(); if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer); let remainingMs = 8000; if (callData.call_started_at) { const startedTime = new Date(callData.call_started_at).getTime(); const elapsedMs = Date.now() - startedTime; remainingMs = Math.max(1000, 8000 - elapsedMs); } ringTimeoutTimer = setTimeout(() => { declineIncomingCall(true); }, remainingMs); } export function pollGlobalActiveCalls() { if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; fetch('/interviews/active-calls', { headers: { 'Accept': 'application/json' } }) .then(r => r.json()) .then(data => { if (!data || !data.active_calls || data.active_calls.length === 0) { if (activeIncomingCall) declineIncomingCall(true); return; } const incoming = data.active_calls.find(call => { if (endedCallIds.has(call.id)) return false; if (iAmTheCaller && currentInterviewId == call.id) return false; if (interviewerLocalStream && currentInterviewId == call.id) return false; if (dismissedCallIds.has(call.id)) return false; return true; }); if (incoming) { const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0; if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) { dismissedCallIds.add(incoming.id); return; } if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) { triggerIncomingCallRing(incoming, false); } } else if (activeIncomingCall) { declineIncomingCall(true); } }) .catch(() => {}); } export function acceptIncomingCall() { callRingtone.stop(); if (ringTimeoutTimer) { clearTimeout(ringTimeoutTimer); ringTimeoutTimer = null; } const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); if (activeIncomingCall) { const targetCall = activeIncomingCall; activeIncomingCall = null; if (window.location.pathname.includes('/interviews/' + targetCall.id)) { startInterviewerCall(true); } else { window.location.href = `/interviews/${targetCall.id}?join_call=1`; } } } export function declineIncomingCall(silent = false) { callRingtone.stop(); if (ringTimeoutTimer) { clearTimeout(ringTimeoutTimer); ringTimeoutTimer = null; } const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); if (activeIncomingCall) { dismissedCallIds.add(activeIncomingCall.id); activeIncomingCall = null; } if (!silent && typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'info', title: 'Call Missed / Declined', text: 'You can click "Join Active Call" from the list whenever you are ready.', timer: 3000, showConfirmButton: false, toast: true, position: 'top-end' }); } } export function subscribeLiveSync(interviewId) { if (liveSyncChannel) { try { liveSyncChannel.close(); } catch(e) {} } liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); liveSyncChannel.onmessage = function(event) { const data = event.data; if (!data) return; if (data.type === 'code_sync') { if (data.language) { const langEl = document.getElementById('modal-code-lang'); if (langEl) langEl.innerText = data.language.toUpperCase(); } if (data.code !== undefined) { const codeEl = document.getElementById('modal-code-display'); if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.'; } } else if (data.type === 'notes_sync') { if (data.notes !== undefined) { const notesEl = document.getElementById('modal-notes-display'); if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.'; } } else if (data.type === 'drawing_sync') { const drawingImg = document.getElementById('modal-drawing-img'); const noDrawing = document.getElementById('no-drawing-placeholder'); if (data.drawing) { if (drawingImg) { drawingImg.src = data.drawing; drawingImg.style.display = 'block'; } if (noDrawing) noDrawing.style.display = 'none'; } else { if (drawingImg) drawingImg.style.display = 'none'; if (noDrawing) noDrawing.style.display = 'block'; } } else if (data.type === 'peer_state_changed') { const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_')); if (isCandPeer) { const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true; const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true; updateCandidateStatusIcons(isMicOn, isCamOn); } pollReviewData(); } else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') { pollReviewData(); } }; if (interviewerSigChannel) { try { interviewerSigChannel.close(); } catch(e) {} } interviewerSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); interviewerSigChannel.onmessage = function(event) { const data = event.data; if (!data) return; if (data.type === 'peer_state_changed') { const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_')); if (isCandPeer) { const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true; const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true; updateCandidateStatusIcons(isMicOn, isCamOn); } setTimeout(pollReviewData, 500); } else if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') { setTimeout(pollReviewData, 500); } }; } export function openReviewModal(id, name, uid, autoJoinCall = false) { currentInterviewId = id; currentSubmissionUniqueId = uid; acknowledgedViolationCount = 0; sosDismissed = false; const toggleSw = document.getElementById('sosToggle'); if (toggleSw) { if (sosAutoTriggerDisabled) { toggleSw.classList.remove('on'); } else { toggleSw.classList.add('on'); } } updateInterviewerNoiseSuppressionUI(interviewerNoiseSuppressor ? interviewerNoiseSuppressor.isEnabled() : getRNNoisePreference()); 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(); if (document.getElementById('inspector-content-container')) { let savedInspectorTab = null; try { savedInspectorTab = sessionStorage.getItem('interview_inspector_tab_' + id); } catch(e) {} if (savedInspectorTab) { switchIdeTab(savedInspectorTab); } else { closeInspectorPanel(); } } else if (document.getElementById('tab-btn-logs')) { switchIdeTab('logs'); } else if (document.getElementById('tab-btn-notes')) { switchIdeTab('notes'); } else { switchIdeTab('code'); } updateCandidateStatusIcons(false, false, false); initInterviewerSigChannel(); subscribeLiveSync(id); pollReviewData(); if (sosPollInterval) clearInterval(sosPollInterval); sosPollInterval = setInterval(pollReviewData, 6000); if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer); peerHeartbeatTimer = setInterval(() => { sendInterviewerPeerHeartbeat('heartbeat'); }, 6000); if (autoJoinCall) { setTimeout(() => { startInterviewerCall(true); }, 400); } } export function sendInterviewerPeerHeartbeat(action = 'heartbeat') { if (!currentInterviewId) return; const currentUserId = window.currentUserId || 0; const currentUserName = window.currentUserName || 'Interviewer'; const currentUserRole = window.currentUserRole || 'interviewer'; const myPeerId = getMyInterviewerPeerId(); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ peer_id: myPeerId, name: currentUserName, role: currentUserRole, mic_on: isInterviewerMicOn, cam_on: isInterviewerCamOn, action: action }) }).catch(() => {}); } function isTrueVal(val) { if (val === undefined || val === null) return true; if (val === false || val === 'false' || val === 0 || val === '0') return false; return Boolean(val); } export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { const micOn = isTrueVal(isMicOn); const camOn = isTrueVal(isCamOn); const candMicIcon = document.getElementById('cand-mic-status'); const candCamIcon = document.getElementById('cand-cam-status'); const candPlaceholder = document.getElementById('cand-video-placeholder'); const candStatusText = document.getElementById('cand-status-text') || (candPlaceholder ? candPlaceholder.querySelector('.status') : null); const candVid = document.getElementById('interviewer-cand-video'); const screenVid = document.getElementById('interviewer-screen-video'); const candVidWrapper = document.getElementById('cand-video-wrapper'); const screenWrapper = document.getElementById('screen-wrapper'); const screenPlaceholder = document.getElementById('screen-video-placeholder'); const joinedPill = document.getElementById('candidate-joined-pill'); if (!isJoined) { if (candVid) candVid.srcObject = null; if (screenVid) screenVid.srcObject = null; if (joinedPill) { joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60'; joinedPill.innerHTML = ' Not Joined'; } if (candMicIcon) { candMicIcon.className = 'fa-solid fa-microphone-slash text-slate-500 text-[10px]'; candMicIcon.title = 'Candidate Not Joined'; } if (candCamIcon) { candCamIcon.className = 'fa-solid fa-video-slash text-slate-500 text-[10px]'; candCamIcon.title = 'Candidate Not Joined'; } if (candVidWrapper) { candVidWrapper.style.opacity = '0.4'; candVidWrapper.style.filter = 'grayscale(60%)'; candVidWrapper.style.transition = 'all 0.3s ease'; } if (screenWrapper) { screenWrapper.style.opacity = '0.4'; screenWrapper.style.filter = 'grayscale(60%)'; screenWrapper.style.transition = 'all 0.3s ease'; } if (candPlaceholder) { candPlaceholder.style.display = 'flex'; if (candStatusText) candStatusText.innerText = 'Candidate Not Joined'; } if (screenPlaceholder) { screenPlaceholder.style.display = 'flex'; } return; } const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); // Joined state: restore styles & active icons if (joinedPill) { joinedPill.className = 'pill green inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-emerald-500/30 text-emerald-400 bg-emerald-500/10'; joinedPill.innerHTML = ' Joined'; } if (candVidWrapper) { candVidWrapper.style.opacity = '1'; candVidWrapper.style.filter = 'none'; } if (screenWrapper) { screenWrapper.style.opacity = '1'; screenWrapper.style.filter = 'none'; } if (candMicIcon && isMicOn !== undefined) { candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted'; } if (candCamIcon && isCamOn !== undefined) { candCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]'; candCamIcon.title = camOn ? 'Camera On' : 'Camera Off'; } if (candPlaceholder) { if (!camOn) { candPlaceholder.style.display = 'flex'; if (candStatusText) candStatusText.innerText = 'Camera Turned Off'; } else if (candidateStreamConnected) { candPlaceholder.style.display = 'none'; } else { candPlaceholder.style.display = 'flex'; if (candStatusText) candStatusText.innerText = 'Waiting for candidate camera stream...'; } } } export function pollReviewData() { if (!currentInterviewId) return; const myPeerId = getMyInterviewerPeerId(); const candPeerId = getCandidatePeerId(); if (interviewerLocalStream) { sendInterviewerPeerHeartbeat('heartbeat'); } fetch(`/interviews/${currentInterviewId}/poll`, { headers: { 'Accept': 'application/json' } }) .then(r => { if (!r.ok) return null; const contentType = r.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) return null; return r.json(); }) .then(data => { if (!data) return; if (data.active_peers && Array.isArray(data.active_peers)) { latestActivePeers = data.active_peers; } const isCallEnded = (data.call_status === 'ended'); if (isCallEnded) { // When call status is "Call Ended", do NOT check for candidate status updateCandidateStatusIcons(false, false, false); const screenVid = document.getElementById('interviewer-screen-video'); const screenPlace = document.getElementById('screen-video-placeholder'); if (screenVid) screenVid.srcObject = null; if (screenPlace) screenPlace.style.display = 'flex'; if (typeof window.updateScreenShareLayout === 'function') { window.updateScreenShareLayout(false); } panelistMeshTiles.forEach((_, pid) => { removePanelistTile(pid); }); } else { const candVid = document.getElementById('interviewer-cand-video'); const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); const candidatePeer = (data.active_peers && Array.isArray(data.active_peers)) ? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_'))))) : null; const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected); if (isCandidateJoined) { const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true; const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true; updateCandidateStatusIcons(isMicOn, isCamOn, true); } else { updateCandidateStatusIcons(false, false, false); } if (data.active_peers && Array.isArray(data.active_peers)) { const activeIds = new Set(data.active_peers.map(p => p.peer_id)); data.active_peers.forEach(p => { if (p.peer_id && p.peer_id !== myPeerId) { let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name ? ('Panelist ' + p.name) : 'Panelist'); const isMicOn = isTrueVal(p.mic_on); const isCamOn = isTrueVal(p.cam_on); const isCandPeer = p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_')))); if (isCandPeer) { if (interviewerLocalStream && interviewerPeer && interviewerPeer.open && !candidateStreamConnected) { try { const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { if (candVid) safePlayMediaStream(candVid, remoteStream); updateCandidateStatusIcons(isMicOn, isCamOn, true); }); } } catch(e) {} } } else if (p.peer_id.startsWith('interviewer_') || p.role === 'interviewer' || p.role === 'admin') { if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { if (!panelistMeshTiles.has(p.peer_id)) { try { const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); }); } } catch(e) {} } else { addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn); } } else { addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id) || null, roleLabel, isMicOn, isCamOn); } } } }); panelistMeshTiles.forEach((_, pid) => { if (!activeIds.has(pid)) { removePanelistTile(pid); } }); } } const codeLang = document.getElementById('modal-code-lang'); const codeDisplay = document.getElementById('modal-code-display'); const outputDisplay = document.getElementById('modal-output-display'); const notesDisplay = document.getElementById('modal-notes-display'); if (codeLang) codeLang.innerText = (data.submitted_language || 'UNKNOWN').toUpperCase(); if (codeDisplay) codeDisplay.innerText = data.submitted_code || '// Candidate has not typed any code yet.'; if (outputDisplay) outputDisplay.innerText = data.code_output || 'No execution output.'; if (notesDisplay) notesDisplay.innerText = data.candidate_notes || 'No candidate notes written yet.'; const drawingImg = document.getElementById('modal-drawing-img'); const noDrawing = document.getElementById('no-drawing-placeholder'); if (data.candidate_drawing) { if (drawingImg) { drawingImg.src = data.candidate_drawing; drawingImg.style.display = 'block'; } if (noDrawing) noDrawing.style.display = 'none'; } else { if (drawingImg) drawingImg.style.display = 'none'; if (noDrawing) noDrawing.style.display = 'block'; } // Saved recordings const recContainer = document.getElementById('modal-recordings-container'); if (recContainer) { const recPath = data.recording_path; let recList = data.recordings || []; if (recList.length === 0 && recPath) { recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }]; } // Auto-clear one-time processing notice if new recordings arrived if (isRecordingProcessing && recList.length > recordingKnownCountBeforeProcessing) { isRecordingProcessing = false; recordingProcessingSessionId = null; } let processingBannerHtml = ''; if (isRecordingProcessing) { processingBannerHtml = `
Processing Call Recording…
Your video recording is currently being assembled on the server. It will appear here shortly with a download link once ready.
`; } function formatDurationBadgeText(sec) { if (!sec || isNaN(sec) || sec <= 0) return null; const m = Math.floor(sec / 60); const s = Math.round(sec % 60); return m > 0 ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`; } function resolveMediaDuration(videoUrl, badgeElementId, fallbackDuration = 0) { const el = document.getElementById(badgeElementId); if (!el) return; if (fallbackDuration && fallbackDuration > 0) { const text = formatDurationBadgeText(fallbackDuration); if (text) { el.innerHTML = ` ${text}`; el.style.display = 'inline-flex'; return; } } if (!videoUrl) return; const v = document.createElement('video'); v.preload = 'metadata'; v.src = videoUrl; const applyDuration = (d) => { if (!d || isNaN(d) || d <= 0) return; const text = formatDurationBadgeText(d); const targetEl = document.getElementById(badgeElementId); if (text && targetEl) { targetEl.innerHTML = ` ${text}`; targetEl.style.display = 'inline-flex'; } }; v.onloadedmetadata = function() { if (v.duration && !isNaN(v.duration) && v.duration !== Infinity && v.duration > 0) { applyDuration(v.duration); } else { // Workaround for WebM streaming containers where duration is Infinity until seeked v.currentTime = 1e101; v.ontimeupdate = function() { v.ontimeupdate = null; if (v.currentTime && !isNaN(v.currentTime) && v.currentTime > 0) { applyDuration(v.currentTime); } }; } }; } 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 = processingBannerHtml + `
`; recList.forEach((r, displayIdx) => { const recNum = recList.length - displayIdx; const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : 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 dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date(); const dateFormatted = dateObj.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); const timeFormatted = dateObj.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: true }); const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`); const badgeId = `rec-duration-badge-${origIdx}-${displayIdx}`; const initialDuration = (r && r.duration && r.duration > 0) ? r.duration : 0; const initialDurationText = formatDurationBadgeText(initialDuration); recHtml += `
Recording #${recNum} ${initialDurationText || ''} WebM
${dateFormatted} • ${timeFormatted}
`; }); recHtml += `
`; recContainer.innerHTML = recHtml; // Dynamically resolve media duration for all cards recList.forEach((r, displayIdx) => { const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : 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 badgeId = `rec-duration-badge-${origIdx}-${displayIdx}`; const initialDuration = (r && r.duration && r.duration > 0) ? r.duration : 0; resolveMediaDuration(videoUrl, badgeId, initialDuration); }); } else { recContainer.innerHTML = processingBannerHtml + `

No video recordings saved yet

Recordings captured during the call will appear here for download.

`; } } // 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-full 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-full overflow-y-auto p-3 mb-2.5'; chatHistory.innerHTML = '
No chat history. Send a message below.
'; } } currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true; updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); // Screenshots container const shotContainer = document.getElementById('modal-screenshots-container'); if (shotContainer) { const shotList = data.tab_switch_screenshots || []; if (shotList.length > 0) { let shotHtml = `
📸 Tab-Switch Screenshots Captured (${shotList.length}) Click image to enlarge full size
`; shotList.forEach((s) => { let url = typeof s === 'string' ? s : (s.url || s.full_url); if (url && !url.startsWith('http') && !url.startsWith('/')) { url = '/' + url; } const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : ''; shotHtml += `
📅 ${timeStr}
📥 View / Download
`; }); shotHtml += `
`; shotContainer.innerHTML = shotHtml; } else { shotContainer.innerHTML = '
No tab-switch screenshots captured for this candidate yet.
'; } } // Header live pulse dot & buttons visibility const livePulseDot = document.getElementById('live-call-pulse'); const recStartBtn = document.getElementById('btn-start-recording'); const recStopBtn = document.getElementById('btn-stop-recording'); const startBtn = document.getElementById('btn-start-call'); const leaveBtn = document.getElementById('btn-leave-call'); const endAllBtn = document.getElementById('btn-end-all-call'); const micBtn = document.getElementById('btn-toggle-interviewer-mic'); const camBtn = document.getElementById('btn-toggle-interviewer-cam'); const timelinePill = document.getElementById('timeline-status-pill'); if (data.call_status) { if (data.call_status === 'active') { if (!interviewerLocalStream) { if (livePulseDot) livePulseDot.style.display = 'none'; if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); if (icon) icon.className = 'fa-solid fa-phone'; if (span) span.innerText = 'Join Call'; startBtn.style.display = 'inline-flex'; } if (leaveBtn) leaveBtn.style.display = 'none'; if (endAllBtn) endAllBtn.style.display = 'none'; if (micBtn) micBtn.style.display = 'none'; if (camBtn) camBtn.style.display = 'none'; if (recStartBtn) recStartBtn.style.display = 'none'; if (recStopBtn) recStopBtn.style.display = 'none'; if (timelinePill) { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; if (span) span.innerText = 'Call in progress'; timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; } } else { if (livePulseDot) livePulseDot.style.display = 'inline-block'; if (startBtn) startBtn.style.display = 'none'; if (leaveBtn) leaveBtn.style.display = 'inline-flex'; if (endAllBtn) endAllBtn.style.display = 'inline-flex'; if (micBtn) micBtn.style.display = 'inline-flex'; if (camBtn) camBtn.style.display = 'inline-flex'; if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { recStartBtn.style.display = 'inline-flex'; } if (timelinePill) { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; if (span) span.innerText = 'Connected (Live)'; timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; } } } else if (data.call_status === 'ended' || data.call_status === 'idle') { if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall(); if (livePulseDot) livePulseDot.style.display = 'none'; if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); if (data.call_status === 'ended') { if (icon) icon.className = 'fa-solid fa-phone-slash'; if (span) span.innerText = 'Call Ended'; startBtn.disabled = true; startBtn.style.opacity = '0.5'; startBtn.style.cursor = 'not-allowed'; startBtn.style.pointerEvents = 'none'; startBtn.style.display = 'inline-flex'; } else { if (icon) icon.className = 'fa-solid fa-phone'; if (span) span.innerText = 'Start Call'; startBtn.disabled = false; startBtn.style.opacity = '1'; startBtn.style.cursor = 'pointer'; startBtn.style.pointerEvents = 'auto'; startBtn.style.display = 'inline-flex'; } } if (leaveBtn) leaveBtn.style.display = 'none'; if (endAllBtn) endAllBtn.style.display = 'none'; if (micBtn) micBtn.style.display = 'none'; if (camBtn) camBtn.style.display = 'none'; if (recStartBtn) recStartBtn.style.display = 'none'; if (recStopBtn) recStopBtn.style.display = 'none'; if (timelinePill && data.call_status === 'ended') { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; if (span) span.innerText = 'Call ended'; timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; } } } // Proctoring logs const logsDisplay = document.getElementById('modal-logs-display'); let logsHtml = ''; let currentViolations = []; if (data.proctor_logs && data.proctor_logs.length > 0) { data.proctor_logs.forEach(l => { if (l.type === 'proctoring_summary_report') return; let icoClass = 'amber'; let faIcon = 'fa-solid fa-window-restore'; let isFlag = false; if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; } else if (l.type === 'looking_away') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye-slash'; } else if (l.type === 'face_missing') { icoClass = 'red'; faIcon = 'fa-solid fa-user-slash'; isFlag = true; } else if (l.type === 'multiple_faces') { icoClass = 'red'; faIcon = 'fa-solid fa-users-viewfinder'; isFlag = true; } else if (l.type === 'prolonged_looking_away') { icoClass = 'red'; faIcon = 'fa-solid fa-clock-rotate-left'; isFlag = true; } else if (l.type === 'phone_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen-button'; isFlag = true; } else if (l.type === 'prolonged_corner_gaze') { icoClass = 'amber'; faIcon = 'fa-solid fa-arrows-to-eye'; isFlag = true; } else if (l.type === 'talking_secondary_person') { icoClass = 'red'; faIcon = 'fa-solid fa-user-group'; isFlag = true; } else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; } else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; } else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; } else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; } else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; } else if (l.type === 'paste_event') { icoClass = 'red'; faIcon = 'fa-solid fa-paste'; isFlag = true; } else if (l.type === 'copy_event') { icoClass = 'amber'; faIcon = 'fa-solid fa-copy'; } if (['paste_event', 'copy_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device', 'looking_away', 'face_missing', 'multiple_faces', 'prolonged_looking_away', 'prolonged_corner_gaze', 'phone_detected'].includes(l.type)) { currentViolations.push(l); } let screenshotLink = l.screenshot_url ? `[ Screenshot]` : ''; let aiBadge = ''; if (l.ai_verdict) { const confPct = Math.round((l.ai_verdict.confidence || 0.85) * 100); const engineName = l.ai_verdict.engine || 'AI Engine'; const toolName = l.ai_verdict.ai_tool_detected ? ` • Detected: ${l.ai_verdict.ai_tool_detected}` : ''; aiBadge = `
AI inspector · ${confPct}% cheating confidence (${engineName}${toolName})
`; isFlag = true; } const formattedType = (l.type || 'violation').replace(/_/g, ' '); const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1); const humanDetails = formatProctoringLogDetails(l); const timeString = new Date(l.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); logsHtml += `
${titleCap} — ${humanDetails}${screenshotLink}${aiBadge}
${timeString}
`; }); } else { logsHtml = '
Zero proctoring violations recorded.
'; } if (logsDisplay) logsDisplay.innerHTML = logsHtml; activeViolations = currentViolations; const totalViolations = currentViolations.length; // Trigger SOS Modal Popup and Ringtone Alarm ONLY if NEW unacknowledged violations occurred & SOS Auto-Trigger is active if (totalViolations > 0 && totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) { sosDismissed = false; const newViolations = currentViolations.slice(acknowledgedViolationCount); openSosModal(newViolations); } }) .catch(() => {}); } export function openSosModal(specificViolations = null) { let violationsToDisplay = specificViolations; if (!violationsToDisplay || violationsToDisplay.length === 0) { if (acknowledgedViolationCount > 0 && activeViolations.length > acknowledgedViolationCount) { violationsToDisplay = activeViolations.slice(acknowledgedViolationCount); } else { violationsToDisplay = activeViolations; } } let html = ''; if (violationsToDisplay && violationsToDisplay.length > 0) { violationsToDisplay.forEach(v => { let typeTitle = (v.type || '').toUpperCase(); if (v.type === 'external_ai_detected') { typeTitle = 'candidate might use external ai'; } else if (v.type === 'tab_switch') { typeTitle = 'Candidate Switched Browser Tab'; } else if (v.type === 'multiple_faces') { typeTitle = 'Multiple Faces Detected'; } else if (v.type === 'face_missing') { typeTitle = 'Candidate Face Not Visible'; } else if (v.type === 'looking_away') { typeTitle = 'Candidate Looking Away'; } else if (v.type === 'prolonged_looking_away') { typeTitle = 'Prolonged Looking Away (>10s)'; } else if (v.type === 'phone_detected') { typeTitle = 'Mobile Phone Detected'; } else if (v.type === 'talking_secondary_person') { typeTitle = 'Candidate Talking to Secondary Person'; } else if (v.type === 'talking_on_phone') { typeTitle = 'Candidate Talking on Phone'; } else if (v.type === 'reading_external_device') { typeTitle = 'Candidate Reading from External Device'; } else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') { typeTitle = 'Candidate Reading Question Out Loud'; } const borderCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'border-red-500' : 'border-amber-500'; const textCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'text-red-300' : 'text-amber-300'; const vDetails = formatProctoringLogDetails(v); const vTime = new Date(v.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true }); html += `
🚨 ${typeTitle}: ${vDetails}
Timestamp: ${vTime}
`; }); } else { html = '
Potential proctoring alert flagged by engine.
'; } const listEl = document.getElementById('sos-violation-list'); const modalEl = document.getElementById('sos-modal'); if (listEl) listEl.innerHTML = html; if (modalEl) modalEl.classList.add('active'); try { callRingtone.start(); } catch(e) {} } export function stopSosAlarm() { try { callRingtone.stop(); } catch(e) {} const modalEl = document.getElementById('sos-modal'); if (modalEl) modalEl.classList.remove('active'); acknowledgedViolationCount = activeViolations.length; sosDismissed = true; } export function disableSosAndStopAlarm() { sosAutoTriggerDisabled = true; const toggleSw = document.getElementById('sosToggle'); const btn = document.getElementById('sos-toggle-btn'); if (toggleSw) toggleSw.classList.remove('on'); if (btn) { btn.innerHTML = ' SOS Auto-Trigger: OFF'; btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300'; } stopSosAlarm(); } export function interviewerLogoutAction() { const form = document.getElementById('logout-form'); if (typeof window.Swal !== 'undefined') { window.Swal.fire({ title: 'Logout of Portal?', text: 'Are you sure you want to log out of your session?', icon: 'question', showCancelButton: true, confirmButtonColor: '#ef4444', cancelButtonColor: '#475569', confirmButtonText: 'Yes, Logout' }).then((result) => { if (result.isConfirmed && form) { form.submit(); } }); } else if (form) { form.submit(); } } export function regenerateCandidatePassword() { if (!currentInterviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; if (typeof window.Swal !== 'undefined') { window.Swal.fire({ title: 'Regenerate Candidate Password?', text: 'This will invalidate the candidate\'s current password and issue a new system password.', icon: 'warning', showCancelButton: true, confirmButtonColor: '#eab308', cancelButtonColor: '#475569', confirmButtonText: 'Regenerate Now' }).then((result) => { if (result.isConfirmed) { fetch(`/interviews/${currentInterviewId}/regenerate-password`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken } }) .then(r => r.json()) .then(res => { if (res.success) { window.Swal.fire({ icon: 'success', title: 'New Password Generated!', text: 'New Candidate Passcode: ' + res.new_password, confirmButtonColor: '#6366f1' }).then(() => window.location.reload()); } }); } }); } } export function blockCandidateAction() { if (!currentInterviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; if (typeof window.Swal !== 'undefined') { window.Swal.fire({ title: 'â›” Block Candidate Email & IP?', text: 'This will immediately terminate the candidate session and block their email and client IP address.', icon: 'error', showCancelButton: true, confirmButtonColor: '#ef4444', cancelButtonColor: '#475569', confirmButtonText: 'Yes, Block Candidate' }).then((result) => { if (result.isConfirmed) { fetch(`/interviews/${currentInterviewId}/block-candidate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken } }) .then(r => r.json()) .then(res => { if (res.success) { window.Swal.fire({ icon: 'success', title: 'Candidate Blocked', text: res.message, confirmButtonColor: '#6366f1' }).then(() => { closeReviewModal(); window.location.reload(); }); } }); } }); } } export { initMeteredIceServers, globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES }; export function safePlayMediaStream(videoElem, stream, isSelf = false) { if (!videoElem || !stream) return; const isScreenShare = (videoElem.id === 'interviewer-screen-video'); if (isSelf || isScreenShare) { videoElem.muted = true; } if (videoElem.srcObject !== stream) { videoElem.srcObject = stream; } // Attach dynamic emerald speaking border VAD monitor if (!isScreenShare && stream.getAudioTracks && stream.getAudioTracks().length > 0) { let targetId = null; let monitorKey = videoElem.id; if (videoElem.id === 'interviewer-cand-video') { targetId = 'cand-video-wrapper'; } else if (videoElem.id === 'interviewer-self-video') { targetId = 'self-video-wrapper'; } else if (videoElem.id === 'webcam') { targetId = 'cand-self-video-box'; } else if (videoElem.id === 'interviewer-video-default') { targetId = 'interviewer-placeholder-box'; } else if (videoElem.id.startsWith('panelist-video-')) { const pid = videoElem.id.replace('panelist-video-', ''); targetId = 'panelist-tile-' + pid; } else if (videoElem.id.startsWith('cand-iv-video-')) { const pid = videoElem.id.replace('cand-iv-video-', ''); targetId = 'cand-iv-tile-' + pid; } if (targetId) { globalAudioMonitor.attach(monitorKey, stream, targetId); } } const playPromise = videoElem.play(); if (playPromise !== undefined) { playPromise.catch(err => { if (err.name === 'AbortError' || err.name === 'NotAllowedError') { videoElem.onloadedmetadata = () => { videoElem.play().catch(() => {}); }; if (!isSelf && !isScreenShare) { videoElem.muted = true; videoElem.play().catch(() => {}); const unmuteHandler = () => { videoElem.muted = false; videoElem.play().catch(() => {}); }; window.addEventListener('click', unmuteHandler, { once: true }); window.addEventListener('keydown', unmuteHandler, { once: true }); window.addEventListener('touchstart', unmuteHandler, { once: true }); } } }); } } export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn = true, camOn = true) { const currentUserId = window.currentUserId || 0; const myPeerId = getMyInterviewerPeerId(); if (!peerId || peerId === myPeerId) return; const grid = document.getElementById('panelist-mesh-grid'); if (!grid) return; let tile = document.getElementById('panelist-tile-' + peerId); let videoElem; const initials = getInitials(name); if (!tile) { tile = document.createElement('div'); tile.id = 'panelist-tile-' + peerId; tile.className = 'video-tile relative w-[220px] aspect-video shrink-0 bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all duration-200'; tile.innerHTML = `
${initials}
${name}
${camOn && stream ? 'Connecting video...' : 'Camera turned off'}
${name} `; grid.appendChild(tile); videoElem = document.getElementById('panelist-video-' + peerId); } else { videoElem = document.getElementById('panelist-video-' + peerId); const meshMicIcon = document.getElementById('mesh-mic-' + peerId); const meshCamIcon = document.getElementById('mesh-cam-' + peerId); const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId); if (meshMicIcon) { meshMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; meshMicIcon.title = micOn ? 'Mic On' : 'Mic Muted'; } if (meshCamIcon) { meshCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]'; meshCamIcon.title = camOn ? 'Camera On' : 'Camera Off'; } if (meshPlaceholder) { meshPlaceholder.style.display = (camOn && stream) ? 'none' : 'flex'; } } if (videoElem && stream) { safePlayMediaStream(videoElem, stream); stream.onaddtrack = () => { safePlayMediaStream(videoElem, stream); const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId); if (meshPlaceholder && camOn) meshPlaceholder.style.display = 'none'; }; } if (stream) { panelistMeshTiles.set(peerId, stream); } } export function removePanelistTile(peerId) { globalAudioMonitor.detach('panelist-video-' + peerId); const tile = document.getElementById('panelist-tile-' + peerId); if (tile) tile.remove(); panelistMeshTiles.delete(peerId); } export async function initInterviewerPeer() { if (!currentInterviewId || typeof window.Peer === 'undefined') return; const iceServers = await initMeteredIceServers(); const currentUserId = window.currentUserId || 0; const currentUserName = window.currentUserName || 'Interviewer'; const myPeerId = getMyInterviewerPeerId(); const candPeerId = getCandidatePeerId(); if (interviewerPeer && !interviewerPeer.destroyed) { try { interviewerPeer.destroy(); } catch(e) {} } interviewerPeer = new window.Peer(myPeerId, { config: { iceServers: iceServers } }); interviewerPeer.on('open', id => { console.log('[WebRTC Interviewer] Registered PeerJS ID:', id); if (interviewerSigChannel) { interviewerSigChannel.postMessage({ type: 'peer_joined', peer_id: id, user_id: currentUserId, user_name: currentUserName, role: 'interviewer' }); } }); function handleIncomingScreenStream(screenStream) { console.log('[WebRTC Screen] Attaching incoming screen stream to video element'); const screenVid = document.getElementById('interviewer-screen-video'); const placeholder = document.getElementById('screen-video-placeholder'); if (typeof window.updateScreenShareLayout === 'function') { window.updateScreenShareLayout(true); } if (screenStream && screenStream.getVideoTracks) { screenStream.getVideoTracks().forEach(track => { track.onended = () => { if (typeof window.updateScreenShareLayout === 'function') { window.updateScreenShareLayout(false); } }; }); } if (screenVid && screenStream) { screenVid.muted = true; if (screenVid.srcObject !== screenStream) { screenVid.srcObject = screenStream; } screenVid.play().catch(() => {}); screenStream.onaddtrack = () => { screenVid.muted = true; if (screenVid.srcObject !== screenStream) { screenVid.srcObject = screenStream; } screenVid.play().catch(() => {}); if (placeholder) placeholder.style.display = 'none'; }; } if (placeholder) placeholder.style.display = 'none'; } interviewerPeer.on('call', async call => { console.log('[WebRTC Interviewer] Incoming PeerJS call from:', call.peer); if (call.peer && call.peer.startsWith('cand_screen_')) { call.on('stream', screenStream => { console.log('[WebRTC Screen] Screen stream received on main peer from:', call.peer); handleIncomingScreenStream(screenStream); }); try { call.answer(); } catch(e) {} return; } const sendStream = interviewerLocalStream || new MediaStream(); call.on('stream', remoteStream => { console.log('[WebRTC Interviewer] Stream received from peer:', call.peer); if (call.peer === candPeerId || (call.peer && call.peer.startsWith('cand_') && !call.peer.startsWith('cand_screen_'))) { console.log('[WebRTC Interviewer] Candidate video stream connected! Candidate ID:', call.peer); const candVid = document.getElementById('interviewer-cand-video'); if (candVid) safePlayMediaStream(candVid, remoteStream); const candCamIcon = document.getElementById('cand-cam-status'); const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true; updateCandidateStatusIcons(true, isCamOn, true); } else { const peerInfo = latestActivePeers.find(p => p.peer_id === call.peer); const roleLabel = peerInfo ? (peerInfo.role === 'admin' ? ('Admin ' + (peerInfo.name || '')) : ('Panelist ' + (peerInfo.name || ''))) : 'Panelist'; const isMicOn = peerInfo ? isTrueVal(peerInfo.mic_on) : true; const isCamOn = peerInfo ? isTrueVal(peerInfo.cam_on) : true; addOrUpdatePanelistTile(call.peer, remoteStream, roleLabel, isMicOn, isCamOn); } const badge = document.getElementById('interviewer-call-status'); if (badge) { badge.innerText = 'CONNECTED (LIVE)'; badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; } }); call.on('error', err => { console.error('[WebRTC Interviewer] Call error from peer:', call.peer, err); }); try { call.answer(sendStream); } catch(e) { console.error('[WebRTC Interviewer] Error answering call:', e); } }); const screenPeerId = getInterviewerScreenPeerId(); if (screenReceiverPeer && !screenReceiverPeer.destroyed) { try { screenReceiverPeer.destroy(); } catch(e) {} } screenReceiverPeer = new window.Peer(screenPeerId, { config: { iceServers: iceServers } }); screenReceiverPeer.on('call', call => { console.log('[WebRTC Screen] Incoming screen call from:', call.peer); call.on('stream', screenStream => { console.log('[WebRTC Screen] Screen stream received from:', call.peer); handleIncomingScreenStream(screenStream); }); try { call.answer(); } catch(e) { console.error('[WebRTC Screen] Error answering screen call:', e); } }); // Also attempt legacy singleton screen peer if current user is admin / user 1 const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId)) ? currentSubmissionUniqueId : null; const legacyScreenPeerId = subId ? ('interviewer_screen_' + currentInterviewId + '_' + subId) : ('interviewer_screen_' + currentInterviewId); if (legacyScreenPeerId !== screenPeerId && (currentUserId === 1 || window.currentUserRole === 'admin')) { try { const legacyScreenPeer = new window.Peer(legacyScreenPeerId, { config: { iceServers: iceServers } }); legacyScreenPeer.on('call', call => { try { call.answer(); } catch(e) {} call.on('stream', screenStream => { handleIncomingScreenStream(screenStream); }); }); legacyScreenPeer.on('error', () => {}); } catch(e) {} } } export function initInterviewerSigChannel() { if (!currentInterviewId) return; if (interviewerSigChannel) interviewerSigChannel.close(); interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId); const currentUserId = window.currentUserId || 0; const currentUserName = window.currentUserName || 'Interviewer'; const myPeerId = getMyInterviewerPeerId(); const candPeerId = getCandidatePeerId(); interviewerSigChannel.onmessage = async (event) => { const msg = event.data; if (!msg) return; if (msg.type === 'violation_occurred') { pollReviewData(); } if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') { if (msg.peer_id && msg.peer_id !== myPeerId) { if (msg.type === 'peer_joined') { interviewerSigChannel.postMessage({ type: 'peer_presence_ack', peer_id: myPeerId, user_id: currentUserId, user_name: currentUserName, role: 'interviewer' }); } if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { try { const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) { const candVid = document.getElementById('interviewer-cand-video'); if (candVid) safePlayMediaStream(candVid, remoteStream); const candCamIcon = document.getElementById('cand-cam-status'); const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true; updateCandidateStatusIcons(undefined, isCamOn); } else { addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist'); } const badge = document.getElementById('interviewer-call-status'); if (badge) { badge.innerText = 'CONNECTED (LIVE)'; badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; } }); } } catch(e) {} } } } else if (msg.type === 'peer_left') { if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) { const placeholder = document.getElementById('cand-video-placeholder'); if (placeholder) placeholder.style.display = 'flex'; } else if (msg.peer_id) { removePanelistTile(msg.peer_id); } } else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) { await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); } else if (msg.type === 'ice-candidate' && msg.sender === 'candidate' && interviewerPeerConnection && msg.candidate) { try { await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); } catch(e) {} } else if (msg.type === 'end_call_all') { leaveInterviewerCall(false); } }; } export function startInterviewerCall(isRejoin = false) { if (!currentInterviewId) return; const startBtn = document.getElementById('btn-start-call'); if (startBtn && startBtn.disabled) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const currentUserName = window.currentUserName || 'Interviewer'; if (!isRejoin) { iAmTheCaller = true; const candNameEl = document.getElementById('modal-cand-name'); const candName = candNameEl ? candNameEl.innerText : 'Candidate'; if (!globalCallChannel) { globalCallChannel = new BroadcastChannel('global_call_alerts'); } globalCallChannel.postMessage({ type: 'incoming_call', interview_id: currentInterviewId, candidate_name: candName, starter_name: currentUserName, call_started_at: new Date().toISOString() }); } initInterviewerSigChannel(); fetch(`/interviews/${currentInterviewId}/call-status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin }) }); const handleStream = async (rawStream) => { if (interviewerNoiseSuppressor) { try { interviewerNoiseSuppressor.dispose(); } catch(e) {} interviewerNoiseSuppressor = null; } let stream = rawStream; if (rawStream && rawStream.getAudioTracks().length > 0) { interviewerNoiseSuppressor = await createNoiseSuppressedStream(rawStream); const processedTrack = interviewerNoiseSuppressor.getProcessedAudioTrack(); const videoTracks = rawStream.getVideoTracks(); const tracks = [...videoTracks]; if (processedTrack) { processedTrack.enabled = isInterviewerMicOn; tracks.push(processedTrack); } stream = new MediaStream(tracks); } interviewerLocalStream = stream; const selfVid = document.getElementById('interviewer-self-video'); const selfPlace = document.getElementById('self-video-placeholder'); if (selfVid) { selfVid.muted = true; safePlayMediaStream(selfVid, stream, true); } if (selfPlace) selfPlace.style.display = 'none'; const micBtn = document.getElementById('btn-toggle-interviewer-mic'); const camBtn = document.getElementById('btn-toggle-interviewer-cam'); const startBtn = document.getElementById('btn-start-call'); const leaveBtn = document.getElementById('btn-leave-call'); const endAllBtn = document.getElementById('btn-end-all-call'); const recStartBtn = document.getElementById('btn-start-recording'); if (micBtn) micBtn.style.display = 'inline-flex'; if (camBtn) camBtn.style.display = 'inline-flex'; if (startBtn) startBtn.style.display = 'none'; if (leaveBtn) leaveBtn.style.display = 'inline-flex'; if (endAllBtn) endAllBtn.style.display = 'inline-flex'; if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { recStartBtn.style.display = 'inline-flex'; } updateInterviewerNoiseSuppressionUI(interviewerNoiseSuppressor ? interviewerNoiseSuppressor.isEnabled() : getRNNoisePreference()); if (interviewerSigChannel) { interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' }); } if (!interviewerPeer || interviewerPeer.destroyed) { initInterviewerPeer(); } const makePeerJSCall = () => { if (!interviewerPeer || interviewerPeer.destroyed) return; const candPeerId = getCandidatePeerId(); const myPeerId = getMyInterviewerPeerId(); activeCall = interviewerPeer.call(candPeerId, stream); if (activeCall) { activeCall.on('stream', candRemoteStream => { const candVid = document.getElementById('interviewer-cand-video'); const placeholder = document.getElementById('cand-video-placeholder'); if (candVid) safePlayMediaStream(candVid, candRemoteStream); if (placeholder) placeholder.style.display = 'none'; candRemoteStream.onaddtrack = () => { if (placeholder) placeholder.style.display = 'none'; if (candVid) safePlayMediaStream(candVid, candRemoteStream); }; const badge = document.getElementById('interviewer-call-status'); if (badge) { badge.innerText = 'CONNECTED (LIVE)'; badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; } }); } // Also call any other already-active interviewers if (Array.isArray(latestActivePeers)) { latestActivePeers.forEach(p => { if (p.peer_id && p.peer_id !== myPeerId && p.peer_id.startsWith('interviewer_')) { try { const outCall = interviewerPeer.call(p.peer_id, stream); if (outCall) { const roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); const isMicOn = isTrueVal(p.mic_on); const isCamOn = isTrueVal(p.cam_on); outCall.on('stream', remoteStream => { addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); }); } } catch(e) {} } }); } }; if (interviewerPeer && interviewerPeer.open) { makePeerJSCall(); } else if (interviewerPeer) { interviewerPeer.on('open', makePeerJSCall); } }; const audioConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints }) .then(handleStream) .catch(() => { navigator.mediaDevices.getUserMedia({ video: true, audio: true }) .then(handleStream) .catch(() => { navigator.mediaDevices.getUserMedia({ video: false, audio: true }) .then(handleStream) .catch(() => { navigator.mediaDevices.getUserMedia({ video: true, audio: false }) .then(handleStream) .catch(err => console.log('WebRTC Stream init error:', err)); }); }); }); } export function leaveInterviewerCall(isEndAll = false) { sendInterviewerPeerHeartbeat('leave'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const endingId = currentInterviewId; if (interviewerSigChannel) { try { interviewerSigChannel.postMessage({ type: 'peer_left', peer_id: getMyInterviewerPeerId() }); } catch(e) {} } if (endingId && isEndAll) { endedCallIds.add(endingId); dismissedCallIds.add(endingId); if (globalCallChannel) { try { globalCallChannel.postMessage({ type: 'call_ended', interview_id: endingId }); } catch(e) {} } if (interviewerSigChannel) { try { interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId }); } catch(e) {} } fetch(`/interviews/${endingId}/call-status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ call_status: 'ended' }) }).catch(() => {}); } if (interviewerLocalStream) { try { interviewerLocalStream.getTracks().forEach(t => t.stop()); } catch(e) {} interviewerLocalStream = null; } if (interviewerNoiseSuppressor) { try { interviewerNoiseSuppressor.dispose(); } catch(e) {} interviewerNoiseSuppressor = null; } if (activeCall) { try { activeCall.close(); } catch(e) {} activeCall = null; } if (interviewerPeer) { try { interviewerPeer.destroy(); } catch(e) {} interviewerPeer = null; } if (screenReceiverPeer) { try { screenReceiverPeer.destroy(); } catch(e) {} screenReceiverPeer = null; } updateCandidateStatusIcons(false, false, false); const candPlace = document.getElementById('cand-video-placeholder'); const screenPlace = document.getElementById('screen-video-placeholder'); const selfPlace = document.getElementById('self-video-placeholder'); if (candPlace) candPlace.style.display = 'flex'; if (screenPlace) screenPlace.style.display = 'flex'; if (selfPlace) selfPlace.style.display = 'flex'; const candVid = document.getElementById('interviewer-cand-video'); if (candVid) candVid.srcObject = null; const screenVid = document.getElementById('interviewer-screen-video'); if (screenVid) screenVid.srcObject = null; const selfVid = document.getElementById('interviewer-self-video'); if (selfVid) selfVid.srcObject = null; panelistMeshTiles.forEach((_, pid) => { removePanelistTile(pid); }); const livePulseDot = document.getElementById('live-call-pulse'); if (livePulseDot) livePulseDot.style.display = 'none'; const startBtn = document.getElementById('btn-start-call'); if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); if (isEndAll) { if (icon) icon.className = 'fa-solid fa-phone-slash'; if (span) span.innerText = 'Call Ended'; startBtn.disabled = true; startBtn.style.opacity = '0.5'; startBtn.style.cursor = 'not-allowed'; startBtn.style.pointerEvents = 'none'; startBtn.style.display = 'inline-flex'; } else { if (icon) icon.className = 'fa-solid fa-phone'; if (span) span.innerText = 'Join Call'; startBtn.disabled = false; startBtn.style.opacity = '1'; startBtn.style.cursor = 'pointer'; startBtn.style.pointerEvents = 'auto'; startBtn.style.display = 'inline-flex'; } } const leaveBtn = document.getElementById('btn-leave-call'); if (leaveBtn) leaveBtn.style.display = 'none'; const endAllBtn = document.getElementById('btn-end-all-call'); if (endAllBtn) endAllBtn.style.display = 'none'; const micBtn = document.getElementById('btn-toggle-interviewer-mic'); if (micBtn) micBtn.style.display = 'none'; const camBtn = document.getElementById('btn-toggle-interviewer-cam'); if (camBtn) camBtn.style.display = 'none'; const recStartBtn = document.getElementById('btn-start-recording'); if (recStartBtn) recStartBtn.style.display = 'none'; const recStopBtn = document.getElementById('btn-stop-recording'); if (recStopBtn) recStopBtn.style.display = 'none'; const recUploadBtn = document.getElementById('btn-uploading-recording'); if (recUploadBtn) recUploadBtn.style.display = 'none'; if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { try { adminMediaRecorder.stop(); } catch(e) {} } const timelinePill = document.getElementById('timeline-status-pill'); if (timelinePill && isEndAll) { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; if (span) span.innerText = 'Call ended'; timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; } } export function endCallForAll() { const executeEndCall = () => { callRingtone.stop(); const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); activeIncomingCall = null; iAmTheCaller = false; leaveInterviewerCall(true); }; if (typeof window.Swal !== 'undefined') { window.Swal.fire({ title: '🛑 End Call for All Participants?', text: 'This will terminate the 2-way call session for candidate and all panelists.', icon: 'warning', showCancelButton: true, confirmButtonColor: '#ef4444', cancelButtonColor: '#475569', confirmButtonText: 'Yes, End Call for All' }).then((result) => { if (result.isConfirmed) { executeEndCall(); } }); } else { if (confirm('End Call for All Participants?')) { executeEndCall(); } } } // MediaRecorder, Chunked Uploader & Recording Compositor let adminMediaRecorder = null; let recordingCompositor = null; let recordingTimerInterval = null; let recordingStartTime = null; let isRecordingProcessing = false; let recordingProcessingSessionId = null; let recordingKnownCountBeforeProcessing = 0; let currentChunkIndex = 0; let chunkUploadQueue = []; let currentRecordingSessionId = null; let isStopRecordingRequested = false; let totalRecordedChunksCount = 0; let isFinalizingRecording = false; const MAX_CONCURRENT_UPLOADS = 2; let activeUploadsCount = 0; window.dismissRecordingProcessingBanner = function() { isRecordingProcessing = false; recordingProcessingSessionId = null; const b = document.getElementById('recording-processing-banner'); if (b) b.remove(); }; function updateRecordingButtonProgress(isFinalStep = false) { const total = Math.max(1, totalRecordedChunksCount || currentChunkIndex || 1); const uploaded = chunkUploadQueue.filter(item => item.isUploaded && !item.isFinal).length; let pct = isFinalStep ? 100 : Math.min(92, Math.max(15, Math.round((uploaded / total) * 90))); const recStopBtn = document.getElementById('btn-stop-recording'); if (!recStopBtn) return; if (isFinalStep) { recStopBtn.innerHTML = 'Saved!'; } else { recStopBtn.innerHTML = 'Saving ' + pct + '%'; } } let currentSessionDuration = 0; function queueChunkUpload(chunkBlob, isFinal = false, duration = null) { if (!currentRecordingSessionId) return; if (chunkBlob && chunkBlob.size > 0) { const index = currentChunkIndex++; chunkUploadQueue.push({ sessionId: currentRecordingSessionId, index: index, blob: chunkBlob, isFinal: false, isUploading: false, isUploaded: false, retries: 0 }); } if (isFinal) { totalRecordedChunksCount = currentChunkIndex; chunkUploadQueue.push({ sessionId: currentRecordingSessionId, index: currentChunkIndex, blob: null, isFinal: true, totalChunks: currentChunkIndex, duration: duration || currentSessionDuration || 0, isUploading: false, isUploaded: false, retries: 0 }); } if (isStopRecordingRequested) { updateRecordingButtonProgress(false); } triggerChunkQueueProcessing(); } function triggerChunkQueueProcessing() { // 1. Dispatch concurrent data uploads while (activeUploadsCount < MAX_CONCURRENT_UPLOADS) { const nextItem = chunkUploadQueue.find(item => !item.isUploading && !item.isUploaded && !item.isFinal); if (!nextItem) break; uploadSingleChunk(nextItem); } // 2. Dispatch finalization trigger once all data chunks are confirmed uploaded const hasPendingDataChunks = chunkUploadQueue.some(item => !item.isUploaded && !item.isFinal); const finalMarker = chunkUploadQueue.find(item => item.isFinal && !item.isUploading && !item.isUploaded); if (!hasPendingDataChunks && finalMarker && activeUploadsCount === 0 && !isFinalizingRecording) { isFinalizingRecording = true; uploadSingleChunk(finalMarker); } } async function uploadSingleChunk(item) { item.isUploading = true; activeUploadsCount++; if (!currentInterviewId && typeof document !== 'undefined') { currentInterviewId = document.body?.dataset?.interviewId || window.interviewId; } if (!currentInterviewId) { console.error('[Recording Chunk] Missing currentInterviewId'); item.isUploading = false; activeUploadsCount--; return; } const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const formData = new FormData(); formData.append('session_id', item.sessionId); formData.append('chunk_index', item.index); formData.append('is_final', item.isFinal ? '1' : '0'); formData.append('total_chunks', item.totalChunks || currentChunkIndex); if (item.duration) { formData.append('duration', item.duration); } formData.append('_token', csrfToken); if (item.blob && item.blob.size > 0) { formData.append('chunk', item.blob, `part_${item.index}.webm`); } try { const response = await fetch(`/candidate/upload-recording-chunk/${currentInterviewId}`, { method: 'POST', headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, body: formData }); if (!response.ok) { const errText = await response.text(); throw new Error(`HTTP ${response.status}: ${errText}`); } const resJson = await response.json(); item.isUploading = false; item.isUploaded = true; activeUploadsCount--; if (isStopRecordingRequested) { updateRecordingButtonProgress(item.isFinal); } if (item.isFinal) { console.log('[Recording Chunk] Final chunk processed by server:', resJson); isRecordingProcessing = true; recordingProcessingSessionId = item.sessionId; isStopRecordingRequested = false; isFinalizingRecording = false; updateRecordingButtonProgress(true); pollReviewData(); setTimeout(() => { const stopBtn = document.getElementById('btn-stop-recording'); const startBtn = document.getElementById('btn-start-recording'); if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.disabled = false; stopBtn.className = 'hidden inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-rose-600 hover:bg-rose-700 text-white shadow-sm animate-pulse transition-all cursor-pointer'; stopBtn.innerHTML = ' Recording (00:00) - Stop'; } if (startBtn) startBtn.style.display = 'inline-flex'; }, 1200); } triggerChunkQueueProcessing(); } catch(err) { console.warn(`[Recording Chunk] Upload failed for chunk ${item.index} (attempt ${item.retries + 1}):`, err); item.isUploading = false; activeUploadsCount--; item.retries++; if (item.retries < 5) { setTimeout(() => { triggerChunkQueueProcessing(); }, item.retries * 800); } else { console.error(`[Recording Chunk] Critical upload failure for chunk ${item.index}. Retrying after delay...`); setTimeout(() => { triggerChunkQueueProcessing(); }, 2500); } } } export function startCallRecording() { if (!currentInterviewId) { alert('Please open an active candidate interview session first.'); return; } if (!interviewerLocalStream) { alert('Please start or join the call before recording.'); return; } const candVid = document.getElementById('interviewer-cand-video'); const candNameEl = document.getElementById('modal-cand-name'); const candidateName = candNameEl ? candNameEl.innerText.trim() : 'Candidate'; // 1. Initialize RecordingCompositor (1280x720 @ 24fps for ultra-fast, smooth streaming) recordingCompositor = new RecordingCompositor({ width: 1280, height: 720, fps: 30, getParticipantsData: () => { const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; const candCamIcon = document.getElementById('cand-cam-status'); const candMicIcon = document.getElementById('cand-mic-status'); const isCandCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true; const isCandMicOn = candMicIcon ? candMicIcon.classList.contains('fa-microphone') : true; const panelistsList = []; const panelistStreamsList = []; panelistMeshTiles.forEach((stream, pid) => { const peerInfo = latestActivePeers.find(p => p.peer_id === pid); const pMicEl = document.getElementById('mesh-mic-' + pid); const pCamEl = document.getElementById('mesh-cam-' + pid); const isMicOn = pMicEl ? pMicEl.classList.contains('fa-microphone') : (peerInfo ? isTrueVal(peerInfo.mic_on) : true); const isCamOn = pCamEl ? pCamEl.classList.contains('fa-video') : (peerInfo ? isTrueVal(peerInfo.cam_on) : true); const pName = peerInfo ? (peerInfo.name || 'Panelist') : 'Panelist'; const pRole = peerInfo ? (peerInfo.role === 'admin' ? 'Admin' : 'Panelist') : 'Panelist'; panelistsList.push({ peerId: pid, name: pName, role: pRole, micOn: isMicOn, camOn: isCamOn }); if (stream) { panelistStreamsList.push({ peerId: pid, stream: stream }); } }); return { candidateStream: candStream, candidateName: candidateName, candidateCamOn: isCandCamOn, candidateMicOn: isCandMicOn, selfStream: interviewerLocalStream, selfName: window.currentUserName || 'Interviewer', selfCamOn: isInterviewerCamOn, selfMicOn: isInterviewerMicOn, panelists: panelistsList, panelistStreams: panelistStreamsList }; } }); try { recordingCompositor.start(); const streamToRecord = recordingCompositor.getStream(); currentRecordingSessionId = 'rec_' + currentInterviewId + '_' + Date.now() + '_' + Math.random().toString(36).substring(2, 7); currentChunkIndex = 0; chunkUploadQueue = []; isStopRecordingRequested = false; totalRecordedChunksCount = 0; isFinalizingRecording = false; activeUploadsCount = 0; currentSessionDuration = 0; const existingCards = document.querySelectorAll('#modal-recordings-container .recording-card, #modal-recordings-container video'); recordingKnownCountBeforeProcessing = existingCards ? existingCards.length : 0; // Controlled bitrate configuration (1.2 Mbps video, 64 kbps audio) let options = { videoBitsPerSecond: 1200000, audioBitsPerSecond: 64000 }; 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); } // Real-time chunk slice handler adminMediaRecorder.ondataavailable = function(e) { if (e.data && e.data.size > 0) { queueChunkUpload(e.data, false); } }; adminMediaRecorder.onstop = function() { if (recordingCompositor) { recordingCompositor.stop(); recordingCompositor = null; } // Signal finalization after the final slice is recorded queueChunkUpload(null, true, currentSessionDuration); }; // Start timeslice recording (6,000ms = 6s chunks ~800KB each for near-instant concurrent background uploads) adminMediaRecorder.start(6000); recordingStartTime = Date.now(); // Update UI buttons & Live Timer 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'; recStopBtn.disabled = false; recStopBtn.className = 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-rose-600 hover:bg-rose-700 text-white shadow-sm animate-pulse transition-all'; recStopBtn.innerHTML = ' Recording (00:00) - Stop'; } if (recordingTimerInterval) clearInterval(recordingTimerInterval); recordingTimerInterval = setInterval(() => { const timerEl = document.getElementById('rec-live-timer'); if (timerEl && recordingStartTime) { const totalSec = Math.floor((Date.now() - recordingStartTime) / 1000); const mins = String(Math.floor(totalSec / 60)).padStart(2, '0'); const secs = String(totalSec % 60).padStart(2, '0'); timerEl.innerText = `(${mins}:${secs})`; } }, 1000); } catch(e) { if (recordingCompositor) { recordingCompositor.stop(); recordingCompositor = null; } alert('Recording failed to initialize: ' + e.message); } } export function stopCallRecording() { if (recordingTimerInterval) { clearInterval(recordingTimerInterval); recordingTimerInterval = null; } isStopRecordingRequested = true; currentSessionDuration = recordingStartTime ? Math.max(1, Math.round((Date.now() - recordingStartTime) / 1000)) : 0; 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'; recStopBtn.disabled = true; recStopBtn.className = 'relative inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-semibold text-white bg-indigo-600 rounded-lg shadow-lg shadow-indigo-500/50 ring-2 ring-indigo-400 ring-offset-2 ring-offset-slate-900 animate-pulse transition-all'; recStopBtn.innerHTML = 'Saving 15%'; } if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { adminMediaRecorder.stop(); } if (recordingCompositor) { recordingCompositor.stop(); recordingCompositor = null; } } export function uploadRecordedBlob(blob, ext = 'webm') { if (!currentInterviewId && typeof document !== 'undefined') { currentInterviewId = document.body?.dataset?.interviewId || window.interviewId; } if (!currentInterviewId) { console.error('Cannot upload recording: missing interview ID'); 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', headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': csrfToken, 'X-Requested-With': 'XMLHttpRequest' }, body: formData }) .then(async r => { const contentType = r.headers.get('content-type') || ''; const isJson = contentType.includes('application/json'); if (!r.ok) { const errText = await r.text(); let errMsg = `Upload failed (HTTP ${r.status})`; if (isJson) { try { const errJson = JSON.parse(errText); if (errJson && errJson.message) errMsg = errJson.message; } catch(e) {} } throw new Error(errMsg); } if (isJson) { return r.json(); } const text = await r.text(); try { return JSON.parse(text); } catch(e) { return { success: true }; } }) .then(res => { if (res && res.success) { if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'success', title: 'Recording Saved!', text: 'Call recording saved successfully to server and candidate report.', toast: true, position: 'top-end', timer: 3500, showConfirmButton: false }); } pollReviewData(); } else if (res && res.message) { if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'warning', title: 'Recording Notice', text: res.message, toast: true, position: 'top-end', timer: 4000, showConfirmButton: false }); } } }) .catch(err => { console.error('Recording upload error:', err); if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'error', title: 'Recording Upload Failed', text: err.message || 'Failed to save recording to server.', toast: true, position: 'top-end', timer: 4500, showConfirmButton: false }); } }); } export function updateInterviewerNoiseSuppressionUI(isEnabled) { const toggleSw = document.getElementById('noiseCancellationToggle'); if (toggleSw) { if (isEnabled) { toggleSw.classList.add('on'); } else { toggleSw.classList.remove('on'); } } } export function toggleInterviewerNoiseSuppression() { let isEnabled = false; if (interviewerNoiseSuppressor) { isEnabled = interviewerNoiseSuppressor.toggle(); } else { isEnabled = !getRNNoisePreference(); setRNNoisePreference(isEnabled); } updateInterviewerNoiseSuppressionUI(isEnabled); return isEnabled; } export function toggleInterviewerMic() { if (!interviewerLocalStream) return; const audioTracks = interviewerLocalStream.getAudioTracks(); if (audioTracks.length === 0) return; isInterviewerMicOn = !isInterviewerMicOn; audioTracks[0].enabled = isInterviewerMicOn; if (interviewerNoiseSuppressor) { const procTrack = interviewerNoiseSuppressor.getProcessedAudioTrack(); if (procTrack) procTrack.enabled = isInterviewerMicOn; const origTrack = interviewerNoiseSuppressor.getOriginalAudioTrack(); if (origTrack) origTrack.enabled = isInterviewerMicOn; } const btn = document.getElementById('btn-toggle-interviewer-mic'); if (btn) { const icon = btn.querySelector('i'); if (icon) { icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; } btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)'; if (isInterviewerMicOn) { btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); } else { btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); } } const selfMicIcon = document.getElementById('self-mic-status'); if (selfMicIcon) { selfMicIcon.className = isInterviewerMicOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; selfMicIcon.title = isInterviewerMicOn ? 'Mic On' : 'Mic Muted'; } sendInterviewerPeerHeartbeat(); } export function toggleInterviewerCam() { const selfPlaceholder = document.getElementById('self-video-placeholder'); const selfVid = document.getElementById('interviewer-self-video'); isInterviewerCamOn = !isInterviewerCamOn; const btn = document.getElementById('btn-toggle-interviewer-cam'); if (btn) { const icon = btn.querySelector('i'); if (icon) { icon.className = isInterviewerCamOn ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'; } btn.title = isInterviewerCamOn ? 'Cam On (Click to Turn Off)' : 'Cam Off (Click to Turn On)'; if (isInterviewerCamOn) { btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); } else { btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]'); btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); } } const selfCamIcon = document.getElementById('self-cam-status'); if (selfCamIcon) { selfCamIcon.className = isInterviewerCamOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]'; selfCamIcon.title = isInterviewerCamOn ? 'Camera On' : 'Camera Off'; } if (interviewerLocalStream) { const videoTracks = interviewerLocalStream.getVideoTracks(); if (videoTracks.length > 0) { videoTracks[0].enabled = isInterviewerCamOn; } } else if (isInterviewerCamOn) { navigator.mediaDevices.getUserMedia({ video: true, audio: false }) .then(stream => { interviewerLocalStream = stream; if (selfVid) { selfVid.srcObject = stream; selfVid.play().catch(() => {}); } if (selfPlaceholder) selfPlaceholder.style.display = 'none'; }) .catch(() => {}); sendInterviewerPeerHeartbeat(); return; } if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex'; sendInterviewerPeerHeartbeat(); } export function closeReviewModal() { if (sosPollInterval) clearInterval(sosPollInterval); if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer); if (liveSyncChannel) { try { liveSyncChannel.close(); } catch(e) {} liveSyncChannel = null; } leaveInterviewerCall(false); const modal = document.getElementById('review-modal'); if (modal) modal.classList.remove('active'); } export function sendSilentWarning() { const input = document.getElementById('warning-input'); if (!input) return; const msg = input.value.trim(); if (!msg || !currentInterviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; input.value = ''; if (liveSyncChannel) { liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' }); } fetch(`/interviews/${currentInterviewId}/warning`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ message: msg }) }) .then(r => r.json()) .then(() => { pollReviewData(); }); } export function setCurrentInterviewId(id) { currentInterviewId = id; } // Bind functions onto window object for template inline onclick handlers if (typeof window !== 'undefined') { window.CallRingtoneEngine = CallRingtoneEngine; window.callRingtone = callRingtone; window.getInitials = getInitials; window.zoomScreen = zoomScreen; window.resetZoomScreen = resetZoomScreen; window.toggleFullscreenScreen = toggleFullscreenScreen; window.zoomCandVideo = zoomCandVideo; window.toggleSosAutoTrigger = toggleSosAutoTrigger; window.openReportPage = openReportPage; window.toggleTabScreenshotCapture = toggleTabScreenshotCapture; window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI; window.switchIdeTab = switchIdeTab; window.triggerIncomingCallRing = triggerIncomingCallRing; window.pollGlobalActiveCalls = pollGlobalActiveCalls; window.acceptIncomingCall = acceptIncomingCall; window.declineIncomingCall = declineIncomingCall; window.subscribeLiveSync = subscribeLiveSync; window.openReviewModal = openReviewModal; window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat; window.pollReviewData = pollReviewData; window.openSosModal = openSosModal; window.stopSosAlarm = stopSosAlarm; window.disableSosAndStopAlarm = disableSosAndStopAlarm; window.toggleSosAutoTrigger = toggleSosAutoTrigger; window.interviewerLogoutAction = interviewerLogoutAction; window.regenerateCandidatePassword = regenerateCandidatePassword; window.blockCandidateAction = blockCandidateAction; window.startInterviewerCall = startInterviewerCall; window.leaveInterviewerCall = leaveInterviewerCall; window.endCallForAll = endCallForAll; window.startCallRecording = startCallRecording; window.stopCallRecording = stopCallRecording; window.toggleInterviewerMic = toggleInterviewerMic; window.toggleInterviewerCam = toggleInterviewerCam; window.toggleInterviewerNoiseSuppression = toggleInterviewerNoiseSuppression; window.updateInterviewerNoiseSuppressionUI = updateInterviewerNoiseSuppressionUI; window.closeReviewModal = closeReviewModal; window.sendSilentWarning = sendSilentWarning; window.setCurrentInterviewId = setCurrentInterviewId; window.closeInspectorPanel = closeInspectorPanel; // Attach listeners ['click', 'keydown', 'touchstart'].forEach(evt => { document.addEventListener(evt, () => callRingtone.init(), { passive: true }); }); const initNoiseState = () => { updateInterviewerNoiseSuppressionUI(getRNNoisePreference()); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initNoiseState); } else { initNoiseState(); } globalCallChannel = new BroadcastChannel('global_call_alerts'); globalCallChannel.onmessage = (event) => { const data = event.data; if (!data) return; if (data.type === 'incoming_call') { if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return; if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return; triggerIncomingCallRing({ id: data.interview_id, candidate_name: data.candidate_name, submission_unique_id: data.submission_unique_id, starter_name: data.starter_name, call_started_at: data.call_started_at || new Date().toISOString() }, true); } else if (data.type === 'call_ended') { endedCallIds.add(data.interview_id); dismissedCallIds.add(data.interview_id); callRingtone.stop(); if (ringTimeoutTimer) { clearTimeout(ringTimeoutTimer); ringTimeoutTimer = null; } const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); if (activeIncomingCall && activeIncomingCall.id == data.interview_id) { activeIncomingCall = null; } if (currentInterviewId && currentInterviewId == data.interview_id) { updateCandidateStatusIcons(false, false, false); leaveInterviewerCall(false); } } }; setInterval(pollGlobalActiveCalls, 6000); }