diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index c8a404e..de5de06 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -8,6 +8,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; @@ -173,41 +174,46 @@ public function executeCode(Request $request) $code = $request->code; $output = ''; - // 1. Ultra-Fast Piston Code Execution API (sub-second for C, C++, Java, JS, Python, PHP, Go, Rust) - $pistonLangMap = [ - 'python' => 'python', - 'py' => 'python', - 'javascript' => 'javascript', - 'js' => 'javascript', - 'c' => 'c', - 'cpp' => 'c++', - 'c++' => 'c++', - 'java' => 'java', - 'php' => 'php', - 'laravel' => 'php', - 'go' => 'go', - 'rust' => 'rust', + // 1. Ultra-Fast Public Judge0 CE Code Execution API (no auth needed) + $judge0LangMap = [ + 'python' => 71, // Python 3 + 'py' => 71, + 'javascript' => 63, // JavaScript (Node.js) + 'js' => 63, + 'c' => 50, // C (GCC) + 'cpp' => 54, // C++ (GCC) + 'c++' => 54, + 'java' => 62, // Java (OpenJDK) + 'php' => 68, // PHP + 'laravel' => 68, + 'go' => 60, // Go + 'rust' => 73, // Rust ]; - - if (isset($pistonLangMap[$langKey])) { + if (isset($judge0LangMap[$langKey])) { try { - $pistonLang = $pistonLangMap[$langKey]; - $response = Http::timeout(4)->post('https://emkc.org/api/v2/piston/execute', [ - 'language' => $pistonLang, - 'version' => '*', - 'files' => [ - ['content' => $code] - ] + $judge0LangId = $judge0LangMap[$langKey]; + $response = Http::timeout(6)->post('https://ce.judge0.com/submissions?base64_encoded=false&wait=true', [ + 'language_id' => $judge0LangId, + 'source_code' => $code, ]); if ($response->successful()) { $resData = $response->json(); - $output = $resData['run']['output'] ?? ($resData['run']['stdout'] ?? ''); - if (empty($output) && !empty($resData['run']['stderr'])) { - $output = $resData['run']['stderr']; + $compileOutput = $resData['compile_output'] ?? ''; + $stdout = $resData['stdout'] ?? ''; + $stderr = $resData['stderr'] ?? ''; + + if (!empty($compileOutput)) { + $output = $compileOutput; + } elseif (!empty($stdout)) { + $output = $stdout; + } elseif (!empty($stderr)) { + $output = $stderr; } } - } catch (\Exception $e) {} + } catch (\Exception $e) { + Log::warning("Judge0 Code execute failed.", [$e->getMessage()]); + } } // 2. Instant Local Process Runner Fallback @@ -742,7 +748,7 @@ public function registerPeerHeartbeat(Request $request, $id) } } - if ($action !== 'leave') { + if ($action === 'heartbeat') { $micOn = $request->has('mic_on') ? filter_var($request->input('mic_on'), FILTER_VALIDATE_BOOLEAN) : true; $camOn = $request->has('cam_on') ? filter_var($request->input('cam_on'), FILTER_VALIDATE_BOOLEAN) : true; diff --git a/resources/js/candidate-proctor.js b/resources/js/candidate-proctor.js index 6ef984e..4791d6b 100644 --- a/resources/js/candidate-proctor.js +++ b/resources/js/candidate-proctor.js @@ -60,6 +60,27 @@ export function isFocusSuppressed() { return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil; } +export function isCandidateAssessmentPage() { + if (typeof window === 'undefined' || typeof document === 'undefined') return false; + const isCandidateUrl = window.location.pathname.includes('/candidate/'); + const hasMonaco = Boolean(document.getElementById('monaco-editor-container')); + const isInterviewerPage = Boolean(document.getElementById('interviewer-cand-video') || document.getElementById('interviewer-self-video') || document.getElementById('panelist-mesh-grid')); + + return (isCandidateUrl || hasMonaco) && !isInterviewerPage; +} + +export function isCandidateCallActive() { + if (!isCandidateAssessmentPage()) return false; + if (typeof window !== 'undefined' && typeof window.isCallConnected === 'function') { + return window.isCallConnected(); + } + const callStatusBadge = document.getElementById('call-status-badge'); + if (callStatusBadge) { + return callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED'); + } + return false; +} + // --- Structured Logging & Backend Reporting --- class StructuredEventLogger { constructor(sessionId) { @@ -87,6 +108,7 @@ class StructuredEventLogger { } sendToServer(event) { + if (!isCandidateAssessmentPage()) return; const interviewId = this.sessionId || document.body.dataset.interviewId; if (!interviewId) return; @@ -147,6 +169,7 @@ class StructuredEventLogger { } export function logViolation(type, details, confidence = 1.0, snapshot = null) { + if (!isCandidateAssessmentPage()) return; if (window.candidateProctoring && window.candidateProctoring.logger) { window.candidateProctoring.logger.record(type, confidence, details, snapshot); } else { @@ -204,10 +227,7 @@ function getScreenShareVideoElement() { } export function captureAndUploadTabScreenshot(reason = 'tab_switch') { - if (!isTabSwitchScreenshotEnabled) return; - const callStatusBadge = document.getElementById('call-status-badge'); - const isCallActive = callStatusBadge ? callStatusBadge.textContent.includes('LIVE') || callStatusBadge.textContent.includes('CONNECTED') : true; - if (!isCallActive) return; + if (!isTabSwitchScreenshotEnabled || !isCandidateAssessmentPage()) return; if (reason !== 'tab_switch' && reason !== 'call_start' && reason !== 'external_ai_detected' && reason !== 'copy_event') return; try { @@ -409,6 +429,88 @@ export function showFullscreenRequiredModal() { } } +let candidateScreenPeerInstance = null; +const activeScreenCallTargets = new Set(); + +export function broadcastCandidateScreenStream(stream) { + if (!stream || !stream.active) return; + const videoTracks = stream.getVideoTracks(); + if (!videoTracks || videoTracks.length === 0 || videoTracks[0].readyState !== 'live') return; + + const interviewId = document.body?.dataset?.interviewId; + const submissionId = document.body?.dataset?.submissionId || interviewId; + if (!interviewId || typeof window.Peer === 'undefined') return; + + const subIdPart = (submissionId && submissionId !== String(interviewId)) ? ('_' + submissionId) : ''; + const legacyScreenPeerId = 'interviewer_screen_' + interviewId + subIdPart; + + const targetPeerIds = new Set(); + + // Collect dedicated screen receiver targets from active interviewers + if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) { + window.latestActivePeers.forEach(p => { + if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { + const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_'); + targetPeerIds.add(screenTargetId); + } + }); + } + + if (targetPeerIds.size === 0) { + targetPeerIds.add(legacyScreenPeerId); + } + + const makeCalls = (peer) => { + targetPeerIds.forEach(targetId => { + if (activeScreenCallTargets.has(targetId)) return; + + try { + console.log('[WebRTC Screen] Broadcasting screen to interviewer target:', targetId); + const outCall = peer.call(targetId, stream); + if (outCall) { + activeScreenCallTargets.add(targetId); + outCall.on('close', () => { + console.log('[WebRTC Screen] Screen call closed for target:', targetId); + activeScreenCallTargets.delete(targetId); + }); + outCall.on('error', (err) => { + console.warn('[WebRTC Screen] Screen call error for target:', targetId, err); + activeScreenCallTargets.delete(targetId); + }); + } + } catch(e) { + activeScreenCallTargets.delete(targetId); + } + }); + }; + + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed && candidateScreenPeerInstance.open) { + makeCalls(candidateScreenPeerInstance); + } else { + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { + try { candidateScreenPeerInstance.destroy(); } catch(e) {} + } + activeScreenCallTargets.clear(); + const screenPeerId = 'cand_screen_' + interviewId + subIdPart + '_' + Date.now(); + initMeteredIceServers().then(iceServers => { + candidateScreenPeerInstance = new window.Peer(screenPeerId, { + config: { iceServers: iceServers } + }); + candidateScreenPeerInstance.on('open', () => { + makeCalls(candidateScreenPeerInstance); + }); + candidateScreenPeerInstance.on('error', err => { + console.warn('[WebRTC Screen] Candidate screen peer error:', err); + }); + }).catch(() => { + candidateScreenPeerInstance = new window.Peer(screenPeerId); + candidateScreenPeerInstance.on('open', () => { + makeCalls(candidateScreenPeerInstance); + }); + }); + } +} + // --- Screen Share Enforcement & Live Overlay Detector (Category 6) --- export function startScreenShare() { if (!navigator.mediaDevices || !navigator.mediaDevices.getDisplayMedia) { @@ -462,34 +564,15 @@ export function startScreenShare() { enforceBrowserFullscreen(); - const interviewId = document.body.dataset.interviewId; - const submissionId = document.body.dataset.submissionId || interviewId; - if (typeof window.Peer !== 'undefined' && interviewId) { - const targetInterviewerScreenPeerId = (submissionId && submissionId !== String(interviewId)) - ? ('interviewer_screen_' + interviewId + '_' + submissionId) - : ('interviewer_screen_' + interviewId); - - const screenPeerId = (submissionId && submissionId !== String(interviewId)) - ? ('cand_screen_' + interviewId + '_' + submissionId + '_' + Date.now()) - : ('cand_screen_' + interviewId + '_' + Date.now()); - - initMeteredIceServers().then(iceServers => { - const screenPeer = new window.Peer(screenPeerId, { - config: { iceServers: iceServers } - }); - screenPeer.on('open', () => { - screenPeer.call(targetInterviewerScreenPeerId, stream); - }); - }).catch(() => { - const screenPeer = new window.Peer(screenPeerId); - screenPeer.on('open', () => { - screenPeer.call(targetInterviewerScreenPeerId, stream); - }); - }); - } + broadcastCandidateScreenStream(stream); track.onended = () => { screenShareStream = null; + activeScreenCallTargets.clear(); + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { + try { candidateScreenPeerInstance.destroy(); } catch(e) {} + candidateScreenPeerInstance = null; + } updateScreenShareButton(false); if (document.fullscreenElement) { @@ -505,6 +588,7 @@ export function startScreenShare() { endNativePrompt(); console.log('Screen sharing cancelled/denied:', err); screenShareStream = null; + activeScreenCallTargets.clear(); updateScreenShareButton(false); }); } @@ -516,6 +600,11 @@ export function stopScreenShare() { } catch(e) {} screenShareStream = null; } + activeScreenCallTargets.clear(); + if (candidateScreenPeerInstance && !candidateScreenPeerInstance.destroyed) { + try { candidateScreenPeerInstance.destroy(); } catch(e) {} + candidateScreenPeerInstance = null; + } updateScreenShareButton(false); if (document.fullscreenElement) { @@ -550,6 +639,7 @@ export function updateScreenShareButton(active) { } export function initLiveOverlayDetector() { + if (!isCandidateAssessmentPage()) return; if (overlayCheckInterval) clearInterval(overlayCheckInterval); overlayCheckInterval = setInterval(() => { @@ -575,6 +665,7 @@ export function initLiveOverlayDetector() { // --- Continuous Speech Recognition (Category 5) --- export function initQuestionRepeatDetection() { + if (!isCandidateAssessmentPage()) return; const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) return; @@ -641,9 +732,11 @@ export function initQuestionRepeatDetection() { // --- Candidate Proctoring Event Listeners (Tab focus, Clipboard, Hotkeys, DOM Observer, PiP, Fullscreen) --- export function initCandidateProctoringListeners() { + if (!isCandidateAssessmentPage()) return; + // 1. Visibility Change (Tab Switched / Window Minimized) document.addEventListener('visibilitychange', function () { - if (isFocusSuppressed()) return; + if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; if (document.hidden) { const now = Date.now(); if (now - lastTabSwitchTime > 1000) { @@ -656,7 +749,7 @@ export function initCandidateProctoringListeners() { // 2. Window Blur (Assessment Window Lost Focus) window.addEventListener('blur', function () { - if (isFocusSuppressed()) return; + if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; const now = Date.now(); if (now - lastTabSwitchTime > 1000) { lastTabSwitchTime = now; @@ -667,7 +760,7 @@ export function initCandidateProctoringListeners() { // 3. Mouse Viewport Departure document.addEventListener('mouseleave', function () { - if (isFocusSuppressed()) return; + if (!isCandidateAssessmentPage() || isFocusSuppressed()) return; const now = Date.now(); if (now - lastTabSwitchTime > 2500) { lastTabSwitchTime = now; @@ -678,16 +771,19 @@ export function initCandidateProctoringListeners() { // 4. Clipboard Events (Paste into Editor / Copy from Assessment Window) document.addEventListener('paste', function () { + if (!isCandidateAssessmentPage()) return; logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)'); }, true); document.addEventListener('copy', function () { + if (!isCandidateAssessmentPage()) return; logViolation('copy_event', 'Candidate copied text from assessment window (possible Parakeet AI prompt ingestion)'); captureAndUploadTabScreenshot(); }); // 5. Global Keydown AI Hotkey Interception (Capture Phase) window.addEventListener('keydown', function (e) { + if (!isCandidateAssessmentPage()) return; if (e.code === 'Tab' || e.key === 'Tab') return; const key = (e.key || '').toLowerCase(); @@ -713,6 +809,7 @@ export function initCandidateProctoringListeners() { // 6. Injected Extension DOM Observer try { const aiMutationObserver = new MutationObserver(mutations => { + if (!isCandidateAssessmentPage()) return; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType === 1) { @@ -730,6 +827,7 @@ export function initCandidateProctoringListeners() { // 7. Picture-in-Picture Floating AI Window Detector setInterval(function() { + if (!isCandidateAssessmentPage()) return; if (document.pictureInPictureElement) { logViolation('external_ai_detected', 'candidate might use external ai: External Picture-in-Picture floating AI window active'); } @@ -737,7 +835,7 @@ export function initCandidateProctoringListeners() { // 8. Fullscreen Exit Listener (Only enforced when screen sharing is active) document.addEventListener('fullscreenchange', function() { - if (isFocusSuppressed() || !isScreenSharingActive()) return; + if (!isCandidateAssessmentPage() || isFocusSuppressed() || !isScreenSharingActive()) return; if (!document.fullscreenElement) { logViolation('tab_switch', 'Candidate exited full-screen proctoring mode while screen sharing'); captureAndUploadTabScreenshot(); @@ -1301,11 +1399,13 @@ class CandidateProctoring { // --- Candidate Proctoring Auto-Boot & Listener Registration --- function bootCandidateProctoring() { - const video = document.getElementById('webcam'); - const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas'); + if (!isCandidateAssessmentPage()) return; const sessionId = document.body.dataset.interviewId; if (!sessionId) return; + const video = document.getElementById('webcam'); + const canvas = document.getElementById('proctor-canvas') || document.createElement('canvas'); + // Boot MediaPipe proctoring if webcam element exists if (video && navigator.mediaDevices) { const proctor = new CandidateProctoring({ sessionId, video, canvas }); @@ -1346,6 +1446,10 @@ if (typeof window !== 'undefined') { window.initLiveOverlayDetector = initLiveOverlayDetector; window.initQuestionRepeatDetection = initQuestionRepeatDetection; window.initCandidateProctoringListeners = initCandidateProctoringListeners; + window.isCandidateAssessmentPage = isCandidateAssessmentPage; + window.isCandidateCallActive = isCandidateCallActive; + window.broadcastCandidateScreenStream = broadcastCandidateScreenStream; + window.getScreenShareStream = () => screenShareStream; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', bootCandidateProctoring, { once: true }); diff --git a/resources/js/candidate-room.js b/resources/js/candidate-room.js index 39e93d5..7731286 100644 --- a/resources/js/candidate-room.js +++ b/resources/js/candidate-room.js @@ -21,11 +21,92 @@ 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; @@ -406,9 +487,8 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = candidateInterviewerTiles.set(peerId, stream); - if (stream || latestCallStatus === 'active') { + if (isCandidateCallConnected && stream) { latestCallStatus = 'active'; - isCandidateCallConnected = true; const badge = document.getElementById('call-status-badge'); if (badge) { @@ -448,10 +528,148 @@ export function removeCandidateInterviewerTile(peerId) { } } +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; - if (latestCallStatus === 'ended') return; + 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(); @@ -469,10 +687,23 @@ export async function acceptCandidateCall() { const stream = candidateLocalStream || new MediaStream(); if (activeCandidateCallInstance) { - try { activeCandidateCallInstance.answer(stream); } catch(e) {} + 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 }); } @@ -481,6 +712,21 @@ export async function acceptCandidateCall() { 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(() => { @@ -489,13 +735,20 @@ export async function acceptCandidateCall() { } } -export function declineCandidateCall() {} - export function candidateLeaveCall(isEndedByHost = false) { isCandidateCallConnected = false; candidateDeclinedOrLeftCall = true; - if (window.callRingtone) window.callRingtone.stop(); + 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) {} @@ -506,6 +759,17 @@ export function candidateLeaveCall(isEndedByHost = false) { 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'); @@ -624,7 +888,7 @@ export async function initCandidatePeer(force = false) { candidatePeerInstance.on('open', id => { console.log('[WebRTC Candidate] Registered PeerJS ID:', id); sendCandidatePeerHeartbeat(); - if (callSigChannel) { + if (callSigChannel && isCandidateCallConnected) { callSigChannel.postMessage({ type: 'peer_joined', peer_id: id, @@ -639,24 +903,35 @@ export async function initCandidatePeer(force = false) { 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'); + 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 => { @@ -697,7 +972,7 @@ export function startCandidateCameraStream() { } if (avatarPlaceholder) avatarPlaceholder.style.display = 'none'; - if (activeCandidateCallInstance && activeCandidateCallInstance.peerConnection) { + 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); @@ -709,7 +984,9 @@ export function startCandidateCameraStream() { }); } - if (callSigChannel) callSigChannel.postMessage({ type: 'candidate_ready' }); + if (callSigChannel && isCandidateCallConnected) { + callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: getCandidatePeerId() }); + } initCandidatePeer(); return stream; }) @@ -740,13 +1017,23 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') { role: 'candidate', mic_on: Boolean(isCandidateMicEnabled), cam_on: Boolean(isCandidateCamEnabled), - action: action + action: isCandidateCallConnected ? action : 'presence' }) }) .then(r => r.ok ? r.json() : null) .then(data => { - if (latestCallStatus !== 'active') return; - if (data && data.active_peers && Array.isArray(data.active_peers)) { + 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; @@ -772,7 +1059,7 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') { }) .catch(() => {}); - if (callSigChannel) { + if (callSigChannel && isCandidateCallConnected) { callSigChannel.postMessage({ type: 'peer_state_changed', peer_id: peerId, @@ -937,6 +1224,15 @@ export function initCandidateRoom() { 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') { @@ -944,20 +1240,23 @@ export function initCandidateRoom() { 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) {} + 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') { @@ -991,7 +1290,16 @@ export function initCandidateRoom() { if (data.type === 'incoming_call' && data.interview_id == interviewId) { latestCallStatus = 'active'; - if (!isCandidateCallConnected) acceptCandidateCall(); + 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'; @@ -1049,66 +1357,93 @@ export function initCandidateRoom() { latestCallStatus = 'active'; if (data.call_started_at) latestCallStartedAt = data.call_started_at; - if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) { - hasCapturedCallStartScreenshot = true; - setTimeout(() => { - window.captureAndUploadTabScreenshot('call_start'); - }, 1200); - } + const sessionKey = getCallSessionKey(data.call_started_at); + const isHandledInSession = typeof sessionStorage !== 'undefined' && sessionStorage.getItem(sessionKey) === 'handled'; - if (!isCandidateCallConnected && candidateLocalStream && !candidateDeclinedOrLeftCall) { - acceptCandidateCall(); + if (!isCandidateCallConnected) { + if (isHandledInSession || candidateDeclinedOrLeftCall) { + showCandidateJoinOption(); + } else { + triggerCandidateRing(null, data.call_started_at); + } } - } else if (data.call_status === 'ended') { - latestCallStatus = 'ended'; - hasCapturedCallStartScreenshot = false; - isCandidateCallConnected = false; - candidateLeaveCall(true); - } else if (data.call_status === 'idle') { - latestCallStatus = 'idle'; + } 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 = 'READY (Waiting for Interviewer)'; - badge.style.background = 'rgba(16,185,129,0.2)'; - badge.style.color = '#34d399'; + 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)); - 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 (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); + 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); - } - }); + candidateInterviewerTiles.forEach((_, pid) => { + if (!activePeerIds.has(pid)) { + removeCandidateInterviewerTile(pid); + } + }); + } } }) .catch(() => {}); @@ -1129,6 +1464,11 @@ if (typeof window !== 'undefined') { 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; diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index c16b00b..a3baa1c 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -126,7 +126,8 @@ export function getMyInterviewerPeerId() { : ('interviewer_' + currentInterviewId + '_' + currentUserId); } -export function getInterviewerScreenPeerId() { +export function getInterviewerScreenPeerId(userId = null) { + const currentUserId = userId !== null ? userId : (window.currentUserId || 0); if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) { currentInterviewId = document.body.dataset.interviewId; } @@ -140,8 +141,8 @@ export function getInterviewerScreenPeerId() { : null; return subId - ? ('interviewer_screen_' + currentInterviewId + '_' + subId) - : ('interviewer_screen_' + currentInterviewId); + ? ('interviewer_screen_' + currentInterviewId + '_' + subId + '_' + currentUserId) + : ('interviewer_screen_' + currentInterviewId + '_' + currentUserId); } let interviewerLocalStream = null; let activeCall = null; @@ -160,6 +161,7 @@ let interviewerSigChannel = null; let interviewerPeerConnection = null; let liveSyncChannel = null; let panelistMeshTiles = new Map(); +let latestActivePeers = []; // Ringing & Call state let activeIncomingCall = null; @@ -559,7 +561,7 @@ export function subscribeLiveSync(interviewId) { if (interviewerSigChannel) { try { interviewerSigChannel.close(); } catch(e) {} } - interviewerSigChannel = new BroadcastChannel('interview_call_' + interviewId); + interviewerSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); interviewerSigChannel.onmessage = function(event) { const data = event.data; if (!data) return; @@ -608,7 +610,9 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) { resetZoomScreen(); switchIdeTab('code'); + updateCandidateStatusIcons(false, false, false); + initInterviewerSigChannel(); subscribeLiveSync(id); pollReviewData(); if (sosPollInterval) clearInterval(sosPollInterval); @@ -666,13 +670,16 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { const candPlaceholder = document.getElementById('cand-video-placeholder'); const candStatusText = document.getElementById('cand-status-text') || (candPlaceholder ? candPlaceholder.querySelector('.status') : null); const candVid = document.getElementById('interviewer-cand-video'); - const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); - + const screenVid = document.getElementById('interviewer-screen-video'); const candVidWrapper = document.getElementById('cand-video-wrapper'); const screenWrapper = document.getElementById('screen-wrapper'); + const screenPlaceholder = document.getElementById('screen-video-placeholder'); const joinedPill = document.getElementById('candidate-joined-pill'); - if (!isJoined && !candidateStreamConnected) { + if (!isJoined) { + if (candVid) candVid.srcObject = null; + if (screenVid) screenVid.srcObject = null; + if (joinedPill) { joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60'; joinedPill.innerHTML = ' Not Joined'; @@ -681,6 +688,10 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { candMicIcon.className = 'fa-solid fa-microphone-slash text-slate-500 text-[10px]'; candMicIcon.title = 'Candidate Not Joined'; } + if (candCamIcon) { + candCamIcon.className = 'fa-solid fa-video-slash text-slate-500 text-[10px]'; + candCamIcon.title = 'Candidate Not Joined'; + } if (candVidWrapper) { candVidWrapper.style.opacity = '0.4'; candVidWrapper.style.filter = 'grayscale(60%)'; @@ -695,9 +706,14 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { candPlaceholder.style.display = 'flex'; if (candStatusText) candStatusText.innerText = 'Candidate Not Joined'; } + if (screenPlaceholder) { + screenPlaceholder.style.display = 'flex'; + } return; } + const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); + // Joined state: restore styles & active icons if (joinedPill) { joinedPill.className = 'pill green inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-emerald-500/30 text-emerald-400 bg-emerald-500/10'; @@ -735,6 +751,9 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) { export function pollReviewData() { if (!currentInterviewId) return; + const myPeerId = getMyInterviewerPeerId(); + const candPeerId = getCandidatePeerId(); + if (interviewerLocalStream) { sendInterviewerPeerHeartbeat('heartbeat'); } @@ -751,73 +770,90 @@ export function pollReviewData() { .then(data => { if (!data) return; - const currentUserId = window.currentUserId || 0; - const myPeerId = getMyInterviewerPeerId(); - const candPeerId = getCandidatePeerId(); - - const candVid = document.getElementById('interviewer-cand-video'); - const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); - - const candidatePeer = (data.active_peers && Array.isArray(data.active_peers)) - ? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_'))))) - : null; - - const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected); - - if (isCandidateJoined) { - const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true; - const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true; - updateCandidateStatusIcons(isMicOn, isCamOn, true); - } else { - updateCandidateStatusIcons(false, false, false); + if (data.active_peers && Array.isArray(data.active_peers)) { + latestActivePeers = data.active_peers; } - if (data.active_peers && Array.isArray(data.active_peers)) { - const activeIds = new Set(data.active_peers.map(p => p.peer_id)); + const isCallEnded = (data.call_status === 'ended'); - data.active_peers.forEach(p => { - if (p.peer_id && p.peer_id !== myPeerId) { - let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); - const isMicOn = isTrueVal(p.mic_on); - const isCamOn = isTrueVal(p.cam_on); + if (isCallEnded) { + // When call status is "Call Ended", do NOT check for candidate status + updateCandidateStatusIcons(false, false, false); + const screenVid = document.getElementById('interviewer-screen-video'); + const screenPlace = document.getElementById('screen-video-placeholder'); + if (screenVid) screenVid.srcObject = null; + if (screenPlace) screenPlace.style.display = 'flex'; - const isCandPeer = p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_')))); - if (isCandPeer) { - if (interviewerLocalStream && interviewerPeer && interviewerPeer.open && !candidateStreamConnected) { - try { - const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); - if (outCall) { - outCall.on('stream', remoteStream => { - if (candVid) safePlayMediaStream(candVid, remoteStream); - updateCandidateStatusIcons(isMicOn, isCamOn, true); - }); - } - } catch(e) {} - } - } else if (p.peer_id.startsWith('interviewer_')) { - if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { - if (!panelistMeshTiles.has(p.peer_id)) { + panelistMeshTiles.forEach((_, pid) => { + removePanelistTile(pid); + }); + } else { + const candVid = document.getElementById('interviewer-cand-video'); + const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0)); + + const candidatePeer = (data.active_peers && Array.isArray(data.active_peers)) + ? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_'))))) + : null; + + const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected); + + if (isCandidateJoined) { + const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true; + const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true; + updateCandidateStatusIcons(isMicOn, isCamOn, true); + } else { + updateCandidateStatusIcons(false, false, false); + } + + if (data.active_peers && Array.isArray(data.active_peers)) { + const activeIds = new Set(data.active_peers.map(p => p.peer_id)); + + data.active_peers.forEach(p => { + if (p.peer_id && p.peer_id !== myPeerId) { + let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name ? ('Panelist ' + p.name) : 'Panelist'); + const isMicOn = isTrueVal(p.mic_on); + const isCamOn = isTrueVal(p.cam_on); + + const isCandPeer = p.role === 'candidate' || (p.peer_id && (p.peer_id === candPeerId || (p.peer_id.startsWith('cand_') && !p.peer_id.startsWith('cand_screen_')))); + if (isCandPeer) { + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open && !candidateStreamConnected) { try { const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { - addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); + if (candVid) safePlayMediaStream(candVid, remoteStream); + updateCandidateStatusIcons(isMicOn, isCamOn, true); }); } } catch(e) {} + } + } else if (p.peer_id.startsWith('interviewer_') || p.role === 'interviewer' || p.role === 'admin') { + if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { + if (!panelistMeshTiles.has(p.peer_id)) { + try { + const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); + }); + } + } catch(e) {} + } else { + addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn); + } } else { - addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn); + addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id) || null, roleLabel, isMicOn, isCamOn); } } } - } - }); + }); - panelistMeshTiles.forEach((_, pid) => { - if (!activeIds.has(pid)) { - removePanelistTile(pid); - } - }); + panelistMeshTiles.forEach((_, pid) => { + if (!activeIds.has(pid)) { + removePanelistTile(pid); + } + }); + } } const codeLang = document.getElementById('modal-code-lang'); @@ -1003,6 +1039,8 @@ export function pollReviewData() { if (endAllBtn) endAllBtn.style.display = 'none'; if (micBtn) micBtn.style.display = 'none'; if (camBtn) camBtn.style.display = 'none'; + if (recStartBtn) recStartBtn.style.display = 'none'; + if (recStopBtn) recStopBtn.style.display = 'none'; if (timelinePill) { const icon = timelinePill.querySelector('i'); @@ -1018,6 +1056,9 @@ export function pollReviewData() { if (endAllBtn) endAllBtn.style.display = 'inline-flex'; if (micBtn) micBtn.style.display = 'inline-flex'; if (camBtn) camBtn.style.display = 'inline-flex'; + if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { + recStartBtn.style.display = 'inline-flex'; + } if (timelinePill) { const icon = timelinePill.querySelector('i'); @@ -1027,18 +1068,29 @@ export function pollReviewData() { timelinePill.className = 'pill blue live inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-1 rounded-full border border-[rgba(75,130,247,0.35)] text-[#4b82f7] bg-[rgba(75,130,247,0.12)]'; } } - if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) { - recStartBtn.style.display = 'inline-flex'; - } } else if (data.call_status === 'ended' || data.call_status === 'idle') { if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall(); if (livePulseDot) livePulseDot.style.display = 'none'; if (startBtn) { const icon = startBtn.querySelector('i'); const span = startBtn.querySelector('span'); - if (icon) icon.className = 'fa-solid fa-phone'; - if (span) span.innerText = (data.call_status === 'ended') ? 'Restart call' : 'Start / Join Call'; - startBtn.style.display = 'inline-flex'; + if (data.call_status === 'ended') { + if (icon) icon.className = 'fa-solid fa-phone-slash'; + if (span) span.innerText = 'Call Ended'; + startBtn.disabled = true; + startBtn.style.opacity = '0.5'; + startBtn.style.cursor = 'not-allowed'; + startBtn.style.pointerEvents = 'none'; + startBtn.style.display = 'inline-flex'; + } else { + if (icon) icon.className = 'fa-solid fa-phone'; + if (span) span.innerText = 'Start Call'; + startBtn.disabled = false; + startBtn.style.opacity = '1'; + startBtn.style.cursor = 'pointer'; + startBtn.style.pointerEvents = 'auto'; + startBtn.style.display = 'inline-flex'; + } } if (leaveBtn) leaveBtn.style.display = 'none'; if (endAllBtn) endAllBtn.style.display = 'none'; @@ -1323,7 +1375,10 @@ export { initMeteredIceServers }; export function safePlayMediaStream(videoElem, stream, isSelf = false) { if (!videoElem || !stream) return; - if (isSelf) videoElem.muted = true; + const isScreenShare = (videoElem.id === 'interviewer-screen-video'); + if (isSelf || isScreenShare) { + videoElem.muted = true; + } if (videoElem.srcObject !== stream) { videoElem.srcObject = stream; } @@ -1334,7 +1389,7 @@ export function safePlayMediaStream(videoElem, stream, isSelf = false) { videoElem.onloadedmetadata = () => { videoElem.play().catch(() => {}); }; - if (!isSelf) { + if (!isSelf && !isScreenShare) { videoElem.muted = true; videoElem.play().catch(() => {}); const unmuteHandler = () => { @@ -1368,12 +1423,12 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn tile.innerHTML = ` -