fix: stun server discovery

This commit is contained in:
kushal.saha 2026-08-13 12:41:52 +00:00
parent ab00516c42
commit be8640c552
5 changed files with 264 additions and 111 deletions

View File

@ -6,6 +6,8 @@
* All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint. * All events (MediaPipe ML + Focus/AI/Speech) are transmitted to the server endpoint.
*/ */
import { initMeteredIceServers } from './metered.js';
// MediaPipe Published Browser Bundle CDN URLs // 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_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'; const VISION_MODULE_FALLBACK_URL = 'https://unpkg.com/@mediapipe/tasks-vision@0.10.22-rc.20250304/vision_bundle.mjs';
@ -461,10 +463,28 @@ export function startScreenShare() {
enforceBrowserFullscreen(); enforceBrowserFullscreen();
const interviewId = document.body.dataset.interviewId; const interviewId = document.body.dataset.interviewId;
const submissionId = document.body.dataset.submissionId || interviewId;
if (typeof window.Peer !== 'undefined' && interviewId) { if (typeof window.Peer !== 'undefined' && interviewId) {
const screenPeer = new window.Peer('cand_screen_' + interviewId + '_' + Date.now()); 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.on('open', () => {
screenPeer.call('interviewer_screen_' + interviewId, stream); screenPeer.call(targetInterviewerScreenPeerId, stream);
});
}).catch(() => {
const screenPeer = new window.Peer(screenPeerId);
screenPeer.on('open', () => {
screenPeer.call(targetInterviewerScreenPeerId, stream);
});
}); });
} }

View File

