/** * Interview Call & Live Assessment Review Engine * Handles WebRTC 2-Way Calling, Candidate Screen Sharing, Real-Time Sync, * Proctoring Alerts, SOS Audio Ringtone, Tab Switching, and Silent Call Recording. */ // --- Global Audio Ringtone Synthesizer Engine --- export class CallRingtoneEngine { constructor() { this.ctx = null; this.interval = null; this.isPlaying = false; } init() { try { if (!this.ctx) { const AudioCtx = window.AudioContext || window.webkitAudioContext; this.ctx = new AudioCtx(); } if (this.ctx && this.ctx.state === 'suspended') { this.ctx.resume(); } } catch (e) { console.log('Audio init warning:', e); } } start() { this.init(); if (this.isPlaying) return; this.isPlaying = true; this.playTone(); if (this.interval) clearInterval(this.interval); this.interval = setInterval(() => { if (this.isPlaying) this.playTone(); }, 2400); } playTone() { this.init(); if (!this.ctx || !this.isPlaying) return; try { const now = this.ctx.currentTime; const osc1 = this.ctx.createOscillator(); const osc2 = this.ctx.createOscillator(); const gain = this.ctx.createGain(); osc1.type = 'sine'; osc2.type = 'sine'; osc1.frequency.setValueAtTime(523.25, now); osc2.frequency.setValueAtTime(659.25, now); gain.gain.setValueAtTime(0.001, now); gain.gain.linearRampToValueAtTime(0.35, now + 0.08); gain.gain.setValueAtTime(0.35, now + 1.1); gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3); osc1.connect(gain); osc2.connect(gain); gain.connect(this.ctx.destination); osc1.start(now); osc2.start(now); osc1.stop(now + 1.35); osc2.stop(now + 1.35); } catch (e) {} } stop() { this.isPlaying = false; if (this.interval) { clearInterval(this.interval); this.interval = null; } } } export const callRingtone = new CallRingtoneEngine(); // State variables let currentInterviewId = null; let interviewerPeer = null; let screenReceiverPeer = null; let interviewerLocalStream = null; let activeCall = null; let isInterviewerMicOn = true; let isInterviewerCamOn = true; let screenZoomLevel = 1.0; let candVidZoomLevel = 1.0; let activeViolations = []; let sosPollInterval = null; let 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(); // 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' } ]; export function getInitials(name) { if (!name) return 'CD'; const parts = name.trim().split(/\s+/); if (parts.length >= 2) { return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } const str = name.trim(); if (str.length >= 2) { return (str[0] + str[str.length - 1]).toUpperCase(); } return str.toUpperCase(); } export function zoomScreen(delta) { screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta)); const screenVid = document.getElementById('interviewer-screen-video'); if (screenVid) { screenVid.style.transform = `scale(${screenZoomLevel})`; } } export function resetZoomScreen() { screenZoomLevel = 1.0; zoomScreen(0); } export function toggleFullscreenScreen() { const wrapper = document.getElementById('screen-wrapper'); if (!wrapper) return; if (!document.fullscreenElement) { wrapper.requestFullscreen().catch(err => console.log(err)); } else { document.exitFullscreen(); } } export function zoomCandVideo(delta) { if (delta === 0) candVidZoomLevel = 1.0; else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta)); const vid = document.getElementById('interviewer-cand-video'); if (vid) { vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`; vid.style.transformOrigin = 'center center'; vid.style.transition = 'transform 0.2s ease'; } } export function toggleSosAutoTrigger() { sosAutoTriggerDisabled = !sosAutoTriggerDisabled; const toggleSw = document.getElementById('sosToggle'); const btn = document.getElementById('sos-toggle-btn'); if (toggleSw) { if (sosAutoTriggerDisabled) { toggleSw.classList.remove('on'); } else { toggleSw.classList.add('on'); } } if (btn) { if (sosAutoTriggerDisabled) { btn.innerHTML = ' SOS Auto-Trigger: OFF'; btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300'; } else { btn.innerHTML = ' SOS Auto-Trigger: ON'; btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300'; } } } export function openReportPage() { if (!currentInterviewId) return; window.open(`/interviews/${currentInterviewId}/report`, '_blank'); } export function toggleTabScreenshotCapture() { if (!currentInterviewId) return; const newStatus = !currentTabScreenshotEnabled; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ enable: newStatus }) }) .then(r => r.json()) .then(res => { if (res.success) { currentTabScreenshotEnabled = res.enable_tab_switch_screenshot; updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'info', title: 'Screenshot Setting Updated', text: res.message, timer: 2500, showConfirmButton: false, toast: true, position: 'top-end' }); } } }) .catch(() => {}); } export function updateScreenshotToggleBtnUI(enabled) { const btn = document.getElementById('modal-toggle-screenshot-btn'); if (btn) { if (enabled) { btn.innerHTML = ' ENABLED'; btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none'; } else { btn.innerHTML = ' DISABLED'; btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none'; } } } export function switchIdeTab(tab) { const tabs = ['code', 'notes', 'drawing', 'recordings', 'screenshots']; tabs.forEach(t => { const contentEl = document.getElementById(`tab-content-${t}`); const btnEl = document.getElementById(`tab-btn-${t}`); if (contentEl) { contentEl.style.display = (t === tab) ? 'block' : 'none'; } if (btnEl) { if (t === tab) { btnEl.classList.add('bg-indigo-600', 'text-white', 'border-indigo-500'); btnEl.classList.remove('bg-white/5', 'text-slate-400', 'border-slate-700/60'); } else { btnEl.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-500'); btnEl.classList.add('bg-white/5', 'text-slate-400', 'border-slate-700/60'); } } }); } export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) { if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return; if (iAmTheCaller && currentInterviewId == callData.id) return; if (interviewerLocalStream && currentInterviewId == callData.id) return; if (callData.call_started_at) { const startedTime = new Date(callData.call_started_at).getTime(); const elapsedMs = Date.now() - startedTime; if (elapsedMs > 8000 && !isRealtimeBroadcast) { dismissedCallIds.add(callData.id); return; } } else if (!isRealtimeBroadcast) { dismissedCallIds.add(callData.id); return; } activeIncomingCall = callData; const nameEl = document.getElementById('incoming-cand-name'); const infoEl = document.getElementById('incoming-starter-info'); const modalEl = document.getElementById('incoming-call-modal'); if (nameEl) nameEl.innerText = callData.candidate_name; if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist'); if (modalEl) modalEl.classList.add('active'); callRingtone.start(); if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer); let remainingMs = 8000; if (callData.call_started_at) { const startedTime = new Date(callData.call_started_at).getTime(); const elapsedMs = Date.now() - startedTime; remainingMs = Math.max(1000, 8000 - elapsedMs); } ringTimeoutTimer = setTimeout(() => { declineIncomingCall(true); }, remainingMs); } export function pollGlobalActiveCalls() { fetch('/interviews/active-calls', { headers: { 'Accept': 'application/json' } }) .then(r => r.json()) .then(data => { if (!data || !data.active_calls || data.active_calls.length === 0) { if (activeIncomingCall) declineIncomingCall(true); return; } const incoming = data.active_calls.find(call => { if (endedCallIds.has(call.id)) return false; if (iAmTheCaller && currentInterviewId == call.id) return false; if (interviewerLocalStream && currentInterviewId == call.id) return false; if (dismissedCallIds.has(call.id)) return false; return true; }); if (incoming) { const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0; if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) { dismissedCallIds.add(incoming.id); return; } if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) { triggerIncomingCallRing(incoming, false); } } else if (activeIncomingCall) { declineIncomingCall(true); } }) .catch(() => {}); } export function acceptIncomingCall() { callRingtone.stop(); if (ringTimeoutTimer) { clearTimeout(ringTimeoutTimer); ringTimeoutTimer = null; } const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); if (activeIncomingCall) { const targetCall = activeIncomingCall; activeIncomingCall = null; if (window.location.pathname.includes('/interviews/' + targetCall.id)) { startInterviewerCall(true); } else { window.location.href = `/interviews/${targetCall.id}?join_call=1`; } } } export function declineIncomingCall(silent = false) { callRingtone.stop(); if (ringTimeoutTimer) { clearTimeout(ringTimeoutTimer); ringTimeoutTimer = null; } const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); if (activeIncomingCall) { dismissedCallIds.add(activeIncomingCall.id); activeIncomingCall = null; } if (!silent && typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'info', title: 'Call Missed / Declined', text: 'You can click "Join Active Call" from the list whenever you are ready.', timer: 3000, showConfirmButton: false, toast: true, position: 'top-end' }); } } export function subscribeLiveSync(interviewId) { if (liveSyncChannel) { try { liveSyncChannel.close(); } catch(e) {} } liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId); liveSyncChannel.onmessage = function(event) { const data = event.data; if (!data) return; if (data.type === 'code_sync') { if (data.language) { const langEl = document.getElementById('modal-code-lang'); if (langEl) langEl.innerText = data.language.toUpperCase(); } if (data.code !== undefined) { const codeEl = document.getElementById('modal-code-display'); if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.'; } } else if (data.type === 'notes_sync') { if (data.notes !== undefined) { const notesEl = document.getElementById('modal-notes-display'); if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.'; } } else if (data.type === 'drawing_sync') { const drawingImg = document.getElementById('modal-drawing-img'); const noDrawing = document.getElementById('no-drawing-placeholder'); if (data.drawing) { if (drawingImg) { drawingImg.src = data.drawing; drawingImg.style.display = 'block'; } if (noDrawing) noDrawing.style.display = 'none'; } else { if (drawingImg) drawingImg.style.display = 'none'; if (noDrawing) noDrawing.style.display = 'block'; } } else if (data.type === '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('interview_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, 100); } else if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') { setTimeout(pollReviewData, 200); } }; } export function openReviewModal(id, name, uid, autoJoinCall = false) { currentInterviewId = id; acknowledgedViolationCount = 0; sosDismissed = false; const toggleSw = document.getElementById('sosToggle'); if (toggleSw) { if (sosAutoTriggerDisabled) { toggleSw.classList.remove('on'); } else { toggleSw.classList.add('on'); } } const candNameEl = document.getElementById('modal-cand-name'); const candUidEl = document.getElementById('modal-cand-uid'); if (candNameEl) candNameEl.innerText = name; if (candUidEl) candUidEl.innerText = 'Unique Submission ID: ' + uid; const candInitials = getInitials(name); const avatarCircle = document.getElementById('modal-cand-avatar-circle'); const avatarName = document.getElementById('cand-avatar-name'); if (avatarCircle) avatarCircle.innerText = candInitials; if (avatarName) avatarName.innerText = name; const reviewModal = document.getElementById('review-modal'); if (reviewModal) reviewModal.classList.add('active'); resetZoomScreen(); switchIdeTab('code'); subscribeLiveSync(id); pollReviewData(); if (sosPollInterval) clearInterval(sosPollInterval); sosPollInterval = setInterval(pollReviewData, 2500); 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 = 'interviewer_' + currentInterviewId + '_' + currentUserId; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ peer_id: myPeerId, name: currentUserName, role: currentUserRole, 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 candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); const candVidWrapper = document.getElementById('cand-video-wrapper'); const screenWrapper = document.getElementById('screen-wrapper'); const joinedPill = document.getElementById('candidate-joined-pill'); if (!isJoined && !candidateStreamConnected) { if (joinedPill) { joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60'; joinedPill.innerHTML = ' Not Joined'; } if (candMicIcon) { candMicIcon.className = 'fa-solid fa-microphone-slash text-slate-500 text-[10px]'; candMicIcon.title = 'Candidate Not Joined'; } if (candCamIcon) { candCamIcon.className = 'fa-solid fa-video-slash text-slate-500 text-[10px]'; candCamIcon.title = 'Candidate Not Joined'; } if (candVidWrapper) { candVidWrapper.style.opacity = '0.4'; candVidWrapper.style.filter = 'grayscale(60%)'; candVidWrapper.style.transition = 'all 0.3s ease'; } if (screenWrapper) { screenWrapper.style.opacity = '0.4'; screenWrapper.style.filter = 'grayscale(60%)'; screenWrapper.style.transition = 'all 0.3s ease'; } if (candPlaceholder) { candPlaceholder.style.display = 'flex'; if (candStatusText) candStatusText.innerText = 'Candidate Not Joined'; } return; } // Joined state: restore styles & active icons if (joinedPill) { joinedPill.className = 'pill green inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-emerald-500/30 text-emerald-400 bg-emerald-500/10'; joinedPill.innerHTML = ' Joined'; } if (candVidWrapper) { candVidWrapper.style.opacity = '1'; candVidWrapper.style.filter = 'none'; } if (screenWrapper) { screenWrapper.style.opacity = '1'; screenWrapper.style.filter = 'none'; } if (candMicIcon && isMicOn !== undefined) { candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted'; } 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; if (interviewerLocalStream) { sendInterviewerPeerHeartbeat('heartbeat'); } fetch(`/interviews/${currentInterviewId}/poll`, { headers: { 'Accept': 'application/json' } }) .then(r => { if (!r.ok) return null; const contentType = r.headers.get('content-type'); if (!contentType || !contentType.includes('application/json')) return null; return r.json(); }) .then(data => { if (!data) return; const currentUserId = window.currentUserId || 0; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; 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.startsWith('cand_'))) : 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 || '')) : ('Panelist ' + (p.name || '')); const isMicOn = isTrueVal(p.mic_on); const isCamOn = isTrueVal(p.cam_on); if (p.peer_id.startsWith('interviewer_')) { if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { if (!panelistMeshTiles.has(p.peer_id)) { try { const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); }); } } catch(e) {} } else { addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn); } } } } }); panelistMeshTiles.forEach((_, pid) => { if (!activeIds.has(pid)) { removePanelistTile(pid); } }); } const codeLang = document.getElementById('modal-code-lang'); const codeDisplay = document.getElementById('modal-code-display'); const outputDisplay = document.getElementById('modal-output-display'); const notesDisplay = document.getElementById('modal-notes-display'); if (codeLang) codeLang.innerText = (data.submitted_language || 'UNKNOWN').toUpperCase(); if (codeDisplay) codeDisplay.innerText = data.submitted_code || '// Candidate has not typed any code yet.'; if (outputDisplay) outputDisplay.innerText = data.code_output || 'No execution output.'; if (notesDisplay) notesDisplay.innerText = data.candidate_notes || 'No candidate notes written yet.'; const drawingImg = document.getElementById('modal-drawing-img'); const noDrawing = document.getElementById('no-drawing-placeholder'); if (data.candidate_drawing) { if (drawingImg) { drawingImg.src = data.candidate_drawing; drawingImg.style.display = 'block'; } if (noDrawing) noDrawing.style.display = 'none'; } else { if (drawingImg) drawingImg.style.display = 'none'; if (noDrawing) noDrawing.style.display = 'block'; } // Saved recordings const recContainer = document.getElementById('modal-recordings-container'); if (recContainer) { const recPath = data.recording_path; let recList = data.recordings || []; if (recList.length === 0 && recPath) { recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }]; } if (recList.length > 0) { recList.sort((a, b) => { const timeA = a.created_at ? new Date(a.created_at).getTime() : 0; const timeB = b.created_at ? new Date(b.created_at).getTime() : 0; return timeB - timeA; }); let recHtml = `
📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''}) 📜 Scroll to view older recordings
`; recList.forEach((r, displayIdx) => { let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath); if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) { videoUrl = '/' + videoUrl; } if (videoUrl && videoUrl.includes('/storage/')) { videoUrl = videoUrl.replace('/storage/', '/media-stream/'); } const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx; const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date(); const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString(); const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`); const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm'); recHtml += `
📹 Session Recording #${recList.length - displayIdx} 📅 ${dateStr}
📥 Download Video
`; }); recContainer.innerHTML = recHtml; } else { recContainer.innerHTML = '
No video recordings saved for this candidate yet.
'; } } // Live Chat history const chatHistory = document.getElementById('interviewer-chat-history'); const interviewerColors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316']; if (chatHistory && data.warnings) { if (data.warnings.length > 0) { let chatHtml = ''; data.warnings.forEach(w => { const isCand = (w.sent_by === null || w.sent_by === undefined); const senderName = isCand ? (data.candidate_name || 'Candidate') : (w.sender ? w.sender.name : 'Interviewer'); const color = isCand ? '#38bdf8' : interviewerColors[(w.sent_by || 1) % interviewerColors.length]; const badgeLabel = isCand ? 'Candidate' : 'Panelist'; const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30'; chatHtml += `
${senderName} ${badgeLabel}
${new Date(w.created_at || Date.now()).toLocaleTimeString()}
${w.message}
`; }); chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 flex flex-col gap-2 mb-2.5'; chatHistory.innerHTML = chatHtml; chatHistory.scrollTop = chatHistory.scrollHeight; } else { chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-[150px] overflow-y-auto p-3 mb-2.5'; chatHistory.innerHTML = '
No chat history. Send a message below.
'; } } currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true; updateScreenshotToggleBtnUI(currentTabScreenshotEnabled); // Screenshots container const shotContainer = document.getElementById('modal-screenshots-container'); if (shotContainer) { const shotList = data.tab_switch_screenshots || []; if (shotList.length > 0) { let shotHtml = `
📸 Tab-Switch Screenshots Captured (${shotList.length}) Click image to enlarge full size
`; shotList.forEach((s) => { let url = typeof s === 'string' ? s : (s.url || s.full_url); if (url && !url.startsWith('http') && !url.startsWith('/')) { url = '/' + url; } const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : ''; shotHtml += `
📅 ${timeStr}
📥 View / Download
`; }); shotHtml += `
`; shotContainer.innerHTML = shotHtml; } else { shotContainer.innerHTML = '
No tab-switch screenshots captured for this candidate yet.
'; } } // Header live pulse dot & buttons visibility const livePulseDot = document.getElementById('live-call-pulse'); const recStartBtn = document.getElementById('btn-start-recording'); const recStopBtn = document.getElementById('btn-stop-recording'); const startBtn = document.getElementById('btn-start-call'); const leaveBtn = document.getElementById('btn-leave-call'); const endAllBtn = document.getElementById('btn-end-all-call'); const micBtn = document.getElementById('btn-toggle-interviewer-mic'); const camBtn = document.getElementById('btn-toggle-interviewer-cam'); const timelinePill = document.getElementById('timeline-status-pill'); if (data.call_status) { if (data.call_status === 'active') { if (!interviewerLocalStream) { if (livePulseDot) livePulseDot.style.display = 'none'; if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); if (icon) icon.className = 'fa-solid fa-phone'; if (span) span.innerText = 'Join Call'; startBtn.style.display = 'inline-flex'; } if (leaveBtn) leaveBtn.style.display = 'none'; if (endAllBtn) endAllBtn.style.display = 'none'; if (micBtn) micBtn.style.display = 'none'; if (camBtn) camBtn.style.display = 'none'; if (timelinePill) { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; if (span) span.innerText = 'Call in progress'; timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; } } else { if (livePulseDot) livePulseDot.style.display = 'inline-block'; if (startBtn) startBtn.style.display = 'none'; if (leaveBtn) leaveBtn.style.display = 'inline-flex'; if (endAllBtn) endAllBtn.style.display = 'inline-flex'; if (micBtn) micBtn.style.display = 'inline-flex'; if (camBtn) camBtn.style.display = 'inline-flex'; if (timelinePill) { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse'; if (span) span.innerText = 'Connected (Live)'; timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; } } if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { recStartBtn.style.display = 'inline-flex'; } } else if (data.call_status === 'ended' || data.call_status === 'idle') { if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall(); if (livePulseDot) livePulseDot.style.display = 'none'; if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); if (icon) icon.className = 'fa-solid fa-phone'; if (span) span.innerText = (data.call_status === 'ended') ? 'Restart call' : 'Start / Join Call'; startBtn.style.display = 'inline-flex'; } if (leaveBtn) leaveBtn.style.display = 'none'; if (endAllBtn) endAllBtn.style.display = 'none'; if (micBtn) micBtn.style.display = 'none'; if (camBtn) camBtn.style.display = 'none'; if (recStartBtn) recStartBtn.style.display = 'none'; if (recStopBtn) recStopBtn.style.display = 'none'; if (timelinePill && data.call_status === 'ended') { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; if (span) span.innerText = 'Call ended'; timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; } } } // Proctoring logs const logsDisplay = document.getElementById('modal-logs-display'); let logsHtml = ''; let currentViolations = []; if (data.proctor_logs && data.proctor_logs.length > 0) { data.proctor_logs.forEach(l => { 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 function initMeteredIceServers() { if (typeof window.getMeteredIceServers === 'function') { return window.getMeteredIceServers().then(servers => { if (Array.isArray(servers) && servers.length > 0) { globalIceServers = servers; } return globalIceServers; }); } return Promise.resolve(globalIceServers); } export function safePlayMediaStream(videoElem, stream, isSelf = false) { if (!videoElem || !stream) return; if (isSelf) videoElem.muted = true; if (videoElem.srcObject !== stream) { videoElem.srcObject = stream; } const playPromise = videoElem.play(); if (playPromise !== undefined) { playPromise.catch(err => { if (err.name === 'AbortError' || err.name === 'NotAllowedError') { videoElem.onloadedmetadata = () => { videoElem.play().catch(() => {}); }; } }); } } export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn = true, camOn = true) { const currentUserId = window.currentUserId || 0; if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return; const grid = document.getElementById('panelist-mesh-grid'); if (!grid) return; let tile = document.getElementById('panelist-tile-' + peerId); let videoElem; const initials = getInitials(name); if (!tile) { tile = document.createElement('div'); tile.id = 'panelist-tile-' + peerId; tile.className = 'video-tile relative aspect-[16/11] bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all'; tile.innerHTML = `
${initials}
${name}
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 ? 'none' : 'flex'; } } if (videoElem && stream) { safePlayMediaStream(videoElem, stream); } panelistMeshTiles.set(peerId, stream); } export function removePanelistTile(peerId) { const tile = document.getElementById('panelist-tile-' + peerId); if (tile) tile.remove(); panelistMeshTiles.delete(peerId); } export async function initInterviewerPeer() { if (!currentInterviewId || typeof window.Peer === 'undefined') return; await initMeteredIceServers(); const currentUserId = window.currentUserId || 0; const currentUserName = window.currentUserName || 'Interviewer'; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; if (interviewerPeer && !interviewerPeer.destroyed) { try { interviewerPeer.destroy(); } catch(e) {} } interviewerPeer = new window.Peer(myPeerId, { config: { iceServers: globalIceServers } }); interviewerPeer.on('open', id => { if (interviewerSigChannel) { interviewerSigChannel.postMessage({ type: 'peer_joined', peer_id: id, user_id: currentUserId, user_name: currentUserName, role: 'interviewer' }); } }); interviewerPeer.on('call', call => { const sendStream = interviewerLocalStream || new MediaStream(); try { call.answer(sendStream); } catch(e) {} call.on('stream', remoteStream => { if (call.peer === 'cand_' + currentInterviewId) { const candVid = document.getElementById('interviewer-cand-video'); 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(call.peer, remoteStream, 'Panelist'); } const badge = document.getElementById('interviewer-call-status'); if (badge) { badge.innerText = 'CONNECTED (LIVE)'; badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; } }); }); const screenPeerId = 'interviewer_screen_' + currentInterviewId; if (screenReceiverPeer && !screenReceiverPeer.destroyed) { try { screenReceiverPeer.destroy(); } catch(e) {} } screenReceiverPeer = new window.Peer(screenPeerId, { config: { iceServers: globalIceServers } }); screenReceiverPeer.on('call', call => { call.answer(); call.on('stream', screenStream => { const screenVid = document.getElementById('interviewer-screen-video'); const placeholder = document.getElementById('screen-video-placeholder'); if (screenVid) safePlayMediaStream(screenVid, screenStream); if (placeholder) placeholder.style.display = 'none'; }); }); } export function initInterviewerSigChannel() { if (!currentInterviewId) return; if (interviewerSigChannel) interviewerSigChannel.close(); interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId); const currentUserId = window.currentUserId || 0; const currentUserName = window.currentUserName || 'Interviewer'; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; interviewerSigChannel.onmessage = async (event) => { const msg = event.data; if (!msg) return; if (msg.type === 'violation_occurred') { pollReviewData(); } if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') { if (msg.peer_id && msg.peer_id !== myPeerId) { if (msg.type === 'peer_joined') { interviewerSigChannel.postMessage({ type: 'peer_presence_ack', peer_id: myPeerId, user_id: currentUserId, user_name: currentUserName, role: 'interviewer' }); } if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { try { const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { if (msg.peer_id === 'cand_' + currentInterviewId) { const candVid = document.getElementById('interviewer-cand-video'); 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 === 'cand_' + currentInterviewId) { const placeholder = document.getElementById('cand-video-placeholder'); if (placeholder) placeholder.style.display = 'flex'; } else if (msg.peer_id) { removePanelistTile(msg.peer_id); } } else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) { await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); } else if (msg.type === 'ice-candidate' && msg.sender === 'candidate' && interviewerPeerConnection && msg.candidate) { try { await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); } catch(e) {} } else if (msg.type === 'end_call_all') { leaveInterviewerCall(false); } }; } export function startInterviewerCall(isRejoin = false) { if (!currentInterviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const currentUserName = window.currentUserName || 'Interviewer'; if (!isRejoin) { iAmTheCaller = true; const candNameEl = document.getElementById('modal-cand-name'); const candName = candNameEl ? candNameEl.innerText : 'Candidate'; if (!globalCallChannel) { globalCallChannel = new BroadcastChannel('global_call_alerts'); } globalCallChannel.postMessage({ type: 'incoming_call', interview_id: currentInterviewId, candidate_name: candName, starter_name: currentUserName, call_started_at: new Date().toISOString() }); } initInterviewerSigChannel(); fetch(`/interviews/${currentInterviewId}/call-status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin }) }); const handleStream = (stream) => { interviewerLocalStream = stream; const selfVid = document.getElementById('interviewer-self-video'); const selfPlace = document.getElementById('self-video-placeholder'); if (selfVid) { selfVid.muted = true; safePlayMediaStream(selfVid, stream, true); } if (selfPlace) selfPlace.style.display = 'none'; const micBtn = document.getElementById('btn-toggle-interviewer-mic'); const camBtn = document.getElementById('btn-toggle-interviewer-cam'); const startBtn = document.getElementById('btn-start-call'); const leaveBtn = document.getElementById('btn-leave-call'); const endAllBtn = document.getElementById('btn-end-all-call'); if (micBtn) micBtn.style.display = 'inline-flex'; if (camBtn) camBtn.style.display = 'inline-flex'; if (startBtn) startBtn.style.display = 'none'; if (leaveBtn) leaveBtn.style.display = 'inline-flex'; if (endAllBtn) endAllBtn.style.display = 'inline-flex'; if (interviewerSigChannel) { interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' }); } if (!interviewerPeer || interviewerPeer.destroyed) { initInterviewerPeer(); } const makePeerJSCall = () => { if (!interviewerPeer || interviewerPeer.destroyed) return; const candPeerId = 'cand_' + currentInterviewId; activeCall = interviewerPeer.call(candPeerId, stream); if (activeCall) { activeCall.on('stream', candRemoteStream => { const candVid = document.getElementById('interviewer-cand-video'); const placeholder = document.getElementById('cand-video-placeholder'); if (candVid) safePlayMediaStream(candVid, candRemoteStream); if (placeholder) placeholder.style.display = 'none'; candRemoteStream.onaddtrack = () => { if (placeholder) placeholder.style.display = 'none'; if (candVid) safePlayMediaStream(candVid, candRemoteStream); }; const badge = document.getElementById('interviewer-call-status'); if (badge) { badge.innerText = 'CONNECTED (LIVE)'; badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500'; } }); } }; if (interviewerPeer && interviewerPeer.open) { makePeerJSCall(); } else if (interviewerPeer) { interviewerPeer.on('open', makePeerJSCall); } }; const audioConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true }; navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints }) .then(handleStream) .catch(() => { navigator.mediaDevices.getUserMedia({ video: true, audio: true }) .then(handleStream) .catch(() => { navigator.mediaDevices.getUserMedia({ video: false, audio: true }) .then(handleStream) .catch(() => { navigator.mediaDevices.getUserMedia({ video: true, audio: false }) .then(handleStream) .catch(err => console.log('WebRTC Stream init error:', err)); }); }); }); } export function leaveInterviewerCall(isExplicitEnd = true) { sendInterviewerPeerHeartbeat('leave'); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; if (currentInterviewId && isExplicitEnd) { const endingId = currentInterviewId; endedCallIds.add(endingId); dismissedCallIds.add(endingId); if (globalCallChannel) { try { globalCallChannel.postMessage({ type: 'call_ended', interview_id: endingId }); } catch(e) {} } if (interviewerSigChannel) { try { interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId }); } catch(e) {} } fetch(`/interviews/${endingId}/call-status`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ call_status: 'ended' }) }).catch(() => {}); } if (interviewerLocalStream) { try { interviewerLocalStream.getTracks().forEach(t => t.stop()); } catch(e) {} interviewerLocalStream = null; } if (activeCall) { try { activeCall.close(); } catch(e) {} activeCall = null; } if (interviewerPeer) { try { interviewerPeer.destroy(); } catch(e) {} interviewerPeer = null; } if (screenReceiverPeer) { try { screenReceiverPeer.destroy(); } catch(e) {} screenReceiverPeer = null; } const candPlace = document.getElementById('cand-video-placeholder'); const screenPlace = document.getElementById('screen-video-placeholder'); const selfPlace = document.getElementById('self-video-placeholder'); if (candPlace) candPlace.style.display = 'flex'; if (screenPlace) screenPlace.style.display = 'flex'; if (selfPlace) selfPlace.style.display = 'flex'; const candVid = document.getElementById('interviewer-cand-video'); if (candVid) candVid.srcObject = null; const selfVid = document.getElementById('interviewer-self-video'); if (selfVid) selfVid.srcObject = null; const livePulseDot = document.getElementById('live-call-pulse'); if (livePulseDot) livePulseDot.style.display = 'none'; const startBtn = document.getElementById('btn-start-call'); if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); if (icon) icon.className = 'fa-solid fa-phone'; if (span) span.innerText = 'Restart Call'; startBtn.style.display = 'inline-flex'; } const leaveBtn = document.getElementById('btn-leave-call'); if (leaveBtn) leaveBtn.style.display = 'none'; const endAllBtn = document.getElementById('btn-end-all-call'); if (endAllBtn) endAllBtn.style.display = 'none'; const micBtn = document.getElementById('btn-toggle-interviewer-mic'); if (micBtn) micBtn.style.display = 'none'; const camBtn = document.getElementById('btn-toggle-interviewer-cam'); if (camBtn) camBtn.style.display = 'none'; const timelinePill = document.getElementById('timeline-status-pill'); if (timelinePill) { const icon = timelinePill.querySelector('i'); const span = timelinePill.querySelector('span'); if (icon) icon.className = 'fa-solid fa-circle text-[8px]'; if (span) span.innerText = 'Call ended'; timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]'; } } export function endCallForAll() { const executeEndCall = () => { callRingtone.stop(); const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); activeIncomingCall = null; iAmTheCaller = false; leaveInterviewerCall(true); }; if (typeof window.Swal !== 'undefined') { window.Swal.fire({ title: '🛑 End Call for All Participants?', text: 'This will terminate the 2-way call session for candidate and all panelists.', icon: 'warning', showCancelButton: true, confirmButtonColor: '#ef4444', cancelButtonColor: '#475569', confirmButtonText: 'Yes, End Call for All' }).then((result) => { if (result.isConfirmed) { executeEndCall(); } }); } else { if (confirm('End Call for All Participants?')) { executeEndCall(); } } } // MediaRecorder recording let adminMediaRecorder = null; let adminRecordedChunks = []; let recAudioContext = null; export function startCallRecording() { if (!currentInterviewId) { alert('Please open an active candidate interview session first.'); return; } const candVid = document.getElementById('interviewer-cand-video'); const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; const streamToRecord = new MediaStream(); if (candidateStream) { candidateStream.getVideoTracks().forEach(track => { if (track.readyState === 'live') { try { streamToRecord.addTrack(track); } catch(e) {} } }); } if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) { interviewerLocalStream.getVideoTracks().forEach(track => { if (track.readyState === 'live') { try { streamToRecord.addTrack(track); } catch(e) {} } }); } const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : []; const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : []; if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) { try { const AudioContextClass = window.AudioContext || window.webkitAudioContext; if (recAudioContext) { try { recAudioContext.close(); } catch(e) {} } recAudioContext = new AudioContextClass(); const audioDestination = recAudioContext.createMediaStreamDestination(); if (candAudioTracks.length > 0) { const candAudioStream = new MediaStream(candAudioTracks); const candSource = recAudioContext.createMediaStreamSource(candAudioStream); candSource.connect(audioDestination); } if (adminAudioTracks.length > 0) { const adminAudioStream = new MediaStream(adminAudioTracks); const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream); adminSource.connect(audioDestination); } const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0]; if (mixedAudioTrack) { streamToRecord.addTrack(mixedAudioTrack); } } catch(e) { const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0]; if (fallbackAudio) { try { streamToRecord.addTrack(fallbackAudio); } catch(err) {} } } } const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live'); if (activeTracks.length === 0) { alert('No active video/audio stream available to record. Please start or join the call first.'); return; } adminRecordedChunks = []; try { let options = {}; if (typeof MediaRecorder.isTypeSupported === 'function') { if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) { options = { mimeType: 'video/webm;codecs=vp8,opus' }; } else if (MediaRecorder.isTypeSupported('video/webm')) { options = { mimeType: 'video/webm' }; } } try { adminMediaRecorder = new MediaRecorder(streamToRecord, options); } catch(errOpt) { adminMediaRecorder = new MediaRecorder(streamToRecord); } adminMediaRecorder.ondataavailable = function(e) { if (e.data && e.data.size > 0) { adminRecordedChunks.push(e.data); } }; adminMediaRecorder.onstop = function() { if (recAudioContext) { try { recAudioContext.close(); } catch(e) {} recAudioContext = null; } if (adminRecordedChunks.length === 0) return; const recMime = adminMediaRecorder.mimeType || 'video/webm'; const isMp4 = recMime.includes('mp4'); const ext = isMp4 ? 'mp4' : 'webm'; const blobType = isMp4 ? 'video/mp4' : 'video/webm'; const blob = new Blob(adminRecordedChunks, { type: blobType }); uploadRecordedBlob(blob, ext); }; adminMediaRecorder.start(1000); const recStartBtn = document.getElementById('btn-start-recording'); const recStopBtn = document.getElementById('btn-stop-recording'); if (recStartBtn) recStartBtn.style.display = 'none'; if (recStopBtn) recStopBtn.style.display = 'inline-flex'; if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'info', title: 'Recording Started', text: 'Interview call recording is active (silent mode - candidate is not notified).', toast: true, position: 'top-end', timer: 3000, showConfirmButton: false }); } } catch(e) { alert('Recording failed to initialize: ' + e.message); } } export function stopCallRecording() { if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { adminMediaRecorder.stop(); } const recStartBtn = document.getElementById('btn-start-recording'); const recStopBtn = document.getElementById('btn-stop-recording'); if (recStartBtn) recStartBtn.style.display = 'inline-flex'; if (recStopBtn) recStopBtn.style.display = 'none'; } export function uploadRecordedBlob(blob, ext = 'webm') { if (!currentInterviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const formData = new FormData(); formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`); formData.append('_token', csrfToken); fetch(`/candidate/upload-recording/${currentInterviewId}`, { method: 'POST', body: formData }) .then(r => r.json()) .then(res => { if (res.success) { if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'success', title: 'Recording Saved!', text: 'Call recording saved successfully under Candidate Profile and Report.', toast: true, position: 'top-end', timer: 3500, showConfirmButton: false }); } pollReviewData(); } }) .catch(err => console.log('Recording upload error:', err)); } export function toggleInterviewerMic() { if (!interviewerLocalStream) return; const audioTracks = interviewerLocalStream.getAudioTracks(); if (audioTracks.length === 0) return; isInterviewerMicOn = !isInterviewerMicOn; audioTracks[0].enabled = isInterviewerMicOn; const btn = document.getElementById('btn-toggle-interviewer-mic'); if (btn) { 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(true); const modal = document.getElementById('review-modal'); if (modal) modal.classList.remove('active'); } export function sendSilentWarning() { const input = document.getElementById('warning-input'); if (!input) return; const msg = input.value.trim(); if (!msg || !currentInterviewId) return; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; input.value = ''; if (liveSyncChannel) { liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' }); } fetch(`/interviews/${currentInterviewId}/warning`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken }, body: JSON.stringify({ message: msg }) }) .then(r => r.json()) .then(() => { pollReviewData(); }); } export function setCurrentInterviewId(id) { currentInterviewId = id; } // Bind functions onto window object for template inline onclick handlers if (typeof window !== 'undefined') { window.CallRingtoneEngine = CallRingtoneEngine; window.callRingtone = callRingtone; window.getInitials = getInitials; window.zoomScreen = zoomScreen; window.resetZoomScreen = resetZoomScreen; window.toggleFullscreenScreen = toggleFullscreenScreen; window.zoomCandVideo = zoomCandVideo; window.toggleSosAutoTrigger = toggleSosAutoTrigger; window.openReportPage = openReportPage; window.toggleTabScreenshotCapture = toggleTabScreenshotCapture; window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI; window.switchIdeTab = switchIdeTab; window.triggerIncomingCallRing = triggerIncomingCallRing; window.pollGlobalActiveCalls = pollGlobalActiveCalls; window.acceptIncomingCall = acceptIncomingCall; window.declineIncomingCall = declineIncomingCall; window.subscribeLiveSync = subscribeLiveSync; window.openReviewModal = openReviewModal; window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat; window.pollReviewData = pollReviewData; window.openSosModal = openSosModal; window.stopSosAlarm = stopSosAlarm; window.disableSosAndStopAlarm = disableSosAndStopAlarm; window.toggleSosAutoTrigger = toggleSosAutoTrigger; window.interviewerLogoutAction = interviewerLogoutAction; window.regenerateCandidatePassword = regenerateCandidatePassword; window.blockCandidateAction = blockCandidateAction; window.startInterviewerCall = startInterviewerCall; window.leaveInterviewerCall = leaveInterviewerCall; window.endCallForAll = endCallForAll; window.startCallRecording = startCallRecording; window.stopCallRecording = stopCallRecording; window.toggleInterviewerMic = toggleInterviewerMic; window.toggleInterviewerCam = toggleInterviewerCam; window.closeReviewModal = closeReviewModal; window.sendSilentWarning = sendSilentWarning; window.setCurrentInterviewId = setCurrentInterviewId; // Attach listeners ['click', 'keydown', 'touchstart'].forEach(evt => { document.addEventListener(evt, () => callRingtone.init(), { passive: true }); }); globalCallChannel = new BroadcastChannel('global_call_alerts'); globalCallChannel.onmessage = (event) => { const data = event.data; if (!data) return; if (data.type === 'incoming_call') { triggerIncomingCallRing({ id: data.interview_id, candidate_name: data.candidate_name, submission_unique_id: data.submission_unique_id, starter_name: data.starter_name, call_started_at: data.call_started_at || new Date().toISOString() }, true); } else if (data.type === 'call_ended') { endedCallIds.add(data.interview_id); dismissedCallIds.add(data.interview_id); callRingtone.stop(); if (ringTimeoutTimer) { clearTimeout(ringTimeoutTimer); ringTimeoutTimer = null; } const modal = document.getElementById('incoming-call-modal'); if (modal) modal.classList.remove('active'); if (activeIncomingCall && activeIncomingCall.id == data.interview_id) { activeIncomingCall = null; } } }; setInterval(pollGlobalActiveCalls, 1500); }