diff --git a/resources/js/candidate-proctor.js b/resources/js/candidate-proctor.js index 7830d76..6ef984e 100644 --- a/resources/js/candidate-proctor.js +++ b/resources/js/candidate-proctor.js @@ -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); + }); }); } diff --git a/resources/js/candidate-room.js b/resources/js/candidate-room.js index 78a8177..6a89667 100644 --- a/resources/js/candidate-room.js +++ b/resources/js/candidate-room.js @@ -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); diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index a6e8072..933d490 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -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); } diff --git a/resources/js/metered.js b/resources/js/metered.js index fd3b02a..29437ce 100644 --- a/resources/js/metered.js +++ b/resources/js/metered.js @@ -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} 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; }); diff --git a/resources/views/interview/show.blade.php b/resources/views/interview/show.blade.php index 9dd9cff..303dc8f 100644 --- a/resources/views/interview/show.blade.php +++ b/resources/views/interview/show.blade.php @@ -66,7 +66,7 @@ details.meta[open] summary i.chev { transform: rotate(180deg); } - +