1949 lines
85 KiB
JavaScript
1949 lines
85 KiB
JavaScript
/**
|
|
* 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 = '<i class="fa-solid fa-bell-slash mr-1"></i> 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 = '<i class="fa-solid fa-bell mr-1"></i> 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 = '<i class="fa-solid fa-camera mr-1"></i> 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 = '<i class="fa-solid fa-camera-retro mr-1"></i> 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 = `<div class="text-xs text-sky-400 font-bold mb-2.5 flex justify-between items-center">
|
|
<span>📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''})</span>
|
|
<span class="text-[0.65rem] text-slate-400">📜 Scroll to view older recordings</span>
|
|
</div>`;
|
|
|
|
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 += `<div class="bg-white/5 border border-slate-700/60 rounded-xl p-3 mb-3">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<div>
|
|
<strong class="text-white text-xs">📹 Session Recording #${recList.length - displayIdx}</strong>
|
|
<span class="text-[0.7rem] text-slate-400 ml-2">📅 ${dateStr}</span>
|
|
</div>
|
|
<a href="${downloadUrl}" target="_blank" download class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3 py-1 rounded-md text-decoration-none inline-flex items-center gap-1">
|
|
📥 Download Video
|
|
</a>
|
|
</div>
|
|
<video controls preload="metadata" class="w-full max-h-[240px] rounded-lg bg-black outline-none border border-white/10">
|
|
<source src="${videoUrl}" type="${mimeType}">
|
|
<source src="${videoUrl}">
|
|
Your browser does not support video playback.
|
|
</video>
|
|
</div>`;
|
|
});
|
|
recContainer.innerHTML = recHtml;
|
|
} else {
|
|
recContainer.innerHTML = '<div class="text-slate-400 text-xs text-center pt-10">No video recordings saved for this candidate yet.</div>';
|
|
}
|
|
}
|
|
|
|
// 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 += `<div class="chat-message mb-2 p-2.5 rounded-lg border-l-2 transition-all w-full shrink-0" style="background: ${color}1a; border-color: ${color};">
|
|
<div class="flex justify-between items-center mb-1 gap-2">
|
|
<div class="flex items-center gap-1.5 min-w-0">
|
|
<span class="w-1.5 h-1.5 rounded-full shrink-0" style="background: ${color};"></span>
|
|
<strong class="text-xs truncate" style="color: ${color};">${senderName}</strong>
|
|
<span class="text-[9.5px] uppercase font-bold px-1.5 py-0.2 rounded ${badgeClass} border shrink-0">${badgeLabel}</span>
|
|
</div>
|
|
<span class="text-[10.5px] text-slate-500 shrink-0 font-mono">${new Date(w.created_at || Date.now()).toLocaleTimeString()}</span>
|
|
</div>
|
|
<div class="text-slate-200 text-xs leading-relaxed font-medium pl-3 break-words">${w.message}</div>
|
|
</div>`;
|
|
});
|
|
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 = '<div class="h-full w-full flex items-center justify-center text-xs text-[#5b637a] italic">No chat history. Send a message below.</div>';
|
|
}
|
|
}
|
|
|
|
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 = `<div class="text-xs text-amber-400 font-bold mb-2.5 flex justify-between items-center">
|
|
<span>📸 Tab-Switch Screenshots Captured (${shotList.length})</span>
|
|
<span class="text-[0.65rem] text-slate-400">Click image to enlarge full size</span>
|
|
</div><div class="grid grid-cols-2 sm:grid-cols-3 gap-3">`;
|
|
|
|
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 += `<div class="bg-white/5 border border-slate-700/60 rounded-lg p-2 text-center">
|
|
<img src="${url}" class="w-full h-[110px] object-cover rounded-md cursor-pointer border border-white/10" onclick="window.open('${url}', '_blank')" title="Click to view full image" />
|
|
<div class="text-[0.65rem] text-slate-400 mt-1">📅 ${timeStr}</div>
|
|
<a href="${url}" target="_blank" download class="bg-amber-600 text-white text-[0.65rem] font-bold px-2 py-0.5 rounded inline-block mt-1">
|
|
📥 View / Download
|
|
</a>
|
|
</div>`;
|
|
});
|
|
shotHtml += `</div>`;
|
|
shotContainer.innerHTML = shotHtml;
|
|
} else {
|
|
shotContainer.innerHTML = '<div class="text-slate-400 text-xs text-center pt-10">No tab-switch screenshots captured for this candidate yet.</div>';
|
|
}
|
|
}
|
|
|
|
// 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 ? `<a href="${l.screenshot_url}" target="_blank" class="text-sky-400 underline ml-1 font-medium">[<i class="fa-solid fa-camera mr-0.5"></i> Screenshot]</a>` : '';
|
|
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: <strong>${l.ai_verdict.ai_tool_detected}</strong>` : '';
|
|
aiBadge = `<div class="mt-1 text-[11px] text-red-300"><b>AI inspector · ${confPct}% cheating confidence</b> (${engineName}${toolName})</div>`;
|
|
isFlag = true;
|
|
}
|
|
|
|
const formattedType = (l.type || 'violation').replace(/_/g, ' ');
|
|
const titleCap = formattedType.charAt(0).toUpperCase() + formattedType.slice(1);
|
|
|
|
logsHtml += `<div class="log-item flex gap-2.5 p-3 border-b border-[#1b2233] last:border-b-0 ${isFlag ? 'bg-[rgba(239,74,95,0.1)] flag' : ''}">
|
|
<div class="log-ico w-6 h-6 rounded-md flex-shrink-0 flex items-center justify-center text-[11px] mt-0.5 ${icoClass === 'amber' ? 'bg-[rgba(240,169,57,0.12)] text-[#f0a939]' : 'bg-[rgba(239,74,95,0.1)] text-[#ef4a5f]'}">
|
|
<i class="${faIcon}"></i>
|
|
</div>
|
|
<div>
|
|
<div class="log-text text-xs leading-relaxed text-slate-300"><b class="text-white ${isFlag ? '!text-[#ef4a5f]' : ''}">${titleCap}</b> — ${l.details || ''}${screenshotLink}${aiBadge}</div>
|
|
<div class="log-time text-[10.5px] text-slate-500 mt-0.5">${new Date(l.timestamp).toLocaleTimeString()}</div>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
} else {
|
|
logsHtml = '<div class="p-4 text-center text-emerald-400 text-xs font-medium"><i class="fa-solid fa-circle-check mr-1"></i> Zero proctoring violations recorded.</div>';
|
|
}
|
|
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 += `<div class="mb-2.5 p-2 bg-white/5 rounded-md border-l-4 ${borderCol}">
|
|
<strong class="${textCol}">🚨 ${typeTitle}:</strong> ${v.details || 'Suspicious activity detected.'}
|
|
<div class="text-[0.65rem] text-slate-400 mt-1">Timestamp: ${new Date(v.timestamp).toLocaleTimeString()}</div>
|
|
</div>`;
|
|
});
|
|
} else {
|
|
html = '<div class="text-slate-300 text-sm">Potential proctoring alert flagged by engine.</div>';
|
|
}
|
|
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 = `
|
|
<video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200"></video>
|
|
<div id="mesh-placeholder-${peerId}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3" style="display: ${camOn ? 'none' : 'flex'};">
|
|
<div class="av w-[58px] h-[58px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[19px] mb-2.5 shadow-md shadow-[#4b82f7]/20">
|
|
${initials}
|
|
</div>
|
|
<div class="name text-[13.5px] font-semibold text-white">${name}</div>
|
|
<div class="status text-[11.5px] text-[#5b637a] mt-1 text-center px-3">Camera turned off</div>
|
|
</div>
|
|
<span class="tile-tag absolute left-2.5 bottom-2.5 text-[10px] font-semibold tracking-wide px-2.5 py-1 rounded-md bg-black/50 text-slate-300 border border-[#1b2233] z-20 flex items-center gap-1.5 backdrop-blur-sm">
|
|
<span>${name}</span>
|
|
<span class="w-px h-2.5 bg-white/20 mx-0.5"></span>
|
|
<i id="mesh-mic-${peerId}" class="${micOn ? 'fa-solid fa-microphone text-[#21c274]' : 'fa-solid fa-microphone-slash text-[#ef4a5f]'} text-[10px]" title="${micOn ? 'Mic On' : 'Mic Muted'}"></i>
|
|
<i id="mesh-cam-${peerId}" class="${camOn ? 'fa-solid fa-video text-[#21c274]' : 'fa-solid fa-video-slash text-[#ef4a5f]'} text-[10px]" title="${camOn ? 'Camera On' : 'Camera Off'}"></i>
|
|
</span>
|
|
`;
|
|
|
|
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);
|
|
}
|