diff --git a/.env.example b/.env.example index 7ada4ff..3f030a1 100644 --- a/.env.example +++ b/.env.example @@ -73,3 +73,6 @@ MICROSOFT_TENANT_ID=common # Metered TURN Server Credentials METERED_URL= METERED_KEY= + +# Interview Configurations +MAX_INTERVIEWER=2 diff --git a/app/Http/Controllers/IceServerController.php b/app/Http/Controllers/IceServerController.php index 56c0b00..10a652b 100644 --- a/app/Http/Controllers/IceServerController.php +++ b/app/Http/Controllers/IceServerController.php @@ -3,6 +3,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; @@ -17,11 +18,9 @@ public function __invoke(): JsonResponse try { $baseUrl = config( "services.metered.url", - env('METERD_URL', 'https://single-login-system.metered.live/api/v1/turn/credentials') ); $apiKey = config( "services.metered.key", - env('METERED_KEY', 'ffc9146ac5c2f75d83d7c679d8553a21c8fa') ); $url = $baseUrl; @@ -30,29 +29,25 @@ public function __invoke(): JsonResponse $url .= $separator . "apiKey=" . urlencode($apiKey); } - $response = Http::timeout(5)->get($url); + $data = Cache::remember('metered_credentials_cache', now()->addMinute(10), function() use($url) { + $response = Http::timeout(5)->get($url); + if(!$response->successful()) + { + Log::error( + "Metered API error response", + [ + "status" => $response->status(), + "body" => $response->body(), + ], + ); - if ($response->successful()) { - return response()->json($response->json()); - } + throw new \RuntimeException('Metered API Error'); + } - Log::error( - "Metered API error response", - [ - "status" => $response->status(), - "body" => $response->body(), - ], - ); + return $response->json(); + }); + return response()->json($data); - return response()->json( - [ - "error" => "Failed to fetch ICE servers", - "status" => $response->status(), - ], - $response->status() >= 400 && $response->status() < 600 - ? $response->status() - : 500, - ); } catch (\Throwable $e) { Log::error( "ICE servers fetch exception: " . $e->getMessage(), @@ -68,4 +63,3 @@ public function __invoke(): JsonResponse } } } - diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 5bffbb1..c9c08fa 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -46,6 +46,7 @@ public function store(Request $request) 'assigned_interviewers' => 'nullable|array', ]); + $tempPassword = 'Pass-' . rand(100000, 999999); $cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone); $cleanName = Str::slug($request->candidate_name); @@ -629,10 +630,20 @@ public function getPollData($id) // Enforce expiration: ended call status if timeline is expired if ($interview->isExpired()) { if ($interview->call_status === 'active') { - $interview->update(['call_status' => 'ended']); + $interview->update(['call_status' => 'ended', 'active_peers' => []]); } } + // Clean up stale active_peers (> 15 seconds) + $activePeers = $interview->active_peers ?? []; + $now = now()->timestamp; + $validPeers = array_values(array_filter($activePeers, function ($p) use ($now) { + return ($now - ($p['last_seen'] ?? 0)) < 15; + })); + if (count($validPeers) !== count($activePeers)) { + $interview->update(['active_peers' => $validPeers]); + } + $formattedRecordings = $this->getFormattedRecordings($interview); $starterName = $interview->callStarter ? $interview->callStarter->name : null; @@ -647,6 +658,7 @@ public function getPollData($id) 'call_started_by' => $interview->call_started_by, 'starter_name' => $starterName, 'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null, + 'active_peers' => $validPeers, 'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled, 'global_screenshot_enabled' => $globalScreenshotsEnabled, 'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [], @@ -665,6 +677,59 @@ public function getPollData($id) ]); } + /** + * Register or update active peer presence heartbeat for WebRTC Mesh call. + */ + public function registerPeerHeartbeat(Request $request, $id) + { + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->firstOrFail(); + + $peerId = $request->input('peer_id'); + if (!$peerId) { + return response()->json(['error' => 'peer_id is required'], 422); + } + + $action = $request->input('action', 'heartbeat'); // heartbeat or leave + $user = Auth::user(); + $name = $request->input('name', $user ? $user->name : $interview->candidate_name); + $role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate'); + $userId = $user ? $user->id : 0; + + $activePeers = $interview->active_peers ?? []; + $now = now()->timestamp; + + $updatedPeers = []; + foreach ($activePeers as $p) { + if (($now - ($p['last_seen'] ?? 0)) < 15) { + if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) { + continue; + } + if (($p['peer_id'] ?? '') !== $peerId) { + $updatedPeers[] = $p; + } + } + } + + if ($action !== 'leave') { + $updatedPeers[] = [ + 'peer_id' => $peerId, + 'user_id' => $userId, + 'name' => $name, + 'role' => $role, + 'last_seen' => $now, + ]; + } + + $interview->update(['active_peers' => array_values($updatedPeers)]); + + return response()->json([ + 'success' => true, + 'active_peers' => $updatedPeers, + ]); + } + /** * Update Interview Call Session Status (active, idle, ended). */ @@ -688,6 +753,7 @@ public function updateCallStatus(Request $request, $id) } elseif (in_array($status, ['ended', 'idle'])) { $updateData['call_started_by'] = null; $updateData['call_started_at'] = null; + $updateData['active_peers'] = []; } $interview->update($updateData); diff --git a/app/Models/Interview.php b/app/Models/Interview.php index b45174b..2c158c3 100644 --- a/app/Models/Interview.php +++ b/app/Models/Interview.php @@ -35,6 +35,7 @@ class Interview extends Model 'call_started_at', 'enable_tab_switch_screenshot', 'tab_switch_screenshots', + 'active_peers', ]; protected $casts = [ @@ -46,6 +47,7 @@ class Interview extends Model 'proctor_logs' => 'array', 'recordings' => 'array', 'tab_switch_screenshots' => 'array', + 'active_peers' => 'array', ]; public function creator() diff --git a/config/interview.php b/config/interview.php new file mode 100644 index 0000000..c58a1d7 --- /dev/null +++ b/config/interview.php @@ -0,0 +1,11 @@ + (int) env('MAX_INTERVIEWER', 2) +]; diff --git a/database/migrations/2026_08_07_000000_add_active_peers_to_interviews_table.php b/database/migrations/2026_08_07_000000_add_active_peers_to_interviews_table.php new file mode 100644 index 0000000..cd06a52 --- /dev/null +++ b/database/migrations/2026_08_07_000000_add_active_peers_to_interviews_table.php @@ -0,0 +1,28 @@ +json('active_peers')->nullable()->after('call_started_at'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('interviews', function (Blueprint $table) { + $table->dropColumn('active_peers'); + }); + } +}; diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 7e77c14..cd2ef7d 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -310,21 +310,24 @@ - -
- -
-
- IV + +
+
+ +
+
+ IV +
+
Interviewer Panel
+
Waiting for interviewer panelist to join call...
+
+
+ 🎙️ Interviewer / Panelist
-
Interviewer Video Feed
-
Waiting for panelist to join call...
-
-
- 🎙️ Interviewer / Panelist
+ @@ -381,8 +384,9 @@
- -
+ +
+
@@ -1110,141 +1114,107 @@ class CandRingtoneEngine { let candRingTimer = null; let latestCallStartedAt = null; - function getCallSessionKey(timestamp) { - return 'handled_call_' + interviewId + '_' + (timestamp || latestCallStartedAt || 'active'); + let candidateInterviewerTiles = new Map(); + + function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interviewer Panelist') { + const grid = document.getElementById('interviewer-mesh-grid'); + const placeholder = document.getElementById('interviewer-placeholder-box'); + if (placeholder) placeholder.style.display = 'none'; + + let tile = document.getElementById('cand-iv-tile-' + peerId); + let videoElem; + + if (!tile) { + tile = document.createElement('div'); + tile.id = 'cand-iv-tile-' + peerId; + tile.className = 'webcam-box'; + tile.style.position = 'relative'; + tile.style.borderColor = '#6366f1'; + + videoElem = document.createElement('video'); + videoElem.id = 'cand-iv-video-' + peerId; + videoElem.autoplay = true; + videoElem.playsInline = true; + videoElem.style.width = '100%'; + videoElem.style.height = '100%'; + videoElem.style.objectFit = 'cover'; + videoElem.style.background = '#050811'; + + const labelDiv = document.createElement('div'); + labelDiv.style.position = 'absolute'; + labelDiv.style.bottom = '8px'; + labelDiv.style.left = '8px'; + labelDiv.style.background = 'rgba(99,102,241,0.85)'; + labelDiv.style.padding = '2px 8px'; + labelDiv.style.borderRadius = '4px'; + labelDiv.style.fontSize = '0.65rem'; + labelDiv.style.color = 'white'; + labelDiv.style.fontWeight = '600'; + labelDiv.style.zIndex = '10'; + labelDiv.innerText = '🎙️ ' + labelName; + + tile.appendChild(videoElem); + tile.appendChild(labelDiv); + if (grid) grid.appendChild(tile); + } else { + videoElem = document.getElementById('cand-iv-video-' + peerId); + } + + if (videoElem && stream) { + safePlayMediaStream(videoElem, stream); + } + + 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'; + } + } + + function removeCandidateInterviewerTile(peerId) { + const tile = document.getElementById('cand-iv-tile-' + peerId); + if (tile) tile.remove(); + candidateInterviewerTiles.delete(peerId); + + if (candidateInterviewerTiles.size === 0) { + const placeholder = document.getElementById('interviewer-placeholder-box'); + if (placeholder) placeholder.style.display = 'block'; + + 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'; + } + } } function triggerCandidateRing(offer = null, callStartedAt = null) { - if (isCandidateCallConnected) return; - if (latestCallStatus === 'ended' || latestCallStatus === 'idle') return; - if (callStartedAt) latestCallStartedAt = callStartedAt; - - const sessionKey = getCallSessionKey(callStartedAt); - const isHandled = sessionStorage.getItem(sessionKey) === 'handled'; - - if (isHandled || candidateDeclinedOrLeftCall) { - showCandidateJoinOption(); - return; - } - + // Disabled ringing modal: Auto-connect is active if (offer) pendingOffer = offer; - - const badge = document.getElementById('call-status-badge'); - if (badge) { - badge.innerText = '🔔 INCOMING CALL... RINGING'; - badge.style.background = 'rgba(234,179,8,0.3)'; - badge.style.color = '#fde047'; + if (!isCandidateCallConnected && localMediaStream) { + acceptCandidateCall(); } - - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '1'; - modal.style.pointerEvents = 'auto'; - } - candRingtone.start(); - - if (candRingTimer) clearTimeout(candRingTimer); - candRingTimer = setTimeout(() => { - stopCandidateRing(); - }, 12000); } - function declineCandidateCall() { - candRingtone.stop(); - if (candRingTimer) { - clearTimeout(candRingTimer); - candRingTimer = null; - } - const sessionKey = getCallSessionKey(); - sessionStorage.setItem(sessionKey, 'handled'); - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '0'; - modal.style.pointerEvents = 'none'; - } - candidateDeclinedOrLeftCall = true; - showCandidateJoinOption(); - } - - function stopCandidateRing() { - candRingtone.stop(); - if (candRingTimer) { - clearTimeout(candRingTimer); - candRingTimer = null; - } - const sessionKey = getCallSessionKey(); - sessionStorage.setItem(sessionKey, 'handled'); - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '0'; - modal.style.pointerEvents = 'none'; - } - candidateDeclinedOrLeftCall = true; - showCandidateJoinOption(); - } + function declineCandidateCall() {} + function stopCandidateRing() {} function showCandidateJoinOption() { - if (isCandidateCallConnected) return; - if (latestCallStatus === 'ended' || latestCallStatus === 'idle') { - const joinBtn = document.getElementById('candidate-join-active-call-btn'); - if (joinBtn) { - joinBtn.style.display = 'none'; - joinBtn.disabled = true; - } - return; - } const badge = document.getElementById('call-status-badge'); - if (badge) { - badge.innerText = '📞 CALL IN PROGRESS'; - badge.style.background = 'rgba(59,130,246,0.3)'; - badge.style.color = '#93c5fd'; - } - const joinBtn = document.getElementById('candidate-join-active-call-btn'); - if (joinBtn) { - joinBtn.style.display = 'block'; - joinBtn.disabled = false; - joinBtn.innerText = '🟢 📞 Call in Progress • Rejoin Call Now'; + if (badge && !isCandidateCallConnected) { + badge.innerText = 'READY (Waiting for Interviewer)'; + badge.style.background = 'rgba(16,185,129,0.2)'; + badge.style.color = '#34d399'; } } async function acceptCandidateCall() { - const sessionKey = getCallSessionKey(); - sessionStorage.setItem(sessionKey, 'handled'); - candidateDeclinedOrLeftCall = false; - candRingtone.stop(); - if (candRingTimer) { - clearTimeout(candRingTimer); - candRingTimer = null; - } - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '0'; - modal.style.pointerEvents = 'none'; - } - const joinBtn = document.getElementById('candidate-join-active-call-btn'); - - if (latestCallStatus === 'ended' || latestCallStatus === 'idle') { - if (joinBtn) { - joinBtn.style.display = 'none'; - joinBtn.disabled = true; - } - if (typeof Swal !== 'undefined') { - Swal.fire({ - icon: 'warning', - title: 'Call Ended', - text: 'The interview call has been ended by the host.', - toast: true, - position: 'top-end', - timer: 3000, - showConfirmButton: false - }); - } - return; - } - - if (joinBtn) joinBtn.style.display = 'none'; - if (!localMediaStream || localMediaStream.getVideoTracks().length === 0) { await startCandidateCameraStream(); } @@ -1265,31 +1235,13 @@ function showCandidateJoinOption() { } // Signal candidate ready via WebRTC BroadcastChannel - callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate' }); + callSigChannel.postMessage({ type: 'candidate_ready', sender: 'candidate', peer_id: 'cand_' + interviewId }); if (pendingOffer) { await handleInterviewerOffer(pendingOffer); pendingOffer = null; } - // Call interviewer peer directly if interviewer registered - if (peerInstance) { - const interviewerPeerId = 'interviewer_' + interviewId; - try { - const outCall = peerInstance.call(interviewerPeerId, stream); - if (outCall) { - outCall.on('stream', remoteStream => { - const remoteVideo = document.getElementById('interviewer-video'); - const placeholder = document.getElementById('interviewer-video-placeholder'); - if (remoteVideo) { - safePlayMediaStream(remoteVideo, remoteStream); - } - if (placeholder) placeholder.style.display = 'none'; - }); - } - } catch(e) {} - } - // Automatically capture candidate system screenshot on call start (if enabled) if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) { hasCapturedCallStartScreenshot = true; @@ -1323,7 +1275,6 @@ function safePlayMediaStream(videoElem, stream, isSelf = false) { } } - // Metered TURN Server Integration (Managed via window.getMeteredIceServers in app.js / metered.js) let globalIceServers = window.globalIceServers || [ { urls: 'stun:stun.l.google.com:19302' } ]; @@ -1340,11 +1291,8 @@ function initMeteredIceServers() { return Promise.resolve(globalIceServers); } - // Initiate TURN credentials pre-fetch immediately initMeteredIceServers(); - - let localMediaStream = null; let peerInstance = null; let candidatePeerConnection = null; @@ -1362,22 +1310,10 @@ function initMeteredIceServers() { if (data.type === 'incoming_call' && data.interview_id == interviewId) { latestCallStatus = 'active'; - triggerCandidateRing(null, data.call_started_at || null); + if (!isCandidateCallConnected) acceptCandidateCall(); } else if (data.type === 'call_ended' && data.interview_id == interviewId) { - candRingtone.stop(); - if (candRingTimer) clearTimeout(candRingTimer); isCandidateCallConnected = false; latestCallStatus = 'ended'; - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '0'; - modal.style.pointerEvents = 'none'; - } - const joinBtn = document.getElementById('candidate-join-active-call-btn'); - if (joinBtn) { - joinBtn.style.display = 'none'; - joinBtn.disabled = true; - } const badge = document.getElementById('call-status-badge'); if (badge) { badge.innerText = 'CALL ENDED BY HOST'; @@ -1390,28 +1326,27 @@ function initMeteredIceServers() { callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); - callSigChannel.onmessage = async (event) => { const msg = event.data; if (!msg) return; - if (msg.type === 'interviewer_joined' || msg.type === 'offer') { + if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer') { latestCallStatus = 'active'; - 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); + if (msg.peer_id && msg.peer_id.startsWith('interviewer_')) { + if (localMediaStream && peerInstance) { + try { + const outCall = peerInstance.call(msg.peer_id, localMediaStream); + if (outCall) { + outCall.on('stream', remoteStream => { + addOrUpdateCandidateInterviewerTile(msg.peer_id, remoteStream, msg.user_name || 'Interviewer Panelist'); + }); + } + } catch(e) {} + } + } + } else if (msg.type === 'peer_left' || msg.type === 'interviewer_left') { + if (msg.peer_id) { + removeCandidateInterviewerTile(msg.peer_id); } } else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) { await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); @@ -1420,13 +1355,14 @@ function initMeteredIceServers() { await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); } catch(e) {} } else if (msg.type === 'end_call_all') { - candRingtone.stop(); isCandidateCallConnected = false; latestCallStatus = 'ended'; - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '0'; - modal.style.pointerEvents = 'none'; + candidateInterviewerTiles.forEach((_, pid) => removeCandidateInterviewerTile(pid)); + const badge = document.getElementById('call-status-badge'); + if (badge) { + badge.innerText = 'CALL ENDED BY HOST'; + badge.style.background = 'rgba(239,68,68,0.2)'; + badge.style.color = '#fca5a5'; } candidateLeaveCall(true); } @@ -1447,34 +1383,8 @@ function initMeteredIceServers() { } candidatePeerConnection.ontrack = (event) => { - const remoteVideo = document.getElementById('interviewer-video'); - const placeholder = document.getElementById('interviewer-video-placeholder'); - if (remoteVideo && event.streams[0]) { - safePlayMediaStream(remoteVideo, event.streams[0]); - } - if (placeholder) placeholder.style.display = 'none'; - - 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'; - } - }; - - candidatePeerConnection.onconnectionstatechange = async () => { - const state = candidatePeerConnection.connectionState; - if (state === 'connected') { - 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'; - } - } else if (state === 'failed' || state === 'disconnected') { - console.warn('Candidate P2P Connection State:', state); - } else if (state === 'closed') { - candidateLeaveCall(); + if (event.streams[0]) { + addOrUpdateCandidateInterviewerTile('default_pc', event.streams[0], 'Interviewer'); } }; @@ -1519,47 +1429,35 @@ function initMeteredIceServers() { peerInstance.on('open', id => { console.log('Candidate WebRTC Peer Registered:', id); + // Broadcast presence so interviewers (even those who joined before candidate) discover candidate instantly + callSigChannel.postMessage({ + type: 'peer_joined', + peer_id: id, + role: 'candidate', + candidate_name: '{{ addslashes($interview->candidate_name) }}' + }); }); peerInstance.on('call', call => { activeCallInstance = call; latestCallStatus = 'active'; - if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) { - triggerCandidateRing(); - } + const sendStream = localMediaStream || new MediaStream(); + try { call.answer(sendStream); } catch(e) {} call.on('stream', remoteStream => { - const remoteVideo = document.getElementById('interviewer-video'); - const placeholder = document.getElementById('interviewer-video-placeholder'); - if (remoteVideo) { - safePlayMediaStream(remoteVideo, remoteStream); - } - if (placeholder) placeholder.style.display = 'none'; - - if (isCandidateCallConnected) { - 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'; - } - } + addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist'); }); - if (localMediaStream && localMediaStream.getVideoTracks().length > 0) { - try { call.answer(localMediaStream); } catch(e) {} - } call.on('close', () => { - candidateLeaveCall(); + removeCandidateInterviewerTile(call.peer); }); }); 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...'); + const meteredServers = await initMeteredIceServers(); if (peerInstance) { try { peerInstance.destroy(); } catch(e) {} } @@ -1570,6 +1468,7 @@ function initMeteredIceServers() { }); } + function startCandidateCameraStream() { const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder'); @@ -2166,14 +2065,63 @@ function saveCanvasDrawing(force = false) { } } + function sendCandidatePeerHeartbeat(action = 'heartbeat') { + if (!interviewId) return; + const candPeerId = 'cand_' + interviewId; + fetch('/candidate/peer-heartbeat/' + interviewId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-TOKEN': '{{ csrf_token() }}' + }, + body: JSON.stringify({ + peer_id: candPeerId, + name: '{{ addslashes($interview->candidate_name) }}', + role: 'candidate', + action: action + }) + }).catch(e => {}); + } + setInterval(() => { - fetch(`{{ route('interview.candidate.poll', $interview->submission_unique_id) }}`, { + if (!interviewId) return; + sendCandidatePeerHeartbeat('heartbeat'); + + fetch('/candidate/poll/' + interviewId, { headers: { 'Accept': 'application/json' } }) .then(r => r.json()) .then(data => { if (!data) return; + 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 (localMediaStream && peerInstance && peerInstance.open) { + if (!candidateInterviewerTiles.has(p.peer_id)) { + try { + const outCall = peerInstance.call(p.peer_id, localMediaStream); + 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.enable_tab_switch_screenshot !== undefined) { isTabSwitchScreenshotEnabled = Boolean(data.enable_tab_switch_screenshot); } @@ -2189,42 +2137,15 @@ function saveCanvasDrawing(force = false) { }, 1200); } - const sessionKey = getCallSessionKey(data.call_started_at); - const isHandledInSession = sessionStorage.getItem(sessionKey) === 'handled'; - - if (!isCandidateCallConnected) { - if (isHandledInSession || candidateDeclinedOrLeftCall) { - showCandidateJoinOption(); - } else { - triggerCandidateRing(null, data.call_started_at); - } + if (!isCandidateCallConnected && localMediaStream) { + acceptCandidateCall(); } } else if (data.call_status === 'ended' || data.call_status === 'idle') { latestCallStatus = data.call_status; hasCapturedCallStartScreenshot = false; candidateDeclinedOrLeftCall = false; - candRingtone.stop(); - if (candRingTimer) clearTimeout(candRingTimer); isCandidateCallConnected = false; - // Clear session storage handled keys for ended/idle call sessions - for (let i = sessionStorage.length - 1; i >= 0; i--) { - const key = sessionStorage.key(i); - if (key && key.startsWith('handled_call_' + interviewId)) { - sessionStorage.removeItem(key); - } - } - - const modal = document.getElementById('candidate-incoming-modal'); - if (modal) { - modal.style.opacity = '0'; - modal.style.pointerEvents = 'none'; - } - const joinBtn = document.getElementById('candidate-join-active-call-btn'); - if (joinBtn) { - joinBtn.style.display = 'none'; - joinBtn.disabled = true; - } const badge = document.getElementById('call-status-badge'); if (badge) { badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY'; @@ -2234,7 +2155,7 @@ function saveCanvasDrawing(force = false) { } }) .catch(() => {}); - }, 800); + }, 6000); diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 7e2a503..4497b69 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -420,13 +420,13 @@ @else @endif - + @empty @@ -497,7 +497,9 @@
- +
+ +
@foreach($interviewers as $usr)
+
@@ -590,7 +593,7 @@
-
+
@@ -660,6 +663,10 @@
+ + +
+
@@ -844,7 +851,31 @@