1492 lines
59 KiB
JavaScript
1492 lines
59 KiB
JavaScript
/**
|
|
* Candidate Assessment Room UI & Interactivity Engine
|
|
* Handles Monaco Editor setup & sync, Code Runner, Final Submission,
|
|
* Candidate Notepad, Live Drawing Board, Live Chat, and WebRTC Call Controls.
|
|
*/
|
|
|
|
import { safePlayMediaStream, getInitials } from './interview-call.js';
|
|
import { initMeteredIceServers } from './metered.js';
|
|
|
|
let editor = null;
|
|
let candidateLocalStream = null;
|
|
let candidatePeerInstance = null;
|
|
let candidatePeerConnection = null;
|
|
let liveSyncChannel = null;
|
|
let callSigChannel = null;
|
|
let globalCallChannel = null;
|
|
|
|
let isCandidateMicEnabled = true;
|
|
let isCandidateCamEnabled = true;
|
|
let activeCandidateCallInstance = null;
|
|
let isCandidateCallConnected = false;
|
|
let candidateDeclinedOrLeftCall = false;
|
|
let pendingOffer = null;
|
|
let candRingTimer = null;
|
|
let latestCallStartedAt = null;
|
|
let hasCapturedCallStartScreenshot = false;
|
|
let candidateInterviewerTiles = new Map();
|
|
let latestCallStatus = 'idle';
|
|
|
|
export class CandRingtoneEngine {
|
|
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) {}
|
|
}
|
|
|
|
start() {
|
|
this.init();
|
|
if (this.isPlaying) return;
|
|
this.isPlaying = true;
|
|
|
|
this.playTone();
|
|
if (this.interval) clearInterval(this.interval);
|
|
this.interval = setInterval(() => {
|
|
if (this.isPlaying) this.playTone();
|
|
}, 2400);
|
|
}
|
|
|
|
playTone() {
|
|
this.init();
|
|
if (!this.ctx || !this.isPlaying) return;
|
|
try {
|
|
const now = this.ctx.currentTime;
|
|
const osc1 = this.ctx.createOscillator();
|
|
const osc2 = this.ctx.createOscillator();
|
|
const gain = this.ctx.createGain();
|
|
|
|
osc1.type = 'sine';
|
|
osc2.type = 'sine';
|
|
|
|
osc1.frequency.setValueAtTime(523.25, now);
|
|
osc2.frequency.setValueAtTime(659.25, now);
|
|
|
|
gain.gain.setValueAtTime(0.001, now);
|
|
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
|
|
gain.gain.setValueAtTime(0.35, now + 1.1);
|
|
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
|
|
|
|
osc1.connect(gain);
|
|
osc2.connect(gain);
|
|
gain.connect(this.ctx.destination);
|
|
|
|
osc1.start(now);
|
|
osc2.start(now);
|
|
|
|
osc1.stop(now + 1.35);
|
|
osc2.stop(now + 1.35);
|
|
} catch (e) {}
|
|
}
|
|
|
|
stop() {
|
|
this.isPlaying = false;
|
|
if (this.interval) {
|
|
clearInterval(this.interval);
|
|
this.interval = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const candRingtone = new CandRingtoneEngine();
|
|
|
|
if (typeof document !== 'undefined') {
|
|
['click', 'keydown', 'touchstart'].forEach(evt => {
|
|
document.addEventListener(evt, () => candRingtone.init(), { passive: true });
|
|
});
|
|
}
|
|
|
|
let candidateHeartbeatIntervalTimer = null;
|
|
let notesTimeout = null;
|
|
|
|
let isDrawing = false;
|
|
let canvas, ctx;
|
|
let drawingSaveTimeout = null;
|
|
let lastDrawingSaveTime = 0;
|
|
|
|
const defaultCode = {
|
|
python: "# Python 3 Assessment Solution\ndef main():\n print(\"Hello from SingleLogin Assessment!\")\n num = 42\n print(f\"Computed Result: {num * 2}\")\n\nif __name__ == \"__main__\":\n main()",
|
|
cpp: "// C++ Assessment Solution\n#include <iostream>\nusing namespace std;\n\nint main() {\n cout << \"Hello from SingleLogin Assessment!\" << endl;\n return 0;\n}",
|
|
c: "// C Assessment Solution\n#include <stdio.h>\n\nint main() {\n printf(\"Hello from SingleLogin Assessment!\\n\");\n return 0;\n}",
|
|
java: "// Java Assessment Solution\npublic class Solution {\n public static void main(String[] args) {\n System.out.println(\"Hello from SingleLogin Assessment!\");\n }\n}",
|
|
php: "<\x3fphp\n// PHP / Laravel Assessment Solution\necho \"Hello from SingleLogin Assessment!\\n\";\n$data = [1, 2, 3, 4, 5];\necho \"Sum: \" . array_sum($data);",
|
|
javascript: "// JavaScript (Node.js) Solution\nconsole.log(\"Hello from SingleLogin Assessment!\");\nconst nums = [10, 20, 30];\nconsole.log(\"Total:\", nums.reduce((a, b) => a + b, 0));"
|
|
};
|
|
|
|
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 getCandidateLocalStream() {
|
|
return candidateLocalStream;
|
|
}
|
|
|
|
export function isCallConnected() {
|
|
return isCandidateCallConnected;
|
|
}
|
|
|
|
// --- Candidate Code Execution & Submission ---
|
|
export function runCode() {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const runStatus = document.getElementById('run-status');
|
|
const termOut = document.getElementById('terminal-output');
|
|
const langSelect = document.getElementById('language-select');
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
if (runStatus) runStatus.innerText = 'Running...';
|
|
if (termOut) termOut.innerText = 'Executing solution on code engine...';
|
|
|
|
const lang = langSelect ? langSelect.value : 'python';
|
|
const code = editor ? editor.getValue() : '';
|
|
|
|
fetch('/candidate/execute-code', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ language: lang, code: code, interview_id: interviewId })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (runStatus) runStatus.innerText = 'Ready';
|
|
const outStr = data.success ? data.output : ('Error: ' + data.output);
|
|
if (termOut) termOut.innerText = outStr;
|
|
|
|
fetch(`/candidate/sync-code/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ code: code, language: lang, output: outStr })
|
|
});
|
|
})
|
|
.catch(() => {
|
|
if (runStatus) runStatus.innerText = 'Error';
|
|
if (termOut) termOut.innerText = 'Failed to reach code runner service.';
|
|
});
|
|
}
|
|
|
|
export function submitSolution() {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const langSelect = document.getElementById('language-select');
|
|
const termOut = document.getElementById('terminal-output');
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
const code = editor ? editor.getValue() : '';
|
|
const lang = langSelect ? langSelect.value : 'python';
|
|
const output = termOut ? termOut.innerText : '';
|
|
|
|
fetch(`/candidate/submit-code/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ code: code, language: lang, output: output })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
alert('✓ ' + data.message);
|
|
});
|
|
}
|
|
|
|
export function candidateLogoutAction() {
|
|
const loginUrl = document.body.dataset.loginUrl || '/candidate/login';
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
title: 'Logout of Assessment?',
|
|
text: 'Are you sure you want to exit the Candidate Assessment Room?',
|
|
icon: 'warning',
|
|
showCancelButton: true,
|
|
confirmButtonColor: '#ef4444',
|
|
cancelButtonColor: '#475569',
|
|
confirmButtonText: 'Yes, Logout'
|
|
}).then((result) => {
|
|
if (result.isConfirmed) {
|
|
window.location.href = loginUrl;
|
|
}
|
|
});
|
|
} else {
|
|
if (confirm('Logout of Assessment?')) {
|
|
window.location.href = loginUrl;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Candidate Notepad & Canvas Drawing ---
|
|
export function toggleNotepadModal() {
|
|
const modal = document.getElementById('notepad-modal');
|
|
if (modal) modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex';
|
|
}
|
|
|
|
export function saveNotepadContent() {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const textarea = document.getElementById('notepad-textarea');
|
|
if (!textarea || !interviewId) return;
|
|
const notes = textarea.value;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
if (liveSyncChannel) {
|
|
liveSyncChannel.postMessage({ type: 'notes_sync', notes: notes });
|
|
}
|
|
|
|
clearTimeout(notesTimeout);
|
|
notesTimeout = setTimeout(() => {
|
|
fetch(`/candidate/notes/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ notes: notes })
|
|
});
|
|
}, 150);
|
|
}
|
|
|
|
export function toggleDrawingModal() {
|
|
const modal = document.getElementById('drawing-modal');
|
|
if (!modal) return;
|
|
modal.style.display = (modal.style.display === 'flex') ? 'none' : 'flex';
|
|
if (modal.style.display === 'flex' && !canvas) {
|
|
initCanvasDrawing();
|
|
}
|
|
}
|
|
|
|
export function initCanvasDrawing() {
|
|
canvas = document.getElementById('drawing-canvas');
|
|
if (!canvas) return;
|
|
ctx = canvas.getContext('2d');
|
|
ctx.lineWidth = 3;
|
|
ctx.lineCap = 'round';
|
|
ctx.strokeStyle = '#38bdf8';
|
|
|
|
let savedDrawing = null;
|
|
const drawScript = document.getElementById('candidate-drawing-data');
|
|
if (drawScript) {
|
|
try { savedDrawing = JSON.parse(drawScript.textContent); } catch(e) {}
|
|
}
|
|
if (savedDrawing) {
|
|
const img = new Image();
|
|
img.onload = () => ctx.drawImage(img, 0, 0);
|
|
img.src = savedDrawing;
|
|
}
|
|
|
|
canvas.addEventListener('mousedown', startDraw);
|
|
canvas.addEventListener('mousemove', draw);
|
|
canvas.addEventListener('mouseup', stopDraw);
|
|
canvas.addEventListener('mouseleave', stopDraw);
|
|
}
|
|
|
|
function startDraw(e) {
|
|
isDrawing = true;
|
|
ctx.beginPath();
|
|
ctx.moveTo(e.offsetX, e.offsetY);
|
|
}
|
|
|
|
function draw(e) {
|
|
if (!isDrawing) return;
|
|
const colorInput = document.getElementById('draw-color');
|
|
ctx.strokeStyle = colorInput ? colorInput.value : '#38bdf8';
|
|
ctx.lineTo(e.offsetX, e.offsetY);
|
|
ctx.stroke();
|
|
saveCanvasDrawing(false);
|
|
}
|
|
|
|
function stopDraw() {
|
|
if (isDrawing) {
|
|
isDrawing = false;
|
|
ctx.closePath();
|
|
saveCanvasDrawing(true);
|
|
}
|
|
}
|
|
|
|
export function clearCanvasDraw() {
|
|
if (ctx && canvas) {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
saveCanvasDrawing(true);
|
|
}
|
|
}
|
|
|
|
export function saveCanvasDrawing(force = false) {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
if (!canvas || !interviewId) return;
|
|
const now = Date.now();
|
|
const drawingData = canvas.toDataURL();
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
if (liveSyncChannel) {
|
|
liveSyncChannel.postMessage({ type: 'drawing_sync', drawing: drawingData });
|
|
}
|
|
|
|
if (force || (now - lastDrawingSaveTime >= 250)) {
|
|
clearTimeout(drawingSaveTimeout);
|
|
lastDrawingSaveTime = now;
|
|
fetch(`/candidate/drawing/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ drawing: drawingData })
|
|
});
|
|
} else {
|
|
clearTimeout(drawingSaveTimeout);
|
|
drawingSaveTimeout = setTimeout(() => {
|
|
saveCanvasDrawing(true);
|
|
}, 250);
|
|
}
|
|
}
|
|
|
|
// --- Candidate Chat Polling & Sending ---
|
|
export function pollCandidateChat(data) {
|
|
if (!data) return;
|
|
const chatHistory = document.getElementById('candidate-chat-history');
|
|
if (!chatHistory) return;
|
|
|
|
if (data.status === 'blocked' || data.is_expired) {
|
|
return;
|
|
}
|
|
|
|
if (data.warnings && Array.isArray(data.warnings)) {
|
|
if (data.warnings.length === 0) {
|
|
chatHistory.innerHTML = '<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>';
|
|
return;
|
|
}
|
|
|
|
let html = '';
|
|
data.warnings.forEach(w => {
|
|
const isInterviewer = w.sent_by !== null;
|
|
const senderName = isInterviewer ? (w.sender ? w.sender.name : 'Interviewer / Panelist') : 'You (Candidate)';
|
|
const badgeColor = isInterviewer ? '#6366f1' : '#38bdf8';
|
|
const bgColor = isInterviewer ? 'rgba(99,102,241,0.15)' : 'rgba(56,189,248,0.15)';
|
|
|
|
html += `<div style="margin-bottom:8px; background:${bgColor}; padding:8px 10px; border-radius:8px; border-left:3px solid ${badgeColor}; text-align: left;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
|
<strong style="color:${badgeColor}; font-size:0.75rem;">${senderName}</strong>
|
|
<span style="font-size:0.65rem; color:#94a3b8;">${new Date(w.created_at || Date.now()).toLocaleTimeString()}</span>
|
|
</div>
|
|
<div style="color: #ffffff !important; font-size:0.85rem; font-weight:500; line-height:1.4; word-break: break-word;">${w.message}</div>
|
|
</div>`;
|
|
});
|
|
chatHistory.innerHTML = html;
|
|
chatHistory.scrollTop = chatHistory.scrollHeight;
|
|
}
|
|
}
|
|
|
|
export function sendCandidateMessage() {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const input = document.getElementById('candidate-chat-input');
|
|
if (!input || !interviewId) return;
|
|
const msg = input.value.trim();
|
|
if (!msg) return;
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
input.value = '';
|
|
if (liveSyncChannel) {
|
|
liveSyncChannel.postMessage({ type: 'chat_message', message: msg, sender: 'candidate' });
|
|
}
|
|
|
|
fetch(`/candidate/send-message/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ message: msg })
|
|
})
|
|
.then(r => r.json())
|
|
.then(() => {
|
|
fetch(`/candidate/poll/${interviewId}`, {
|
|
headers: { 'Accept': 'application/json' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data) pollCandidateChat(data);
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Candidate WebRTC Calling Controls ---
|
|
export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interviewer Panelist', micOn = true, camOn = true) {
|
|
const micOnVal = isTrueVal(micOn);
|
|
const camOnVal = isTrueVal(camOn);
|
|
|
|
const grid = document.getElementById('interviewer-mesh-grid');
|
|
const placeholder = document.getElementById('interviewer-placeholder-box');
|
|
if (placeholder) placeholder.style.display = 'none';
|
|
|
|
let tile = document.getElementById('cand-iv-tile-' + peerId);
|
|
let videoElem;
|
|
const initials = getInitials(labelName);
|
|
|
|
if (!tile) {
|
|
tile = document.createElement('div');
|
|
tile.id = 'cand-iv-tile-' + peerId;
|
|
tile.className = 'webcam-box';
|
|
tile.style.position = 'relative';
|
|
tile.style.borderColor = '#6366f1';
|
|
|
|
tile.innerHTML = `
|
|
<video id="cand-iv-video-${peerId}" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video>
|
|
<div id="cand-iv-placeholder-${peerId}" style="position: absolute; inset: 0; display: ${camOnVal ? 'none' : 'flex'}; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;">
|
|
<div style="width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 800; color: white; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
|
|
${initials}
|
|
</div>
|
|
<div style="font-size: 0.8rem; font-weight: 700; color: white; margin-top: 6px;">${labelName}</div>
|
|
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 2px;">Camera Turned Off</div>
|
|
</div>
|
|
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.85); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10; display: flex; align-items: center; gap: 6px;">
|
|
<span>🎙️ ${labelName}</span>
|
|
<span style="opacity: 0.4;">|</span>
|
|
<i id="cand-iv-mic-${peerId}" class="${micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}" style="color: ${micOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${micOnVal ? 'Mic On' : 'Mic Muted'}"></i>
|
|
<i id="cand-iv-cam-${peerId}" class="${camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}" style="color: ${camOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${camOnVal ? 'Camera On' : 'Camera Off'}"></i>
|
|
</div>
|
|
`;
|
|
|
|
if (grid) grid.appendChild(tile);
|
|
videoElem = document.getElementById('cand-iv-video-' + peerId);
|
|
} else {
|
|
videoElem = document.getElementById('cand-iv-video-' + peerId);
|
|
const micIcon = document.getElementById('cand-iv-mic-' + peerId);
|
|
const camIcon = document.getElementById('cand-iv-cam-' + peerId);
|
|
const placeholderEl = document.getElementById('cand-iv-placeholder-' + peerId);
|
|
|
|
if (micIcon) {
|
|
micIcon.className = micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
|
|
micIcon.style.color = micOnVal ? '#34d399' : '#ef4444';
|
|
micIcon.title = micOnVal ? 'Mic On' : 'Mic Muted';
|
|
}
|
|
if (camIcon) {
|
|
camIcon.className = camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
|
|
camIcon.style.color = camOnVal ? '#34d399' : '#ef4444';
|
|
camIcon.title = camOnVal ? 'Camera On' : 'Camera Off';
|
|
}
|
|
if (placeholderEl) {
|
|
placeholderEl.style.display = camOnVal ? 'none' : 'flex';
|
|
}
|
|
}
|
|
|
|
if (videoElem && stream) {
|
|
safePlayMediaStream(videoElem, stream);
|
|
}
|
|
|
|
candidateInterviewerTiles.set(peerId, stream);
|
|
|
|
if (isCandidateCallConnected && stream) {
|
|
latestCallStatus = 'active';
|
|
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
|
badge.style.background = 'rgba(16,185,129,0.3)';
|
|
badge.style.color = '#34d399';
|
|
}
|
|
}
|
|
}
|
|
|
|
export function removeCandidateInterviewerTile(peerId) {
|
|
const stream = candidateInterviewerTiles.get(peerId);
|
|
if (stream && stream.active && latestCallStatus !== 'ended') {
|
|
return;
|
|
}
|
|
|
|
const tile = document.getElementById('cand-iv-tile-' + peerId);
|
|
if (tile) tile.remove();
|
|
candidateInterviewerTiles.delete(peerId);
|
|
|
|
if (candidateInterviewerTiles.size === 0) {
|
|
const placeholder = document.getElementById('interviewer-placeholder-box');
|
|
if (placeholder) placeholder.style.display = 'block';
|
|
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
if (latestCallStatus === 'ended') {
|
|
badge.innerText = 'CALL ENDED BY HOST';
|
|
badge.style.background = 'rgba(239,68,68,0.2)';
|
|
badge.style.color = '#fca5a5';
|
|
} else {
|
|
badge.innerText = 'READY (Waiting for Interviewer)';
|
|
badge.style.background = 'rgba(16,185,129,0.2)';
|
|
badge.style.color = '#34d399';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function getCallSessionKey(timestamp = null) {
|
|
const interviewId = document.body?.dataset?.interviewId || '';
|
|
return 'handled_call_' + interviewId + '_' + (timestamp || latestCallStartedAt || 'active');
|
|
}
|
|
|
|
export function triggerCandidateRing(offer = null, callStartedAt = null) {
|
|
if (isCandidateCallConnected) return;
|
|
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') return;
|
|
if (callStartedAt) latestCallStartedAt = callStartedAt;
|
|
|
|
const sessionKey = getCallSessionKey(callStartedAt);
|
|
const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled';
|
|
|
|
if (isHandled || candidateDeclinedOrLeftCall) {
|
|
showCandidateJoinOption();
|
|
return;
|
|
}
|
|
|
|
if (offer) pendingOffer = offer;
|
|
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
badge.innerText = '🔔 INCOMING CALL... RINGING';
|
|
badge.style.background = 'rgba(234,179,8,0.3)';
|
|
badge.style.color = '#fde047';
|
|
}
|
|
|
|
const modal = document.getElementById('candidate-incoming-modal');
|
|
if (modal) {
|
|
modal.style.opacity = '1';
|
|
modal.style.pointerEvents = 'auto';
|
|
}
|
|
candRingtone.start();
|
|
|
|
if (candRingTimer) clearTimeout(candRingTimer);
|
|
candRingTimer = setTimeout(() => {
|
|
stopCandidateRing();
|
|
}, 15000);
|
|
}
|
|
|
|
export function declineCandidateCall() {
|
|
candRingtone.stop();
|
|
if (candRingTimer) {
|
|
clearTimeout(candRingTimer);
|
|
candRingTimer = null;
|
|
}
|
|
const sessionKey = getCallSessionKey();
|
|
if (typeof sessionStorage !== 'undefined') {
|
|
sessionStorage.setItem(sessionKey, 'handled');
|
|
}
|
|
const modal = document.getElementById('candidate-incoming-modal');
|
|
if (modal) {
|
|
modal.style.opacity = '0';
|
|
modal.style.pointerEvents = 'none';
|
|
}
|
|
candidateDeclinedOrLeftCall = true;
|
|
showCandidateJoinOption();
|
|
}
|
|
|
|
export function stopCandidateRing() {
|
|
candRingtone.stop();
|
|
if (candRingTimer) {
|
|
clearTimeout(candRingTimer);
|
|
candRingTimer = null;
|
|
}
|
|
const sessionKey = getCallSessionKey();
|
|
if (typeof sessionStorage !== 'undefined') {
|
|
sessionStorage.setItem(sessionKey, 'handled');
|
|
}
|
|
const modal = document.getElementById('candidate-incoming-modal');
|
|
if (modal) {
|
|
modal.style.opacity = '0';
|
|
modal.style.pointerEvents = 'none';
|
|
}
|
|
candidateDeclinedOrLeftCall = true;
|
|
showCandidateJoinOption();
|
|
}
|
|
|
|
export function showCandidateJoinOption() {
|
|
if (isCandidateCallConnected) return;
|
|
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') {
|
|
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
|
if (joinBtn) {
|
|
joinBtn.style.display = 'none';
|
|
joinBtn.disabled = true;
|
|
}
|
|
return;
|
|
}
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
badge.innerText = '📞 CALL IN PROGRESS';
|
|
badge.style.background = 'rgba(59,130,246,0.3)';
|
|
badge.style.color = '#93c5fd';
|
|
}
|
|
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
|
if (joinBtn) {
|
|
joinBtn.style.display = 'block';
|
|
joinBtn.disabled = false;
|
|
joinBtn.innerText = '🟢 📞 Call in Progress • Join Call Now';
|
|
}
|
|
}
|
|
|
|
export async function acceptCandidateCall() {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const sessionKey = getCallSessionKey();
|
|
if (typeof sessionStorage !== 'undefined') {
|
|
sessionStorage.setItem(sessionKey, 'handled');
|
|
}
|
|
|
|
candidateDeclinedOrLeftCall = false;
|
|
candRingtone.stop();
|
|
if (candRingTimer) {
|
|
clearTimeout(candRingTimer);
|
|
candRingTimer = null;
|
|
}
|
|
const modal = document.getElementById('candidate-incoming-modal');
|
|
if (modal) {
|
|
modal.style.opacity = '0';
|
|
modal.style.pointerEvents = 'none';
|
|
}
|
|
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
|
|
|
if (latestCallStatus === 'ended' || latestCallStatus === 'idle') {
|
|
if (joinBtn) {
|
|
joinBtn.style.display = 'none';
|
|
joinBtn.disabled = true;
|
|
}
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'warning',
|
|
title: 'Call Ended',
|
|
text: 'The interview call has been ended by the host.',
|
|
toast: true,
|
|
position: 'top-end',
|
|
timer: 3000,
|
|
showConfirmButton: false
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (joinBtn) joinBtn.style.display = 'none';
|
|
|
|
if (!candidateLocalStream || candidateLocalStream.getVideoTracks().length === 0) {
|
|
await startCandidateCameraStream();
|
|
}
|
|
|
|
isCandidateCallConnected = true;
|
|
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
|
|
badge.style.background = 'rgba(16,185,129,0.3)';
|
|
badge.style.color = '#34d399';
|
|
}
|
|
|
|
const stream = candidateLocalStream || new MediaStream();
|
|
|
|
if (activeCandidateCallInstance) {
|
|
try {
|
|
activeCandidateCallInstance.answer(stream);
|
|
activeCandidateCallInstance.on('stream', remoteStream => {
|
|
addOrUpdateCandidateInterviewerTile(activeCandidateCallInstance.peer, remoteStream, 'Interviewer Panelist');
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
sendCandidatePeerHeartbeat('heartbeat');
|
|
|
|
if (callSigChannel && interviewId) {
|
|
callSigChannel.postMessage({
|
|
type: 'peer_joined',
|
|
peer_id: getCandidatePeerId(),
|
|
role: 'candidate',
|
|
candidate_name: document.body.dataset.candidateName || 'Candidate'
|
|
});
|
|
callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: 'cand_' + interviewId });
|
|
}
|
|
|
|
if (pendingOffer) {
|
|
await handleInterviewerOffer(pendingOffer);
|
|
pendingOffer = null;
|
|
}
|
|
|
|
if (candidatePeerInstance && candidatePeerInstance.open) {
|
|
candidateInterviewerTiles.forEach((existingStream, peerId) => {
|
|
if (!existingStream && !peerId.startsWith('cand_')) {
|
|
try {
|
|
const outCall = candidatePeerInstance.call(peerId, stream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
addOrUpdateCandidateInterviewerTile(peerId, remoteStream);
|
|
});
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) {
|
|
hasCapturedCallStartScreenshot = true;
|
|
setTimeout(() => {
|
|
window.captureAndUploadTabScreenshot('call_start');
|
|
}, 1200);
|
|
}
|
|
}
|
|
|
|
export function candidateLeaveCall(isEndedByHost = false) {
|
|
isCandidateCallConnected = false;
|
|
candidateDeclinedOrLeftCall = true;
|
|
|
|
candRingtone.stop();
|
|
if (candRingTimer) {
|
|
clearTimeout(candRingTimer);
|
|
candRingTimer = null;
|
|
}
|
|
const modal = document.getElementById('candidate-incoming-modal');
|
|
if (modal) {
|
|
modal.style.opacity = '0';
|
|
modal.style.pointerEvents = 'none';
|
|
}
|
|
|
|
if (activeCandidateCallInstance) {
|
|
try { activeCandidateCallInstance.close(); } catch(e) {}
|
|
activeCandidateCallInstance = null;
|
|
}
|
|
if (candidatePeerConnection) {
|
|
try { candidatePeerConnection.close(); } catch(e) {}
|
|
candidatePeerConnection = null;
|
|
}
|
|
|
|
sendCandidatePeerHeartbeat('leave');
|
|
|
|
const interviewId = document.body.dataset.interviewId;
|
|
if (callSigChannel && interviewId) {
|
|
callSigChannel.postMessage({
|
|
type: 'peer_left',
|
|
peer_id: getCandidatePeerId(),
|
|
role: 'candidate'
|
|
});
|
|
}
|
|
|
|
const remoteVideo = document.getElementById('interviewer-video-default');
|
|
if (remoteVideo) remoteVideo.srcObject = null;
|
|
const placeholder = document.getElementById('interviewer-video-placeholder');
|
|
if (placeholder) placeholder.style.display = 'flex';
|
|
|
|
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
|
const badge = document.getElementById('call-status-badge');
|
|
|
|
if (isEndedByHost || latestCallStatus === 'ended' || latestCallStatus === 'idle') {
|
|
latestCallStatus = 'ended';
|
|
if (joinBtn) {
|
|
joinBtn.style.display = 'none';
|
|
joinBtn.disabled = true;
|
|
}
|
|
if (badge) {
|
|
badge.innerText = 'CALL ENDED BY HOST';
|
|
badge.style.background = 'rgba(239,68,68,0.2)';
|
|
badge.style.color = '#fca5a5';
|
|
}
|
|
} else {
|
|
if (joinBtn) {
|
|
joinBtn.innerText = '🟢 📞 Rejoin Call Now';
|
|
joinBtn.style.display = 'block';
|
|
joinBtn.disabled = false;
|
|
}
|
|
if (badge) {
|
|
badge.innerText = 'CALL DISCONNECTED';
|
|
badge.style.background = 'rgba(239,68,68,0.2)';
|
|
badge.style.color = '#fca5a5';
|
|
}
|
|
}
|
|
|
|
if (typeof window.Swal !== 'undefined') {
|
|
window.Swal.fire({
|
|
icon: 'info',
|
|
title: isEndedByHost ? 'Call Ended' : 'Call Disconnected',
|
|
text: isEndedByHost ? 'The interviewer panel has ended the call session.' : 'You have left the call. Click "Rejoin Call Now" anytime to connect back while call is active.',
|
|
timer: 3000,
|
|
showConfirmButton: false,
|
|
toast: true,
|
|
position: 'top-end'
|
|
});
|
|
}
|
|
}
|
|
|
|
async function createCandidatePeerConnection() {
|
|
if (candidatePeerConnection) return candidatePeerConnection;
|
|
|
|
const iceServers = await initMeteredIceServers();
|
|
candidatePeerConnection = new RTCPeerConnection({ iceServers: iceServers });
|
|
|
|
if (candidateLocalStream) {
|
|
candidateLocalStream.getTracks().forEach(track => {
|
|
candidatePeerConnection.addTrack(track, candidateLocalStream);
|
|
});
|
|
}
|
|
|
|
candidatePeerConnection.ontrack = (event) => {
|
|
if (event.streams[0]) {
|
|
addOrUpdateCandidateInterviewerTile('default_pc', event.streams[0], 'Interviewer');
|
|
}
|
|
};
|
|
|
|
candidatePeerConnection.onicecandidate = (event) => {
|
|
if (event.candidate && callSigChannel) {
|
|
callSigChannel.postMessage({ type: 'ice-candidate', candidate: event.candidate, sender: 'candidate' });
|
|
}
|
|
};
|
|
|
|
return candidatePeerConnection;
|
|
}
|
|
|
|
async function handleInterviewerOffer(offer) {
|
|
const pc = await createCandidatePeerConnection();
|
|
await pc.setRemoteDescription(new RTCSessionDescription(offer));
|
|
const answer = await pc.createAnswer();
|
|
await pc.setLocalDescription(answer);
|
|
if (callSigChannel) {
|
|
callSigChannel.postMessage({ type: 'answer', answer: answer, sender: 'candidate' });
|
|
}
|
|
}
|
|
|
|
export function getCandidateSubmissionId() {
|
|
return document.body?.dataset?.submissionId || '';
|
|
}
|
|
|
|
export function getCandidatePeerId() {
|
|
const interviewId = document.body?.dataset?.interviewId || '';
|
|
const subId = getCandidateSubmissionId();
|
|
if (!interviewId) return '';
|
|
|
|
return (subId && subId !== String(interviewId))
|
|
? ('cand_' + interviewId + '_' + subId)
|
|
: ('cand_' + interviewId);
|
|
}
|
|
|
|
export async function initCandidatePeer(force = false) {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const candName = document.body.dataset.candidateName || 'Candidate';
|
|
if (!interviewId || typeof window.Peer === 'undefined') return;
|
|
|
|
if (!force && candidatePeerInstance && !candidatePeerInstance.destroyed && candidatePeerInstance.open) {
|
|
return candidatePeerInstance;
|
|
}
|
|
|
|
const iceServers = await initMeteredIceServers();
|
|
const peerId = getCandidatePeerId();
|
|
if (candidatePeerInstance && !candidatePeerInstance.destroyed) {
|
|
try { candidatePeerInstance.destroy(); } catch(e) {}
|
|
}
|
|
|
|
candidatePeerInstance = new window.Peer(peerId, {
|
|
config: { iceServers: iceServers }
|
|
});
|
|
|
|
candidatePeerInstance.on('open', id => {
|
|
console.log('[WebRTC Candidate] Registered PeerJS ID:', id);
|
|
sendCandidatePeerHeartbeat();
|
|
if (callSigChannel && isCandidateCallConnected) {
|
|
callSigChannel.postMessage({
|
|
type: 'peer_joined',
|
|
peer_id: id,
|
|
role: 'candidate',
|
|
candidate_name: candName
|
|
});
|
|
}
|
|
});
|
|
|
|
candidatePeerInstance.on('call', async call => {
|
|
console.log('[WebRTC Candidate] Incoming PeerJS call from:', call.peer);
|
|
activeCandidateCallInstance = call;
|
|
if (latestCallStatus !== 'ended') latestCallStatus = 'active';
|
|
|
|
call.on('stream', remoteStream => {
|
|
console.log('[WebRTC Candidate] Received remote stream from interviewer:', call.peer);
|
|
if (isCandidateCallConnected) {
|
|
addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist');
|
|
}
|
|
});
|
|
|
|
call.on('close', () => {
|
|
console.log('[WebRTC Candidate] Call closed by peer:', call.peer);
|
|
removeCandidateInterviewerTile(call.peer);
|
|
});
|
|
|
|
if (isCandidateCallConnected) {
|
|
if (!candidateLocalStream || candidateLocalStream.getTracks().length === 0) {
|
|
await startCandidateCameraStream();
|
|
}
|
|
const sendStream = candidateLocalStream || new MediaStream();
|
|
try { call.answer(sendStream); } catch(e) {
|
|
console.error('[WebRTC Candidate] Error answering call:', e);
|
|
}
|
|
} else {
|
|
const sessionKey = getCallSessionKey();
|
|
const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled';
|
|
if (isHandled || candidateDeclinedOrLeftCall) {
|
|
showCandidateJoinOption();
|
|
} else {
|
|
triggerCandidateRing();
|
|
}
|
|
}
|
|
});
|
|
|
|
candidatePeerInstance.on('error', async err => {
|
|
console.warn('[WebRTC Candidate] PeerJS error:', err);
|
|
if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') {
|
|
if (candidatePeerInstance) {
|
|
try { candidatePeerInstance.destroy(); } catch(e) {}
|
|
}
|
|
const iceServers = await initMeteredIceServers();
|
|
candidatePeerInstance = new window.Peer(peerId, {
|
|
config: { iceServers: iceServers }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
export function startCandidateCameraStream() {
|
|
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder');
|
|
const audioConstraints = { echoCancellation: true, noiseSuppression: true, autoGainControl: true };
|
|
|
|
if (window.beginNativePrompt) window.beginNativePrompt();
|
|
|
|
return navigator.mediaDevices.getUserMedia({
|
|
video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' },
|
|
audio: audioConstraints
|
|
})
|
|
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints }))
|
|
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: true }))
|
|
.catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true }))
|
|
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false }))
|
|
.then(stream => {
|
|
if (window.endNativePrompt) window.endNativePrompt();
|
|
candidateLocalStream = stream;
|
|
const vid = document.getElementById('webcam');
|
|
if (vid) {
|
|
vid.muted = true;
|
|
safePlayMediaStream(vid, stream, true);
|
|
}
|
|
if (avatarPlaceholder) avatarPlaceholder.style.display = 'none';
|
|
|
|
if (activeCandidateCallInstance && activeCandidateCallInstance.peerConnection && isCandidateCallConnected) {
|
|
stream.getTracks().forEach(track => {
|
|
const senders = activeCandidateCallInstance.peerConnection.getSenders();
|
|
const sender = senders.find(s => s.track && s.track.kind === track.kind);
|
|
if (sender) {
|
|
sender.replaceTrack(track).catch(() => {});
|
|
} else {
|
|
try { activeCandidateCallInstance.peerConnection.addTrack(track, stream); } catch(e) {}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (callSigChannel && isCandidateCallConnected) {
|
|
callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: getCandidatePeerId() });
|
|
}
|
|
initCandidatePeer();
|
|
return stream;
|
|
})
|
|
.catch(err => {
|
|
if (window.endNativePrompt) window.endNativePrompt();
|
|
console.log('Candidate media access error:', err);
|
|
if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex';
|
|
initCandidatePeer();
|
|
});
|
|
}
|
|
|
|
export function sendCandidatePeerHeartbeat(action = 'heartbeat') {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
const candName = document.body.dataset.candidateName || 'Candidate';
|
|
if (!interviewId) return;
|
|
const peerId = getCandidatePeerId();
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
|
|
fetch(`/candidate/peer-heartbeat/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({
|
|
peer_id: peerId,
|
|
name: candName,
|
|
role: 'candidate',
|
|
mic_on: Boolean(isCandidateMicEnabled),
|
|
cam_on: Boolean(isCandidateCamEnabled),
|
|
action: isCandidateCallConnected ? action : 'presence'
|
|
})
|
|
})
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(data => {
|
|
if (!data) return;
|
|
if (data.active_peers && Array.isArray(data.active_peers)) {
|
|
window.latestActivePeers = data.active_peers;
|
|
if (window.isScreenSharingActive && window.isScreenSharingActive() && window.broadcastCandidateScreenStream) {
|
|
const sStream = window.getScreenShareStream ? window.getScreenShareStream() : null;
|
|
if (sStream && sStream.active) {
|
|
window.broadcastCandidateScreenStream(sStream);
|
|
}
|
|
}
|
|
}
|
|
if (!isCandidateCallConnected || latestCallStatus !== 'active') return;
|
|
if (data.active_peers && Array.isArray(data.active_peers)) {
|
|
data.active_peers.forEach(p => {
|
|
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
|
|
const existingStream = candidateInterviewerTiles.get(p.peer_id) || null;
|
|
const nameLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name || 'Interviewer Panelist');
|
|
const isMicOn = isTrueVal(p.mic_on);
|
|
const isCamOn = isTrueVal(p.cam_on);
|
|
|
|
if (!existingStream && candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !p.peer_id.startsWith('cand_')) {
|
|
try {
|
|
const outCall = candidatePeerInstance.call(p.peer_id, candidateLocalStream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
addOrUpdateCandidateInterviewerTile(p.peer_id, remoteStream, nameLabel, isMicOn, isCamOn);
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
} else {
|
|
addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, nameLabel, isMicOn, isCamOn);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
|
|
if (callSigChannel && isCandidateCallConnected) {
|
|
callSigChannel.postMessage({
|
|
type: 'peer_state_changed',
|
|
peer_id: peerId,
|
|
role: 'candidate',
|
|
name: candName,
|
|
mic_on: Boolean(isCandidateMicEnabled),
|
|
cam_on: Boolean(isCandidateCamEnabled)
|
|
});
|
|
}
|
|
}
|
|
|
|
let candidateHeartbeatDebounceTimer = null;
|
|
function debouncedCandidatePeerHeartbeat() {
|
|
if (candidateHeartbeatDebounceTimer) clearTimeout(candidateHeartbeatDebounceTimer);
|
|
sendCandidatePeerHeartbeat();
|
|
candidateHeartbeatDebounceTimer = setTimeout(() => {
|
|
sendCandidatePeerHeartbeat();
|
|
}, 300);
|
|
}
|
|
|
|
export function toggleMic() {
|
|
if (!candidateLocalStream) return;
|
|
const audioTracks = candidateLocalStream.getAudioTracks();
|
|
if (audioTracks.length > 0) {
|
|
isCandidateMicEnabled = !isCandidateMicEnabled;
|
|
audioTracks[0].enabled = isCandidateMicEnabled;
|
|
const btn = document.getElementById('toggle-mic-btn');
|
|
if (btn) {
|
|
btn.innerText = isCandidateMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted';
|
|
btn.style.background = isCandidateMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
|
btn.style.borderColor = isCandidateMicEnabled ? '#10b981' : '#ef4444';
|
|
}
|
|
const icon = document.getElementById('cand-self-mic-icon');
|
|
if (icon) {
|
|
icon.className = isCandidateMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
|
|
icon.style.color = isCandidateMicEnabled ? '#34d399' : '#ef4444';
|
|
icon.title = isCandidateMicEnabled ? 'Mic On' : 'Mic Muted';
|
|
}
|
|
debouncedCandidatePeerHeartbeat();
|
|
}
|
|
}
|
|
|
|
export function toggleCam() {
|
|
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder');
|
|
if (candidateLocalStream) {
|
|
const videoTracks = candidateLocalStream.getVideoTracks();
|
|
if (videoTracks.length > 0) {
|
|
isCandidateCamEnabled = !isCandidateCamEnabled;
|
|
videoTracks[0].enabled = isCandidateCamEnabled;
|
|
const btn = document.getElementById('toggle-cam-btn');
|
|
if (btn) {
|
|
btn.innerText = isCandidateCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
|
|
btn.style.background = isCandidateCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
|
|
btn.style.borderColor = isCandidateCamEnabled ? '#10b981' : '#ef4444';
|
|
}
|
|
if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex';
|
|
}
|
|
} else {
|
|
isCandidateCamEnabled = !isCandidateCamEnabled;
|
|
if (avatarPlaceholder) avatarPlaceholder.style.display = isCandidateCamEnabled ? 'none' : 'flex';
|
|
}
|
|
const icon = document.getElementById('cand-self-cam-icon');
|
|
if (icon) {
|
|
icon.className = isCandidateCamEnabled ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
|
|
icon.style.color = isCandidateCamEnabled ? '#34d399' : '#ef4444';
|
|
icon.title = isCandidateCamEnabled ? 'Camera On' : 'Camera Off';
|
|
}
|
|
debouncedCandidatePeerHeartbeat();
|
|
}
|
|
|
|
// --- Monaco Editor Initialization ---
|
|
export function initCandidateMonacoEditor(interviewId) {
|
|
const container = document.getElementById('monaco-editor-container');
|
|
if (!container || typeof window.require === 'undefined') return;
|
|
|
|
window.require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } });
|
|
window.require(['vs/editor/editor.main'], function () {
|
|
const langSelect = document.getElementById('language-select');
|
|
const initialLang = (langSelect ? langSelect.value : (document.body.dataset.language || 'python')).toLowerCase();
|
|
|
|
let initialCode = '';
|
|
const codeScript = document.getElementById('candidate-submitted-code');
|
|
if (codeScript) {
|
|
try { initialCode = JSON.parse(codeScript.textContent); } catch(e) {}
|
|
}
|
|
if (!initialCode) initialCode = defaultCode[initialLang] || defaultCode.python;
|
|
|
|
editor = monaco.editor.create(container, {
|
|
value: initialCode,
|
|
language: initialLang === 'cpp' ? 'cpp' : (initialLang === 'c' ? 'c' : initialLang),
|
|
theme: 'vs-dark',
|
|
fontSize: 14,
|
|
fontFamily: 'Fira Code, monospace',
|
|
automaticLayout: true,
|
|
minimap: { enabled: true }
|
|
});
|
|
|
|
// Monaco Editor AI Hotkey Interception Hook
|
|
editor.onKeyDown(function (e) {
|
|
const isAlt = e.altKey;
|
|
const isCtrl = e.ctrlKey;
|
|
const isMeta = e.metaKey;
|
|
const isShift = e.shiftKey;
|
|
const code = e.browserEvent ? (e.browserEvent.code || '') : '';
|
|
|
|
if (isAlt || isMeta || (isCtrl && isShift) || code === 'F12') {
|
|
if (window.logViolation) {
|
|
window.logViolation('external_ai_detected', `candidate might use external ai: Intercepted AI hotkey shortcut in editor (${code || e.keyCode})`);
|
|
}
|
|
if (window.captureAndUploadTabScreenshot) {
|
|
window.captureAndUploadTabScreenshot('external_ai_detected');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Real-Time BroadcastChannel + Backend Code Sync
|
|
let codeSyncTimeout = null;
|
|
editor.onDidChangeModelContent(function () {
|
|
const code = editor.getValue();
|
|
const lang = document.getElementById('language-select')?.value || 'python';
|
|
|
|
if (liveSyncChannel) {
|
|
liveSyncChannel.postMessage({ type: 'code_sync', code: code, language: lang });
|
|
}
|
|
|
|
clearTimeout(codeSyncTimeout);
|
|
codeSyncTimeout = setTimeout(function () {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
|
fetch(`/candidate/sync-code/${interviewId}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken
|
|
},
|
|
body: JSON.stringify({ code: code, language: lang })
|
|
});
|
|
}, 200);
|
|
});
|
|
});
|
|
}
|
|
|
|
// --- Candidate Room Boot Function ---
|
|
export function initCandidateRoom() {
|
|
const interviewId = document.body.dataset.interviewId;
|
|
if (!interviewId) return;
|
|
|
|
latestCallStatus = document.body.dataset.callStatus || 'idle';
|
|
|
|
// BroadcastChannels
|
|
callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
|
|
liveSyncChannel = new BroadcastChannel('live_sync_' + interviewId);
|
|
|
|
liveSyncChannel.onmessage = function(event) {
|
|
if (event.data && event.data.type === 'chat_message' && typeof pollCandidateChat === 'function') {
|
|
pollCandidateChat();
|
|
}
|
|
};
|
|
|
|
callSigChannel.onmessage = async (event) => {
|
|
const msg = event.data;
|
|
if (!msg) return;
|
|
|
|
if (msg.type === 'interviewer_joined' || msg.type === 'offer') {
|
|
latestCallStatus = 'active';
|
|
if (!isCandidateCallConnected) {
|
|
const sessionKey = getCallSessionKey();
|
|
const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled';
|
|
if (isHandled || candidateDeclinedOrLeftCall) {
|
|
showCandidateJoinOption();
|
|
} else {
|
|
triggerCandidateRing(msg.offer || null);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') {
|
|
const peerIdToCall = msg.peer_id || msg.sender_peer_id;
|
|
if (peerIdToCall && peerIdToCall.startsWith('interviewer_')) {
|
|
const isMicOn = (msg.mic_on !== undefined) ? Boolean(msg.mic_on) : true;
|
|
const isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true;
|
|
|
|
if (isCandidateCallConnected) {
|
|
const existingStream = candidateInterviewerTiles.get(peerIdToCall) || null;
|
|
addOrUpdateCandidateInterviewerTile(peerIdToCall, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
|
|
|
|
if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !existingStream) {
|
|
try {
|
|
console.log('[WebRTC Candidate] Signal received, calling interviewer:', peerIdToCall);
|
|
const outCall = candidatePeerInstance.call(peerIdToCall, candidateLocalStream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
console.log('[WebRTC Candidate] Stream received via signal call:', peerIdToCall);
|
|
addOrUpdateCandidateInterviewerTile(peerIdToCall, remoteStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
}
|
|
}
|
|
} else if (msg.type === 'peer_left' || msg.type === 'interviewer_left') {
|
|
if (msg.peer_id) {
|
|
removeCandidateInterviewerTile(msg.peer_id);
|
|
}
|
|
} else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) {
|
|
await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
|
|
} else if (msg.type === 'ice-candidate' && msg.sender !== 'candidate' && candidatePeerConnection && msg.candidate) {
|
|
try {
|
|
await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
|
|
} catch(e) {}
|
|
} else if (msg.type === 'end_call_all') {
|
|
isCandidateCallConnected = false;
|
|
latestCallStatus = 'ended';
|
|
candidateInterviewerTiles.forEach((_, pid) => removeCandidateInterviewerTile(pid));
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
badge.innerText = 'CALL ENDED BY HOST';
|
|
badge.style.background = 'rgba(239,68,68,0.2)';
|
|
badge.style.color = '#fca5a5';
|
|
}
|
|
candidateLeaveCall(true);
|
|
}
|
|
};
|
|
|
|
globalCallChannel = new BroadcastChannel('global_call_alerts');
|
|
globalCallChannel.onmessage = (event) => {
|
|
const data = event.data;
|
|
if (!data) return;
|
|
|
|
if (data.type === 'incoming_call' && data.interview_id == interviewId) {
|
|
latestCallStatus = 'active';
|
|
if (data.call_started_at) latestCallStartedAt = data.call_started_at;
|
|
const sessionKey = getCallSessionKey(data.call_started_at);
|
|
const isHandled = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled';
|
|
if (!isCandidateCallConnected) {
|
|
if (isHandled || candidateDeclinedOrLeftCall) {
|
|
showCandidateJoinOption();
|
|
} else {
|
|
triggerCandidateRing(null, data.call_started_at);
|
|
}
|
|
}
|
|
} else if (data.type === 'call_ended' && data.interview_id == interviewId) {
|
|
isCandidateCallConnected = false;
|
|
latestCallStatus = 'ended';
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge) {
|
|
badge.innerText = 'CALL ENDED BY HOST';
|
|
badge.style.background = 'rgba(239,68,68,0.2)';
|
|
badge.style.color = '#fca5a5';
|
|
}
|
|
candidateLeaveCall(true);
|
|
}
|
|
};
|
|
|
|
// Monaco Editor
|
|
initCandidateMonacoEditor(interviewId);
|
|
|
|
// Language Select Listener
|
|
const langSelect = document.getElementById('language-select');
|
|
if (langSelect) {
|
|
langSelect.addEventListener('change', function () {
|
|
const lang = this.value;
|
|
const monacoLang = lang === 'cpp' ? 'cpp' : (lang === 'c' ? 'c' : lang);
|
|
if (editor) {
|
|
monaco.editor.setModelLanguage(editor.getModel(), monacoLang);
|
|
if (!editor.getValue() || editor.getValue() === defaultCode[lang]) {
|
|
editor.setValue(defaultCode[lang] || '');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Start Candidate Camera
|
|
startCandidateCameraStream();
|
|
|
|
// Heartbeat & Polling
|
|
if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer);
|
|
sendCandidatePeerHeartbeat();
|
|
candidateHeartbeatIntervalTimer = setInterval(() => {
|
|
sendCandidatePeerHeartbeat();
|
|
}, 6000);
|
|
|
|
setInterval(() => {
|
|
if (!interviewId) return;
|
|
|
|
fetch('/candidate/poll/' + interviewId, {
|
|
headers: { 'Accept': 'application/json' }
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (!data) return;
|
|
|
|
pollCandidateChat(data);
|
|
|
|
if (data.call_status === 'active' && latestCallStatus !== 'ended') {
|
|
latestCallStatus = 'active';
|
|
if (data.call_started_at) latestCallStartedAt = data.call_started_at;
|
|
|
|
const sessionKey = getCallSessionKey(data.call_started_at);
|
|
const isHandledInSession = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled';
|
|
|
|
if (!isCandidateCallConnected) {
|
|
if (isHandledInSession || candidateDeclinedOrLeftCall) {
|
|
showCandidateJoinOption();
|
|
} else {
|
|
triggerCandidateRing(null, data.call_started_at);
|
|
}
|
|
}
|
|
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
|
|
latestCallStatus = data.call_status;
|
|
hasCapturedCallStartScreenshot = false;
|
|
candidateDeclinedOrLeftCall = false;
|
|
candRingtone.stop();
|
|
if (candRingTimer) clearTimeout(candRingTimer);
|
|
isCandidateCallConnected = false;
|
|
|
|
// Clear session storage handled keys for ended/idle call sessions
|
|
if (typeof sessionStorage !== 'undefined') {
|
|
for (let i = sessionStorage.length - 1; i >= 0; i--) {
|
|
const key = sessionStorage.key(i);
|
|
if (key && key.startsWith('handled_call_' + interviewId)) {
|
|
sessionStorage.removeItem(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
const modal = document.getElementById('candidate-incoming-modal');
|
|
if (modal) {
|
|
modal.style.opacity = '0';
|
|
modal.style.pointerEvents = 'none';
|
|
}
|
|
const joinBtn = document.getElementById('candidate-join-active-call-btn');
|
|
if (joinBtn) {
|
|
joinBtn.style.display = 'none';
|
|
joinBtn.disabled = true;
|
|
}
|
|
const badge = document.getElementById('call-status-badge');
|
|
if (badge && candidateInterviewerTiles.size === 0) {
|
|
badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY';
|
|
badge.style.background = (data.call_status === 'ended') ? 'rgba(239,68,68,0.2)' : 'rgba(16,185,129,0.2)';
|
|
badge.style.color = (data.call_status === 'ended') ? '#fca5a5' : '#34d399';
|
|
}
|
|
}
|
|
|
|
if (data.active_peers && Array.isArray(data.active_peers) && latestCallStatus !== 'ended') {
|
|
window.latestActivePeers = data.active_peers;
|
|
if (window.isScreenSharingActive && window.isScreenSharingActive() && window.broadcastCandidateScreenStream) {
|
|
const sStream = window.getScreenShareStream ? window.getScreenShareStream() : null;
|
|
if (sStream && sStream.active) {
|
|
window.broadcastCandidateScreenStream(sStream);
|
|
}
|
|
}
|
|
const activePeerIds = new Set(data.active_peers.map(p => p.peer_id));
|
|
|
|
if (isCandidateCallConnected) {
|
|
data.active_peers.forEach(p => {
|
|
if (p.peer_id && p.peer_id.startsWith('interviewer_')) {
|
|
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Interviewer ' + (p.name || ''));
|
|
const isMicOn = isTrueVal(p.mic_on);
|
|
const isCamOn = isTrueVal(p.cam_on);
|
|
|
|
if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open) {
|
|
if (!candidateInterviewerTiles.has(p.peer_id)) {
|
|
try {
|
|
const outCall = candidatePeerInstance.call(p.peer_id, candidateLocalStream);
|
|
if (outCall) {
|
|
outCall.on('stream', remoteStream => {
|
|
addOrUpdateCandidateInterviewerTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
} else {
|
|
const existingStream = candidateInterviewerTiles.get(p.peer_id);
|
|
addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, roleLabel, isMicOn, isCamOn);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
candidateInterviewerTiles.forEach((_, pid) => {
|
|
if (!activePeerIds.has(pid)) {
|
|
removeCandidateInterviewerTile(pid);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
})
|
|
.catch(() => {});
|
|
}, 6000);
|
|
}
|
|
|
|
// Window Bindings
|
|
if (typeof window !== 'undefined') {
|
|
window.runCode = runCode;
|
|
window.submitSolution = submitSolution;
|
|
window.candidateLogoutAction = candidateLogoutAction;
|
|
window.toggleNotepadModal = toggleNotepadModal;
|
|
window.saveNotepadContent = saveNotepadContent;
|
|
window.toggleDrawingModal = toggleDrawingModal;
|
|
window.initCanvasDrawing = initCanvasDrawing;
|
|
window.clearCanvasDraw = clearCanvasDraw;
|
|
window.saveCanvasDrawing = saveCanvasDrawing;
|
|
window.acceptCandidateCall = acceptCandidateCall;
|
|
window.declineCandidateCall = declineCandidateCall;
|
|
window.candidateLeaveCall = candidateLeaveCall;
|
|
window.triggerCandidateRing = triggerCandidateRing;
|
|
window.stopCandidateRing = stopCandidateRing;
|
|
window.showCandidateJoinOption = showCandidateJoinOption;
|
|
window.getCallSessionKey = getCallSessionKey;
|
|
window.candRingtone = candRingtone;
|
|
window.toggleMic = toggleMic;
|
|
window.toggleCam = toggleCam;
|
|
window.sendCandidateMessage = sendCandidateMessage;
|
|
window.pollCandidateChat = pollCandidateChat;
|
|
window.initCandidateRoom = initCandidateRoom;
|
|
window.getCandidateLocalStream = getCandidateLocalStream;
|
|
window.isCallConnected = isCallConnected;
|
|
|
|
const bootCandidate = () => {
|
|
if (document.body && document.body.dataset.interviewId && document.getElementById('monaco-editor-container')) {
|
|
initCandidateRoom();
|
|
}
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', bootCandidate);
|
|
} else {
|
|
bootCandidate();
|
|
}
|
|
}
|