- current compositor has a 2 pane layout, with candidate video/screenshare on left - other peers are added on right and bellow
2620 lines
116 KiB
JavaScript
2620 lines
116 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.
|
|
*/
|
|
|
|
import { initMeteredIceServers } from './metered.js';
|
|
import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js';
|
|
import { RecordingCompositor } from './recording-compositor.js';
|
|
|
|
// --- Global Audio Ringtone Synthesizer Engine ---
|
|
export class CallRingtoneEngine {
|
|
constructor() {
|
|
this.ctx = null;
|
|
this.interval = null;
|
|
this.isPlaying = false;
|
|
}
|
|
|
|
init() {
|
|
try {
|
|
if (!this.ctx) {
|
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
this.ctx = new AudioCtx();
|
|
}
|
|
if (this.ctx && this.ctx.state === 'suspended') {
|
|
this.ctx.resume();
|
|
}
|
|
} catch (e) {
|
|
console.log('Audio init warning:', e);
|
|
}
|
|
}
|
|
|
|
start() {
|
|
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
|
|
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
|
|
this.init();
|
|
if (this.isPlaying) return;
|
|
this.isPlaying = true;
|
|
|
|
this.playTone();
|
|
if (this.interval) clearInterval(this.interval);
|
|
this.interval = setInterval(() => {
|
|
if (this.isPlaying) this.playTone();
|
|
}, 2400);
|
|
}
|
|
|
|
playTone() {
|
|
this.init();
|
|
if (!this.ctx || !this.isPlaying) return;
|
|
try {
|
|
const now = this.ctx.currentTime;
|
|
const osc1 = this.ctx.createOscillator();
|
|
const osc2 = this.ctx.createOscillator();
|
|
const gain = this.ctx.createGain();
|
|
|
|
osc1.type = 'sine';
|
|
osc2.type = 'sine';
|
|
|
|
osc1.frequency.setValueAtTime(523.25, now);
|
|
osc2.frequency.setValueAtTime(659.25, now);
|
|
|
|
gain.gain.setValueAtTime(0.001, now);
|
|
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
|
|
gain.gain.setValueAtTime(0.35, now + 1.1);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
|
|
|
|
osc1.connect(gain);
|
|
osc2.connect(gain);
|
|
gain.connect(this.ctx.destination);
|
|
|
|
osc1.start(now);
|
|
osc2.start(now);
|
|
|
|
osc1.stop(now + 1.35);
|
|
osc2.stop(now + 1.35);
|
|
} catch (e) {}
|
|
}
|
|
|
|
stop() {
|
|
this.isPlaying = false;
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const callRingtone = new CallRingtoneEngine();
|
|
|
|
// State variables
|
|
let currentInterviewId = null;
|
|
let currentSubmissionUniqueId = null;
|
|
let interviewerPeer = null;
|
|
let screenReceiverPeer = null;
|
|
|
|
export function getCandidatePeerId() {
|
|
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
|
|
currentInterviewId = document.body.dataset.interviewId;
|
|
}
|
|
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
|
|
currentSubmissionUniqueId = document.body.dataset.submissionId;
|
|
}
|
|
if (!currentInterviewId) return '';
|
|
|
|
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
|
|
? currentSubmissionUniqueId
|
|
: null;
|
|
|
|
return subId ? ('cand_' + currentInterviewId + '_' + subId) : ('cand_' + currentInterviewId);
|
|
}
|
|
|
|
export function getMyInterviewerPeerId() {
|
|
const currentUserId = window.currentUserId || 0;
|
|
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
|
|
currentInterviewId = document.body.dataset.interviewId;
|
|
}
|
|
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
|
|
currentSubmissionUniqueId = document.body.dataset.submissionId;
|
|
}
|
|
if (!currentInterviewId) return '';
|
|
|
|
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
|
|
? currentSubmissionUniqueId
|
|
: null;
|
|
|
|
return subId
|
|
? ('interviewer_' + currentInterviewId + '_' + subId + '_' + currentUserId)
|
|
: ('interviewer_' + currentInterviewId + '_' + currentUserId);
|
|
}
|
|
|
|
export function getInterviewerScreenPeerId(userId = null) {
|
|
const currentUserId = userId !== null ? userId : (window.currentUserId || 0);
|
|
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
|
|
currentInterviewId = document.body.dataset.interviewId;
|
|
}
|
|
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
|
|
currentSubmissionUniqueId = document.body.dataset.submissionId;
|
|
}
|
|
if (!currentInterviewId) return '';
|
|
|
|
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
|
|
? currentSubmissionUniqueId
|
|
: null;
|
|
|
|
return subId
|
|
? ('interviewer_screen_' + currentInterviewId + '_' + subId + '_' + currentUserId)
|
|
: ('interviewer_screen_' + currentInterviewId + '_' + currentUserId);
|
|
}
|
|
let interviewerLocalStream = null;
|
|
let activeCall = null;
|
|
let isInterviewerMicOn = true;
|
|
let isInterviewerCamOn = true;
|
|
let screenZoomLevel = 1.0;
|
|
let candVidZoomLevel = 1.0;
|
|
let activeViolations = [];
|
|
let sosPollInterval = null;
|
|
let peerHeartbeatTimer = null;
|
|
let acknowledgedViolationCount = 0;
|
|
let sosDismissed = false;
|
|
let sosAutoTriggerDisabled = true;
|
|
let currentTabScreenshotEnabled = true;
|
|
let interviewerSigChannel = null;
|
|
let interviewerPeerConnection = null;
|
|
let liveSyncChannel = null;
|
|
let panelistMeshTiles = new Map();
|
|
let latestActivePeers = [];
|
|
|
|
// Ringing & Call state
|
|
let activeIncomingCall = null;
|
|
let iAmTheCaller = false;
|
|
let ringTimeoutTimer = null;
|
|
const dismissedCallIds = new Set();
|
|
|
|
export function formatProctoringLogDetails(l) {
|
|
let detailsObj = l.details;
|
|
if (typeof detailsObj === 'string') {
|
|
try { detailsObj = JSON.parse(detailsObj); } catch(e) {}
|
|
}
|
|
|
|
const type = l.type || '';
|
|
|
|
if (type === 'multiple_faces') {
|
|
const count = (typeof detailsObj === 'object' && detailsObj && detailsObj.face_count) ? detailsObj.face_count : 2;
|
|
return `Multiple faces detected in camera frame (${count} faces)`;
|
|
}
|
|
|
|
if (type === 'face_missing') {
|
|
return 'Candidate face is not visible in camera frame';
|
|
}
|
|
|
|
if (type === 'looking_away' || type === 'prolonged_looking_away') {
|
|
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.reasons || typeof detailsObj.yaw === 'number')) {
|
|
const reasonLabels = {
|
|
'head_yaw': 'Head Yaw',
|
|
'head_up': 'Head Pitch Up',
|
|
'head_down': 'Head Pitch Down',
|
|
'eye_gaze_left': 'Eye Gaze Left',
|
|
'eye_gaze_right': 'Eye Gaze Right',
|
|
'eye_gaze_down': 'Eye Gaze Down'
|
|
};
|
|
const reasonsStr = Array.isArray(detailsObj.reasons)
|
|
? detailsObj.reasons.map(r => reasonLabels[r] || r.replace(/_/g, ' ')).join(', ')
|
|
: '';
|
|
|
|
const durationPrefix = (type === 'prolonged_looking_away') ? 'Looking away continuously (>10s)' : 'Looking away from camera';
|
|
return reasonsStr ? `${durationPrefix} (${reasonsStr})` : durationPrefix;
|
|
} else if (typeof l.details === 'string' && l.details && !l.details.startsWith('{')) {
|
|
return l.details;
|
|
}
|
|
return type === 'prolonged_looking_away' ? 'Candidate prolonged looking away' : 'Candidate looking away from camera';
|
|
}
|
|
|
|
if (type === 'phone_detected') {
|
|
const label = (typeof detailsObj === 'object' && detailsObj && detailsObj.label) ? detailsObj.label : 'cell phone';
|
|
return `Possible mobile phone detected in camera frame (${label})`;
|
|
}
|
|
|
|
if (type === 'prolonged_corner_gaze') {
|
|
if (typeof detailsObj === 'object' && detailsObj && (detailsObj.corner_label || detailsObj.corner)) {
|
|
const cornerLabel = detailsObj.corner_label || (detailsObj.corner || 'screen corner').replace(/_/g, ' ');
|
|
const dur = detailsObj.duration_s || Math.round((detailsObj.duration_ms || 18000) / 1000);
|
|
return `Fixated on ${cornerLabel} for ${dur}s`;
|
|
}
|
|
return 'Candidate fixated on screen corner for prolonged period';
|
|
}
|
|
|
|
if (type === 'talking_secondary_person') {
|
|
return typeof l.details === 'string' && !l.details.startsWith('{') ? l.details : 'Candidate detected speaking out loud to a secondary person';
|
|
}
|
|
|
|
if (typeof l.details === 'object' && l.details !== null) {
|
|
try { return JSON.stringify(l.details); } catch(e) { return String(l.details); }
|
|
}
|
|
|
|
return l.details || '';
|
|
}
|
|
const endedCallIds = new Set();
|
|
let globalCallChannel = null;
|
|
const adminPageLoadTimestamp = Date.now();
|
|
|
|
let globalIceServers = window.globalIceServers || [
|
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
{ urls: 'stun:stun.cloudflare.com:3478' }
|
|
];
|
|
|
|
export function getInitials(name) {
|
|
if (!name) return 'CD';
|
|
const parts = name.trim().split(/\s+/);
|
|
if (parts.length >= 2) {
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
const str = name.trim();
|
|
if (str.length >= 2) {
|
|
return (str[0] + str[str.length - 1]).toUpperCase();
|
|
}
|
|
return str.toUpperCase();
|
|
}
|
|
|
|
export function zoomScreen(delta) {
|
|
screenZoomLevel = Math.max(0.5, Math.min(3.0, screenZoomLevel + delta));
|
|
const screenVid = document.getElementById('interviewer-screen-video');
|
|
if (screenVid) {
|
|
screenVid.style.transform = `scale(${screenZoomLevel})`;
|
|
}
|
|
}
|
|
|
|
export function resetZoomScreen() {
|
|
screenZoomLevel = 1.0;
|
|
zoomScreen(0);
|
|
}
|
|
|
|
export function toggleFullscreenScreen() {
|
|
const wrapper = document.getElementById('screen-wrapper');
|
|
if (!wrapper) return;
|
|
if (!document.fullscreenElement) {
|
|
wrapper.requestFullscreen().catch(err => console.log(err));
|
|
} else {
|
|
document.exitFullscreen();
|
|
}
|
|
}
|
|
|
|
export function zoomCandVideo(delta) {
|
|
if (delta === 0) candVidZoomLevel = 1.0;
|
|
else candVidZoomLevel = Math.max(0.5, Math.min(3.0, candVidZoomLevel + delta));
|
|
const vid = document.getElementById('interviewer-cand-video');
|
|
if (vid) {
|
|
vid.style.transform = `scale(-${candVidZoomLevel}, ${candVidZoomLevel})`;
|
|
vid.style.transformOrigin = 'center center';
|
|
vid.style.transition = 'transform 0.2s ease';
|
|
}
|
|
}
|
|
|
|
export function toggleSosAutoTrigger() {
|
|
sosAutoTriggerDisabled = !sosAutoTriggerDisabled;
|
|
const toggleSw = document.getElementById('sosToggle');
|
|
const btn = document.getElementById('sos-toggle-btn');
|
|
|
|
if (toggleSw) {
|
|
if (sosAutoTriggerDisabled) {
|
|
toggleSw.classList.remove('on');
|
|
} else {
|
|
toggleSw.classList.add('on');
|
|
}
|
|
}
|
|
if (btn) {
|
|
if (sosAutoTriggerDisabled) {
|
|
btn.innerHTML = '<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';
|
|
}
|
|
}
|
|
}
|
|
|
|
let currentInspectorTab = null;
|
|
|
|
export function closeInspectorPanel() {
|
|
currentInspectorTab = null;
|
|
try {
|
|
if (currentInterviewId) {
|
|
sessionStorage.removeItem('interview_inspector_tab_' + currentInterviewId);
|
|
}
|
|
} catch(e) {}
|
|
|
|
const details = document.getElementById('inspector-details');
|
|
if (details) details.open = false;
|
|
|
|
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
|
tabs.forEach(t => {
|
|
const contentEl = document.getElementById(`tab-content-${t}`);
|
|
const btnEl = document.getElementById(`tab-btn-${t}`);
|
|
if (contentEl) contentEl.style.display = 'none';
|
|
if (btnEl) {
|
|
btnEl.classList.remove('bg-indigo-600', 'text-white');
|
|
btnEl.classList.add('text-slate-400', 'hover:bg-white/5');
|
|
}
|
|
});
|
|
}
|
|
|
|
export function switchIdeTab(tab, event = null) {
|
|
if (event) {
|
|
event.stopPropagation();
|
|
}
|
|
|
|
const details = document.getElementById('inspector-details');
|
|
|
|
// If user clicks the currently active open tab button, toggle it collapsed
|
|
if (currentInspectorTab === tab && details && details.open) {
|
|
closeInspectorPanel();
|
|
return;
|
|
}
|
|
|
|
currentInspectorTab = tab;
|
|
try {
|
|
if (currentInterviewId) {
|
|
sessionStorage.setItem('interview_inspector_tab_' + currentInterviewId, tab);
|
|
}
|
|
} catch(e) {}
|
|
|
|
if (details) details.open = true;
|
|
|
|
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
|
const tabMeta = {
|
|
logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' },
|
|
notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' },
|
|
drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' },
|
|
recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' },
|
|
screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' },
|
|
code: { title: 'Candidate Code', icon: 'fa-solid fa-code' }
|
|
};
|
|
|
|
const floatingTitle = document.getElementById('inspector-floating-text');
|
|
const floatingIcon = document.getElementById('inspector-floating-icon');
|
|
|
|
if (tabMeta[tab]) {
|
|
if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title;
|
|
if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`;
|
|
}
|
|
|
|
tabs.forEach(t => {
|
|
const contentEl = document.getElementById(`tab-content-${t}`);
|
|
const btnEl = document.getElementById(`tab-btn-${t}`);
|
|
if (contentEl) {
|
|
contentEl.style.display = (t === tab) ? 'block' : 'none';
|
|
}
|
|
if (btnEl) {
|
|
if (t === tab) {
|
|
btnEl.classList.add('bg-indigo-600', 'text-white');
|
|
btnEl.classList.remove('text-slate-400', 'hover:bg-white/5');
|
|
} else {
|
|
btnEl.classList.remove('bg-indigo-600', 'text-white');
|
|
btnEl.classList.add('text-slate-400', 'hover:bg-white/5');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Click outside to dismiss floating inspector details
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('click', function(e) {
|
|
const details = document.getElementById('inspector-details');
|
|
if (details && details.open && !details.contains(e.target)) {
|
|
closeInspectorPanel();
|
|
}
|
|
});
|
|
}
|
|
|
|
export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
|
|
if (!callData || !callData.id) return;
|
|
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
|
|
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
|
|
if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return;
|
|
if (iAmTheCaller && currentInterviewId == callData.id) return;
|
|
if (interviewerLocalStream && currentInterviewId == callData.id) return;
|
|
|
|
if (callData.call_started_at) {
|
|
const startedTime = new Date(callData.call_started_at).getTime();
|
|
const elapsedMs = Date.now() - startedTime;
|
|
if (elapsedMs > 8000 && !isRealtimeBroadcast) {
|
|
dismissedCallIds.add(callData.id);
|
|
return;
|
|
}
|
|
} else if (!isRealtimeBroadcast) {
|
|
dismissedCallIds.add(callData.id);
|
|
return;
|
|
}
|
|
|
|
activeIncomingCall = callData;
|
|
const nameEl = document.getElementById('incoming-cand-name');
|
|
const infoEl = document.getElementById('incoming-starter-info');
|
|
const modalEl = document.getElementById('incoming-call-modal');
|
|
if (nameEl) nameEl.innerText = callData.candidate_name;
|
|
if (infoEl) infoEl.innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
|
|
if (modalEl) modalEl.classList.add('active');
|
|
callRingtone.start();
|
|
|
|
if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer);
|
|
let remainingMs = 8000;
|
|
if (callData.call_started_at) {
|
|
const startedTime = new Date(callData.call_started_at).getTime();
|
|
const elapsedMs = Date.now() - startedTime;
|
|
remainingMs = Math.max(1000, 8000 - elapsedMs);
|
|
}
|
|
|
|
ringTimeoutTimer = setTimeout(() => {
|
|
declineIncomingCall(true);
|
|
}, remainingMs);
|
|
}
|
|
|
|
export function pollGlobalActiveCalls() {
|
|
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
|
|
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
|
|
|
|
fetch('/interviews/active-calls', {
|
|
headers: { 'Accept': 'application/json' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data || !data.active_calls || data.active_calls.length === 0) {
|
|
if (activeIncomingCall) declineIncomingCall(true);
|
|
return;
|
|
}
|
|
|
|
const incoming = data.active_calls.find(call => {
|
|
if (endedCallIds.has(call.id)) return false;
|
|
if (iAmTheCaller && currentInterviewId == call.id) return false;
|
|
if (interviewerLocalStream && currentInterviewId == call.id) return false;
|
|
if (dismissedCallIds.has(call.id)) return false;
|
|
return true;
|
|
});
|
|
|
|
if (incoming) {
|
|
const startedTime = incoming.call_started_at ? new Date(incoming.call_started_at).getTime() : 0;
|
|
if (startedTime > 0 && startedTime < (adminPageLoadTimestamp - 4000)) {
|
|
dismissedCallIds.add(incoming.id);
|
|
return;
|
|
}
|
|
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
|
|
triggerIncomingCallRing(incoming, false);
|
|
}
|
|
} else if (activeIncomingCall) {
|
|
declineIncomingCall(true);
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
export function acceptIncomingCall() {
|
|
callRingtone.stop();
|
|
if (ringTimeoutTimer) {
|
|
clearTimeout(ringTimeoutTimer);
|
|
ringTimeoutTimer = null;
|
|
}
|
|
const modal = document.getElementById('incoming-call-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
|
|
if (activeIncomingCall) {
|
|
const targetCall = activeIncomingCall;
|
|
activeIncomingCall = null;
|
|
if (window.location.pathname.includes('/interviews/' + targetCall.id)) {
|
|
startInterviewerCall(true);
|
|
} else {
|
|
window.location.href = `/interviews/${targetCall.id}?join_call=1`;
|
|
}
|
|
}
|
|
}
|
|
|
|
export function declineIncomingCall(silent = false) {
|
|
callRingtone.stop();
|
|
if (ringTimeoutTimer) {
|
|
clearTimeout(ringTimeoutTimer);
|
|
ringTimeoutTimer = null;
|
|
}
|
|
const modal = document.getElementById('incoming-call-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
|
|
if (activeIncomingCall) {
|
|
dismissedCallIds.add(activeIncomingCall.id);
|
|
activeIncomingCall = null;
|
|
}
|
|
|
|
if (!silent && typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'info',
|
|
title: 'Call Missed / Declined',
|
|
text: 'You can click "Join Active Call" from the list whenever you are ready.',
|
|
timer: 3000,
|
|
showConfirmButton: false,
|
|
toast: true,
|
|
position: 'top-end'
|
|
});
|
|
}
|
|
}
|
|
|
|
export function subscribeLiveSync(interviewId) {
|
|
if (liveSyncChannel) {
|
|
try { liveSyncChannel.close(); } catch(e) {}
|
|
}
|
|
liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
|
|
liveSyncChannel.onmessage = function(event) {
|
|
const data = event.data;
|
|
if (!data) return;
|
|
|
|
if (data.type === 'code_sync') {
|
|
if (data.language) {
|
|
const langEl = document.getElementById('modal-code-lang');
|
|
if (langEl) langEl.innerText = data.language.toUpperCase();
|
|
}
|
|
if (data.code !== undefined) {
|
|
const codeEl = document.getElementById('modal-code-display');
|
|
if (codeEl) codeEl.innerText = data.code || '// Candidate has not typed any code yet.';
|
|
}
|
|
} else if (data.type === 'notes_sync') {
|
|
if (data.notes !== undefined) {
|
|
const notesEl = document.getElementById('modal-notes-display');
|
|
if (notesEl) notesEl.innerText = data.notes || 'No candidate notes written yet.';
|
|
}
|
|
} else if (data.type === 'drawing_sync') {
|
|
const drawingImg = document.getElementById('modal-drawing-img');
|
|
const noDrawing = document.getElementById('no-drawing-placeholder');
|
|
if (data.drawing) {
|
|
if (drawingImg) {
|
|
drawingImg.src = data.drawing;
|
|
drawingImg.style.display = 'block';
|
|
}
|
|
if (noDrawing) noDrawing.style.display = 'none';
|
|
} else {
|
|
if (drawingImg) drawingImg.style.display = 'none';
|
|
if (noDrawing) noDrawing.style.display = 'block';
|
|
}
|
|
} else if (data.type === 'peer_state_changed') {
|
|
const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_'));
|
|
if (isCandPeer) {
|
|
const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true;
|
|
const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true;
|
|
updateCandidateStatusIcons(isMicOn, isCamOn);
|
|
}
|
|
pollReviewData();
|
|
} else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') {
|
|
pollReviewData();
|
|
}
|
|
};
|
|
|
|
if (interviewerSigChannel) {
|
|
try { interviewerSigChannel.close(); } catch(e) {}
|
|
}
|
|
interviewerSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
|
interviewerSigChannel.onmessage = function(event) {
|
|
const data = event.data;
|
|
if (!data) return;
|
|
if (data.type === 'peer_state_changed') {
|
|
const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_'));
|
|
if (isCandPeer) {
|
|
const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true;
|
|
const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true;
|
|
updateCandidateStatusIcons(isMicOn, isCamOn);
|
|
}
|
|
setTimeout(pollReviewData, 500);
|
|
} else if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') {
|
|
setTimeout(pollReviewData, 500);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function openReviewModal(id, name, uid, autoJoinCall = false) {
|
|
currentInterviewId = id;
|
|
currentSubmissionUniqueId = uid;
|
|
acknowledgedViolationCount = 0;
|
|
sosDismissed = false;
|
|
|
|
const toggleSw = document.getElementById('sosToggle');
|
|
if (toggleSw) {
|
|
if (sosAutoTriggerDisabled) {
|
|
toggleSw.classList.remove('on');
|
|
} else {
|
|
toggleSw.classList.add('on');
|
|
}
|
|
}
|
|
|
|
const candNameEl = document.getElementById('modal-cand-name');
|
|
const candUidEl = document.getElementById('modal-cand-uid');
|
|
if (candNameEl) candNameEl.innerText = name;
|
|
if (candUidEl) candUidEl.innerText = 'Unique Submission ID: ' + uid;
|
|
|
|
const candInitials = getInitials(name);
|
|
const avatarCircle = document.getElementById('modal-cand-avatar-circle');
|
|
const avatarName = document.getElementById('cand-avatar-name');
|
|
if (avatarCircle) avatarCircle.innerText = candInitials;
|
|
if (avatarName) avatarName.innerText = name;
|
|
|
|
const reviewModal = document.getElementById('review-modal');
|
|
if (reviewModal) reviewModal.classList.add('active');
|
|
|
|
resetZoomScreen();
|
|
if (document.getElementById('inspector-content-container')) {
|
|
let savedInspectorTab = null;
|
|
try {
|
|
savedInspectorTab = sessionStorage.getItem('interview_inspector_tab_' + id);
|
|
} catch(e) {}
|
|
|
|
if (savedInspectorTab) {
|
|
switchIdeTab(savedInspectorTab);
|
|
} else {
|
|
closeInspectorPanel();
|
|
}
|
|
} else if (document.getElementById('tab-btn-logs')) {
|
|
switchIdeTab('logs');
|
|
} else if (document.getElementById('tab-btn-notes')) {
|
|
switchIdeTab('notes');
|
|
} else {
|
|
switchIdeTab('code');
|
|
}
|
|
updateCandidateStatusIcons(false, false, false);
|
|
|
|
initInterviewerSigChannel();
|
|
subscribeLiveSync(id);
|
|
pollReviewData();
|
|
if (sosPollInterval) clearInterval(sosPollInterval);
|
|
sosPollInterval = setInterval(pollReviewData, 6000);
|
|
|
|
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
|
|
peerHeartbeatTimer = setInterval(() => {
|
|
sendInterviewerPeerHeartbeat('heartbeat');
|
|
}, 6000);
|
|
|
|
if (autoJoinCall) {
|
|
setTimeout(() => {
|
|
startInterviewerCall(true);
|
|
}, 400);
|
|
}
|
|
}
|
|
|
|
export function sendInterviewerPeerHeartbeat(action = 'heartbeat') {
|
|
if (!currentInterviewId) return;
|
|
const currentUserId = window.currentUserId || 0;
|
|
const currentUserName = window.currentUserName || 'Interviewer';
|
|
const currentUserRole = window.currentUserRole || 'interviewer';
|
|
const myPeerId = getMyInterviewerPeerId();
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
peer_id: myPeerId,
|
|
name: currentUserName,
|
|
role: currentUserRole,
|
|
mic_on: isInterviewerMicOn,
|
|
cam_on: isInterviewerCamOn,
|
|
action: action
|
|
})
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function isTrueVal(val) {
|
|
if (val === undefined || val === null) return true;
|
|
if (val === false || val === 'false' || val === 0 || val === '0') return false;
|
|
return Boolean(val);
|
|
}
|
|
|
|
export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
|
|
const micOn = isTrueVal(isMicOn);
|
|
const camOn = isTrueVal(isCamOn);
|
|
|
|
const candMicIcon = document.getElementById('cand-mic-status');
|
|
const candCamIcon = document.getElementById('cand-cam-status');
|
|
const candPlaceholder = document.getElementById('cand-video-placeholder');
|
|
const candStatusText = document.getElementById('cand-status-text') || (candPlaceholder ? candPlaceholder.querySelector('.status') : null);
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
const screenVid = document.getElementById('interviewer-screen-video');
|
|
const candVidWrapper = document.getElementById('cand-video-wrapper');
|
|
const screenWrapper = document.getElementById('screen-wrapper');
|
|
const screenPlaceholder = document.getElementById('screen-video-placeholder');
|
|
const joinedPill = document.getElementById('candidate-joined-pill');
|
|
|
|
if (!isJoined) {
|
|
if (candVid) candVid.srcObject = null;
|
|
if (screenVid) screenVid.srcObject = null;
|
|
|
|
if (joinedPill) {
|
|
joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60';
|
|
joinedPill.innerHTML = '<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> Not Joined';
|
|
}
|
|
if (candMicIcon) {
|
|
candMicIcon.className = 'fa-solid fa-microphone-slash text-slate-500 text-[10px]';
|
|
candMicIcon.title = 'Candidate Not Joined';
|
|
}
|
|
if (candCamIcon) {
|
|
candCamIcon.className = 'fa-solid fa-video-slash text-slate-500 text-[10px]';
|
|
candCamIcon.title = 'Candidate Not Joined';
|
|
}
|
|
if (candVidWrapper) {
|
|
candVidWrapper.style.opacity = '0.4';
|
|
candVidWrapper.style.filter = 'grayscale(60%)';
|
|
candVidWrapper.style.transition = 'all 0.3s ease';
|
|
}
|
|
if (screenWrapper) {
|
|
screenWrapper.style.opacity = '0.4';
|
|
screenWrapper.style.filter = 'grayscale(60%)';
|
|
screenWrapper.style.transition = 'all 0.3s ease';
|
|
}
|
|
if (candPlaceholder) {
|
|
candPlaceholder.style.display = 'flex';
|
|
if (candStatusText) candStatusText.innerText = 'Candidate Not Joined';
|
|
}
|
|
if (screenPlaceholder) {
|
|
screenPlaceholder.style.display = 'flex';
|
|
}
|
|
return;
|
|
}
|
|
|
|
const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0));
|
|
|
|
// Joined state: restore styles & active icons
|
|
if (joinedPill) {
|
|
joinedPill.className = 'pill green inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-emerald-500/30 text-emerald-400 bg-emerald-500/10';
|
|
joinedPill.innerHTML = '<i class="fa-solid fa-circle text-[8px] text-emerald-400 animate-pulse"></i> Joined';
|
|
}
|
|
if (candVidWrapper) {
|
|
candVidWrapper.style.opacity = '1';
|
|
candVidWrapper.style.filter = 'none';
|
|
}
|
|
if (screenWrapper) {
|
|
screenWrapper.style.opacity = '1';
|
|
screenWrapper.style.filter = 'none';
|
|
}
|
|
|
|
if (candMicIcon && isMicOn !== undefined) {
|
|
candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
|
|
candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted';
|
|
}
|
|
if (candCamIcon && isCamOn !== undefined) {
|
|
candCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]';
|
|
candCamIcon.title = camOn ? 'Camera On' : 'Camera Off';
|
|
}
|
|
if (candPlaceholder) {
|
|
if (!camOn) {
|
|
candPlaceholder.style.display = 'flex';
|
|
if (candStatusText) candStatusText.innerText = 'Camera Turned Off';
|
|
} else if (candidateStreamConnected) {
|
|
candPlaceholder.style.display = 'none';
|
|
} else {
|
|
candPlaceholder.style.display = 'flex';
|
|
if (candStatusText) candStatusText.innerText = 'Waiting for candidate camera stream...';
|
|
}
|
|
}
|
|
}
|
|
|
|
export function pollReviewData() {
|
|
if (!currentInterviewId) return;
|
|
const myPeerId = getMyInterviewerPeerId();
|
|
const candPeerId = getCandidatePeerId();
|
|
|
|
if (interviewerLocalStream) {
|
|
sendInterviewerPeerHeartbeat('heartbeat');
|
|
}
|
|
|
|
fetch(`/interviews/${currentInterviewId}/poll`, {
|
|
headers: { 'Accept': 'application/json' }
|
|
})
|
|
.then(r => {
|
|
if (!r.ok) return null;
|
|
const contentType = r.headers.get('content-type');
|
|
if (!contentType || !contentType.includes('application/json')) return null;
|
|
return r.json();
|
|
})
|
|
.then(data => {
|
|
if (!data) return;
|
|
|
|
if (data.active_peers && Array.isArray(data.active_peers)) {
|
|
latestActivePeers = data.active_peers;
|
|
}
|
|
|
|
const isCallEnded = (data.call_status === 'ended');
|
|
|
|
if (isCallEnded) {
|
|
// When call status is "Call Ended", do NOT check for candidate status
|
|
updateCandidateStatusIcons(false, false, false);
|
|
const screenVid = document.getElementById('interviewer-screen-video');
|
|
const screenPlace = document.getElementById('screen-video-placeholder');
|
|
if (screenVid) screenVid.srcObject = null;
|
|
if (screenPlace) screenPlace.style.display = 'flex';
|
|
if (typeof window.updateScreenShareLayout === 'function') {
|
|
window.updateScreenShareLayout(false);
|
|
}
|
|
|
|
panelistMeshTiles.forEach((_, pid) => {
|
|
removePanelistTile(pid);
|
|
});
|
|
} else {
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0));
|
|
|
|
const candidatePeer = (data.active_peers && Array.isArray(data.active_peers))
|
|
? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_')))))
|
|
: null;
|
|
|
|
const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected);
|
|
|
|
if (isCandidateJoined) {
|
|
const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true;
|
|
const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true;
|
|
updateCandidateStatusIcons(isMicOn, isCamOn, true);
|
|
} else {
|
|
updateCandidateStatusIcons(false, false, false);
|
|
}
|
|
|
|
if (data.active_peers && Array.isArray(data.active_peers)) {
|
|
const activeIds = new Set(data.active_peers.map(p => p.peer_id));
|
|
|
|
data.active_peers.forEach(p => {
|
|
if (p.peer_id && p.peer_id !== myPeerId) {
|
|
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name ? ('Panelist ' + p.name) : 'Panelist');
|
|
const isMicOn = isTrueVal(p.mic_on);
|
|
const isCamOn = isTrueVal(p.cam_on);
|
|
|
|
const isCandPeer = p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_'))));
|
|
if (isCandPeer) {
|
|
if (interviewerLocalStream && interviewerPeer && interviewerPeer.open && !candidateStreamConnected) {
|
|
try {
|
|
const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
if (candVid) safePlayMediaStream(candVid, remoteStream);
|
|
updateCandidateStatusIcons(isMicOn, isCamOn, true);
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
} else if (p.peer_id.startsWith('interviewer_') || p.role === 'interviewer' || p.role === 'admin') {
|
|
if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
|
|
if (!panelistMeshTiles.has(p.peer_id)) {
|
|
try {
|
|
const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
} else {
|
|
addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn);
|
|
}
|
|
} else {
|
|
addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id) || null, roleLabel, isMicOn, isCamOn);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
panelistMeshTiles.forEach((_, pid) => {
|
|
if (!activeIds.has(pid)) {
|
|
removePanelistTile(pid);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
const codeLang = document.getElementById('modal-code-lang');
|
|
const codeDisplay = document.getElementById('modal-code-display');
|
|
const outputDisplay = document.getElementById('modal-output-display');
|
|
const notesDisplay = document.getElementById('modal-notes-display');
|
|
|
|
if (codeLang) codeLang.innerText = (data.submitted_language || 'UNKNOWN').toUpperCase();
|
|
if (codeDisplay) codeDisplay.innerText = data.submitted_code || '// Candidate has not typed any code yet.';
|
|
if (outputDisplay) outputDisplay.innerText = data.code_output || 'No execution output.';
|
|
if (notesDisplay) notesDisplay.innerText = data.candidate_notes || 'No candidate notes written yet.';
|
|
|
|
const drawingImg = document.getElementById('modal-drawing-img');
|
|
const noDrawing = document.getElementById('no-drawing-placeholder');
|
|
if (data.candidate_drawing) {
|
|
if (drawingImg) {
|
|
drawingImg.src = data.candidate_drawing;
|
|
drawingImg.style.display = 'block';
|
|
}
|
|
if (noDrawing) noDrawing.style.display = 'none';
|
|
} else {
|
|
if (drawingImg) drawingImg.style.display = 'none';
|
|
if (noDrawing) noDrawing.style.display = 'block';
|
|
}
|
|
|
|
// Saved recordings
|
|
const recContainer = document.getElementById('modal-recordings-container');
|
|
if (recContainer) {
|
|
const recPath = data.recording_path;
|
|
let recList = data.recordings || [];
|
|
if (recList.length === 0 && recPath) {
|
|
recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }];
|
|
}
|
|
|
|
if (recList.length > 0) {
|
|
recList.sort((a, b) => {
|
|
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
|
const timeB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
|
return timeB - timeA;
|
|
});
|
|
|
|
let recHtml = `<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 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-full overflow-y-auto p-3 flex flex-col gap-2 mb-2.5';
|
|
chatHistory.innerHTML = chatHtml;
|
|
chatHistory.scrollTop = chatHistory.scrollHeight;
|
|
} else {
|
|
chatHistory.className = 'bg-[#0d1220] border border-[#1b2233] rounded-[9px] h-full overflow-y-auto p-3 mb-2.5';
|
|
chatHistory.innerHTML = '<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 (recStartBtn) recStartBtn.style.display = 'none';
|
|
if (recStopBtn) recStopBtn.style.display = 'none';
|
|
|
|
if (timelinePill) {
|
|
const icon = timelinePill.querySelector('i');
|
|
const span = timelinePill.querySelector('span');
|
|
if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse';
|
|
if (span) span.innerText = 'Call in progress';
|
|
timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]';
|
|
}
|
|
} else {
|
|
if (livePulseDot) livePulseDot.style.display = 'inline-block';
|
|
if (startBtn) startBtn.style.display = 'none';
|
|
if (leaveBtn) leaveBtn.style.display = 'inline-flex';
|
|
if (endAllBtn) endAllBtn.style.display = 'inline-flex';
|
|
if (micBtn) micBtn.style.display = 'inline-flex';
|
|
if (camBtn) camBtn.style.display = 'inline-flex';
|
|
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
|
|
recStartBtn.style.display = 'inline-flex';
|
|
}
|
|
|
|
if (timelinePill) {
|
|
const icon = timelinePill.querySelector('i');
|
|
const span = timelinePill.querySelector('span');
|
|
if (icon) icon.className = 'fa-solid fa-circle text-[8px] animate-pulse';
|
|
if (span) span.innerText = 'Connected (Live)';
|
|
timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]';
|
|
}
|
|
}
|
|
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
|
if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall();
|
|
if (livePulseDot) livePulseDot.style.display = 'none';
|
|
if (startBtn) {
|
|
const icon = startBtn.querySelector('i');
|
|
const span = startBtn.querySelector('span');
|
|
if (data.call_status === 'ended') {
|
|
if (icon) icon.className = 'fa-solid fa-phone-slash';
|
|
if (span) span.innerText = 'Call Ended';
|
|
startBtn.disabled = true;
|
|
startBtn.style.opacity = '0.5';
|
|
startBtn.style.cursor = 'not-allowed';
|
|
startBtn.style.pointerEvents = 'none';
|
|
startBtn.style.display = 'inline-flex';
|
|
} else {
|
|
if (icon) icon.className = 'fa-solid fa-phone';
|
|
if (span) span.innerText = 'Start Call';
|
|
startBtn.disabled = false;
|
|
startBtn.style.opacity = '1';
|
|
startBtn.style.cursor = 'pointer';
|
|
startBtn.style.pointerEvents = 'auto';
|
|
startBtn.style.display = 'inline-flex';
|
|
}
|
|
}
|
|
if (leaveBtn) leaveBtn.style.display = 'none';
|
|
if (endAllBtn) endAllBtn.style.display = 'none';
|
|
if (micBtn) micBtn.style.display = 'none';
|
|
if (camBtn) camBtn.style.display = 'none';
|
|
if (recStartBtn) recStartBtn.style.display = 'none';
|
|
if (recStopBtn) recStopBtn.style.display = 'none';
|
|
|
|
if (timelinePill && data.call_status === 'ended') {
|
|
const icon = timelinePill.querySelector('i');
|
|
const span = timelinePill.querySelector('span');
|
|
if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
|
|
if (span) span.innerText = 'Call ended';
|
|
timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Proctoring logs
|
|
const logsDisplay = document.getElementById('modal-logs-display');
|
|
let logsHtml = '';
|
|
let currentViolations = [];
|
|
|
|
if (data.proctor_logs && data.proctor_logs.length > 0) {
|
|
data.proctor_logs.forEach(l => {
|
|
if (l.type === 'proctoring_summary_report') return;
|
|
|
|
let icoClass = 'amber';
|
|
let faIcon = 'fa-solid fa-window-restore';
|
|
let isFlag = false;
|
|
|
|
if (l.type === 'focus_lost') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; }
|
|
else if (l.type === 'gaze_anomaly' || l.type === 'gaze_fixed_staring') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye'; }
|
|
else if (l.type === 'looking_away') { icoClass = 'amber'; faIcon = 'fa-solid fa-eye-slash'; }
|
|
else if (l.type === 'face_missing') { icoClass = 'red'; faIcon = 'fa-solid fa-user-slash'; isFlag = true; }
|
|
else if (l.type === 'multiple_faces') { icoClass = 'red'; faIcon = 'fa-solid fa-users-viewfinder'; isFlag = true; }
|
|
else if (l.type === 'prolonged_looking_away') { icoClass = 'red'; faIcon = 'fa-solid fa-clock-rotate-left'; isFlag = true; }
|
|
else if (l.type === 'phone_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen-button'; isFlag = true; }
|
|
else if (l.type === 'prolonged_corner_gaze') { icoClass = 'amber'; faIcon = 'fa-solid fa-arrows-to-eye'; isFlag = true; }
|
|
else if (l.type === 'talking_secondary_person') { icoClass = 'red'; faIcon = 'fa-solid fa-user-group'; isFlag = true; }
|
|
else if (l.type === 'gaze_lower_device' || l.type === 'reading_external_device') { icoClass = 'red'; faIcon = 'fa-solid fa-mobile-screen'; isFlag = true; }
|
|
else if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') { icoClass = 'red'; faIcon = 'fa-solid fa-comments'; isFlag = true; }
|
|
else if (l.type === 'talking_on_phone') { icoClass = 'red'; faIcon = 'fa-solid fa-phone'; isFlag = true; }
|
|
else if (l.type === 'external_ai_detected') { icoClass = 'red'; faIcon = 'fa-solid fa-robot'; isFlag = true; }
|
|
else if (l.type === 'tab_switch') { icoClass = 'amber'; faIcon = 'fa-solid fa-window-restore'; }
|
|
else if (l.type === 'paste_event') { icoClass = 'red'; faIcon = 'fa-solid fa-paste'; isFlag = true; }
|
|
else if (l.type === 'copy_event') { icoClass = 'amber'; faIcon = 'fa-solid fa-copy'; }
|
|
|
|
if (['paste_event', 'copy_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected', 'talking_secondary_person', 'talking_on_phone', 'reading_external_device', 'looking_away', 'face_missing', 'multiple_faces', 'prolonged_looking_away', 'prolonged_corner_gaze', 'phone_detected'].includes(l.type)) {
|
|
currentViolations.push(l);
|
|
}
|
|
|
|
let screenshotLink = l.screenshot_url ? `<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);
|
|
const humanDetails = formatProctoringLogDetails(l);
|
|
const timeString = new Date(l.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true });
|
|
|
|
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> — ${humanDetails}${screenshotLink}${aiBadge}</div>
|
|
<div class="log-time text-[10.5px] text-slate-500 mt-0.5">${timeString}</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 === 'multiple_faces') {
|
|
typeTitle = 'Multiple Faces Detected';
|
|
} else if (v.type === 'face_missing') {
|
|
typeTitle = 'Candidate Face Not Visible';
|
|
} else if (v.type === 'looking_away') {
|
|
typeTitle = 'Candidate Looking Away';
|
|
} else if (v.type === 'prolonged_looking_away') {
|
|
typeTitle = 'Prolonged Looking Away (>10s)';
|
|
} else if (v.type === 'phone_detected') {
|
|
typeTitle = 'Mobile Phone Detected';
|
|
} else if (v.type === 'talking_secondary_person') {
|
|
typeTitle = 'Candidate Talking to Secondary Person';
|
|
} else if (v.type === 'talking_on_phone') {
|
|
typeTitle = 'Candidate Talking on Phone';
|
|
} else if (v.type === 'reading_external_device') {
|
|
typeTitle = 'Candidate Reading from External Device';
|
|
} else if (v.type === 'question_repetition' || v.type === 'question_repeat_lower') {
|
|
typeTitle = 'Candidate Reading Question Out Loud';
|
|
}
|
|
const borderCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'border-red-500' : 'border-amber-500';
|
|
const textCol = (v.type === 'external_ai_detected' || v.type === 'multiple_faces' || v.type === 'phone_detected' || v.type === 'face_missing' || v.type === 'prolonged_looking_away') ? 'text-red-300' : 'text-amber-300';
|
|
const vDetails = formatProctoringLogDetails(v);
|
|
const vTime = new Date(v.timestamp).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit', second: '2-digit', hour12: true });
|
|
|
|
html += `<div class="mb-2.5 p-2 bg-white/5 rounded-md border-l-4 ${borderCol}">
|
|
<strong class="${textCol}">🚨 ${typeTitle}:</strong> ${vDetails}
|
|
<div class="text-[0.65rem] text-slate-400 mt-1">Timestamp: ${vTime}</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 disableSosAndStopAlarm() {
|
|
sosAutoTriggerDisabled = true;
|
|
const toggleSw = document.getElementById('sosToggle');
|
|
const btn = document.getElementById('sos-toggle-btn');
|
|
if (toggleSw) toggleSw.classList.remove('on');
|
|
if (btn) {
|
|
btn.innerHTML = '<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';
|
|
}
|
|
stopSosAlarm();
|
|
}
|
|
|
|
export function interviewerLogoutAction() {
|
|
const form = document.getElementById('logout-form');
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
title: 'Logout of Portal?',
|
|
text: 'Are you sure you want to log out of your session?',
|
|
icon: 'question',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#ef4444',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: 'Yes, Logout'
|
|
}).then((result) => {
|
|
if (result.isConfirmed && form) {
|
|
form.submit();
|
|
}
|
|
});
|
|
} else if (form) {
|
|
form.submit();
|
|
}
|
|
}
|
|
|
|
export function regenerateCandidatePassword() {
|
|
if (!currentInterviewId) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
title: 'Regenerate Candidate Password?',
|
|
text: 'This will invalidate the candidate\'s current password and issue a new system password.',
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#eab308',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: 'Regenerate Now'
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
fetch(`/interviews/${currentInterviewId}/regenerate-password`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
}
|
|
})
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
if (res.success) {
|
|
window.Swal.fire({
|
|
icon: 'success',
|
|
title: 'New Password Generated!',
|
|
text: 'New Candidate Passcode: ' + res.new_password,
|
|
confirmButtonColor: '#6366f1'
|
|
}).then(() => window.location.reload());
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function blockCandidateAction() {
|
|
if (!currentInterviewId) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
title: '⛔ Block Candidate Email & IP?',
|
|
text: 'This will immediately terminate the candidate session and block their email and client IP address.',
|
|
icon: 'error',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#ef4444',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: 'Yes, Block Candidate'
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
fetch(`/interviews/${currentInterviewId}/block-candidate`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
}
|
|
})
|
|
.then(r => r.json())
|
|
.then(res => {
|
|
if (res.success) {
|
|
window.Swal.fire({
|
|
icon: 'success',
|
|
title: 'Candidate Blocked',
|
|
text: res.message,
|
|
confirmButtonColor: '#6366f1'
|
|
}).then(() => {
|
|
closeReviewModal();
|
|
window.location.reload();
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export { initMeteredIceServers, globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES };
|
|
|
|
export function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
|
if (!videoElem || !stream) return;
|
|
const isScreenShare = (videoElem.id === 'interviewer-screen-video');
|
|
if (isSelf || isScreenShare) {
|
|
videoElem.muted = true;
|
|
}
|
|
if (videoElem.srcObject !== stream) {
|
|
videoElem.srcObject = stream;
|
|
}
|
|
|
|
// Attach dynamic emerald speaking border VAD monitor
|
|
if (!isScreenShare && stream.getAudioTracks && stream.getAudioTracks().length > 0) {
|
|
let targetId = null;
|
|
let monitorKey = videoElem.id;
|
|
|
|
if (videoElem.id === 'interviewer-cand-video') {
|
|
targetId = 'cand-video-wrapper';
|
|
} else if (videoElem.id === 'interviewer-self-video') {
|
|
targetId = 'self-video-wrapper';
|
|
} else if (videoElem.id === 'webcam') {
|
|
targetId = 'cand-self-video-box';
|
|
} else if (videoElem.id === 'interviewer-video-default') {
|
|
targetId = 'interviewer-placeholder-box';
|
|
} else if (videoElem.id.startsWith('panelist-video-')) {
|
|
const pid = videoElem.id.replace('panelist-video-', '');
|
|
targetId = 'panelist-tile-' + pid;
|
|
} else if (videoElem.id.startsWith('cand-iv-video-')) {
|
|
const pid = videoElem.id.replace('cand-iv-video-', '');
|
|
targetId = 'cand-iv-tile-' + pid;
|
|
}
|
|
|
|
if (targetId) {
|
|
globalAudioMonitor.attach(monitorKey, stream, targetId);
|
|
}
|
|
}
|
|
|
|
const playPromise = videoElem.play();
|
|
if (playPromise !== undefined) {
|
|
playPromise.catch(err => {
|
|
if (err.name === 'AbortError' || err.name === 'NotAllowedError') {
|
|
videoElem.onloadedmetadata = () => {
|
|
videoElem.play().catch(() => {});
|
|
};
|
|
if (!isSelf && !isScreenShare) {
|
|
videoElem.muted = true;
|
|
videoElem.play().catch(() => {});
|
|
const unmuteHandler = () => {
|
|
videoElem.muted = false;
|
|
videoElem.play().catch(() => {});
|
|
};
|
|
window.addEventListener('click', unmuteHandler, { once: true });
|
|
window.addEventListener('keydown', unmuteHandler, { once: true });
|
|
window.addEventListener('touchstart', unmuteHandler, { once: true });
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn = true, camOn = true) {
|
|
const currentUserId = window.currentUserId || 0;
|
|
const myPeerId = getMyInterviewerPeerId();
|
|
if (!peerId || peerId === myPeerId) return;
|
|
const grid = document.getElementById('panelist-mesh-grid');
|
|
if (!grid) return;
|
|
|
|
let tile = document.getElementById('panelist-tile-' + peerId);
|
|
let videoElem;
|
|
const initials = getInitials(name);
|
|
|
|
if (!tile) {
|
|
tile = document.createElement('div');
|
|
tile.id = 'panelist-tile-' + peerId;
|
|
tile.className = 'video-tile relative w-[220px] aspect-video shrink-0 bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all duration-200';
|
|
|
|
tile.innerHTML = `
|
|
<video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="transform: scaleX(-1);"></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 && stream ? '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">${camOn && stream ? 'Connecting video...' : '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 && stream) ? 'none' : 'flex';
|
|
}
|
|
}
|
|
|
|
if (videoElem && stream) {
|
|
safePlayMediaStream(videoElem, stream);
|
|
stream.onaddtrack = () => {
|
|
safePlayMediaStream(videoElem, stream);
|
|
const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId);
|
|
if (meshPlaceholder && camOn) meshPlaceholder.style.display = 'none';
|
|
};
|
|
}
|
|
|
|
if (stream) {
|
|
panelistMeshTiles.set(peerId, stream);
|
|
}
|
|
}
|
|
|
|
export function removePanelistTile(peerId) {
|
|
globalAudioMonitor.detach('panelist-video-' + peerId);
|
|
const tile = document.getElementById('panelist-tile-' + peerId);
|
|
if (tile) tile.remove();
|
|
panelistMeshTiles.delete(peerId);
|
|
}
|
|
|
|
export async function initInterviewerPeer() {
|
|
if (!currentInterviewId || typeof window.Peer === 'undefined') return;
|
|
const iceServers = await initMeteredIceServers();
|
|
|
|
const currentUserId = window.currentUserId || 0;
|
|
const currentUserName = window.currentUserName || 'Interviewer';
|
|
const myPeerId = getMyInterviewerPeerId();
|
|
const candPeerId = getCandidatePeerId();
|
|
|
|
if (interviewerPeer && !interviewerPeer.destroyed) {
|
|
try { interviewerPeer.destroy(); } catch(e) {}
|
|
}
|
|
|
|
interviewerPeer = new window.Peer(myPeerId, {
|
|
config: { iceServers: iceServers }
|
|
});
|
|
|
|
interviewerPeer.on('open', id => {
|
|
console.log('[WebRTC Interviewer] Registered PeerJS ID:', id);
|
|
if (interviewerSigChannel) {
|
|
interviewerSigChannel.postMessage({
|
|
type: 'peer_joined',
|
|
peer_id: id,
|
|
user_id: currentUserId,
|
|
user_name: currentUserName,
|
|
role: 'interviewer'
|
|
});
|
|
}
|
|
});
|
|
|
|
function handleIncomingScreenStream(screenStream) {
|
|
console.log('[WebRTC Screen] Attaching incoming screen stream to video element');
|
|
const screenVid = document.getElementById('interviewer-screen-video');
|
|
const placeholder = document.getElementById('screen-video-placeholder');
|
|
if (typeof window.updateScreenShareLayout === 'function') {
|
|
window.updateScreenShareLayout(true);
|
|
}
|
|
if (screenStream && screenStream.getVideoTracks) {
|
|
screenStream.getVideoTracks().forEach(track => {
|
|
track.onended = () => {
|
|
if (typeof window.updateScreenShareLayout === 'function') {
|
|
window.updateScreenShareLayout(false);
|
|
}
|
|
};
|
|
});
|
|
}
|
|
if (screenVid && screenStream) {
|
|
screenVid.muted = true;
|
|
if (screenVid.srcObject !== screenStream) {
|
|
screenVid.srcObject = screenStream;
|
|
}
|
|
screenVid.play().catch(() => {});
|
|
screenStream.onaddtrack = () => {
|
|
screenVid.muted = true;
|
|
if (screenVid.srcObject !== screenStream) {
|
|
screenVid.srcObject = screenStream;
|
|
}
|
|
screenVid.play().catch(() => {});
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
};
|
|
}
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
}
|
|
|
|
interviewerPeer.on('call', async call => {
|
|
console.log('[WebRTC Interviewer] Incoming PeerJS call from:', call.peer);
|
|
if (call.peer && call.peer.startsWith('cand_screen_')) {
|
|
call.on('stream', screenStream => {
|
|
console.log('[WebRTC Screen] Screen stream received on main peer from:', call.peer);
|
|
handleIncomingScreenStream(screenStream);
|
|
});
|
|
try { call.answer(); } catch(e) {}
|
|
return;
|
|
}
|
|
|
|
const sendStream = interviewerLocalStream || new MediaStream();
|
|
call.on('stream', remoteStream => {
|
|
console.log('[WebRTC Interviewer] Stream received from peer:', call.peer);
|
|
if (call.peer === candPeerId || (call.peer && call.peer.startsWith('cand_') && !call.peer.startsWith('cand_screen_'))) {
|
|
console.log('[WebRTC Interviewer] Candidate video stream connected! Candidate ID:', call.peer);
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
if (candVid) safePlayMediaStream(candVid, remoteStream);
|
|
const candCamIcon = document.getElementById('cand-cam-status');
|
|
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
|
|
updateCandidateStatusIcons(true, isCamOn, true);
|
|
} else {
|
|
const peerInfo = latestActivePeers.find(p => p.peer_id === call.peer);
|
|
const roleLabel = peerInfo ? (peerInfo.role === 'admin' ? ('Admin ' + (peerInfo.name || '')) : ('Panelist ' + (peerInfo.name || ''))) : 'Panelist';
|
|
const isMicOn = peerInfo ? isTrueVal(peerInfo.mic_on) : true;
|
|
const isCamOn = peerInfo ? isTrueVal(peerInfo.cam_on) : true;
|
|
addOrUpdatePanelistTile(call.peer, remoteStream, roleLabel, isMicOn, isCamOn);
|
|
}
|
|
|
|
const badge = document.getElementById('interviewer-call-status');
|
|
if (badge) {
|
|
badge.innerText = 'CONNECTED (LIVE)';
|
|
badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
|
|
}
|
|
});
|
|
|
|
call.on('error', err => {
|
|
console.error('[WebRTC Interviewer] Call error from peer:', call.peer, err);
|
|
});
|
|
|
|
try { call.answer(sendStream); } catch(e) {
|
|
console.error('[WebRTC Interviewer] Error answering call:', e);
|
|
}
|
|
});
|
|
|
|
const screenPeerId = getInterviewerScreenPeerId();
|
|
if (screenReceiverPeer && !screenReceiverPeer.destroyed) {
|
|
try { screenReceiverPeer.destroy(); } catch(e) {}
|
|
}
|
|
screenReceiverPeer = new window.Peer(screenPeerId, {
|
|
config: { iceServers: iceServers }
|
|
});
|
|
screenReceiverPeer.on('call', call => {
|
|
console.log('[WebRTC Screen] Incoming screen call from:', call.peer);
|
|
call.on('stream', screenStream => {
|
|
console.log('[WebRTC Screen] Screen stream received from:', call.peer);
|
|
handleIncomingScreenStream(screenStream);
|
|
});
|
|
try { call.answer(); } catch(e) {
|
|
console.error('[WebRTC Screen] Error answering screen call:', e);
|
|
}
|
|
});
|
|
|
|
// Also attempt legacy singleton screen peer if current user is admin / user 1
|
|
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
|
|
? currentSubmissionUniqueId
|
|
: null;
|
|
const legacyScreenPeerId = subId ? ('interviewer_screen_' + currentInterviewId + '_' + subId) : ('interviewer_screen_' + currentInterviewId);
|
|
|
|
if (legacyScreenPeerId !== screenPeerId && (currentUserId === 1 || window.currentUserRole === 'admin')) {
|
|
try {
|
|
const legacyScreenPeer = new window.Peer(legacyScreenPeerId, {
|
|
config: { iceServers: iceServers }
|
|
});
|
|
legacyScreenPeer.on('call', call => {
|
|
try { call.answer(); } catch(e) {}
|
|
call.on('stream', screenStream => {
|
|
handleIncomingScreenStream(screenStream);
|
|
});
|
|
});
|
|
legacyScreenPeer.on('error', () => {});
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
|
|
export function initInterviewerSigChannel() {
|
|
if (!currentInterviewId) return;
|
|
if (interviewerSigChannel) interviewerSigChannel.close();
|
|
|
|
interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId);
|
|
const currentUserId = window.currentUserId || 0;
|
|
const currentUserName = window.currentUserName || 'Interviewer';
|
|
const myPeerId = getMyInterviewerPeerId();
|
|
const candPeerId = getCandidatePeerId();
|
|
|
|
interviewerSigChannel.onmessage = async (event) => {
|
|
const msg = event.data;
|
|
if (!msg) return;
|
|
|
|
if (msg.type === 'violation_occurred') {
|
|
pollReviewData();
|
|
}
|
|
|
|
if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') {
|
|
if (msg.peer_id && msg.peer_id !== myPeerId) {
|
|
if (msg.type === 'peer_joined') {
|
|
interviewerSigChannel.postMessage({
|
|
type: 'peer_presence_ack',
|
|
peer_id: myPeerId,
|
|
user_id: currentUserId,
|
|
user_name: currentUserName,
|
|
role: 'interviewer'
|
|
});
|
|
}
|
|
|
|
if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
|
|
try {
|
|
const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) {
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
if (candVid) safePlayMediaStream(candVid, remoteStream);
|
|
const candCamIcon = document.getElementById('cand-cam-status');
|
|
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
|
|
updateCandidateStatusIcons(undefined, isCamOn);
|
|
} else {
|
|
addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist');
|
|
}
|
|
|
|
const badge = document.getElementById('interviewer-call-status');
|
|
if (badge) {
|
|
badge.innerText = 'CONNECTED (LIVE)';
|
|
badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
|
|
}
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
} else if (msg.type === 'peer_left') {
|
|
if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) {
|
|
const placeholder = document.getElementById('cand-video-placeholder');
|
|
if (placeholder) placeholder.style.display = 'flex';
|
|
} else if (msg.peer_id) {
|
|
removePanelistTile(msg.peer_id);
|
|
}
|
|
} else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) {
|
|
await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
|
|
} else if (msg.type === 'ice-candidate' && msg.sender === 'candidate' && interviewerPeerConnection && msg.candidate) {
|
|
try {
|
|
await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
|
|
} catch(e) {}
|
|
} else if (msg.type === 'end_call_all') {
|
|
leaveInterviewerCall(false);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function startInterviewerCall(isRejoin = false) {
|
|
if (!currentInterviewId) return;
|
|
const startBtn = document.getElementById('btn-start-call');
|
|
if (startBtn && startBtn.disabled) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
const currentUserName = window.currentUserName || 'Interviewer';
|
|
|
|
if (!isRejoin) {
|
|
iAmTheCaller = true;
|
|
const candNameEl = document.getElementById('modal-cand-name');
|
|
const candName = candNameEl ? candNameEl.innerText : 'Candidate';
|
|
if (!globalCallChannel) {
|
|
globalCallChannel = new BroadcastChannel('global_call_alerts');
|
|
}
|
|
globalCallChannel.postMessage({
|
|
type: 'incoming_call',
|
|
interview_id: currentInterviewId,
|
|
candidate_name: candName,
|
|
starter_name: currentUserName,
|
|
call_started_at: new Date().toISOString()
|
|
});
|
|
}
|
|
|
|
initInterviewerSigChannel();
|
|
|
|
fetch(`/interviews/${currentInterviewId}/call-status`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin })
|
|
});
|
|
|
|
const handleStream = (stream) => {
|
|
interviewerLocalStream = stream;
|
|
const selfVid = document.getElementById('interviewer-self-video');
|
|
const selfPlace = document.getElementById('self-video-placeholder');
|
|
if (selfVid) {
|
|
selfVid.muted = true;
|
|
safePlayMediaStream(selfVid, stream, true);
|
|
}
|
|
if (selfPlace) selfPlace.style.display = 'none';
|
|
|
|
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
|
|
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
|
|
const startBtn = document.getElementById('btn-start-call');
|
|
const leaveBtn = document.getElementById('btn-leave-call');
|
|
const endAllBtn = document.getElementById('btn-end-all-call');
|
|
const recStartBtn = document.getElementById('btn-start-recording');
|
|
|
|
if (micBtn) micBtn.style.display = 'inline-flex';
|
|
if (camBtn) camBtn.style.display = 'inline-flex';
|
|
if (startBtn) startBtn.style.display = 'none';
|
|
if (leaveBtn) leaveBtn.style.display = 'inline-flex';
|
|
if (endAllBtn) endAllBtn.style.display = 'inline-flex';
|
|
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
|
|
recStartBtn.style.display = 'inline-flex';
|
|
}
|
|
|
|
if (interviewerSigChannel) {
|
|
interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' });
|
|
}
|
|
|
|
if (!interviewerPeer || interviewerPeer.destroyed) {
|
|
initInterviewerPeer();
|
|
}
|
|
|
|
const makePeerJSCall = () => {
|
|
if (!interviewerPeer || interviewerPeer.destroyed) return;
|
|
const candPeerId = getCandidatePeerId();
|
|
const myPeerId = getMyInterviewerPeerId();
|
|
activeCall = interviewerPeer.call(candPeerId, stream);
|
|
|
|
if (activeCall) {
|
|
activeCall.on('stream', candRemoteStream => {
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
const placeholder = document.getElementById('cand-video-placeholder');
|
|
if (candVid) safePlayMediaStream(candVid, candRemoteStream);
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
|
|
candRemoteStream.onaddtrack = () => {
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
if (candVid) safePlayMediaStream(candVid, candRemoteStream);
|
|
};
|
|
|
|
const badge = document.getElementById('interviewer-call-status');
|
|
if (badge) {
|
|
badge.innerText = 'CONNECTED (LIVE)';
|
|
badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Also call any other already-active interviewers
|
|
if (Array.isArray(latestActivePeers)) {
|
|
latestActivePeers.forEach(p => {
|
|
if (p.peer_id && p.peer_id !== myPeerId && p.peer_id.startsWith('interviewer_')) {
|
|
try {
|
|
const outCall = interviewerPeer.call(p.peer_id, stream);
|
|
if (outCall) {
|
|
const roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
|
|
const isMicOn = isTrueVal(p.mic_on);
|
|
const isCamOn = isTrueVal(p.cam_on);
|
|
outCall.on('stream', remoteStream => {
|
|
addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
if (interviewerPeer && interviewerPeer.open) {
|
|
makePeerJSCall();
|
|
} else if (interviewerPeer) {
|
|
interviewerPeer.on('open', makePeerJSCall);
|
|
}
|
|
};
|
|
|
|
const audioConstraints = {
|
|
echoCancellation: true,
|
|
noiseSuppression: true,
|
|
autoGainControl: true
|
|
};
|
|
|
|
navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints })
|
|
.then(handleStream)
|
|
.catch(() => {
|
|
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
|
|
.then(handleStream)
|
|
.catch(() => {
|
|
navigator.mediaDevices.getUserMedia({ video: false, audio: true })
|
|
.then(handleStream)
|
|
.catch(() => {
|
|
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
|
.then(handleStream)
|
|
.catch(err => console.log('WebRTC Stream init error:', err));
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
export function leaveInterviewerCall(isEndAll = false) {
|
|
sendInterviewerPeerHeartbeat('leave');
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
const endingId = currentInterviewId;
|
|
|
|
if (interviewerSigChannel) {
|
|
try {
|
|
interviewerSigChannel.postMessage({
|
|
type: 'peer_left',
|
|
peer_id: getMyInterviewerPeerId()
|
|
});
|
|
} catch(e) {}
|
|
}
|
|
|
|
if (endingId && isEndAll) {
|
|
endedCallIds.add(endingId);
|
|
dismissedCallIds.add(endingId);
|
|
|
|
if (globalCallChannel) {
|
|
try {
|
|
globalCallChannel.postMessage({
|
|
type: 'call_ended',
|
|
interview_id: endingId
|
|
});
|
|
} catch(e) {}
|
|
}
|
|
|
|
if (interviewerSigChannel) {
|
|
try {
|
|
interviewerSigChannel.postMessage({ type: 'end_call_all', interview_id: endingId });
|
|
} catch(e) {}
|
|
}
|
|
|
|
fetch(`/interviews/${endingId}/call-status`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ call_status: 'ended' })
|
|
}).catch(() => {});
|
|
}
|
|
|
|
if (interviewerLocalStream) {
|
|
try {
|
|
interviewerLocalStream.getTracks().forEach(t => t.stop());
|
|
} catch(e) {}
|
|
interviewerLocalStream = null;
|
|
}
|
|
if (activeCall) {
|
|
try { activeCall.close(); } catch(e) {}
|
|
activeCall = null;
|
|
}
|
|
if (interviewerPeer) {
|
|
try { interviewerPeer.destroy(); } catch(e) {}
|
|
interviewerPeer = null;
|
|
}
|
|
if (screenReceiverPeer) {
|
|
try { screenReceiverPeer.destroy(); } catch(e) {}
|
|
screenReceiverPeer = null;
|
|
}
|
|
|
|
updateCandidateStatusIcons(false, false, false);
|
|
|
|
const candPlace = document.getElementById('cand-video-placeholder');
|
|
const screenPlace = document.getElementById('screen-video-placeholder');
|
|
const selfPlace = document.getElementById('self-video-placeholder');
|
|
if (candPlace) candPlace.style.display = 'flex';
|
|
if (screenPlace) screenPlace.style.display = 'flex';
|
|
if (selfPlace) selfPlace.style.display = 'flex';
|
|
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
if (candVid) candVid.srcObject = null;
|
|
|
|
const screenVid = document.getElementById('interviewer-screen-video');
|
|
if (screenVid) screenVid.srcObject = null;
|
|
|
|
const selfVid = document.getElementById('interviewer-self-video');
|
|
if (selfVid) selfVid.srcObject = null;
|
|
|
|
panelistMeshTiles.forEach((_, pid) => {
|
|
removePanelistTile(pid);
|
|
});
|
|
|
|
const livePulseDot = document.getElementById('live-call-pulse');
|
|
if (livePulseDot) livePulseDot.style.display = 'none';
|
|
|
|
const startBtn = document.getElementById('btn-start-call');
|
|
if (startBtn) {
|
|
const icon = startBtn.querySelector('i');
|
|
const span = startBtn.querySelector('span');
|
|
if (isEndAll) {
|
|
if (icon) icon.className = 'fa-solid fa-phone-slash';
|
|
if (span) span.innerText = 'Call Ended';
|
|
startBtn.disabled = true;
|
|
startBtn.style.opacity = '0.5';
|
|
startBtn.style.cursor = 'not-allowed';
|
|
startBtn.style.pointerEvents = 'none';
|
|
startBtn.style.display = 'inline-flex';
|
|
} else {
|
|
if (icon) icon.className = 'fa-solid fa-phone';
|
|
if (span) span.innerText = 'Join Call';
|
|
startBtn.disabled = false;
|
|
startBtn.style.opacity = '1';
|
|
startBtn.style.cursor = 'pointer';
|
|
startBtn.style.pointerEvents = 'auto';
|
|
startBtn.style.display = 'inline-flex';
|
|
}
|
|
}
|
|
|
|
const leaveBtn = document.getElementById('btn-leave-call');
|
|
if (leaveBtn) leaveBtn.style.display = 'none';
|
|
|
|
const endAllBtn = document.getElementById('btn-end-all-call');
|
|
if (endAllBtn) endAllBtn.style.display = 'none';
|
|
|
|
const micBtn = document.getElementById('btn-toggle-interviewer-mic');
|
|
if (micBtn) micBtn.style.display = 'none';
|
|
|
|
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
|
|
if (camBtn) camBtn.style.display = 'none';
|
|
|
|
const recStartBtn = document.getElementById('btn-start-recording');
|
|
if (recStartBtn) recStartBtn.style.display = 'none';
|
|
|
|
const recStopBtn = document.getElementById('btn-stop-recording');
|
|
if (recStopBtn) recStopBtn.style.display = 'none';
|
|
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
|
|
try { adminMediaRecorder.stop(); } catch(e) {}
|
|
}
|
|
|
|
const timelinePill = document.getElementById('timeline-status-pill');
|
|
if (timelinePill && isEndAll) {
|
|
const icon = timelinePill.querySelector('i');
|
|
const span = timelinePill.querySelector('span');
|
|
if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
|
|
if (span) span.innerText = 'Call ended';
|
|
timelinePill.className = 'pill red inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(239,74,95,0.32)] text-[#ef4a5f] bg-[rgba(239,74,95,0.1)]';
|
|
}
|
|
}
|
|
|
|
export function endCallForAll() {
|
|
const executeEndCall = () => {
|
|
callRingtone.stop();
|
|
const modal = document.getElementById('incoming-call-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
activeIncomingCall = null;
|
|
iAmTheCaller = false;
|
|
leaveInterviewerCall(true);
|
|
};
|
|
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
title: '🛑 End Call for All Participants?',
|
|
text: 'This will terminate the 2-way call session for candidate and all panelists.',
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#ef4444',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: 'Yes, End Call for All'
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
executeEndCall();
|
|
}
|
|
});
|
|
} else {
|
|
if (confirm('End Call for All Participants?')) {
|
|
executeEndCall();
|
|
}
|
|
}
|
|
}
|
|
|
|
// MediaRecorder & Recording Compositor
|
|
let adminMediaRecorder = null;
|
|
let adminRecordedChunks = [];
|
|
let recordingCompositor = null;
|
|
let recordingTimerInterval = null;
|
|
let recordingStartTime = null;
|
|
|
|
export function startCallRecording() {
|
|
if (!currentInterviewId) {
|
|
alert('Please open an active candidate interview session first.');
|
|
return;
|
|
}
|
|
if (!interviewerLocalStream) {
|
|
alert('Please start or join the call before recording.');
|
|
return;
|
|
}
|
|
|
|
const candVid = document.getElementById('interviewer-cand-video');
|
|
const candNameEl = document.getElementById('modal-cand-name');
|
|
const candidateName = candNameEl ? candNameEl.innerText.trim() : 'Candidate';
|
|
|
|
// 1. Initialize RecordingCompositor (1920x1080 @ 30fps)
|
|
recordingCompositor = new RecordingCompositor({
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 30,
|
|
getParticipantsData: () => {
|
|
const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
|
const candCamIcon = document.getElementById('cand-cam-status');
|
|
const candMicIcon = document.getElementById('cand-mic-status');
|
|
const isCandCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
|
|
const isCandMicOn = candMicIcon ? candMicIcon.classList.contains('fa-microphone') : true;
|
|
|
|
const panelistsList = [];
|
|
const panelistStreamsList = [];
|
|
|
|
panelistMeshTiles.forEach((stream, pid) => {
|
|
const peerInfo = latestActivePeers.find(p => p.peer_id === pid);
|
|
const pMicEl = document.getElementById('mesh-mic-' + pid);
|
|
const pCamEl = document.getElementById('mesh-cam-' + pid);
|
|
const isMicOn = pMicEl ? pMicEl.classList.contains('fa-microphone') : (peerInfo ? isTrueVal(peerInfo.mic_on) : true);
|
|
const isCamOn = pCamEl ? pCamEl.classList.contains('fa-video') : (peerInfo ? isTrueVal(peerInfo.cam_on) : true);
|
|
const pName = peerInfo ? (peerInfo.name || 'Panelist') : 'Panelist';
|
|
const pRole = peerInfo ? (peerInfo.role === 'admin' ? 'Admin' : 'Panelist') : 'Panelist';
|
|
|
|
panelistsList.push({
|
|
peerId: pid,
|
|
name: pName,
|
|
role: pRole,
|
|
micOn: isMicOn,
|
|
camOn: isCamOn
|
|
});
|
|
|
|
if (stream) {
|
|
panelistStreamsList.push({ peerId: pid, stream: stream });
|
|
}
|
|
});
|
|
|
|
return {
|
|
candidateStream: candStream,
|
|
candidateName: candidateName,
|
|
candidateCamOn: isCandCamOn,
|
|
candidateMicOn: isCandMicOn,
|
|
selfStream: interviewerLocalStream,
|
|
selfName: window.currentUserName || 'Interviewer',
|
|
selfCamOn: isInterviewerCamOn,
|
|
selfMicOn: isInterviewerMicOn,
|
|
panelists: panelistsList,
|
|
panelistStreams: panelistStreamsList
|
|
};
|
|
}
|
|
});
|
|
|
|
try {
|
|
recordingCompositor.start();
|
|
const streamToRecord = recordingCompositor.getStream();
|
|
|
|
adminRecordedChunks = [];
|
|
let options = {};
|
|
if (typeof MediaRecorder.isTypeSupported === 'function') {
|
|
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
|
|
options = { mimeType: 'video/webm;codecs=vp8,opus' };
|
|
} else if (MediaRecorder.isTypeSupported('video/webm')) {
|
|
options = { mimeType: 'video/webm' };
|
|
}
|
|
}
|
|
|
|
try {
|
|
adminMediaRecorder = new MediaRecorder(streamToRecord, options);
|
|
} catch(errOpt) {
|
|
adminMediaRecorder = new MediaRecorder(streamToRecord);
|
|
}
|
|
|
|
adminMediaRecorder.ondataavailable = function(e) {
|
|
if (e.data && e.data.size > 0) {
|
|
adminRecordedChunks.push(e.data);
|
|
}
|
|
};
|
|
|
|
adminMediaRecorder.onstop = function() {
|
|
if (recordingCompositor) {
|
|
recordingCompositor.stop();
|
|
recordingCompositor = null;
|
|
}
|
|
if (adminRecordedChunks.length === 0) return;
|
|
const recMime = adminMediaRecorder.mimeType || 'video/webm';
|
|
const isMp4 = recMime.includes('mp4');
|
|
const ext = isMp4 ? 'mp4' : 'webm';
|
|
const blobType = isMp4 ? 'video/mp4' : 'video/webm';
|
|
|
|
const blob = new Blob(adminRecordedChunks, { type: blobType });
|
|
uploadRecordedBlob(blob, ext);
|
|
};
|
|
|
|
adminMediaRecorder.start(1000);
|
|
recordingStartTime = Date.now();
|
|
|
|
// Update UI buttons & Live Timer
|
|
const recStartBtn = document.getElementById('btn-start-recording');
|
|
const recStopBtn = document.getElementById('btn-stop-recording');
|
|
if (recStartBtn) recStartBtn.style.display = 'none';
|
|
if (recStopBtn) recStopBtn.style.display = 'inline-flex';
|
|
|
|
if (recordingTimerInterval) clearInterval(recordingTimerInterval);
|
|
recordingTimerInterval = setInterval(() => {
|
|
const timerEl = document.getElementById('rec-live-timer');
|
|
if (timerEl && recordingStartTime) {
|
|
const totalSec = Math.floor((Date.now() - recordingStartTime) / 1000);
|
|
const mins = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
|
const secs = String(totalSec % 60).padStart(2, '0');
|
|
timerEl.innerText = `(${mins}:${secs})`;
|
|
}
|
|
}, 1000);
|
|
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'info',
|
|
title: 'Composite Recording Started',
|
|
text: 'Recording composite layout (Candidate, Screen, and Panelists in HD).',
|
|
toast: true,
|
|
position: 'top-end',
|
|
timer: 3500,
|
|
showConfirmButton: false
|
|
});
|
|
}
|
|
} catch(e) {
|
|
if (recordingCompositor) {
|
|
recordingCompositor.stop();
|
|
recordingCompositor = null;
|
|
}
|
|
alert('Recording failed to initialize: ' + e.message);
|
|
}
|
|
}
|
|
|
|
export function stopCallRecording() {
|
|
if (recordingTimerInterval) {
|
|
clearInterval(recordingTimerInterval);
|
|
recordingTimerInterval = null;
|
|
}
|
|
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
|
|
adminMediaRecorder.stop();
|
|
}
|
|
if (recordingCompositor) {
|
|
recordingCompositor.stop();
|
|
recordingCompositor = null;
|
|
}
|
|
const recStartBtn = document.getElementById('btn-start-recording');
|
|
const recStopBtn = document.getElementById('btn-stop-recording');
|
|
if (recStartBtn) recStartBtn.style.display = 'inline-flex';
|
|
if (recStopBtn) recStopBtn.style.display = 'none';
|
|
}
|
|
|
|
export function uploadRecordedBlob(blob, ext = 'webm') {
|
|
if (!currentInterviewId && typeof document !== 'undefined') {
|
|
currentInterviewId = document.body?.dataset?.interviewId || window.interviewId;
|
|
}
|
|
if (!currentInterviewId) {
|
|
console.error('Cannot upload recording: missing interview ID');
|
|
return;
|
|
}
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
const formData = new FormData();
|
|
formData.append('video', blob, `interview_${currentInterviewId}_${Date.now()}.${ext}`);
|
|
formData.append('_token', csrfToken);
|
|
|
|
fetch(`/candidate/upload-recording/${currentInterviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'X-Requested-With': 'XMLHttpRequest'
|
|
},
|
|
body: formData
|
|
})
|
|
.then(async r => {
|
|
const contentType = r.headers.get('content-type') || '';
|
|
const isJson = contentType.includes('application/json');
|
|
if (!r.ok) {
|
|
const errText = await r.text();
|
|
let errMsg = `Upload failed (HTTP ${r.status})`;
|
|
if (isJson) {
|
|
try {
|
|
const errJson = JSON.parse(errText);
|
|
if (errJson && errJson.message) errMsg = errJson.message;
|
|
} catch(e) {}
|
|
}
|
|
throw new Error(errMsg);
|
|
}
|
|
if (isJson) {
|
|
return r.json();
|
|
}
|
|
const text = await r.text();
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch(e) {
|
|
return { success: true };
|
|
}
|
|
})
|
|
.then(res => {
|
|
if (res && res.success) {
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'success',
|
|
title: 'Recording Saved!',
|
|
text: 'Call recording saved successfully to server and candidate report.',
|
|
toast: true,
|
|
position: 'top-end',
|
|
timer: 3500,
|
|
showConfirmButton: false
|
|
});
|
|
}
|
|
pollReviewData();
|
|
} else if (res && res.message) {
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'warning',
|
|
title: 'Recording Notice',
|
|
text: res.message,
|
|
toast: true,
|
|
position: 'top-end',
|
|
timer: 4000,
|
|
showConfirmButton: false
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Recording upload error:', err);
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'error',
|
|
title: 'Recording Upload Failed',
|
|
text: err.message || 'Failed to save recording to server.',
|
|
toast: true,
|
|
position: 'top-end',
|
|
timer: 4500,
|
|
showConfirmButton: false
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
export function toggleInterviewerMic() {
|
|
if (!interviewerLocalStream) return;
|
|
const audioTracks = interviewerLocalStream.getAudioTracks();
|
|
if (audioTracks.length === 0) return;
|
|
|
|
isInterviewerMicOn = !isInterviewerMicOn;
|
|
audioTracks[0].enabled = isInterviewerMicOn;
|
|
|
|
const btn = document.getElementById('btn-toggle-interviewer-mic');
|
|
if (btn) {
|
|
const icon = btn.querySelector('i');
|
|
if (icon) {
|
|
icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
|
|
}
|
|
|
|
btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)';
|
|
|
|
if (isInterviewerMicOn) {
|
|
btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
|
|
btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
|
|
} else {
|
|
btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
|
|
btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
|
|
}
|
|
}
|
|
|
|
const selfMicIcon = document.getElementById('self-mic-status');
|
|
if (selfMicIcon) {
|
|
selfMicIcon.className = isInterviewerMicOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
|
|
selfMicIcon.title = isInterviewerMicOn ? 'Mic On' : 'Mic Muted';
|
|
}
|
|
|
|
sendInterviewerPeerHeartbeat();
|
|
}
|
|
|
|
export function toggleInterviewerCam() {
|
|
const selfPlaceholder = document.getElementById('self-video-placeholder');
|
|
const selfVid = document.getElementById('interviewer-self-video');
|
|
|
|
isInterviewerCamOn = !isInterviewerCamOn;
|
|
|
|
const btn = document.getElementById('btn-toggle-interviewer-cam');
|
|
if (btn) {
|
|
const icon = btn.querySelector('i');
|
|
if (icon) {
|
|
icon.className = isInterviewerCamOn ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
|
|
}
|
|
btn.title = isInterviewerCamOn ? 'Cam On (Click to Turn Off)' : 'Cam Off (Click to Turn On)';
|
|
|
|
if (isInterviewerCamOn) {
|
|
btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
|
|
btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
|
|
} else {
|
|
btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
|
|
btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
|
|
}
|
|
}
|
|
|
|
const selfCamIcon = document.getElementById('self-cam-status');
|
|
if (selfCamIcon) {
|
|
selfCamIcon.className = isInterviewerCamOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]';
|
|
selfCamIcon.title = isInterviewerCamOn ? 'Camera On' : 'Camera Off';
|
|
}
|
|
|
|
if (interviewerLocalStream) {
|
|
const videoTracks = interviewerLocalStream.getVideoTracks();
|
|
if (videoTracks.length > 0) {
|
|
videoTracks[0].enabled = isInterviewerCamOn;
|
|
}
|
|
} else if (isInterviewerCamOn) {
|
|
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
|
|
.then(stream => {
|
|
interviewerLocalStream = stream;
|
|
if (selfVid) {
|
|
selfVid.srcObject = stream;
|
|
selfVid.play().catch(() => {});
|
|
}
|
|
if (selfPlaceholder) selfPlaceholder.style.display = 'none';
|
|
})
|
|
.catch(() => {});
|
|
sendInterviewerPeerHeartbeat();
|
|
return;
|
|
}
|
|
|
|
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
|
|
sendInterviewerPeerHeartbeat();
|
|
}
|
|
|
|
export function closeReviewModal() {
|
|
if (sosPollInterval) clearInterval(sosPollInterval);
|
|
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
|
|
if (liveSyncChannel) {
|
|
try { liveSyncChannel.close(); } catch(e) {}
|
|
liveSyncChannel = null;
|
|
}
|
|
leaveInterviewerCall(false);
|
|
const modal = document.getElementById('review-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
}
|
|
|
|
export function sendSilentWarning() {
|
|
const input = document.getElementById('warning-input');
|
|
if (!input) return;
|
|
const msg = input.value.trim();
|
|
if (!msg || !currentInterviewId) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
input.value = '';
|
|
if (liveSyncChannel) {
|
|
liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'interviewer' });
|
|
}
|
|
|
|
fetch(`/interviews/${currentInterviewId}/warning`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ message: msg })
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
pollReviewData();
|
|
});
|
|
}
|
|
|
|
export function setCurrentInterviewId(id) {
|
|
currentInterviewId = id;
|
|
}
|
|
|
|
// Bind functions onto window object for template inline onclick handlers
|
|
if (typeof window !== 'undefined') {
|
|
window.CallRingtoneEngine = CallRingtoneEngine;
|
|
window.callRingtone = callRingtone;
|
|
window.getInitials = getInitials;
|
|
window.zoomScreen = zoomScreen;
|
|
window.resetZoomScreen = resetZoomScreen;
|
|
window.toggleFullscreenScreen = toggleFullscreenScreen;
|
|
window.zoomCandVideo = zoomCandVideo;
|
|
window.toggleSosAutoTrigger = toggleSosAutoTrigger;
|
|
window.openReportPage = openReportPage;
|
|
window.toggleTabScreenshotCapture = toggleTabScreenshotCapture;
|
|
window.updateScreenshotToggleBtnUI = updateScreenshotToggleBtnUI;
|
|
window.switchIdeTab = switchIdeTab;
|
|
window.triggerIncomingCallRing = triggerIncomingCallRing;
|
|
window.pollGlobalActiveCalls = pollGlobalActiveCalls;
|
|
window.acceptIncomingCall = acceptIncomingCall;
|
|
window.declineIncomingCall = declineIncomingCall;
|
|
window.subscribeLiveSync = subscribeLiveSync;
|
|
window.openReviewModal = openReviewModal;
|
|
window.sendInterviewerPeerHeartbeat = sendInterviewerPeerHeartbeat;
|
|
window.pollReviewData = pollReviewData;
|
|
window.openSosModal = openSosModal;
|
|
window.stopSosAlarm = stopSosAlarm;
|
|
window.disableSosAndStopAlarm = disableSosAndStopAlarm;
|
|
window.toggleSosAutoTrigger = toggleSosAutoTrigger;
|
|
window.interviewerLogoutAction = interviewerLogoutAction;
|
|
window.regenerateCandidatePassword = regenerateCandidatePassword;
|
|
window.blockCandidateAction = blockCandidateAction;
|
|
window.startInterviewerCall = startInterviewerCall;
|
|
window.leaveInterviewerCall = leaveInterviewerCall;
|
|
window.endCallForAll = endCallForAll;
|
|
window.startCallRecording = startCallRecording;
|
|
window.stopCallRecording = stopCallRecording;
|
|
window.toggleInterviewerMic = toggleInterviewerMic;
|
|
window.toggleInterviewerCam = toggleInterviewerCam;
|
|
window.closeReviewModal = closeReviewModal;
|
|
window.sendSilentWarning = sendSilentWarning;
|
|
window.setCurrentInterviewId = setCurrentInterviewId;
|
|
window.closeInspectorPanel = closeInspectorPanel;
|
|
|
|
// Attach listeners
|
|
['click', 'keydown', 'touchstart'].forEach(evt => {
|
|
document.addEventListener(evt, () => callRingtone.init(), { passive: true });
|
|
});
|
|
|
|
globalCallChannel = new BroadcastChannel('global_call_alerts');
|
|
globalCallChannel.onmessage = (event) => {
|
|
const data = event.data;
|
|
if (!data) return;
|
|
|
|
if (data.type === 'incoming_call') {
|
|
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
|
|
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
|
|
triggerIncomingCallRing({
|
|
id: data.interview_id,
|
|
candidate_name: data.candidate_name,
|
|
submission_unique_id: data.submission_unique_id,
|
|
starter_name: data.starter_name,
|
|
call_started_at: data.call_started_at || new Date().toISOString()
|
|
}, true);
|
|
} else if (data.type === 'call_ended') {
|
|
endedCallIds.add(data.interview_id);
|
|
dismissedCallIds.add(data.interview_id);
|
|
callRingtone.stop();
|
|
if (ringTimeoutTimer) {
|
|
clearTimeout(ringTimeoutTimer);
|
|
ringTimeoutTimer = null;
|
|
}
|
|
const modal = document.getElementById('incoming-call-modal');
|
|
if (modal) modal.classList.remove('active');
|
|
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
|
|
activeIncomingCall = null;
|
|
}
|
|
if (currentInterviewId && currentInterviewId == data.interview_id) {
|
|
updateCandidateStatusIcons(false, false, false);
|
|
leaveInterviewerCall(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
setInterval(pollGlobalActiveCalls, 6000);
|
|
}
|