Compare commits

..

No commits in common. "be8640c5523540fc233d81e1954b5681efe6fc01" and "78ed98bc436e69c42919a1adc4d4b9fca55126aa" have entirely different histories.

6 changed files with 161 additions and 307 deletions

View File

@ -663,11 +663,11 @@ public function getPollData($id)
}
}
// Clean up stale active_peers (> 30 seconds)
// Clean up stale active_peers (> 15 seconds)
$activePeers = $interview->active_peers ?? [];
$now = now()->timestamp;
$validPeers = array_values(array_filter($activePeers, function ($p) use ($now) {
return ($now - ($p['last_seen'] ?? 0)) < 30;
return ($now - ($p['last_seen'] ?? 0)) < 15;
}));
if (count($validPeers) !== count($activePeers)) {
$interview->update(['active_peers' => $validPeers]);
@ -726,13 +726,12 @@ public function registerPeerHeartbeat(Request $request, $id)
$role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate');
$userId = $user ? $user->id : 0;
$interview->refresh();
$activePeers = $interview->active_peers ?? [];
$now = now()->timestamp;
$updatedPeers = [];
foreach ($activePeers as $p) {
if (($now - ($p['last_seen'] ?? 0)) < 30) {
if (($now - ($p['last_seen'] ?? 0)) < 15) {
if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) {
continue;
}

View File

@ -6,8 +6,6 @@
* All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint.
*/
import { initMeteredIceServers } from './metered.js';
// MediaPipe Published Browser Bundle CDN URLs
const VISION_MODULE_URL = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
@ -463,28 +461,10 @@ 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);
});
const screenPeer = new window.Peer('cand_screen_' + interviewId + '_' + Date.now());
screenPeer.on('open', () => {
screenPeer.call('interviewer_screen_' + interviewId, stream);
});
}
@ -1155,7 +1135,7 @@ class CandidateProctoring {
const objectDetector = await this.withAmdLoaderDisabled(() =>
this.createModel(vision.ObjectDetector, fileset, {
modelAssetPath: OBJECT_MODEL_URL,
runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.35,
runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.15,
})
);
this.phoneDetector = new PhoneDetector(objectDetector);

View File

@ -5,7 +5,6 @@
*/
import { safePlayMediaStream, getInitials } from './interview-call.js';
import { initMeteredIceServers } from './metered.js';
let editor = null;
let candidateLocalStream = null;
@ -272,39 +271,55 @@ export function saveCanvasDrawing(force = false) {
}
// --- Candidate Chat Polling & Sending ---
export function pollCandidateChat(data) {
if (!data) return;
const chatHistory = document.getElementById('candidate-chat-history');
if (!chatHistory) return;
export function pollCandidateChat() {
const interviewId = document.body.dataset.interviewId;
if (!interviewId) return;
if (data.status === 'blocked' || data.is_expired) {
return;
}
fetch(`/candidate/poll/${interviewId}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => {
if (!r.ok) return null;
const contentType = r.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) return null;
return r.json();
})
.then(data => {
if (!data) return;
const chatHistory = document.getElementById('candidate-chat-history');
if (!chatHistory) return;
if (data.warnings && Array.isArray(data.warnings)) {
if (data.warnings.length === 0) {
chatHistory.innerHTML = '<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>';
if (data.status === 'blocked' || data.is_expired) {
window.location.reload();
return;
}
let html = '';
data.warnings.forEach(w => {
const isInterviewer = w.sent_by !== null;
const senderName = isInterviewer ? (w.sender ? w.sender.name : 'Interviewer / Panelist') : 'You (Candidate)';
const badgeColor = isInterviewer ? '#6366f1' : '#38bdf8';
const bgColor = isInterviewer ? 'rgba(99,102,241,0.15)' : 'rgba(56,189,248,0.15)';
if (data.warnings && Array.isArray(data.warnings)) {
if (data.warnings.length === 0) {
chatHistory.innerHTML = '<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>';
return;
}
html += `<div style="margin-bottom:8px; background:${bgColor}; padding:8px 10px; border-radius:8px; border-left:3px solid ${badgeColor}; text-align: left;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<strong style="color:${badgeColor}; font-size:0.75rem;">${senderName}</strong>
<span style="font-size:0.65rem; color:#94a3b8;">${new Date(w.created_at || Date.now()).toLocaleTimeString()}</span>
</div>
<div style="color: #ffffff !important; font-size:0.85rem; font-weight:500; line-height:1.4; word-break: break-word;">${w.message}</div>
</div>`;
});
chatHistory.innerHTML = html;
chatHistory.scrollTop = chatHistory.scrollHeight;
}
let html = '';
data.warnings.forEach(w => {
const isInterviewer = w.sent_by !== null;
const senderName = isInterviewer ? (w.sender ? w.sender.name : 'Interviewer / Panelist') : 'You (Candidate)';
const badgeColor = isInterviewer ? '#6366f1' : '#38bdf8';
const bgColor = isInterviewer ? 'rgba(99,102,241,0.15)' : 'rgba(56,189,248,0.15)';
html += `<div style="margin-bottom:8px; background:${bgColor}; padding:8px 10px; border-radius:8px; border-left:3px solid ${badgeColor}; text-align: left;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
<strong style="color:${badgeColor}; font-size:0.75rem;">${senderName}</strong>
<span style="font-size:0.65rem; color:#94a3b8;">${new Date(w.created_at || Date.now()).toLocaleTimeString()}</span>
</div>
<div style="color: #ffffff !important; font-size:0.85rem; font-weight:500; line-height:1.4; word-break: break-word;">${w.message}</div>
</div>`;
});
chatHistory.innerHTML = html;
chatHistory.scrollTop = chatHistory.scrollHeight;
}
})
.catch(() => {});
}
export function sendCandidateMessage() {
@ -330,13 +345,7 @@ export function sendCandidateMessage() {
})
.then(r => r.json())
.then(() => {
fetch(`/candidate/poll/${interviewId}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data) pollCandidateChat(data);
});
pollCandidateChat();
});
}
@ -405,26 +414,17 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
}
candidateInterviewerTiles.set(peerId, stream);
isCandidateCallConnected = true;
if (stream || latestCallStatus === 'active') {
latestCallStatus = 'active';
isCandidateCallConnected = true;
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399';
}
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399';
}
}
export function removeCandidateInterviewerTile(peerId) {
const stream = candidateInterviewerTiles.get(peerId);
if (stream && stream.active && latestCallStatus !== 'ended') {
return;
}
const tile = document.getElementById('cand-iv-tile-' + peerId);
if (tile) tile.remove();
candidateInterviewerTiles.delete(peerId);
@ -435,22 +435,15 @@ export function removeCandidateInterviewerTile(peerId) {
const badge = document.getElementById('call-status-badge');
if (badge) {
if (latestCallStatus === 'ended') {
badge.innerText = 'CALL ENDED BY HOST';
badge.style.background = 'rgba(239,68,68,0.2)';
badge.style.color = '#fca5a5';
} else {
badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
}
badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
}
}
}
export async function acceptCandidateCall() {
const interviewId = document.body.dataset.interviewId;
if (latestCallStatus === 'ended') return;
candidateDeclinedOrLeftCall = false;
if (!candidateLocalStream || candidateLocalStream.getVideoTracks().length === 0) {
@ -554,8 +547,8 @@ export function candidateLeaveCall(isEndedByHost = false) {
async function createCandidatePeerConnection() {
if (candidatePeerConnection) return candidatePeerConnection;
const iceServers = await initMeteredIceServers();
candidatePeerConnection = new RTCPeerConnection({ iceServers: iceServers });
const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }];
candidatePeerConnection = new RTCPeerConnection({ iceServers: globalIceServers });
if (candidateLocalStream) {
candidateLocalStream.getTracks().forEach(track => {
@ -588,38 +581,23 @@ async function handleInterviewerOffer(offer) {
}
}
export function getCandidateSubmissionId() {
return document.body?.dataset?.submissionId || '';
}
export function getCandidatePeerId() {
const interviewId = document.body?.dataset?.interviewId || '';
const subId = getCandidateSubmissionId();
if (!interviewId) return '';
return (subId && subId !== String(interviewId))
? ('cand_' + interviewId + '_' + subId)
: ('cand_' + interviewId);
}
export async function initCandidatePeer() {
const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId || typeof window.Peer === 'undefined') return;
const iceServers = await initMeteredIceServers();
const peerId = getCandidatePeerId();
const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }];
const peerId = 'cand_' + interviewId;
if (candidatePeerInstance) {
try { candidatePeerInstance.destroy(); } catch(e) {}
}
candidatePeerInstance = new window.Peer(peerId, {
config: { iceServers: iceServers }
config: { iceServers: globalIceServers }
});
candidatePeerInstance.on('open', id => {
console.log('[WebRTC Candidate] Registered PeerJS ID:', id);
sendCandidatePeerHeartbeat();
console.log('Candidate WebRTC Peer Registered:', id);
if (callSigChannel) {
callSigChannel.postMessage({
type: 'peer_joined',
@ -630,40 +608,30 @@ export async function initCandidatePeer() {
}
});
candidatePeerInstance.on('call', async call => {
console.log('[WebRTC Candidate] Incoming PeerJS call from:', call.peer);
candidatePeerInstance.on('call', call => {
activeCandidateCallInstance = call;
if (latestCallStatus !== 'ended') latestCallStatus = 'active';
if (!candidateLocalStream || candidateLocalStream.getTracks().length === 0) {
await startCandidateCameraStream();
}
latestCallStatus = 'active';
const sendStream = candidateLocalStream || new MediaStream();
try { call.answer(sendStream); } catch(e) {
console.error('[WebRTC Candidate] Error answering call:', e);
}
try { call.answer(sendStream); } catch(e) {}
call.on('stream', remoteStream => {
console.log('[WebRTC Candidate] Received remote stream from interviewer:', call.peer);
addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist');
});
call.on('close', () => {
console.log('[WebRTC Candidate] Call closed by peer:', call.peer);
removeCandidateInterviewerTile(call.peer);
});
});
candidatePeerInstance.on('error', async err => {
console.warn('[WebRTC Candidate] PeerJS error:', err);
console.warn('Candidate PeerJS error:', err);
if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') {
if (candidatePeerInstance) {
try { candidatePeerInstance.destroy(); } catch(e) {}
}
const iceServers = await initMeteredIceServers();
candidatePeerInstance = new window.Peer(peerId, {
config: { iceServers: iceServers }
config: { iceServers: globalIceServers }
});
}
});
@ -721,7 +689,7 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') {
const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId) return;
const peerId = getCandidatePeerId();
const peerId = 'cand_' + interviewId;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch(`/candidate/peer-heartbeat/${interviewId}`, {
@ -741,7 +709,6 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') {
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (latestCallStatus !== 'active') return;
if (data && data.active_peers && Array.isArray(data.active_peers)) {
data.active_peers.forEach(p => {
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
@ -920,26 +887,20 @@ export function initCandidateRoom() {
const msg = event.data;
if (!msg) return;
if (msg.type === 'interviewer_joined' || msg.type === 'offer') {
latestCallStatus = 'active';
}
if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') {
const peerIdToCall = msg.peer_id || msg.sender_peer_id;
if (peerIdToCall && peerIdToCall.startsWith('interviewer_')) {
latestCallStatus = 'active';
if (msg.peer_id && msg.peer_id.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);
const existingStream = candidateInterviewerTiles.get(msg.peer_id) || null;
addOrUpdateCandidateInterviewerTile(msg.peer_id, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !existingStream) {
if (candidateLocalStream && candidatePeerInstance && !existingStream) {
try {
console.log('[WebRTC Candidate] Signal received, calling interviewer:', peerIdToCall);
const outCall = candidatePeerInstance.call(peerIdToCall, candidateLocalStream);
const outCall = candidatePeerInstance.call(msg.peer_id, 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);
addOrUpdateCandidateInterviewerTile(msg.peer_id, remoteStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
});
}
} catch(e) {}
@ -1011,6 +972,10 @@ export function initCandidateRoom() {
// Start Candidate Camera
startCandidateCameraStream();
// Chat polling
setInterval(pollCandidateChat, 600);
pollCandidateChat();
// Heartbeat & Polling
if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer);
sendCandidatePeerHeartbeat();
@ -1020,6 +985,7 @@ export function initCandidateRoom() {
setInterval(() => {
if (!interviewId) return;
sendCandidatePeerHeartbeat('heartbeat');
fetch('/candidate/poll/' + interviewId, {
headers: { 'Accept': 'application/json' }
@ -1028,62 +994,22 @@ export function initCandidateRoom() {
.then(data => {
if (!data) return;
pollCandidateChat(data);
if (data.call_status === 'active' && latestCallStatus !== 'ended') {
latestCallStatus = 'active';
if (data.call_started_at) latestCallStartedAt = data.call_started_at;
if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) {
hasCapturedCallStartScreenshot = true;
setTimeout(() => {
window.captureAndUploadTabScreenshot('call_start');
}, 1200);
}
if (!isCandidateCallConnected && candidateLocalStream && !candidateDeclinedOrLeftCall) {
acceptCandidateCall();
}
} else if (data.call_status === 'ended') {
latestCallStatus = 'ended';
hasCapturedCallStartScreenshot = false;
isCandidateCallConnected = false;
candidateLeaveCall(true);
} else if (data.call_status === 'idle') {
latestCallStatus = 'idle';
hasCapturedCallStartScreenshot = false;
isCandidateCallConnected = false;
const badge = document.getElementById('call-status-badge');
if (badge && candidateInterviewerTiles.size === 0) {
badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
}
}
if (data.active_peers && Array.isArray(data.active_peers) && latestCallStatus !== 'ended') {
if (data.active_peers && Array.isArray(data.active_peers)) {
const activePeerIds = new Set(data.active_peers.map(p => p.peer_id));
data.active_peers.forEach(p => {
if (p.peer_id && p.peer_id.startsWith('interviewer_')) {
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Interviewer ' + (p.name || ''));
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open) {
if (!candidateInterviewerTiles.has(p.peer_id)) {
try {
const outCall = candidatePeerInstance.call(p.peer_id, candidateLocalStream);
if (outCall) {
outCall.on('stream', remoteStream => {
addOrUpdateCandidateInterviewerTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
addOrUpdateCandidateInterviewerTile(p.peer_id, remoteStream, roleLabel);
});
}
} catch(e) {}
} else {
const existingStream = candidateInterviewerTiles.get(p.peer_id);
addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, roleLabel, isMicOn, isCamOn);
}
}
}
@ -1095,6 +1021,34 @@ export function initCandidateRoom() {
}
});
}
if (data.call_status === 'active') {
latestCallStatus = 'active';
if (data.call_started_at) latestCallStartedAt = data.call_started_at;
if (!hasCapturedCallStartScreenshot && window.captureAndUploadTabScreenshot) {
hasCapturedCallStartScreenshot = true;
setTimeout(() => {
window.captureAndUploadTabScreenshot('call_start');
}, 1200);
}
if (!isCandidateCallConnected && candidateLocalStream) {
acceptCandidateCall();
}
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
latestCallStatus = data.call_status;
hasCapturedCallStartScreenshot = false;
candidateDeclinedOrLeftCall = false;
isCandidateCallConnected = false;
const badge = document.getElementById('call-status-badge');
if (badge) {
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';
}
}
})
.catch(() => {});
}, 6000);

View File

@ -4,8 +4,6 @@
* Proctoring Alerts, SOS Audio Ringtone, Tab Switching, and Silent Call Recording.
*/
import { initMeteredIceServers } from './metered.js';
// --- Global Audio Ringtone Synthesizer Engine ---
export class CallRingtoneEngine {
constructor() {
@ -29,8 +27,6 @@ export class CallRingtoneEngine {
}
start() {
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
this.init();
if (this.isPlaying) return;
this.isPlaying = true;
@ -87,62 +83,8 @@ export const callRingtone = new CallRingtoneEngine();
// State variables
let currentInterviewId = null;
let currentSubmissionUniqueId = null;
let interviewerPeer = null;
let screenReceiverPeer = null;
export function getCandidatePeerId() {
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
currentInterviewId = document.body.dataset.interviewId;
}
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
currentSubmissionUniqueId = document.body.dataset.submissionId;
}
if (!currentInterviewId) return '';
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
return subId ? ('cand_' + currentInterviewId + '_' + subId) : ('cand_' + currentInterviewId);
}
export function getMyInterviewerPeerId() {
const currentUserId = window.currentUserId || 0;
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
currentInterviewId = document.body.dataset.interviewId;
}
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
currentSubmissionUniqueId = document.body.dataset.submissionId;
}
if (!currentInterviewId) return '';
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
return subId
? ('interviewer_' + currentInterviewId + '_' + subId + '_' + currentUserId)
: ('interviewer_' + currentInterviewId + '_' + currentUserId);
}
export function getInterviewerScreenPeerId() {
if (!currentInterviewId && typeof document !== 'undefined' && document.body?.dataset?.interviewId) {
currentInterviewId = document.body.dataset.interviewId;
}
if (!currentSubmissionUniqueId && typeof document !== 'undefined' && document.body?.dataset?.submissionId) {
currentSubmissionUniqueId = document.body.dataset.submissionId;
}
if (!currentInterviewId) return '';
const subId = (currentSubmissionUniqueId && currentSubmissionUniqueId !== String(currentInterviewId))
? currentSubmissionUniqueId
: null;
return subId
? ('interviewer_screen_' + currentInterviewId + '_' + subId)
: ('interviewer_screen_' + currentInterviewId);
}
let interviewerLocalStream = null;
let activeCall = null;
let isInterviewerMicOn = true;
@ -235,8 +177,7 @@ let globalCallChannel = null;
const adminPageLoadTimestamp = Date.now();
let globalIceServers = window.globalIceServers || [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' }
{ urls: 'stun:stun.l.google.com:19302' }
];
export function getInitials(name) {
@ -381,9 +322,6 @@ export function switchIdeTab(tab) {
}
export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
if (!callData || !callData.id) return;
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return;
if (iAmTheCaller && currentInterviewId == callData.id) return;
if (interviewerLocalStream && currentInterviewId == callData.id) return;
@ -423,9 +361,6 @@ export function triggerIncomingCallRing(callData, isRealtimeBroadcast = false) {
}
export function pollGlobalActiveCalls() {
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
fetch('/interviews/active-calls', {
headers: { 'Accept': 'application/json' }
})
@ -570,16 +505,15 @@ export function subscribeLiveSync(interviewId) {
const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true;
updateCandidateStatusIcons(isMicOn, isCamOn);
}
setTimeout(pollReviewData, 500);
setTimeout(pollReviewData, 100);
} else if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') {
setTimeout(pollReviewData, 500);
setTimeout(pollReviewData, 200);
}
};
}
export function openReviewModal(id, name, uid, autoJoinCall = false) {
currentInterviewId = id;
currentSubmissionUniqueId = uid;
acknowledgedViolationCount = 0;
sosDismissed = false;
@ -612,7 +546,7 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) {
subscribeLiveSync(id);
pollReviewData();
if (sosPollInterval) clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 6000);
sosPollInterval = setInterval(pollReviewData, 2500);
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
peerHeartbeatTimer = setInterval(() => {
@ -631,7 +565,7 @@ export function sendInterviewerPeerHeartbeat(action = 'heartbeat') {
const currentUserId = window.currentUserId || 0;
const currentUserName = window.currentUserName || 'Interviewer';
const currentUserRole = window.currentUserRole || 'interviewer';
const myPeerId = getMyInterviewerPeerId();
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch(`/interviews/${currentInterviewId}/peer-heartbeat`, {
@ -672,7 +606,7 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
const screenWrapper = document.getElementById('screen-wrapper');
const joinedPill = document.getElementById('candidate-joined-pill');
if (!isJoined && !candidateStreamConnected) {
if (!isJoined) {
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 +615,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%)';
@ -752,21 +690,15 @@ export function pollReviewData() {
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 myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
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_')))))
? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_')))
: 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;
if (candidatePeer) {
const isMicOn = isTrueVal(candidatePeer.mic_on);
const isCamOn = isTrueVal(candidatePeer.cam_on);
updateCandidateStatusIcons(isMicOn, isCamOn, true);
} else {
updateCandidateStatusIcons(false, false, false);
@ -1306,7 +1238,17 @@ export function blockCandidateAction() {
}
}
export { initMeteredIceServers };
export function initMeteredIceServers() {
if (typeof window.getMeteredIceServers === 'function') {
return window.getMeteredIceServers().then(servers => {
if (Array.isArray(servers) && servers.length > 0) {
globalIceServers = servers;
}
return globalIceServers;
});
}
return Promise.resolve(globalIceServers);
}
export function safePlayMediaStream(videoElem, stream, isSelf = false) {
if (!videoElem || !stream) return;
@ -1328,8 +1270,7 @@ export function safePlayMediaStream(videoElem, stream, isSelf = false) {
export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn = true, camOn = true) {
const currentUserId = window.currentUserId || 0;
const myPeerId = getMyInterviewerPeerId();
if (!peerId || peerId === myPeerId) return;
if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return;
const grid = document.getElementById('panelist-mesh-grid');
if (!grid) return;
@ -1395,23 +1336,21 @@ export function removePanelistTile(peerId) {
export async function initInterviewerPeer() {
if (!currentInterviewId || typeof window.Peer === 'undefined') return;
const iceServers = await initMeteredIceServers();
await initMeteredIceServers();
const currentUserId = window.currentUserId || 0;
const currentUserName = window.currentUserName || 'Interviewer';
const myPeerId = getMyInterviewerPeerId();
const candPeerId = getCandidatePeerId();
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
if (interviewerPeer && !interviewerPeer.destroyed) {
try { interviewerPeer.destroy(); } catch(e) {}
}
interviewerPeer = new window.Peer(myPeerId, {
config: { iceServers: iceServers }
config: { iceServers: globalIceServers }
});
interviewerPeer.on('open', id => {
console.log('[WebRTC Interviewer] Registered PeerJS ID:', id);
if (interviewerSigChannel) {
interviewerSigChannel.postMessage({
type: 'peer_joined',
@ -1423,22 +1362,17 @@ export async function initInterviewerPeer() {
}
});
interviewerPeer.on('call', async call => {
console.log('[WebRTC Interviewer] Incoming PeerJS call from:', call.peer);
interviewerPeer.on('call', call => {
const sendStream = interviewerLocalStream || new MediaStream();
try { call.answer(sendStream); } catch(e) {
console.error('[WebRTC Interviewer] Error answering call:', e);
}
try { call.answer(sendStream); } catch(e) {}
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_'))) {
console.log('[WebRTC Interviewer] Candidate video stream connected! Candidate ID:', call.peer);
if (call.peer === 'cand_' + currentInterviewId) {
const candVid = document.getElementById('interviewer-cand-video');
if (candVid) safePlayMediaStream(candVid, remoteStream);
const candCamIcon = document.getElementById('cand-cam-status');
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
updateCandidateStatusIcons(true, isCamOn, true);
updateCandidateStatusIcons(undefined, isCamOn);
} else {
addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist');
}
@ -1449,18 +1383,14 @@ export async function initInterviewerPeer() {
badge.className = 'px-2.5 py-1 text-xs font-semibold rounded-full bg-emerald-500/30 text-emerald-300 border border-emerald-500';
}
});
call.on('error', err => {
console.error('[WebRTC Interviewer] Call error from peer:', call.peer, err);
});
});
const screenPeerId = getInterviewerScreenPeerId();
const screenPeerId = 'interviewer_screen_' + currentInterviewId;
if (screenReceiverPeer && !screenReceiverPeer.destroyed) {
try { screenReceiverPeer.destroy(); } catch(e) {}
}
screenReceiverPeer = new window.Peer(screenPeerId, {
config: { iceServers: iceServers }
config: { iceServers: globalIceServers }
});
screenReceiverPeer.on('call', call => {
call.answer();
@ -1480,8 +1410,7 @@ export function initInterviewerSigChannel() {
interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId);
const currentUserId = window.currentUserId || 0;
const currentUserName = window.currentUserName || 'Interviewer';
const myPeerId = getMyInterviewerPeerId();
const candPeerId = getCandidatePeerId();
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
interviewerSigChannel.onmessage = async (event) => {
const msg = event.data;
@ -1508,7 +1437,7 @@ export function initInterviewerSigChannel() {
const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream);
if (outCall) {
outCall.on('stream', remoteStream => {
if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) {
if (msg.peer_id === 'cand_' + currentInterviewId) {
const candVid = document.getElementById('interviewer-cand-video');
if (candVid) safePlayMediaStream(candVid, remoteStream);
const candCamIcon = document.getElementById('cand-cam-status');
@ -1529,7 +1458,7 @@ export function initInterviewerSigChannel() {
}
}
} else if (msg.type === 'peer_left') {
if (msg.peer_id === candPeerId || (msg.peer_id && msg.peer_id.startsWith('cand_') && !msg.peer_id.startsWith('cand_screen_'))) {
if (msg.peer_id === 'cand_' + currentInterviewId) {
const placeholder = document.getElementById('cand-video-placeholder');
if (placeholder) placeholder.style.display = 'flex';
} else if (msg.peer_id) {
@ -1611,7 +1540,7 @@ export function startInterviewerCall(isRejoin = false) {
const makePeerJSCall = () => {
if (!interviewerPeer || interviewerPeer.destroyed) return;
const candPeerId = getCandidatePeerId();
const candPeerId = 'cand_' + currentInterviewId;
activeCall = interviewerPeer.call(candPeerId, stream);
if (activeCall) {
@ -2153,8 +2082,6 @@ if (typeof window !== 'undefined') {
if (!data) return;
if (data.type === 'incoming_call') {
if (typeof document !== 'undefined' && document.body && document.body.dataset.interviewId) return;
if (typeof window !== 'undefined' && window.location.pathname.includes('/candidate/')) return;
triggerIncomingCallRing({
id: data.interview_id,
candidate_name: data.candidate_name,
@ -2178,5 +2105,5 @@ if (typeof window !== 'undefined') {
}
};
setInterval(pollGlobalActiveCalls, 6000);
setInterval(pollGlobalActiveCalls, 1500);
}

View File

@ -3,8 +3,7 @@
*/
let globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
{ urls: 'stun:stun.l.google.com:19302' }
];
let meteredIceFetchPromise = null;
@ -33,20 +32,15 @@ export function getMeteredIceServers() {
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun.cloudflare.com:3478' },
...meteredIce
...meteredIce.slice(0, 3)
];
}
if (typeof window !== 'undefined') {
window.globalIceServers = globalIceServers;
}
window.globalIceServers = globalIceServers;
return globalIceServers;
})
.catch(e => {
console.warn('Metered TURN fetch warning:', e);
if (typeof window !== 'undefined') {
window.globalIceServers = globalIceServers;
}
window.globalIceServers = globalIceServers;
return globalIceServers;
});

View File

@ -66,7 +66,7 @@
details.meta[open] summary i.chev { transform: rotate(180deg); }
</style>
</head>
<body class="min-h-screen flex flex-col antialiased" data-interview-id="{{ $interview->id }}" data-submission-id="{{ $interview->submission_unique_id }}">
<body class="min-h-screen flex flex-col antialiased">
<div class="w-full mx-auto px-7 py-5 pb-16 flex-1">