feat: new interview page, MediaPipe Integration and Stun server discovery #20
@ -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;
|
||||
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;">
|
||||
|
||||
@ -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() }};
|
||||
|
||||
@ -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 & Save Recording"></x-button>
|
||||
|
||||
@ -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)
|
||||
]);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user