@ -5,6 +5,7 @@
*/ */
import { safePlayMediaStream, getInitials } from './interview-call.js'; import { safePlayMediaStream, getInitials } from './interview-call.js';
import { initMeteredIceServers } from './metered.js';
let editor = null; let editor = null;
let candidateLocalStream = null; let candidateLocalStream = null;
@ -404,6 +405,9 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
} }
candidateInterviewerTiles.set(peerId, stream); candidateInterviewerTiles.set(peerId, stream);
if (stream || latestCallStatus === 'active') {
latestCallStatus = 'active';
isCandidateCallConnected = true; isCandidateCallConnected = true;
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
@ -412,9 +416,15 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
badge.style.background = 'rgba(16,185,129,0.3)'; badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399'; badge.style.color = '#34d399';
} }
}
} }
export function removeCandidateInterviewerTile(peerId) { export function removeCandidateInterviewerTile(peerId) {
const stream = candidateInterviewerTiles.get(peerId);
if (stream && stream.active && latestCallStatus !== 'ended') {
return;
}
const tile = document.getElementById('cand-iv-tile-' + peerId); const tile = document.getElementById('cand-iv-tile-' + peerId);
if (tile) tile.remove(); if (tile) tile.remove();
candidateInterviewerTiles.delete(peerId); candidateInterviewerTiles.delete(peerId);
@ -425,15 +435,22 @@ export function removeCandidateInterviewerTile(peerId) {
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (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.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(16,185,129,0.2)'; badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399'; badge.style.color = '#34d399';
} }
} }
}
} }
export async function acceptCandidateCall() { export async function acceptCandidateCall() {
const interviewId = document.body.dataset.interviewId; const interviewId = document.body.dataset.interviewId;
if (latestCallStatus === 'ended') return;
candidateDeclinedOrLeftCall = false; candidateDeclinedOrLeftCall = false;
if (!candidateLocalStream || candidateLocalStream.getVideoTracks().length === 0) { if (!candidateLocalStream || candidateLocalStream.getVideoTracks().length === 0) {
@ -537,8 +554,8 @@ export function candidateLeaveCall(isEndedByHost = false) {
async function createCandidatePeerConnection() { async function createCandidatePeerConnection() {
if (candidatePeerConnection) return candidatePeerConnection; if (candidatePeerConnection) return candidatePeerConnection;
const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }]; const iceServers = await initMeteredIceServers();
candidatePeerConnection = new RTCPeerConnection({ iceServers: globalIceServers }); candidatePeerConnection = new RTCPeerConnection({ iceServers: iceServers });
if (candidateLocalStream) { if (candidateLocalStream) {
candidateLocalStream.getTracks().forEach(track => { candidateLocalStream.getTracks().forEach(track => {
@ -571,23 +588,38 @@ 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() { export async function initCandidatePeer() {
const interviewId = document.body.dataset.interviewId; const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate'; const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId || typeof window.Peer === 'undefined') return; if (!interviewId || typeof window.Peer === 'undefined') return;
const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }]; const iceServers = await initMeteredIceServers();
const peerId = 'cand_' + interviewId; const peerId = getCandidatePeerId();
if (candidatePeerInstance) { if (candidatePeerInstance) {
try { candidatePeerInstance.destroy(); } catch(e) {} try { candidatePeerInstance.destroy(); } catch(e) {}
} }
candidatePeerInstance = new window.Peer(peerId, { candidatePeerInstance = new window.Peer(peerId, {
config: { iceServers: globalIceServers } config: { iceServers: iceServers }
}); });
candidatePeerInstance.on('open', id => { candidatePeerInstance.on('open', id => {
console.log('Candidate WebRTC Peer Registered:', id); console.log('[WebRTC Candidate] Registered PeerJS ID:', id);
sendCandidatePeerHeartbeat();
if (callSigChannel) { if (callSigChannel) {
callSigChannel.postMessage({ callSigChannel.postMessage({
type: 'peer_joined', type: 'peer_joined',
@ -598,30 +630,40 @@ export async function initCandidatePeer() {
} }
}); });
candidatePeerInstance.on('call', call => { candidatePeerInstance.on('call', async call => {
console.log('[WebRTC Candidate] Incoming PeerJS call from:', call.peer);
activeCandidateCallInstance = call; activeCandidateCallInstance = call;
latestCallStatus = 'active'; if (latestCallStatus !== 'ended') latestCallStatus = 'active';
if (!candidateLocalStream || candidateLocalStream.getTracks().length === 0) {
await startCandidateCameraStream();
}
const sendStream = candidateLocalStream || new MediaStream(); const sendStream = candidateLocalStream || new MediaStream();
try { call.answer(sendStream); } catch(e) {} try { call.answer(sendStream); } catch(e) {
console.error('[WebRTC Candidate] Error answering call:', e);
}
call.on('stream', remoteStream => { call.on('stream', remoteStream => {
console.log('[WebRTC Candidate] Received remote stream from interviewer:', call.peer);
addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist'); addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist');
}); });
call.on('close', () => { call.on('close', () => {
console.log('[WebRTC Candidate] Call closed by peer:', call.peer);
removeCandidateInterviewerTile(call.peer); removeCandidateInterviewerTile(call.peer);
}); });
}); });
candidatePeerInstance.on('error', async err => { candidatePeerInstance.on('error', async err => {
console.warn('Candidate PeerJS error:', err); console.warn('[WebRTC Candidate] PeerJS error:', err);
if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') { if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') {
if (candidatePeerInstance) { if (candidatePeerInstance) {
try { candidatePeerInstance.destroy(); } catch(e) {} try { candidatePeerInstance.destroy(); } catch(e) {}
} }
const iceServers = await initMeteredIceServers();
candidatePeerInstance = new window.Peer(peerId, { candidatePeerInstance = new window.Peer(peerId, {
config: { iceServers: globalIceServers } config: { iceServers: iceServers }
}); });
} }
}); });
@ -679,7 +721,7 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') {
const interviewId = document.body.dataset.interviewId; const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate'; const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId) return; if (!interviewId) return;
const peerId = 'cand_' + interviewId; const peerId = getCandidatePeerId();
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch(`/candidate/peer-heartbeat/${interviewId}`, { fetch(`/candidate/peer-heartbeat/${interviewId}`, {
@ -699,6 +741,7 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') {
}) })
.then(r => r.ok ? r.json() : null) .then(r => r.ok ? r.json() : null)
.then(data => { .then(data => {
if (latestCallStatus !== 'active') return;
if (data && data.active_peers && Array.isArray(data.active_peers)) { if (data && data.active_peers && Array.isArray(data.active_peers)) {
data.active_peers.forEach(p => { data.active_peers.forEach(p => {
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
@ -877,20 +920,26 @@ export function initCandidateRoom() {
const msg = event.data; const msg = event.data;
if (!msg) return; if (!msg) return;
if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') { if (msg.type === 'interviewer_joined' || msg.type === 'offer') {
latestCallStatus = 'active'; latestCallStatus = 'active';
if (msg.peer_id && msg.peer_id.startsWith('interviewer_')) { }
if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') {
const peerIdToCall = msg.peer_id || msg.sender_peer_id;
if (peerIdToCall && peerIdToCall.startsWith('interviewer_')) {
const isMicOn = (msg.mic_on !== undefined) ? Boolean(msg.mic_on) : true; const isMicOn = (msg.mic_on !== undefined) ? Boolean(msg.mic_on) : true;
const isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true; const isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true;
const existingStream = candidateInterviewerTiles.get(msg.peer_id) || null; const existingStream = candidateInterviewerTiles.get(peerIdToCall) || null;
addOrUpdateCandidateInterviewerTile(msg.peer_id, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn); addOrUpdateCandidateInterviewerTile(peerIdToCall, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
if (candidateLocalStream && candidatePeerInstance && !existingStream) { if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !existingStream) {
try { try {
const outCall = candidatePeerInstance.call(msg.peer_id, candidateLocalStream); console.log('[WebRTC Candidate] Signal received, calling interviewer:', peerIdToCall);
const outCall = candidatePeerInstance.call(peerIdToCall, candidateLocalStream);
if (outCall) { if (outCall) {
outCall.on('stream', remoteStream => { outCall.on('stream', remoteStream => {
addOrUpdateCandidateInterviewerTile(msg.peer_id, remoteStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn); console.log('[WebRTC Candidate] Stream received via signal call:', peerIdToCall);
addOrUpdateCandidateInterviewerTile(peerIdToCall, remoteStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
}); });
} }
} catch(e) {} } catch(e) {}
@ -981,35 +1030,7 @@ export function initCandidateRoom() {
pollCandidateChat(data); pollCandidateChat(data);
if (data.active_peers && Array.isArray(data.active_peers)) { if (data.call_status === 'active' && latestCallStatus !== 'ended') {
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 || ''));
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);
});
}
} catch(e) {}
}
}
}
});
candidateInterviewerTiles.forEach((_, pid) => {
if (!activePeerIds.has(pid)) {
removeCandidateInterviewerTile(pid);
}
});
}
if (data.call_status === 'active') {
latestCallStatus = 'active'; latestCallStatus = 'active';
if (data.call_started_at) latestCallStartedAt = data.call_started_at; if (data.call_started_at) latestCallStartedAt = data.call_started_at;
@ -1020,22 +1041,60 @@ export function initCandidateRoom() {
}, 1200); }, 1200);
} }
if (!isCandidateCallConnected && candidateLocalStream) { if (!isCandidateCallConnected && candidateLocalStream && !candidateDeclinedOrLeftCall) {
acceptCandidateCall(); acceptCandidateCall();
} }
} else if (data.call_status === 'ended' || data.call_status === 'idle') { } else if (data.call_status === 'ended') {
latestCallStatus = data.call_status; latestCallStatus = 'ended';
hasCapturedCallStartScreenshot = false;
isCandidateCallConnected = false;
candidateLeaveCall(true);
} else if (data.call_status === 'idle') {
latestCallStatus = 'idle';
hasCapturedCallStartScreenshot = false; hasCapturedCallStartScreenshot = false;
candidateDeclinedOrLeftCall = false;
isCandidateCallConnected = false; isCandidateCallConnected = false;
const badge = document.getElementById('call-status-badge'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge && candidateInterviewerTiles.size === 0) {
badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY'; badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = (data.call_status === 'ended') ? 'rgba(239,68,68,0.2)' : 'rgba(16,185,129,0.2)'; badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = (data.call_status === 'ended') ? '#fca5a5' : '#34d399'; badge.style.color = '#34d399';
} }
} }
if (data.active_peers && Array.isArray(data.active_peers) && latestCallStatus !== 'ended') {
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);
});
}
} catch(e) {}
} else {
const existingStream = candidateInterviewerTiles.get(p.peer_id);
addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, roleLabel, isMicOn, isCamOn);
}
}
}
});
candidateInterviewerTiles.forEach((_, pid) => {
if (!activePeerIds.has(pid)) {
removeCandidateInterviewerTile(pid);
}
});
}
}) })
.catch(() => {}); .catch(() => {});
}, 6000); }, 6000);

View File

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

View File

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

View File

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