/** * 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 \nusing namespace std;\n\nint main() {\n cout << \"Hello from SingleLogin Assessment!\" << endl;\n return 0;\n}", c: "// C Assessment Solution\n#include \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 = '
No messages yet. Send a message below.
'; 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 += `
${senderName} ${new Date(w.created_at || Date.now()).toLocaleTimeString()}
${w.message}
`; }); 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 = `
${initials}
${labelName}
Camera Turned Off
🎙️ ${labelName} |
`; 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(); } }