/**
* 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';
// --- Global Audio Ringtone Synthesizer Engine ---
export class CallRingtoneEngine {
constructor() {
this.ctx = null;
this.interval = null;
this.isPlaying = false;
}
init() {
try {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
} catch (e) {
console.log('Audio init warning:', e);
}
}
start() {
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
this.init();
if (this.isPlaying) return;
this.isPlaying = true;
this.playTone();
if (this.interval) clearInterval(this.interval);
this.interval = setInterval(() => {
if (this.isPlaying) this.playTone();
}, 2400);
}
playTone() {
this.init();
if (!this.ctx || !this.isPlaying) return;
try {
const now = this.ctx.currentTime;
const osc1 = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc1.type = 'sine';
osc2.type = 'sine';
osc1.frequency.setValueAtTime(523.25, now);
osc2.frequency.setValueAtTime(659.25, now);
gain.gain.setValueAtTime(0.001, now);
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
gain.gain.setValueAtTime(0.35, now + 1.1);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(this.ctx.destination);
osc1.start(now);
osc2.start(now);
osc1.stop(now + 1.35);
osc2.stop(now + 1.35);
} catch (e) {}
}
stop() {
this.isPlaying = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
export const callRingtone = new CallRingtoneEngine();
// State variables
let currentInterviewId = null;
let currentSubmissionUniqueId = null;
let interviewerPeer = null;
let screenReceiverPeer = null;
export function getCandidatePeerId() {
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
currentInterviewId = document.body.dataset.interviewId;
}
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
currentSubmissionUniqueId = document.body.dataset.submissionId;
}
if (!currentInterviewId) return '';
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
return subId ? ('cand_' + currentInterviewId + '_' + subId) : ('cand_' + currentInterviewId);
}
export function getMyInterviewerPeerId() {
const currentUserId = window.currentUserId || 0;
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
currentInterviewId = document.body.dataset.interviewId;
}
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
currentSubmissionUniqueId = document.body.dataset.submissionId;
}
if (!currentInterviewId) return '';
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
return subId
? ('interviewer_' + currentInterviewId + '_' + subId + '_' + currentUserId)
: ('interviewer_' + currentInterviewId + '_' + currentUserId);
}
export function getInterviewerScreenPeerId(userId = null) {
const currentUserId = userId !== null ? userId : (window.currentUserId || 0);
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
currentInterviewId = document.body.dataset.interviewId;
}
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
currentSubmissionUniqueId = document.body.dataset.submissionId;
}
if (!currentInterviewId) return '';
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
return subId
? ('interviewer_screen_' + currentInterviewId + '_' + subId + '_' + currentUserId)
: ('interviewer_screen_' + currentInterviewId + '_' + currentUserId);
}
let interviewerLocalStream = null;
let activeCall = null;
let isInterviewerMicOn = true;
let isInterviewerCamOn = true;
let screenZoomLevel = 1.0;
let candVidZoomLevel = 1.0;
let activeViolations = [];
let sosPollInterval = null;
let peerHeartbeatTimer = null;
let acknowledgedViolationCount = 0;
let sosDismissed = false;
let sosAutoTriggerDisabled = true;
let currentTabScreenshotEnabled = true;
let interviewerSigChannel = null;
let interviewerPeerConnection = null;
let liveSyncChannel = null;
let panelistMeshTiles = new Map();
let latestActivePeers = [];
// Ringing & Call state
let activeIncomingCall = null;
let iAmTheCaller = false;
let ringTimeoutTimer = null;
const dismissedCallIds = new Set();
export function formatProctoringLogDetails(l) {
let detailsObj = l.details;
if (typeof detailsObj === 'string') {
try { detailsObj = JSON.parse(detailsObj); } catch(e) {}
}
const type = l.type || '';
if (type === 'multiple_faces') {
const count = (typeof detailsObj === 'object' && detailsObj && detailsObj.face_count) ? detailsObj.face_count : 2;
return `Multiple faces detected in camera frame (${count} faces)`;
}
if (type === 'face_missing') {
return 'Candidate face is not visible in camera frame';
}
if (type === 'looking_away' || type === 'prolonged_looking_away') {
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.reasons || typeof detailsObj.yaw === 'number')) {
const reasonLabels = {
'head_yaw': 'Head Yaw',
'head_up': 'Head Pitch Up',
'head_down': 'Head Pitch Down',
'eye_gaze_left': 'Eye Gaze Left',
'eye_gaze_right': 'Eye Gaze Right',
'eye_gaze_down': 'Eye Gaze Down'
};
const reasonsStr = Array.isArray(detailsObj.reasons)
? detailsObj.reasons.map(r => reasonLabels[r] || r.replace(/_/g, ' ')).join(', ')
: '';
const durationPrefix = (type === 'prolonged_looking_away') ? 'Looking away continuously (>10s)' : 'Looking away from camera';
return reasonsStr ? `${durationPrefix} (${reasonsStr})` : durationPrefix;
} else if (typeof l.details === 'string' && l.details && !l.details.startsWith('{')) {
return l.details;
}
return type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera';
}
if (type === 'phone_detected') {
const label = (typeof detailsObj === 'object' && detailsObj && detailsObj.label) ? detailsObj.label : 'cell phone';
return `Possible mobile phone detected in camera frame (${label})`;
}
if (type === 'prolonged_corner_gaze') {
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.corner_label || detailsObj.corner)) {
const cornerLabel = detailsObj.corner_label || (detailsObj.corner || 'screen corner').replace(/_/g, ' ');
const dur = detailsObj.duration_s || Math.round((detailsObj.duration_ms || 18000) / 1000);
return `Fixated on ${cornerLabel} for ${dur}s`;
}
return 'Candidate fixated on screen corner for prolonged period';
}
if (type === 'talking_secondary_person') {
return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a secondary person';
}
if (typeof l.details === 'object' && l.details !== null) {
try { return JSON.stringify(l.details); } catch(e) { return String(l.details); }
}
return l.details || '';
}
const endedCallIds = new Set();
let globalCallChannel = null;
const adminPageLoadTimestamp = Date.now();
let globalIceServers = window.globalIceServers || [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' }
];
export function getInitials(name) {
if (!name) return 'CD';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const str = name.trim();
if (str.length >= 2) {
return (str[0] + str[str.length - 1]).toUpperCase();
}
return str.toUpperCase();
}
export function zoomScreen(delta) {
screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta));
const screenVid = document.getElementById('interviewer-screen-video');
if (screenVid) {
screenVid.style.transform = `scale(${screenZoomLevel})`;
}
}
export function resetZoomScreen() {
screenZoomLevel = 1.0;
zoomScreen(0);
}
export function toggleFullscreenScreen() {
const wrapper = document.getElementById('screen-wrapper');
if (!wrapper) return;
if (!document.fullscreenElement) {
wrapper.requestFullscreen().catch(err => console.log(err));
} else {
document.exitFullscreen();
}
}
export function zoomCandVideo(delta) {
if (delta === 0) candVidZoomLevel = 1.0;
else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta));
const vid = document.getElementById('interviewer-cand-video');
if (vid) {
vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`;
vid.style.transformOrigin = 'center center';
vid.style.transition = 'transform 0.2s ease';
}
}
export function toggleSosAutoTrigger() {
sosAutoTriggerDisabled = !sosAutoTriggerDisabled;
const toggleSw = document.getElementById('sosToggle');
const btn = document.getElementById('sos-toggle-btn');
if (toggleSw) {
if (sosAutoTriggerDisabled) {
toggleSw.classList.remove('on');
} else {
toggleSw.classList.add('on');
}
}
if (btn) {
if (sosAutoTriggerDisabled) {
btn.innerHTML = ' SOS Auto-Trigger: OFF';
btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300';
} else {
btn.innerHTML = ' SOS Auto-Trigger: ON';
btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300';
}
}
}
export function openReportPage() {
if (!currentInterviewId) return;
window.open(`/interviews/${currentInterviewId}/report`, '_blank');
}
export function toggleTabScreenshotCapture() {
if (!currentInterviewId) return;
const newStatus = !currentTabScreenshotEnabled;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ enable: newStatus })
})
.then(r => r.json())
.then(res => {
if (res.success) {
currentTabScreenshotEnabled = res.enable_tab_switch_screenshot;
updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
icon: 'info',
title: 'Screenshot Setting Updated',
text: res.message,
timer: 2500,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
}
})
.catch(() => {});
}
export function updateScreenshotToggleBtnUI(enabled) {
const btn = document.getElementById('modal-toggle-screenshot-btn');
if (btn) {
if (enabled) {
btn.innerHTML = ' ENABLED';
btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none';
} else {
btn.innerHTML = ' DISABLED';
btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none';
}
}
}
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');
}
}
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 }];
}
if (recList.length > 0) {
recList.sort((a, b) => {
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
return timeB - timeA;
});
let recHtml = `
📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''})
📜 Scroll to view older recordings
`;
recList.forEach((r, displayIdx) => {
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
videoUrl = '/' + videoUrl;
}
if (videoUrl && videoUrl.includes('/storage/')) {
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
}
const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`);
const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm');
recHtml += `
`;
});
recContainer.innerHTML = recHtml;
} else {
recContainer.innerHTML = 'No video recordings saved for this candidate yet.
';
}
}
// Live Chat history
const chatHistory = document.getElementById('interviewer-chat-history');
const interviewerColors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316'];
if (chatHistory && data.warnings) {
if (data.warnings.length > 0) {
let chatHtml = '';
data.warnings.forEach(w => {
const isCand = (w.sent_by === null || w.sent_by === undefined);
const senderName = isCand
? (data.candidate_name || 'Candidate')
: (w.sender ? w.sender.name : 'Interviewer');
const color = isCand
? '#38bdf8'
: interviewerColors[(w.sent_by || 1) % interviewerColors.length];
const badgeLabel = isCand ? 'Candidate' : 'Panelist';
const badgeClass = isCand ? 'bg-sky-500/20 text-sky-300 border-sky-500/30' : 'bg-indigo-500/20 text-indigo-300 border-indigo-500/30';
chatHtml += `
${senderName}
${badgeLabel}
${new Date(w.created_at || Date.now()).toLocaleTimeString()}
${w.message}
`;
});
chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-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 += `
`;
});
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 = (stream) => {
interviewerLocalStream = stream;
const selfVid = document.getElementById('interviewer-self-video');
const selfPlace = document.getElementById('self-video-placeholder');
if (selfVid) {
selfVid.muted = true;
safePlayMediaStream(selfVid, stream, true);
}
if (selfPlace) selfPlace.style.display = 'none';
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
const startBtn = document.getElementById('btn-start-call');
const leaveBtn = document.getElementById('btn-leave-call');
const endAllBtn = document.getElementById('btn-end-all-call');
const recStartBtn = document.getElementById('btn-start-recording');
if (micBtn) micBtn.style.display = 'inline-flex';
if (camBtn) camBtn.style.display = 'inline-flex';
if (startBtn) startBtn.style.display = 'none';
if (leaveBtn) leaveBtn.style.display = 'inline-flex';
if (endAllBtn) endAllBtn.style.display = 'inline-flex';
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
recStartBtn.style.display = 'inline-flex';
}
if (interviewerSigChannel) {
interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' });
}
if (!interviewerPeer || interviewerPeer.destroyed) {
initInterviewerPeer();
}
const makePeerJSCall = () => {
if (!interviewerPeer || interviewerPeer.destroyed) return;
const candPeerId = getCandidatePeerId();
const myPeerId = getMyInterviewerPeerId();
activeCall = interviewerPeer.call(candPeerId, stream);
if (activeCall) {
activeCall.on('stream', candRemoteStream => {
const candVid = document.getElementById('interviewer-cand-video');
const placeholder = document.getElementById('cand-video-placeholder');
if (candVid) safePlayMediaStream(candVid, candRemoteStream);
if (placeholder) placeholder.style.display = 'none';
candRemoteStream.onaddtrack = () => {
if (placeholder) placeholder.style.display = 'none';
if (candVid) safePlayMediaStream(candVid, candRemoteStream);
};
const badge = document.getElementById('interviewer-call-status');
if (badge) {
badge.innerText = 'CONNECTED (LIVE)';
badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
}
});
}
// Also call any other already-active interviewers
if (Array.isArray(latestActivePeers)) {
latestActivePeers.forEach(p => {
if (p.peer_id && p.peer_id !== myPeerId && p.peer_id.startsWith('interviewer_')) {
try {
const outCall = interviewerPeer.call(p.peer_id, stream);
if (outCall) {
const roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
outCall.on('stream', remoteStream => {
addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
});
}
} catch(e) {}
}
});
}
};
if (interviewerPeer && interviewerPeer.open) {
makePeerJSCall();
} else if (interviewerPeer) {
interviewerPeer.on('open', makePeerJSCall);
}
};
const audioConstraints = {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true
};
navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints })
.then(handleStream)
.catch(() => {
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(handleStream)
.catch(() => {
navigator.mediaDevices.getUserMedia({ video: false, audio: true })
.then(handleStream)
.catch(() => {
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(handleStream)
.catch(err => console.log('WebRTC Stream init error:', err));
});
});
});
}
export function leaveInterviewerCall(isEndAll = false) {
sendInterviewerPeerHeartbeat('leave');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
const endingId = currentInterviewId;
if (interviewerSigChannel) {
try {
interviewerSigChannel.postMessage({
type: 'peer_left',
peer_id: getMyInterviewerPeerId()
});
} catch(e) {}
}
if (endingId && isEndAll) {
endedCallIds.add(endingId);
dismissedCallIds.add(endingId);
if (globalCallChannel) {
try {
globalCallChannel.postMessage({
type: 'call_ended',
interview_id: endingId
});
} catch(e) {}
}
if (interviewerSigChannel) {
try {
interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
} catch(e) {}
}
fetch(`/interviews/${endingId}/call-status`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ call_status: 'ended' })
}).catch(() => {});
}
if (interviewerLocalStream) {
try {
interviewerLocalStream.getTracks().forEach(t => t.stop());
} catch(e) {}
interviewerLocalStream = null;
}
if (activeCall) {
try { activeCall.close(); } catch(e) {}
activeCall = null;
}
if (interviewerPeer) {
try { interviewerPeer.destroy(); } catch(e) {}
interviewerPeer = null;
}
if (screenReceiverPeer) {
try { screenReceiverPeer.destroy(); } catch(e) {}
screenReceiverPeer = null;
}
updateCandidateStatusIcons(false, false, false);
const candPlace = document.getElementById('cand-video-placeholder');
const screenPlace = document.getElementById('screen-video-placeholder');
const selfPlace = document.getElementById('self-video-placeholder');
if (candPlace) candPlace.style.display = 'flex';
if (screenPlace) screenPlace.style.display = 'flex';
if (selfPlace) selfPlace.style.display = 'flex';
const candVid = document.getElementById('interviewer-cand-video');
if (candVid) candVid.srcObject = null;
const screenVid = document.getElementById('interviewer-screen-video');
if (screenVid) screenVid.srcObject = null;
const selfVid = document.getElementById('interviewer-self-video');
if (selfVid) selfVid.srcObject = null;
panelistMeshTiles.forEach((_, pid) => {
removePanelistTile(pid);
});
const livePulseDot = document.getElementById('live-call-pulse');
if (livePulseDot) livePulseDot.style.display = 'none';
const startBtn = document.getElementById('btn-start-call');
if (startBtn) {
const icon = startBtn.querySelector('i');
const span = startBtn.querySelector('span');
if (isEndAll) {
if (icon) icon.className = 'fa-solid fa-phone-slash';
if (span) span.innerText = 'Call Ended';
startBtn.disabled = true;
startBtn.style.opacity = '0.5';
startBtn.style.cursor = 'not-allowed';
startBtn.style.pointerEvents = 'none';
startBtn.style.display = 'inline-flex';
} else {
if (icon) icon.className = 'fa-solid fa-phone';
if (span) span.innerText = 'Join Call';
startBtn.disabled = false;
startBtn.style.opacity = '1';
startBtn.style.cursor = 'pointer';
startBtn.style.pointerEvents = 'auto';
startBtn.style.display = 'inline-flex';
}
}
const leaveBtn = document.getElementById('btn-leave-call');
if (leaveBtn) leaveBtn.style.display = 'none';
const endAllBtn = document.getElementById('btn-end-all-call');
if (endAllBtn) endAllBtn.style.display = 'none';
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
if (micBtn) micBtn.style.display = 'none';
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
if (camBtn) camBtn.style.display = 'none';
const recStartBtn = document.getElementById('btn-start-recording');
if (recStartBtn) recStartBtn.style.display = 'none';
const recStopBtn = document.getElementById('btn-stop-recording');
if (recStopBtn) recStopBtn.style.display = 'none';
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
try { adminMediaRecorder.stop(); } catch(e) {}
}
const timelinePill = document.getElementById('timeline-status-pill');
if (timelinePill && isEndAll) {
const icon = timelinePill.querySelector('i');
const span = timelinePill.querySelector('span');
if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
if (span) span.innerText = 'Call ended';
timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]';
}
}
export function endCallForAll() {
const executeEndCall = () => {
callRingtone.stop();
const modal = document.getElementById('incoming-call-modal');
if (modal) modal.classList.remove('active');
activeIncomingCall = null;
iAmTheCaller = false;
leaveInterviewerCall(true);
};
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
title: '🛑 End Call for All Participants?',
text: 'This will terminate the 2-way call session for candidate and all panelists.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#475569',
confirmButtonText: 'Yes, End Call for All'
}).then((result) => {
if (result.isConfirmed) {
executeEndCall();
}
});
} else {
if (confirm('End Call for All Participants?')) {
executeEndCall();
}
}
}
// MediaRecorder & Recording Compositor
let adminMediaRecorder = null;
let adminRecordedChunks = [];
let recordingCompositor = null;
let recordingTimerInterval = null;
let recordingStartTime = null;
export function startCallRecording() {
if (!currentInterviewId) {
alert('Please open an active candidate interview session first.');
return;
}
if (!interviewerLocalStream) {
alert('Please start or join the call before recording.');
return;
}
const candVid = document.getElementById('interviewer-cand-video');
const candNameEl = document.getElementById('modal-cand-name');
const candidateName = candNameEl ? candNameEl.innerText.trim() : 'Candidate';
// 1. Initialize RecordingCompositor (1920x1080 @ 30fps)
recordingCompositor = new RecordingCompositor({
width: 1920,
height: 1080,
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();
adminRecordedChunks = [];
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 (recordingCompositor) {
recordingCompositor.stop();
recordingCompositor = 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);
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';
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);
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
icon: 'info',
title: 'Composite Recording Started',
text: 'Recording composite layout (Candidate, Screen, and Panelists in HD).',
toast: true,
position: 'top-end',
timer: 3500,
showConfirmButton: false
});
}
} catch(e) {
if (recordingCompositor) {
recordingCompositor.stop();
recordingCompositor = null;
}
alert('Recording failed to initialize: ' + e.message);
}
}
export function stopCallRecording() {
if (recordingTimerInterval) {
clearInterval(recordingTimerInterval);
recordingTimerInterval = null;
}
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
adminMediaRecorder.stop();
}
if (recordingCompositor) {
recordingCompositor.stop();
recordingCompositor = null;
}
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 && 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 toggleInterviewerMic() {
if (!interviewerLocalStream) return;
const audioTracks = interviewerLocalStream.getAudioTracks();
if (audioTracks.length === 0) return;
isInterviewerMicOn = !isInterviewerMicOn;
audioTracks[0].enabled = isInterviewerMicOn;
const btn = document.getElementById('btn-toggle-interviewer-mic');
if (btn) {
const icon = btn.querySelector('i');
if (icon) {
icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
}
btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)';
if (isInterviewerMicOn) {
btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
} else {
btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
}
}
const selfMicIcon = document.getElementById('self-mic-status');
if (selfMicIcon) {
selfMicIcon.className = isInterviewerMicOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
selfMicIcon.title = isInterviewerMicOn ? 'Mic On' : 'Mic Muted';
}
sendInterviewerPeerHeartbeat();
}
export function toggleInterviewerCam() {
const selfPlaceholder = document.getElementById('self-video-placeholder');
const selfVid = document.getElementById('interviewer-self-video');
isInterviewerCamOn = !isInterviewerCamOn;
const btn = document.getElementById('btn-toggle-interviewer-cam');
if (btn) {
const icon = btn.querySelector('i');
if (icon) {
icon.className = isInterviewerCamOn ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
}
btn.title = isInterviewerCamOn ? 'Cam On (Click to Turn Off)' : 'Cam Off (Click to Turn On)';
if (isInterviewerCamOn) {
btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
} else {
btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
}
}
const selfCamIcon = document.getElementById('self-cam-status');
if (selfCamIcon) {
selfCamIcon.className = isInterviewerCamOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]';
selfCamIcon.title = isInterviewerCamOn ? 'Camera On' : 'Camera Off';
}
if (interviewerLocalStream) {
const videoTracks = interviewerLocalStream.getVideoTracks();
if (videoTracks.length > 0) {
videoTracks[0].enabled = isInterviewerCamOn;
}
} else if (isInterviewerCamOn) {
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(stream => {
interviewerLocalStream = stream;
if (selfVid) {
selfVid.srcObject = stream;
selfVid.play().catch(() => {});
}
if (selfPlaceholder) selfPlaceholder.style.display = 'none';
})
.catch(() => {});
sendInterviewerPeerHeartbeat();
return;
}
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
sendInterviewerPeerHeartbeat();
}
export function closeReviewModal() {
if (sosPollInterval) clearInterval(sosPollInterval);
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
if (liveSyncChannel) {
try { liveSyncChannel.close(); } catch(e) {}
liveSyncChannel = null;
}
leaveInterviewerCall(false);
const modal = document.getElementById('review-modal');
if (modal) modal.classList.remove('active');
}
export function sendSilentWarning() {
const input = document.getElementById('warning-input');
if (!input) return;
const msg = input.value.trim();
if (!msg || !currentInterviewId) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
input.value = '';
if (liveSyncChannel) {
liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' });
}
fetch(`/interviews/${currentInterviewId}/warning`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ message: msg })
})
.then(r => r.json())
.then(() => {
pollReviewData();
});
}
export function setCurrentInterviewId(id) {
currentInterviewId = id;
}
// Bind functions onto window object for template inline onclick handlers
if (typeof window !== 'undefined') {
window.CallRingtoneEngine = CallRingtoneEngine;
window.callRingtone = callRingtone;
window.getInitials = getInitials;
window.zoomScreen = zoomScreen;
window.resetZoomScreen = resetZoomScreen;
window.toggleFullscreenScreen = toggleFullscreenScreen;
window.zoomCandVideo = zoomCandVideo;
window.toggleSosAutoTrigger = toggleSosAutoTrigger;
window.openReportPage = openReportPage;
window.toggleTabScreenshotCapture = toggleTabScreenshotCapture;
window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI;
window.switchIdeTab = switchIdeTab;
window.triggerIncomingCallRing = triggerIncomingCallRing;
window.pollGlobalActiveCalls = pollGlobalActiveCalls;
window.acceptIncomingCall = acceptIncomingCall;
window.declineIncomingCall = declineIncomingCall;
window.subscribeLiveSync = subscribeLiveSync;
window.openReviewModal = openReviewModal;
window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat;
window.pollReviewData = pollReviewData;
window.openSosModal = openSosModal;
window.stopSosAlarm = stopSosAlarm;
window.disableSosAndStopAlarm = disableSosAndStopAlarm;
window.toggleSosAutoTrigger = toggleSosAutoTrigger;
window.interviewerLogoutAction = interviewerLogoutAction;
window.regenerateCandidatePassword = regenerateCandidatePassword;
window.blockCandidateAction = blockCandidateAction;
window.startInterviewerCall = startInterviewerCall;
window.leaveInterviewerCall = leaveInterviewerCall;
window.endCallForAll = endCallForAll;
window.startCallRecording = startCallRecording;
window.stopCallRecording = stopCallRecording;
window.toggleInterviewerMic = toggleInterviewerMic;
window.toggleInterviewerCam = toggleInterviewerCam;
window.closeReviewModal = closeReviewModal;
window.sendSilentWarning = sendSilentWarning;
window.setCurrentInterviewId = setCurrentInterviewId;
window.closeInspectorPanel = closeInspectorPanel;
// Attach listeners
['click', 'keydown', 'touchstart'].forEach(evt => {
document.addEventListener(evt, () => callRingtone.init(), { passive: true });
});
globalCallChannel = new BroadcastChannel('global_call_alerts');
globalCallChannel.onmessage = (event) => {
const data = event.data;
if (!data) return;
if (data.type === 'incoming_call') {
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
triggerIncomingCallRing({
id: data.interview_id,
candidate_name: data.candidate_name,
submission_unique_id: data.submission_unique_id,
starter_name: data.starter_name,
call_started_at: data.call_started_at || new Date().toISOString()
}, true);
} else if (data.type === 'call_ended') {
endedCallIds.add(data.interview_id);
dismissedCallIds.add(data.interview_id);
callRingtone.stop();
if (ringTimeoutTimer) {
clearTimeout(ringTimeoutTimer);
ringTimeoutTimer = null;
}
const modal = document.getElementById('incoming-call-modal');
if (modal) modal.classList.remove('active');
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
activeIncomingCall = null;
}
if (currentInterviewId && currentInterviewId == data.interview_id) {
updateCandidateStatusIcons(false, false, false);
leaveInterviewerCall(false);
}
}
};
setInterval(pollGlobalActiveCalls, 6000);
}