sls/resources/js/candidate-room.js

1137 lines
46 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 latestCallStartedAt = null;
let hasCapturedCallStartScreenshot = false;
let candidateInterviewerTiles = new Map();
let latestCallStatus = 'idle';
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 (stream || latestCallStatus === 'active') {
latestCallStatus = 'active';
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';
}
}
}
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 async function acceptCandidateCall() {
const interviewId = document.body.dataset.interviewId;
if (latestCallStatus === 'ended') return;
candidateDeclinedOrLeftCall = false;
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); } catch(e) {}
}
if (callSigChannel && interviewId) {
callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: 'cand_' + interviewId });
}
if (pendingOffer) {
await handleInterviewerOffer(pendingOffer);
pendingOffer = null;
}
if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) {
hasCapturedCallStartScreenshot = true;
setTimeout(() => {
window.captureAndUploadTabScreenshot('call_start');
}, 1200);
}
}
export function declineCandidateCall() {}
export function candidateLeaveCall(isEndedByHost = false) {
isCandidateCallConnected = false;
candidateDeclinedOrLeftCall = true;
if (window.callRingtone) window.callRingtone.stop();
if (activeCandidateCallInstance) {
try { activeCandidateCallInstance.close(); } catch(e) {}
activeCandidateCallInstance = null;
}
if (candidatePeerConnection) {
try { candidatePeerConnection.close(); } catch(e) {}
candidatePeerConnection = null;
}
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() {
const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId || typeof window.Peer === 'undefined') return;
const iceServers = await initMeteredIceServers();
const peerId = getCandidatePeerId();
if (candidatePeerInstance) {
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) {
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';
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);
}
call.on('stream', remoteStream => {
console.log('[WebRTC Candidate] Received remote stream from interviewer:', call.peer);
addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist');
});
call.on('close', () => {
console.log('[WebRTC Candidate] Call closed by peer:', call.peer);
removeCandidateInterviewerTile(call.peer);
});
});
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) {
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) callSigChannel.postMessage({ type: 'candidate_ready' });
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: action
})
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (latestCallStatus !== 'active') return;
if (data && 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);
addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, nameLabel, isMicOn, isCamOn);
}
});
}
})
.catch(() => {});
if (callSigChannel) {
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 (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;
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 (!isCandidateCallConnected) acceptCandidateCall();
} 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;
if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) {
hasCapturedCallStartScreenshot = true;
setTimeout(() => {
window.captureAndUploadTabScreenshot('call_start');
}, 1200);
}
if (!isCandidateCallConnected && candidateLocalStream && !candidateDeclinedOrLeftCall) {
acceptCandidateCall();
}
} else if (data.call_status === 'ended') {
latestCallStatus = 'ended';
hasCapturedCallStartScreenshot = false;
isCandidateCallConnected = false;
candidateLeaveCall(true);
} else if (data.call_status === 'idle') {
latestCallStatus = 'idle';
hasCapturedCallStartScreenshot = false;
isCandidateCallConnected = false;
const badge = document.getElementById('call-status-badge');
if (badge && candidateInterviewerTiles.size === 0) {
badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
}
}
if (data.active_peers && Array.isArray(data.active_peers) && latestCallStatus !== 'ended') {
const activePeerIds = new Set(data.active_peers.map(p => p.peer_id));
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.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();
}
}