From 1f22185ecf1a675cbf91b41899af43c24d667ddc Mon Sep 17 00:00:00 2001 From: "kushal.saha" Date: Wed, 5 Aug 2026 12:55:08 +0000 Subject: [PATCH] feat: add Metered turn server --- .../views/interview/candidate_room.blade.php | 150 ++++++++++---- resources/views/interview/index.blade.php | 189 +++++++++++++----- 2 files changed, 260 insertions(+), 79 deletions(-) diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 4b75d5f..060828d 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -13,6 +13,7 @@ + @@ -428,7 +429,7 @@ javascript: "// JavaScript (Node.js) Solution\nconsole.log(\"Hello from SingleLogin Assessment!\");\nconst nums = [10, 20, 30];\nconsole.log(\"Total:\", nums.reduce((a, b) => a + b, 0));" }; - + require.config({ paths: { vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs' } }); require(['vs/editor/editor.main'], function () { const initialLang = '{{ strtolower($interview->language) }}'; @@ -592,7 +593,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') { let drewScreenStream = false; const isCallStart = (reason === 'call_start'); - const bannerLabel = isCallStart + const bannerLabel = isCallStart ? '📸 CALL STARTED • CANDIDATE SYSTEM SCREEN SNAPSHOT • ' + new Date().toLocaleString() : '🚨 CANDIDATE SYSTEM COMPUTER DESKTOP SNAPSHOT • ' + new Date().toLocaleString(); @@ -697,7 +698,7 @@ function fallbackCompositeSnapshot(reason = 'tab_switch') { tempCtx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42); tempCtx.fillStyle = isCallStart ? '#10b981' : '#ef4444'; tempCtx.font = 'bold 14px sans-serif'; - const text = isCallStart + const text = isCallStart ? '📸 CALL STARTED • CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString() : '🚨 CANDIDATE SYSTEM SNAPSHOT • ' + new Date().toLocaleString(); tempCtx.fillText(text, 16, tempCanvas.height - 15); @@ -1016,7 +1017,7 @@ class CandRingtoneEngine { this.init(); if (this.isPlaying) return; this.isPlaying = true; - + this.playTone(); if (this.interval) clearInterval(this.interval); this.interval = setInterval(() => { @@ -1211,7 +1212,7 @@ function showCandidateJoinOption() { if (joinBtn) joinBtn.style.display = 'none'; - if (!localMediaStream) { + if (!localMediaStream || localMediaStream.getVideoTracks().length === 0) { await startCandidateCameraStream(); } @@ -1224,7 +1225,6 @@ function showCandidateJoinOption() { badge.style.color = '#34d399'; } - // Dual 2-Way Connection: Answer incoming call AND establish connection to interviewer peer const stream = localMediaStream || new MediaStream(); if (activeCallInstance) { @@ -1249,8 +1249,7 @@ function showCandidateJoinOption() { const remoteVideo = document.getElementById('interviewer-video'); const placeholder = document.getElementById('interviewer-video-placeholder'); if (remoteVideo) { - remoteVideo.srcObject = remoteStream; - remoteVideo.play().catch(e => console.log('Remote video play error:', e)); + safePlayMediaStream(remoteVideo, remoteStream); } if (placeholder) placeholder.style.display = 'none'; }); @@ -1267,7 +1266,60 @@ function showCandidateJoinOption() { } } - // 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS) + function safePlayMediaStream(videoElem, stream, isSelf = false) { + if (!videoElem || !stream) return; + if (isSelf) { + videoElem.muted = true; + } + if (videoElem.srcObject !== stream) { + videoElem.srcObject = stream; + } + const playPromise = videoElem.play(); + if (playPromise !== undefined) { + playPromise.catch(err => { + if (err.name === 'AbortError') { + videoElem.onloadedmetadata = () => { + videoElem.play().catch(() => {}); + }; + } else if (err.name === 'NotAllowedError') { + videoElem.play().catch(() => {}); + } else { + console.log('Media play warning:', err); + } + }); + } + } + + // Metered TURN Server Integration (Pre-fetched for seamless WebRTC P2P + TURN relay) + const METERED_CREDENTIALS_URL = "https://single-login-system.metered.live/api/v1/turn/credentials?apiKey=ffc9146ac5c2f75d83d7c679d8553a21c8fa"; + let globalIceServers = [ + { urls: 'stun:stun.l.google.com:19302' } + ]; + let meteredIceFetchPromise = null; + + function initMeteredIceServers() { + if (meteredIceFetchPromise) return meteredIceFetchPromise; + meteredIceFetchPromise = fetch(METERED_CREDENTIALS_URL) + .then(r => r.json()) + .then(meteredIce => { + if (Array.isArray(meteredIce) && meteredIce.length > 0) { + globalIceServers = [ + { urls: 'stun:stun.l.google.com:19302' }, + ...meteredIce.slice(0, 3) + ]; + } + return globalIceServers; + }) + .catch(e => { + console.warn('Metered TURN fetch warning:', e); + return globalIceServers; + }); + return meteredIceFetchPromise; + } + + // Initiate TURN credentials pre-fetch immediately + initMeteredIceServers(); + let localMediaStream = null; let peerInstance = null; let candidatePeerConnection = null; @@ -1319,7 +1371,22 @@ function showCandidateJoinOption() { if (msg.type === 'interviewer_joined' || msg.type === 'offer') { latestCallStatus = 'active'; - triggerCandidateRing(msg.offer || null); + if (isCandidateCallConnected && localMediaStream && peerInstance) { + const interviewerPeerId = 'interviewer_' + interviewId; + try { + const outCall = peerInstance.call(interviewerPeerId, localMediaStream); + if (outCall) { + outCall.on('stream', remoteStream => { + const remoteVideo = document.getElementById('interviewer-video'); + const placeholder = document.getElementById('interviewer-video-placeholder'); + if (remoteVideo) safePlayMediaStream(remoteVideo, remoteStream, false); + if (placeholder) placeholder.style.display = 'none'; + }); + } + } catch(e) {} + } else { + triggerCandidateRing(msg.offer || null); + } } else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) { await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); } else if (msg.type === 'ice-candidate' && msg.sender !== 'candidate' && candidatePeerConnection && msg.candidate) { @@ -1341,13 +1408,10 @@ function showCandidateJoinOption() { async function createCandidatePeerConnection() { if (candidatePeerConnection) return candidatePeerConnection; + await initMeteredIceServers(); candidatePeerConnection = new RTCPeerConnection({ - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun2.l.google.com:19302' } - ] + iceServers: globalIceServers }); if (localMediaStream) { @@ -1360,8 +1424,7 @@ function showCandidateJoinOption() { const remoteVideo = document.getElementById('interviewer-video'); const placeholder = document.getElementById('interviewer-video-placeholder'); if (remoteVideo && event.streams[0]) { - remoteVideo.srcObject = event.streams[0]; - remoteVideo.play().catch(e => console.log(e)); + safePlayMediaStream(remoteVideo, event.streams[0]); } if (placeholder) placeholder.style.display = 'none'; @@ -1373,7 +1436,7 @@ function showCandidateJoinOption() { } }; - candidatePeerConnection.onconnectionstatechange = () => { + candidatePeerConnection.onconnectionstatechange = async () => { const state = candidatePeerConnection.connectionState; if (state === 'connected') { const badge = document.getElementById('call-status-badge'); @@ -1382,7 +1445,9 @@ function showCandidateJoinOption() { badge.style.background = 'rgba(16,185,129,0.3)'; badge.style.color = '#34d399'; } - } else if (state === 'disconnected' || state === 'failed' || state === 'closed') { + } else if (state === 'failed' || state === 'disconnected') { + console.warn('Candidate P2P Connection State:', state); + } else if (state === 'closed') { candidateLeaveCall(); } }; @@ -1411,8 +1476,9 @@ function showCandidateJoinOption() { callSigChannel.postMessage({ type: 'answer', answer: answer, sender: 'candidate' }); } - function initCandidatePeer() { + async function initCandidatePeer() { if (typeof Peer === 'undefined') return; + await initMeteredIceServers(); const peerId = 'cand_' + interviewId; if (peerInstance) { @@ -1421,11 +1487,7 @@ function initCandidatePeer() { peerInstance = new Peer(peerId, { config: { - iceServers: [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun2.l.google.com:19302' } - ] + iceServers: globalIceServers } }); @@ -1441,15 +1503,11 @@ function initCandidatePeer() { triggerCandidateRing(); } - const sendStream = localMediaStream || new MediaStream(); - call.answer(sendStream); - call.on('stream', remoteStream => { const remoteVideo = document.getElementById('interviewer-video'); const placeholder = document.getElementById('interviewer-video-placeholder'); if (remoteVideo) { - remoteVideo.srcObject = remoteStream; - remoteVideo.play().catch(e => console.log('Candidate remote video play error:', e)); + safePlayMediaStream(remoteVideo, remoteStream); } if (placeholder) placeholder.style.display = 'none'; @@ -1462,33 +1520,57 @@ function initCandidatePeer() { } } }); + if (localMediaStream && localMediaStream.getVideoTracks().length > 0) { + try { call.answer(localMediaStream); } catch(e) {} + } call.on('close', () => { candidateLeaveCall(); }); }); + + peerInstance.on('error', async err => { + console.warn('Candidate PeerJS error:', err); + if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') { + const meteredServers = await fetchMeteredTurnIceServers(); + console.log('Re-initializing Candidate PeerJS with Metered TURN Relay Servers...'); + if (peerInstance) { + try { peerInstance.destroy(); } catch(e) {} + } + peerInstance = new Peer(peerId, { + config: { iceServers: meteredServers } + }); + } + }); } function startCandidateCameraStream() { const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder'); + const audioConstraints = { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }; + return navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 }, facingMode: 'user' }, - audio: true + audio: audioConstraints }) - .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false })) + .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: audioConstraints })) + .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: true })) .catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true })) + .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false })) .then(stream => { localMediaStream = stream; const vid = document.getElementById('webcam'); if (vid) { vid.muted = true; - vid.srcObject = stream; - vid.play().catch(e => console.log('Candidate webcam play error:', e)); + safePlayMediaStream(vid, stream, true); } if (avatarPlaceholder) avatarPlaceholder.style.display = 'none'; diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 934792f..a82dbe4 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -10,6 +10,7 @@ +