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

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

View File

@ -6,6 +6,8 @@
* 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';
@ -461,10 +463,28 @@ export function startScreenShare() {
enforceBrowserFullscreen();
const interviewId = document.body.dataset.interviewId;
const submissionId = document.body.dataset.submissionId || interviewId;
if (typeof window.Peer !== 'undefined' && interviewId) {
const screenPeer = new window.Peer('cand_screen_' + interviewId + '_' + Date.now());
screenPeer.on('open', () => {
screenPeer.call('interviewer_screen_' + interviewId, stream);
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);
});
});
}

View File

@ -5,6 +5,7 @@
*/
import { safePlayMediaStream, getInitials } from './interview-call.js';
import { initMeteredIceServers } from './metered.js';
let editor = null;
let candidateLocalStream = null;
@ -404,17 +405,26 @@ export function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName =
}
candidateInterviewerTiles.set(peerId, stream);
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';
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';
}
}
}
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);
@ -425,15 +435,22 @@ export function removeCandidateInterviewerTile(peerId) {
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
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';
}
}
}
}
export async function acceptCandidateCall() {
const interviewId = document.body.dataset.interviewId;
if (latestCallStatus === 'ended') return;
candidateDeclinedOrLeftCall = false;
if (!candidateLocalStream || candidateLocalStream.getVideoTracks().length === 0) {
@ -537,8 +554,8 @@ export function candidateLeaveCall(isEndedByHost = false) {
async function createCandidatePeerConnection() {
if (candidatePeerConnection) return candidatePeerConnection;
const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }];
candidatePeerConnection = new RTCPeerConnection({ iceServers: globalIceServers });
const iceServers = await initMeteredIceServers();
candidatePeerConnection = new RTCPeerConnection({ iceServers: iceServers });
if (candidateLocalStream) {
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() {
const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId || typeof window.Peer === 'undefined') return;
const globalIceServers = window.globalIceServers || [{ urls: 'stun:stun.l.google.com:19302' }];
const peerId = 'cand_' + interviewId;
const iceServers = await initMeteredIceServers();
const peerId = getCandidatePeerId();
if (candidatePeerInstance) {
try { candidatePeerInstance.destroy(); } catch(e) {}
}
candidatePeerInstance = new window.Peer(peerId, {
config: { iceServers: globalIceServers }
config: { iceServers: iceServers }
});
candidatePeerInstance.on('open', id => {
console.log('Candidate WebRTC Peer Registered:', id);
console.log('[WebRTC Candidate] Registered PeerJS ID:', id);
sendCandidatePeerHeartbeat();
if (callSigChannel) {
callSigChannel.postMessage({
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;
latestCallStatus = 'active';
if (latestCallStatus !== 'ended') latestCallStatus = 'active';
if (!candidateLocalStream || candidateLocalStream.getTracks().length === 0) {
await startCandidateCameraStream();
}
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 => {
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('Candidate PeerJS error:', err);
console.warn('[WebRTC 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: globalIceServers }
config: { iceServers: iceServers }
});
}
});
@ -679,7 +721,7 @@ export function sendCandidatePeerHeartbeat(action = 'heartbeat') {
const interviewId = document.body.dataset.interviewId;
const candName = document.body.dataset.candidateName || 'Candidate';
if (!interviewId) return;
const peerId = 'cand_' + interviewId;
const peerId = getCandidatePeerId();
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch(`/candidate/peer-heartbeat/${interviewId}`, {
@ -699,6 +741,7 @@ 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')) {
@ -877,20 +920,26 @@ export function initCandidateRoom() {
const msg = event.data;
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';
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 isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true;
const existingStream = candidateInterviewerTiles.get(msg.peer_id) || null;
addOrUpdateCandidateInterviewerTile(msg.peer_id, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
const existingStream = candidateInterviewerTiles.get(peerIdToCall) || null;
addOrUpdateCandidateInterviewerTile(peerIdToCall, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
if (candidateLocalStream && candidatePeerInstance && !existingStream) {
if (candidateLocalStream && candidatePeerInstance && candidatePeerInstance.open && !existingStream) {
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) {
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) {}
@ -981,35 +1030,7 @@ export function initCandidateRoom() {
pollCandidateChat(data);
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 || ''));
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') {
if (data.call_status === 'active' && latestCallStatus !== 'ended') {
latestCallStatus = 'active';
if (data.call_started_at) latestCallStartedAt = data.call_started_at;
@ -1020,22 +1041,60 @@ export function initCandidateRoom() {
}, 1200);
}
if (!isCandidateCallConnected && candidateLocalStream) {
if (!isCandidateCallConnected && candidateLocalStream && !candidateDeclinedOrLeftCall) {
acceptCandidateCall();
}
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
latestCallStatus = data.call_status;
} else if (data.call_status === 'ended') {
latestCallStatus = 'ended';
hasCapturedCallStartScreenshot = false;
isCandidateCallConnected = false;
candidateLeaveCall(true);
} else if (data.call_status === 'idle') {
latestCallStatus = 'idle';
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';
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') {
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(() => {});
}, 6000);

View File

@ -4,6 +4,8 @@
* 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() {
@ -27,6 +29,8 @@ 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;
@ -83,8 +87,62 @@ 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;
@ -177,7 +235,8 @@ let globalCallChannel = null;
const adminPageLoadTimestamp = Date.now();
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) {
@ -322,6 +381,9 @@ 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;
@ -361,6 +423,9 @@ 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' }
})
@ -505,15 +570,16 @@ export function subscribeLiveSync(interviewId) {
const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true;
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') {
setTimeout(pollReviewData, 200);
setTimeout(pollReviewData, 500);
}
};
}
export function openReviewModal(id, name, uid, autoJoinCall = false) {
currentInterviewId = id;
currentSubmissionUniqueId = uid;
acknowledgedViolationCount = 0;
sosDismissed = false;
@ -546,7 +612,7 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) {
subscribeLiveSync(id);
pollReviewData();
if (sosPollInterval) clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 2500);
sosPollInterval = setInterval(pollReviewData, 6000);
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
peerHeartbeatTimer = setInterval(() => {
@ -565,7 +631,7 @@ export function sendInterviewerPeerHeartbeat(action = 'heartbeat') {
const currentUserId = window.currentUserId || 0;
const currentUserName = window.currentUserName || '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;
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.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%)';
@ -690,13 +752,14 @@ export function pollReviewData() {
if (!data) return;
const currentUserId = window.currentUserId || 0;
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
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.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;
const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected);
@ -1243,17 +1306,7 @@ export function blockCandidateAction() {
}
}
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 { initMeteredIceServers };
export function safePlayMediaStream(videoElem, stream, isSelf = false) {
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) {
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');
if (!grid) return;
@ -1341,21 +1395,23 @@ export function removePanelistTile(peerId) {
export async function initInterviewerPeer() {
if (!currentInterviewId || typeof window.Peer === 'undefined') return;
await initMeteredIceServers();
const iceServers = await initMeteredIceServers();
const currentUserId = window.currentUserId || 0;
const currentUserName = window.currentUserName || 'Interviewer';
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
const myPeerId = getMyInterviewerPeerId();
const candPeerId = getCandidatePeerId();
if (interviewerPeer && !interviewerPeer.destroyed) {
try { interviewerPeer.destroy(); } catch(e) {}
}
interviewerPeer = new window.Peer(myPeerId, {
config: { iceServers: globalIceServers }
config: { iceServers: iceServers }
});
interviewerPeer.on('open', id => {
console.log('[WebRTC Interviewer] Registered PeerJS ID:', id);
if (interviewerSigChannel) {
interviewerSigChannel.postMessage({
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();
try { call.answer(sendStream); } catch(e) {}
try { call.answer(sendStream); } catch(e) {
console.error('[WebRTC Interviewer] Error answering call:', e);
}
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');
if (candVid) safePlayMediaStream(candVid, remoteStream);
const candCamIcon = document.getElementById('cand-cam-status');
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
updateCandidateStatusIcons(undefined, isCamOn);
updateCandidateStatusIcons(true, isCamOn, true);
} else {
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';
}
});
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) {
try { screenReceiverPeer.destroy(); } catch(e) {}
}
screenReceiverPeer = new window.Peer(screenPeerId, {
config: { iceServers: globalIceServers }
config: { iceServers: iceServers }
});
screenReceiverPeer.on('call', call => {
call.answer();
@ -1415,7 +1480,8 @@ export function initInterviewerSigChannel() {
interviewerSigChannel = new BroadcastChannel('webrtc_call_' + currentInterviewId);
const currentUserId = window.currentUserId || 0;
const currentUserName = window.currentUserName || 'Interviewer';
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
const myPeerId = getMyInterviewerPeerId();
const candPeerId = getCandidatePeerId();
interviewerSigChannel.onmessage = async (event) => {
const msg = event.data;
@ -1442,7 +1508,7 @@ export function initInterviewerSigChannel() {
const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream);
if (outCall) {
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');
if (candVid) safePlayMediaStream(candVid, remoteStream);
const candCamIcon = document.getElementById('cand-cam-status');
@ -1463,7 +1529,7 @@ export function initInterviewerSigChannel() {
}
}
} 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');
if (placeholder) placeholder.style.display = 'flex';
} else if (msg.peer_id) {
@ -1545,7 +1611,7 @@ export function startInterviewerCall(isRejoin = false) {
const makePeerJSCall = () => {
if (!interviewerPeer || interviewerPeer.destroyed) return;
const candPeerId = 'cand_' + currentInterviewId;
const candPeerId = getCandidatePeerId();
activeCall = interviewerPeer.call(candPeerId, stream);
if (activeCall) {
@ -2087,6 +2153,8 @@ 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,
@ -2110,5 +2178,5 @@ if (typeof window !== 'undefined') {
}
};
setInterval(pollGlobalActiveCalls, 1500);
setInterval(pollGlobalActiveCalls, 6000);
}

View File

@ -3,14 +3,15 @@
*/
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;
/**
* Fetch ICE servers from /ice-servers endpoint and merge with default STUN server.
* Handles network errors gracefully with fallback.
*
*
* @returns {Promise<Array>} Resolves with the array of ICE server objects.
*/
export function getMeteredIceServers() {
@ -32,15 +33,20 @@ export function getMeteredIceServers() {
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
...meteredIce.slice(0, 3)
{ urls: 'stun:stun.cloudflare.com:3478' },
...meteredIce
];
}
window.globalIceServers = globalIceServers;
if (typeof window !== 'undefined') {
window.globalIceServers = globalIceServers;
}
return globalIceServers;
})
.catch(e => {
console.warn('Metered TURN fetch warning:', e);
window.globalIceServers = globalIceServers;
if (typeof window !== 'undefined') {
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">
<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">