feat: new interview page, MediaPipe Integration and Stun server discovery #20

Merged
subhajit.pal merged 12 commits from experimental/mediapipe-integration into main 2026-08-14 09:29:03 +00:00
10 changed files with 941 additions and 245 deletions
Showing only changes of commit f85caec920 - Show all commits

View File

@ -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;

View File

@ -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 });

View File

@ -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;

View File

@ -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 = '<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> 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 = `
<video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="transform: scaleX(-1);"></video>
<div id="mesh-placeholder-${peerId}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3" style="display: ${camOn ? 'none' : 'flex'};">
<div id="mesh-placeholder-${peerId}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3" style="display: ${camOn && stream ? 'none' : 'flex'};">
<div class="av w-[58px] h-[58px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[19px] mb-2.5 shadow-md shadow-[#4b82f7]/20">
${initials}
</div>
<div class="name text-[13.5px] font-semibold text-white">${name}</div>
<div class="status text-[11.5px] text-[#5b637a] mt-1 text-center px-3">Camera turned off</div>
<div class="status text-[11.5px] text-[#5b637a] mt-1 text-center px-3">${camOn && stream ? 'Connecting video...' : 'Camera turned off'}</div>
</div>
<span class="tile-tag absolute left-2.5 bottom-2.5 text-[10px] font-semibold tracking-wide px-2.5 py-1 rounded-md bg-black/50 text-slate-300 border border-[#1b2233] z-20 flex items-center gap-1.5 backdrop-blur-sm">
<span>${name}</span>
@ -1400,15 +1455,22 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn
meshCamIcon.title = camOn ? 'Camera On' : 'Camera Off';
}
if (meshPlaceholder) {
meshPlaceholder.style.display = camOn ? 'none' : 'flex';
meshPlaceholder.style.display = (camOn && stream) ? 'none' : 'flex';
}
}
if (videoElem && stream) {
safePlayMediaStream(videoElem, stream);
stream.onaddtrack = () => {
safePlayMediaStream(videoElem, stream);
const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId);
if (meshPlaceholder && camOn) meshPlaceholder.style.display = 'none';
};
}
panelistMeshTiles.set(peerId, stream);
if (stream) {
panelistMeshTiles.set(peerId, stream);
}
}
export function removePanelistTile(peerId) {
@ -1447,13 +1509,40 @@ export async function initInterviewerPeer() {
}
});
function handleIncomingScreenStream(screenStream) {
console.log('[WebRTC Screen] Attaching incoming screen stream to video element');
const screenVid = document.getElementById('interviewer-screen-video');
const placeholder = document.getElementById('screen-video-placeholder');
if (screenVid && screenStream) {
screenVid.muted = true;
if (screenVid.srcObject !== screenStream) {
screenVid.srcObject = screenStream;
}
screenVid.play().catch(() => {});
screenStream.onaddtrack = () => {
screenVid.muted = true;
if (screenVid.srcObject !== screenStream) {
screenVid.srcObject = screenStream;
}
screenVid.play().catch(() => {});
if (placeholder) placeholder.style.display = 'none';
};
}
if (placeholder) placeholder.style.display = 'none';
}
interviewerPeer.on('call', async call => {
console.log('[WebRTC Interviewer] Incoming PeerJS call from:', call.peer);
const sendStream = interviewerLocalStream || new MediaStream();
try { call.answer(sendStream); } catch(e) {
console.error('[WebRTC Interviewer] Error answering call:', e);
if (call.peer && call.peer.startsWith('cand_screen_')) {
call.on('stream', screenStream => {
console.log('[WebRTC Screen] Screen stream received on main peer from:', call.peer);
handleIncomingScreenStream(screenStream);
});
try { call.answer(); } catch(e) {}
return;
}
const sendStream = interviewerLocalStream || new MediaStream();
call.on('stream', remoteStream => {
console.log('[WebRTC Interviewer] Stream received from peer:', call.peer);
if (call.peer === candPeerId || (call.peer && call.peer.startsWith('cand_') && !call.peer.startsWith('cand_screen_'))) {
@ -1464,7 +1553,11 @@ export async function initInterviewerPeer() {
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
updateCandidateStatusIcons(true, isCamOn, true);
} else {
addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist');
const peerInfo = latestActivePeers.find(p => p.peer_id === call.peer);
const roleLabel = peerInfo ? (peerInfo.role === 'admin' ? ('Admin ' + (peerInfo.name || '')) : ('Panelist ' + (peerInfo.name || ''))) : 'Panelist';
const isMicOn = peerInfo ? isTrueVal(peerInfo.mic_on) : true;
const isCamOn = peerInfo ? isTrueVal(peerInfo.cam_on) : true;
addOrUpdatePanelistTile(call.peer, remoteStream, roleLabel, isMicOn, isCamOn);
}
const badge = document.getElementById('interviewer-call-status');
@ -1477,6 +1570,10 @@ export async function initInterviewerPeer() {
call.on('error', err => {
console.error('[WebRTC Interviewer] Call error from peer:', call.peer, err);
});
try { call.answer(sendStream); } catch(e) {
console.error('[WebRTC Interviewer] Error answering call:', e);
}
});
const screenPeerId = getInterviewerScreenPeerId();
@ -1487,14 +1584,36 @@ export async function initInterviewerPeer() {
config: { iceServers: iceServers }
});
screenReceiverPeer.on('call', call => {
call.answer();
console.log('[WebRTC Screen] Incoming screen call from:', call.peer);
call.on('stream', screenStream => {
const screenVid = document.getElementById('interviewer-screen-video');
const placeholder = document.getElementById('screen-video-placeholder');
if (screenVid) safePlayMediaStream(screenVid, screenStream);
if (placeholder) placeholder.style.display = 'none';
console.log('[WebRTC Screen] Screen stream received from:', call.peer);
handleIncomingScreenStream(screenStream);
});
try { call.answer(); } catch(e) {
console.error('[WebRTC Screen] Error answering screen call:', e);
}
});
// Also attempt legacy singleton screen peer if current user is admin / user 1
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
const legacyScreenPeerId = subId ? ('interviewer_screen_' + currentInterviewId + '_' + subId) : ('interviewer_screen_' + currentInterviewId);
if (legacyScreenPeerId !== screenPeerId && (currentUserId === 1 || window.currentUserRole === 'admin')) {
try {
const legacyScreenPeer = new window.Peer(legacyScreenPeerId, {
config: { iceServers: iceServers }
});
legacyScreenPeer.on('call', call => {
try { call.answer(); } catch(e) {}
call.on('stream', screenStream => {
handleIncomingScreenStream(screenStream);
});
});
legacyScreenPeer.on('error', () => {});
} catch(e) {}
}
}
export function initInterviewerSigChannel() {
@ -1573,6 +1692,8 @@ export function initInterviewerSigChannel() {
export function startInterviewerCall(isRejoin = false) {
if (!currentInterviewId) return;
const startBtn = document.getElementById('btn-start-call');
if (startBtn && startBtn.disabled) return;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
const currentUserName = window.currentUserName || 'Interviewer';
@ -1618,12 +1739,16 @@ export function startInterviewerCall(isRejoin = false) {
const startBtn = document.getElementById('btn-start-call');
const leaveBtn = document.getElementById('btn-leave-call');
const endAllBtn = document.getElementById('btn-end-all-call');
const recStartBtn = document.getElementById('btn-start-recording');
if (micBtn) micBtn.style.display = 'inline-flex';
if (camBtn) camBtn.style.display = 'inline-flex';
if (startBtn) startBtn.style.display = 'none';
if (leaveBtn) leaveBtn.style.display = 'inline-flex';
if (endAllBtn) endAllBtn.style.display = 'inline-flex';
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
recStartBtn.style.display = 'inline-flex';
}
if (interviewerSigChannel) {
interviewerSigChannel.postMessage({ type: 'interviewer_joined', sender: 'interviewer' });
@ -1636,6 +1761,7 @@ export function startInterviewerCall(isRejoin = false) {
const makePeerJSCall = () => {
if (!interviewerPeer || interviewerPeer.destroyed) return;
const candPeerId = getCandidatePeerId();
const myPeerId = getMyInterviewerPeerId();
activeCall = interviewerPeer.call(candPeerId, stream);
if (activeCall) {
@ -1657,6 +1783,25 @@ export function startInterviewerCall(isRejoin = false) {
}
});
}
// Also call any other already-active interviewers
if (Array.isArray(latestActivePeers)) {
latestActivePeers.forEach(p => {
if (p.peer_id && p.peer_id !== myPeerId && p.peer_id.startsWith('interviewer_')) {
try {
const outCall = interviewerPeer.call(p.peer_id, stream);
if (outCall) {
const roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
outCall.on('stream', remoteStream => {
addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
});
}
} catch(e) {}
}
});
}
};
if (interviewerPeer && interviewerPeer.open) {
@ -1689,12 +1834,21 @@ export function startInterviewerCall(isRejoin = false) {
});
}
export function leaveInterviewerCall(isExplicitEnd = true) {
export function leaveInterviewerCall(isEndAll = false) {
sendInterviewerPeerHeartbeat('leave');
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
const endingId = currentInterviewId;
if (currentInterviewId && isExplicitEnd) {
const endingId = currentInterviewId;
if (interviewerSigChannel) {
try {
interviewerSigChannel.postMessage({
type: 'peer_left',
peer_id: getMyInterviewerPeerId()
});
} catch(e) {}
}
if (endingId && isEndAll) {
endedCallIds.add(endingId);
dismissedCallIds.add(endingId);
@ -1742,6 +1896,8 @@ export function leaveInterviewerCall(isExplicitEnd = true) {
screenReceiverPeer = null;
}
updateCandidateStatusIcons(false, false, false);
const candPlace = document.getElementById('cand-video-placeholder');
const screenPlace = document.getElementById('screen-video-placeholder');
const selfPlace = document.getElementById('self-video-placeholder');
@ -1752,9 +1908,16 @@ export function leaveInterviewerCall(isExplicitEnd = true) {
const candVid = document.getElementById('interviewer-cand-video');
if (candVid) candVid.srcObject = null;
const screenVid = document.getElementById('interviewer-screen-video');
if (screenVid) screenVid.srcObject = null;
const selfVid = document.getElementById('interviewer-self-video');
if (selfVid) selfVid.srcObject = null;
panelistMeshTiles.forEach((_, pid) => {
removePanelistTile(pid);
});
const livePulseDot = document.getElementById('live-call-pulse');
if (livePulseDot) livePulseDot.style.display = 'none';
@ -1762,9 +1925,23 @@ export function leaveInterviewerCall(isExplicitEnd = true) {
if (startBtn) {
const icon = startBtn.querySelector('i');
const span = startBtn.querySelector('span');
if (icon) icon.className = 'fa-solid fa-phone';
if (span) span.innerText = 'Restart Call';
startBtn.style.display = 'inline-flex';
if (isEndAll) {
if (icon) icon.className = 'fa-solid fa-phone-slash';
if (span) span.innerText = 'Call Ended';
startBtn.disabled = true;
startBtn.style.opacity = '0.5';
startBtn.style.cursor = 'not-allowed';
startBtn.style.pointerEvents = 'none';
startBtn.style.display = 'inline-flex';
} else {
if (icon) icon.className = 'fa-solid fa-phone';
if (span) span.innerText = 'Join Call';
startBtn.disabled = false;
startBtn.style.opacity = '1';
startBtn.style.cursor = 'pointer';
startBtn.style.pointerEvents = 'auto';
startBtn.style.display = 'inline-flex';
}
}
const leaveBtn = document.getElementById('btn-leave-call');
@ -1779,8 +1956,17 @@ export function leaveInterviewerCall(isExplicitEnd = true) {
const camBtn = document.getElementById('btn-toggle-interviewer-cam');
if (camBtn) camBtn.style.display = 'none';
const recStartBtn = document.getElementById('btn-start-recording');
if (recStartBtn) recStartBtn.style.display = 'none';
const recStopBtn = document.getElementById('btn-stop-recording');
if (recStopBtn) recStopBtn.style.display = 'none';
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
try { adminMediaRecorder.stop(); } catch(e) {}
}
const timelinePill = document.getElementById('timeline-status-pill');
if (timelinePill) {
if (timelinePill && isEndAll) {
const icon = timelinePill.querySelector('i');
const span = timelinePill.querySelector('span');
if (icon) icon.className = 'fa-solid fa-circle text-[8px]';
@ -1830,6 +2016,10 @@ export function startCallRecording() {
alert('Please open an active candidate interview session first.');
return;
}
if (!interviewerLocalStream) {
alert('Please start or join the call before recording.');
return;
}
const candVid = document.getElementById('interviewer-cand-video');
const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
@ -2091,7 +2281,7 @@ export function closeReviewModal() {
try { liveSyncChannel.close(); } catch(e) {}
liveSyncChannel = null;
}
leaveInterviewerCall(true);
leaveInterviewerCall(false);
const modal = document.getElementById('review-modal');
if (modal) modal.classList.remove('active');
}
@ -2199,6 +2389,10 @@ if (typeof window !== 'undefined') {
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
activeIncomingCall = null;
}
if (currentInterviewId && currentInterviewId == data.interview_id) {
updateCandidateStatusIcons(false, false, false);
leaveInterviewerCall(false);
}
}
};

View File

@ -37,10 +37,10 @@
@if($containerId)
<div id="{{ $containerId }}" class="w-full h-full overflow-auto">
<video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-contain origin-top-left transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video>
<video id="{{ $videoId }}" autoplay playsinline {{ in_array($videoId, ['interviewer-screen-video', 'interviewer-self-video']) ? 'muted' : '' }} class="w-full h-full object-contain origin-top-left transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video>
</div>
@else
<video id="{{ $videoId }}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video>
<video id="{{ $videoId }}" autoplay playsinline {{ in_array($videoId, ['interviewer-screen-video', 'interviewer-self-video']) ? 'muted' : '' }} class="w-full h-full object-cover origin-center transition-transform duration-200" style="{{ $videoId === 'interviewer-screen-video' ? 'transform: none;' : 'transform: scaleX(-1);' }}"></video>
@endif
<div id="{{ $placeholderId }}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3">

View File

@ -1567,7 +1567,7 @@ function acceptIncomingCall() {
if (activeIncomingCall) {
const targetId = activeIncomingCall.id;
activeIncomingCall = null;
window.location.href = "{{ route('interview.index') }}?open_interview=" + targetId + "&join_call=1";
window.location.href = "/interviews/" + targetId + "?join_call=1";
}
}

View File

@ -392,8 +392,8 @@
</div>
</div>
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY (DISABLED: Auto-connect enabled) -->
<div id="candidate-incoming-modal" style="display: none !important; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY -->
<div id="candidate-incoming-modal" style="display: flex; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
<div style="max-width: 440px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #10b981; border-radius: 24px; padding: 32px; text-align: center; color: white; box-shadow: 0 0 50px rgba(16, 185, 129, 0.6);">
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">

View File

@ -346,6 +346,44 @@
</form>
</x-modal>
<!-- Incoming Call Ring Modal -->
<x-modal id="incoming-call-modal" maxWidth="md">
<div class="p-6 text-center">
<div class="relative w-20 h-20 mx-auto mb-5 flex items-center justify-center">
<div class="absolute -inset-3 rounded-full border-2 border-emerald-500/60 animate-ping"></div>
<div class="absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse"></div>
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-emerald-500 to-sky-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg shadow-emerald-500/50 z-10">
<i class="fa-solid fa-phone"></i>
</div>
</div>
<div class="text-[11px] uppercase tracking-widest text-emerald-400 font-bold mb-1.5">
Incoming Assessment Call
</div>
<h3 id="incoming-cand-name" class="font-outfit text-2xl font-extrabold text-white mb-1">
Candidate Name
</h3>
<div id="incoming-starter-info" class="text-xs text-[#4b82f7] mb-4">
Initiated by Panelist
</div>
<div class="bg-white/5 border border-white/10 rounded-xl p-3 text-xs text-slate-300 mb-6 leading-relaxed">
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy.
</div>
<div class="flex gap-3 justify-center">
<x-button id="incoming-decline-btn" onclick="declineIncomingCall()" variant="danger" size="md" icon="fa-solid fa-xmark" class="flex-1">
Decline / Miss
</x-button>
<x-button id="incoming-accept-btn" onclick="acceptIncomingCall()" variant="success" size="md" icon="fa-solid fa-phone" class="flex-[1.3] font-extrabold animate-pulse">
Accept Call
</x-button>
</div>
</div>
</x-modal>
<script>
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
window.currentUserId = {{ Auth::id() }};

View File

@ -190,12 +190,22 @@
<x-button id="btn-toggle-interviewer-cam" onclick="toggleInterviewerCam()" variant="ghost" icon="fa-solid fa-video" style="display: none;" title="Cam On / Off"></x-button>
<!-- 3. Start / Join / Restart Call -->
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Start / Join Call
</x-button>
@if($interview->call_status === 'ended')
<x-button id="btn-start-call" disabled variant="primary" icon="fa-solid fa-phone-slash" class="opacity-50 cursor-not-allowed pointer-events-none">
Call Ended
</x-button>
@elseif($interview->call_status === 'active')
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Join Call
</x-button>
@else
<x-button id="btn-start-call" onclick="startInterviewerCall()" variant="primary" icon="fa-solid fa-phone">
Start Call
</x-button>
@endif
<!-- 4. Record Call (Silent) -->
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot">
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot" style="display: none;">
Record Call (Silent)
</x-button>
<x-button id="btn-stop-recording" onclick="stopCallRecording()" variant="danger" icon="fa-solid fa-square" style="display: none;" class="animate-pulse" title="Stop &amp; Save Recording"></x-button>

View File

@ -71,13 +71,17 @@ public function test_candidate_can_login_with_temp_credentials_and_access_ide_ro
->assertSee('sarah-candidate_9123456789_1752000000');
}
public function test_live_code_execution_via_piston_api_proxy(): void
public function test_live_code_execution_via_judge0_api_proxy(): void
{
Http::fake([
'https://emkc.org/api/v2/piston/execute' => Http::response([
'run' => [
'output' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n"
]
'https://ce.judge0.com/submissions*' => Http::response([
'stdout' => "Hello from SingleLogin Assessment!\nComputed Result: 84\n",
'stderr' => null,
'compile_output' => null,
'status' => [
'id' => 3,
'description' => 'Accepted',
],
], 200)
]);