/**
* 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 = false;
let currentTabScreenshotEnabled = true;
let interviewerSigChannel = null;
let interviewerPeerConnection = null;
let liveSyncChannel = null;
let panelistMeshTiles = new Map();
// Ringing & Call state
let activeIncomingCall = null;
let iAmTheCaller = false;
let ringTimeoutTimer = null;
const dismissedCallIds = new Set();
const endedCallIds = new Set();
let globalCallChannel = null;
const adminPageLoadTimestamp = Date.now();
let globalIceServers = window.globalIceServers || [
{ urls: 'stun:stun.l.google.com:19302' }
];
export function getInitials(name) {
if (!name) return 'CD';
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const str = name.trim();
if (str.length >= 2) {
return (str[0] + str[str.length - 1]).toUpperCase();
}
return str.toUpperCase();
}
export function zoomScreen(delta) {
screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta));
const screenVid = document.getElementById('interviewer-screen-video');
if (screenVid) {
screenVid.style.transform = `scale(${screenZoomLevel})`;
}
}
export function resetZoomScreen() {
screenZoomLevel = 1.0;
zoomScreen(0);
}
export function toggleFullscreenScreen() {
const wrapper = document.getElementById('screen-wrapper');
if (!wrapper) return;
if (!document.fullscreenElement) {
wrapper.requestFullscreen().catch(err => console.log(err));
} else {
document.exitFullscreen();
}
}
export function zoomCandVideo(delta) {
if (delta === 0) candVidZoomLevel = 1.0;
else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta));
const vid = document.getElementById('interviewer-cand-video');
if (vid) {
vid.style.transform = `scale(${candVidZoomLevel})`;
vid.style.transformOrigin = 'center center';
vid.style.transition = 'transform 0.2s ease';
}
}
export function toggleSosAutoTrigger() {
sosAutoTriggerDisabled = !sosAutoTriggerDisabled;
const toggleSw = document.getElementById('sosToggle');
const btn = document.getElementById('sos-toggle-btn');
if (toggleSw) {
if (sosAutoTriggerDisabled) {
toggleSw.classList.remove('on');
} else {
toggleSw.classList.add('on');
}
}
if (btn) {
if (sosAutoTriggerDisabled) {
btn.innerHTML = ' SOS Auto-Trigger: OFF';
btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-red-500 bg-red-500/15 text-red-300';
} else {
btn.innerHTML = ' SOS Auto-Trigger: ON';
btn.className = 'px-3 py-1.5 text-xs font-semibold rounded-lg border border-indigo-500 bg-indigo-500/15 text-indigo-300';
}
}
}
export function openReportPage() {
if (!currentInterviewId) return;
window.open(`/interviews/${currentInterviewId}/report`, '_blank');
}
export function toggleTabScreenshotCapture() {
if (!currentInterviewId) return;
const newStatus = !currentTabScreenshotEnabled;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch(`/interviews/${currentInterviewId}/toggle-tab-screenshot`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({ enable: newStatus })
})
.then(r => r.json())
.then(res => {
if (res.success) {
currentTabScreenshotEnabled = res.enable_tab_switch_screenshot;
updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
icon: 'info',
title: 'Screenshot Setting Updated',
text: res.message,
timer: 2500,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
}
})
.catch(() => {});
}
export function updateScreenshotToggleBtnUI(enabled) {
const btn = document.getElementById('modal-toggle-screenshot-btn');
if (btn) {
if (enabled) {
btn.innerHTML = ' ENABLED';
btn.className = 'px-2.5 py-1 text-xs font-bold bg-emerald-600 hover:bg-emerald-500 text-white rounded-md cursor-pointer border-none';
} else {
btn.innerHTML = ' DISABLED';
btn.className = 'px-2.5 py-1 text-xs font-bold bg-red-600 hover:bg-red-500 text-white rounded-md cursor-pointer border-none';
}
}
}
export function switchIdeTab(tab) {
const tabs = ['code', 'notes', 'drawing', 'recordings', 'screenshots'];
tabs.forEach(t => {
const contentEl = document.getElementById(`tab-content-${t}`);
const btnEl = document.getElementById(`tab-btn-${t}`);
if (contentEl) {
contentEl.style.display = (t === tab) ? 'block' : 'none';
}
if (btnEl) {
if (t === tab) {
btnEl.classList.add('bg-indigo-600', 'text-white', 'border-indigo-500');
btnEl.classList.remove('bg-white/5', 'text-slate-400', 'border-slate-700/60');
} else {
btnEl.classList.remove('bg-indigo-600', 'text-white', 'border-indigo-500');
btnEl.classList.add('bg-white/5', 'text-slate-400', 'border-slate-700/60');
}
}
});
}
export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return;
if (iAmTheCaller && currentInterviewId == callData.id) return;
if (interviewerLocalStream && currentInterviewId == callData.id) return;
if (callData.call_started_at) {
const startedTime = new Date(callData.call_started_at).getTime();
const elapsedMs = Date.now() - startedTime;
if (elapsedMs > 8000 && !isRealtimeBroadcast) {
dismissedCallIds.add(callData.id);
return;
}
} else if (!isRealtimeBroadcast) {
dismissedCallIds.add(callData.id);
return;
}
activeIncomingCall = callData;
const nameEl = document.getElementById('incoming-cand-name');
const infoEl = document.getElementById('incoming-starter-info');
const modalEl = document.getElementById('incoming-call-modal');
if (nameEl) nameEl.innerText = callData.candidate_name;
if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
if (modalEl) modalEl.classList.add('active');
callRingtone.start();
if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer);
let remainingMs = 8000;
if (callData.call_started_at) {
const startedTime = new Date(callData.call_started_at).getTime();
const elapsedMs = Date.now() - startedTime;
remainingMs = Math.max(1000, 8000 - elapsedMs);
}
ringTimeoutTimer = setTimeout(() => {
declineIncomingCall(true);
}, remainingMs);
}
export function pollGlobalActiveCalls() {
fetch('/interviews/active-calls', {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (!data || !data.active_calls || data.active_calls.length === 0) {
if (activeIncomingCall) declineIncomingCall(true);
return;
}
const incoming = data.active_calls.find(call => {
if (endedCallIds.has(call.id)) return false;
if (iAmTheCaller && currentInterviewId == call.id) return false;
if (interviewerLocalStream && currentInterviewId == call.id) return false;
if (dismissedCallIds.has(call.id)) return false;
return true;
});
if (incoming) {
const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0;
if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) {
dismissedCallIds.add(incoming.id);
return;
}
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
triggerIncomingCallRing(incoming, false);
}
} else if (activeIncomingCall) {
declineIncomingCall(true);
}
})
.catch(() => {});
}
export function acceptIncomingCall() {
callRingtone.stop();
if (ringTimeoutTimer) {
clearTimeout(ringTimeoutTimer);
ringTimeoutTimer = null;
}
const modal = document.getElementById('incoming-call-modal');
if (modal) modal.classList.remove('active');
if (activeIncomingCall) {
const targetCall = activeIncomingCall;
activeIncomingCall = null;
if (window.location.pathname.includes('/interviews/' + targetCall.id)) {
startInterviewerCall(true);
} else {
window.location.href = `/interviews/${targetCall.id}?join_call=1`;
}
}
}
export function declineIncomingCall(silent = false) {
callRingtone.stop();
if (ringTimeoutTimer) {
clearTimeout(ringTimeoutTimer);
ringTimeoutTimer = null;
}
const modal = document.getElementById('incoming-call-modal');
if (modal) modal.classList.remove('active');
if (activeIncomingCall) {
dismissedCallIds.add(activeIncomingCall.id);
activeIncomingCall = null;
}
if (!silent && typeof window.Swal !== 'undefined') {
window.Swal.fire({
icon: 'info',
title: 'Call Missed / Declined',
text: 'You can click "Join Active Call" from the list whenever you are ready.',
timer: 3000,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
}
export function subscribeLiveSync(interviewId) {
if (liveSyncChannel) {
try { liveSyncChannel.close(); } catch(e) {}
}
liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
liveSyncChannel.onmessage = function(event) {
const data = event.data;
if (!data) return;
if (data.type === 'code_sync') {
if (data.language) {
const langEl = document.getElementById('modal-code-lang');
if (langEl) langEl.innerText = data.language.toUpperCase();
}
if (data.code !== undefined) {
const codeEl = document.getElementById('modal-code-display');
if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.';
}
} else if (data.type === 'notes_sync') {
if (data.notes !== undefined) {
const notesEl = document.getElementById('modal-notes-display');
if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.';
}
} else if (data.type === 'drawing_sync') {
const drawingImg = document.getElementById('modal-drawing-img');
const noDrawing = document.getElementById('no-drawing-placeholder');
if (data.drawing) {
if (drawingImg) {
drawingImg.src = data.drawing;
drawingImg.style.display = 'block';
}
if (noDrawing) noDrawing.style.display = 'none';
} else {
if (drawingImg) drawingImg.style.display = 'none';
if (noDrawing) noDrawing.style.display = 'block';
}
} else if (data.type === '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;
sosAutoTriggerDisabled = false;
const toggleSw = document.getElementById('sosToggle');
if (toggleSw) toggleSw.classList.add('on');
const candNameEl = document.getElementById('modal-cand-name');
const candUidEl = document.getElementById('modal-cand-uid');
if (candNameEl) candNameEl.innerText = name;
if (candUidEl) candUidEl.innerText = 'Unique Submission ID: ' + uid;
const candInitials = getInitials(name);
const avatarCircle = document.getElementById('modal-cand-avatar-circle');
const avatarName = document.getElementById('cand-avatar-name');
if (avatarCircle) avatarCircle.innerText = candInitials;
if (avatarName) avatarName.innerText = name;
const reviewModal = document.getElementById('review-modal');
if (reviewModal) reviewModal.classList.add('active');
resetZoomScreen();
switchIdeTab('code');
subscribeLiveSync(id);
pollReviewData();
if (sosPollInterval) clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 2500);
if (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) {
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));
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.innerHTML = 'Waiting for candidate…';
}
}
}
export function pollReviewData() {
if (!currentInterviewId) return;
if (interviewerLocalStream) {
sendInterviewerPeerHeartbeat('heartbeat');
}
fetch(`/interviews/${currentInterviewId}/poll`, {
headers: { 'Accept': 'application/json' }
})
.then(r => {
if (!r.ok) return null;
const contentType = r.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) return null;
return r.json();
})
.then(data => {
if (!data) return;
const currentUserId = window.currentUserId || 0;
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
if (data.active_peers && Array.isArray(data.active_peers)) {
const activeIds = new Set(data.active_peers.map(p => p.peer_id));
data.active_peers.forEach(p => {
const isCandPeer = p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_'));
if (isCandPeer) {
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
updateCandidateStatusIcons(isMicOn, isCamOn);
}
if (p.peer_id && p.peer_id !== myPeerId) {
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
const isMicOn = isTrueVal(p.mic_on);
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 += `
`;
});
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 += `
`;
});
shotHtml += `
`;
shotContainer.innerHTML = shotHtml;
} else {
shotContainer.innerHTML = 'No tab-switch screenshots captured for this candidate yet.
';
}
}
// Header live pulse dot & buttons visibility
const livePulseDot = document.getElementById('live-call-pulse');
const recStartBtn = document.getElementById('btn-start-recording');
const recStopBtn = document.getElementById('btn-stop-recording');
const startBtn = document.getElementById('btn-start-call');
const leaveBtn = document.getElementById('btn-leave-call');
const endAllBtn = document.getElementById('btn-end-all-call');
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
const timelinePill = document.getElementById('timeline-status-pill');
if (data.call_status) {
if (data.call_status === 'active') {
if (!interviewerLocalStream) {
if (livePulseDot) livePulseDot.style.display = 'none';
if (startBtn) {
const icon = startBtn.querySelector('i');
const span = startBtn.querySelector('span');
if (icon) icon.className = 'fa-solid fa-phone';
if (span) span.innerText = 'Join Call';
startBtn.style.display = 'inline-flex';
}
if (leaveBtn) leaveBtn.style.display = 'none';
if (endAllBtn) endAllBtn.style.display = 'none';
if (micBtn) micBtn.style.display = 'none';
if (camBtn) camBtn.style.display = 'none';
if (timelinePill) {
const icon = timelinePill.querySelector('i');
const span = timelinePill.querySelector('span');
if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse';
if (span) span.innerText = 'Call in progress';
timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]';
}
} else {
if (livePulseDot) livePulseDot.style.display = 'inline-block';
if (startBtn) startBtn.style.display = 'none';
if (leaveBtn) leaveBtn.style.display = 'inline-flex';
if (endAllBtn) endAllBtn.style.display = 'inline-flex';
if (micBtn) micBtn.style.display = 'inline-flex';
if (camBtn) camBtn.style.display = 'inline-flex';
if (timelinePill) {
const icon = timelinePill.querySelector('i');
const span = timelinePill.querySelector('span');
if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse';
if (span) span.innerText = 'Connected (Live)';
timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]';
}
}
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
recStartBtn.style.display = 'inline-flex';
}
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall();
if (livePulseDot) livePulseDot.style.display = 'none';
if (startBtn) {
const icon = startBtn.querySelector('i');
const span = startBtn.querySelector('span');
if (icon) icon.className = 'fa-solid fa-phone';
if (span) span.innerText = (data.call_status === 'ended') ? 'Restart call' : 'Start / Join Call';
startBtn.style.display = 'inline-flex';
}
if (leaveBtn) leaveBtn.style.display = 'none';
if (endAllBtn) endAllBtn.style.display = 'none';
if (micBtn) micBtn.style.display = 'none';
if (camBtn) camBtn.style.display = 'none';
if (recStartBtn) recStartBtn.style.display = 'none';
if (recStopBtn) recStopBtn.style.display = 'none';
if (timelinePill && data.call_status === 'ended') {
const icon = timelinePill.querySelector('i');
const span = timelinePill.querySelector('span');
if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
if (span) span.innerText = 'Call ended';
timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]';
}
}
}
// Proctoring logs
const logsDisplay = document.getElementById('modal-logs-display');
let logsHtml = '';
let currentViolations = [];
if (data.proctor_logs && data.proctor_logs.length > 0) {
data.proctor_logs.forEach(l => {
let icoClass = 'amber';
let faIcon = 'fa-solid fa-window-restore';
let isFlag = false;
if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; }
else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; }
else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; }
else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; }
else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; }
else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; }
else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; }
if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device'].includes(l.type)) {
currentViolations.push(l);
}
let screenshotLink = l.screenshot_url ? `[ Screenshot]` : '';
let aiBadge = '';
if (l.ai_verdict) {
const confPct = Math.round((l.ai_verdict.confidence || 0.85) * 100);
const engineName = l.ai_verdict.engine || 'AI Engine';
const toolName = l.ai_verdict.ai_tool_detected ? ` • Detected: ${l.ai_verdict.ai_tool_detected}` : '';
aiBadge = `AI inspector · ${confPct}% cheating confidence (${engineName}${toolName})
`;
isFlag = true;
}
const formattedType = (l.type || 'violation').replace(/_/g, ' ');
const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1);
logsHtml += `
${titleCap} — ${l.details || ''}${screenshotLink}${aiBadge}
${new Date(l.timestamp).toLocaleTimeString()}
`;
});
} else {
logsHtml = ' Zero proctoring violations recorded.
';
}
if (logsDisplay) logsDisplay.innerHTML = logsHtml;
activeViolations = currentViolations;
const totalViolations = currentViolations.length;
// Trigger SOS Modal Popup and Ringtone Alarm ONLY if NEW unacknowledged violations occurred & SOS Auto-Trigger is active
if (totalViolations > 0 && totalViolations > acknowledgedViolationCount && !sosAutoTriggerDisabled) {
sosDismissed = false;
const newViolations = currentViolations.slice(acknowledgedViolationCount);
openSosModal(newViolations);
}
})
.catch(() => {});
}
export function openSosModal(specificViolations = null) {
let violationsToDisplay = specificViolations;
if (!violationsToDisplay || violationsToDisplay.length === 0) {
if (acknowledgedViolationCount > 0 && activeViolations.length > acknowledgedViolationCount) {
violationsToDisplay = activeViolations.slice(acknowledgedViolationCount);
} else {
violationsToDisplay = activeViolations;
}
}
let html = '';
if (violationsToDisplay && violationsToDisplay.length > 0) {
violationsToDisplay.forEach(v => {
let typeTitle = (v.type || '').toUpperCase();
if (v.type === 'external_ai_detected') {
typeTitle = 'candidate might use external ai';
} else if (v.type === 'tab_switch') {
typeTitle = 'Candidate Switched Browser Tab';
} else if (v.type === 'talking_on_phone') {
typeTitle = 'Candidate Talking on Phone';
} else if (v.type === 'reading_external_device') {
typeTitle = 'Candidate Reading from External Device';
} else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') {
typeTitle = 'Candidate Reading Question Out Loud';
} else if (v.type === 'gaze_anomaly' || v.type === 'gaze_lower_device') {
typeTitle = 'Candidate Lower Eye Gaze Detected';
}
const borderCol = v.type === 'external_ai_detected' ? 'border-red-500' : 'border-amber-500';
const textCol = v.type === 'external_ai_detected' ? 'text-red-300' : 'text-amber-300';
html += `
🚨 ${typeTitle}: ${v.details || 'Suspicious activity detected.'}
Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}
`;
});
} else {
html = 'Potential proctoring alert flagged by engine.
';
}
const listEl = document.getElementById('sos-violation-list');
const modalEl = document.getElementById('sos-modal');
if (listEl) listEl.innerHTML = html;
if (modalEl) modalEl.classList.add('active');
try {
callRingtone.start();
} catch(e) {}
}
export function stopSosAlarm() {
try {
callRingtone.stop();
} catch(e) {}
const modalEl = document.getElementById('sos-modal');
if (modalEl) modalEl.classList.remove('active');
acknowledgedViolationCount = activeViolations.length;
sosDismissed = true;
}
export function interviewerLogoutAction() {
const form = document.getElementById('logout-form');
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
title: 'Logout of Portal?',
text: 'Are you sure you want to log out of your session?',
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#475569',
confirmButtonText: 'Yes, Logout'
}).then((result) => {
if (result.isConfirmed && form) {
form.submit();
}
});
} else if (form) {
form.submit();
}
}
export function regenerateCandidatePassword() {
if (!currentInterviewId) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
title: 'Regenerate Candidate Password?',
text: 'This will invalidate the candidate\'s current password and issue a new system password.',
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#eab308',
cancelButtonColor: '#475569',
confirmButtonText: 'Regenerate Now'
}).then((result) => {
if (result.isConfirmed) {
fetch(`/interviews/${currentInterviewId}/regenerate-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(r => r.json())
.then(res => {
if (res.success) {
window.Swal.fire({
icon: 'success',
title: 'New Password Generated!',
text: 'New Candidate Passcode: ' + res.new_password,
confirmButtonColor: '#6366f1'
}).then(() => window.location.reload());
}
});
}
});
}
}
export function blockCandidateAction() {
if (!currentInterviewId) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
title: 'â›” Block Candidate Email & IP?',
text: 'This will immediately terminate the candidate session and block their email and client IP address.',
icon: 'error',
showCancelButton: true,
confirmButtonColor: '#ef4444',
cancelButtonColor: '#475569',
confirmButtonText: 'Yes, Block Candidate'
}).then((result) => {
if (result.isConfirmed) {
fetch(`/interviews/${currentInterviewId}/block-candidate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
}
})
.then(r => r.json())
.then(res => {
if (res.success) {
window.Swal.fire({
icon: 'success',
title: 'Candidate Blocked',
text: res.message,
confirmButtonColor: '#6366f1'
}).then(() => {
closeReviewModal();
window.location.reload();
});
}
});
}
});
}
}
export function initMeteredIceServers() {
if (typeof window.getMeteredIceServers === 'function') {
return window.getMeteredIceServers().then(servers => {
if (Array.isArray(servers) && servers.length > 0) {
globalIceServers = servers;
}
return globalIceServers;
});
}
return Promise.resolve(globalIceServers);
}
export function safePlayMediaStream(videoElem, stream, isSelf = false) {
if (!videoElem || !stream) return;
if (isSelf) videoElem.muted = true;
if (videoElem.srcObject !== stream) {
videoElem.srcObject = stream;
}
const playPromise = videoElem.play();
if (playPromise !== undefined) {
playPromise.catch(err => {
if (err.name === 'AbortError' || err.name === 'NotAllowedError') {
videoElem.onloadedmetadata = () => {
videoElem.play().catch(() => {});
};
}
});
}
}
export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', 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.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);
}