feat: multiple interviewer call support

This commit is contained in:
kushal.saha 2026-08-07 12:49:43 +00:00
parent b03a01df47
commit f0a8e559a6
9 changed files with 551 additions and 324 deletions

View File

@ -73,3 +73,6 @@ MICROSOFT_TENANT_ID=common
# Metered TURN Server Credentials # Metered TURN Server Credentials
METERED_URL= METERED_URL=
METERED_KEY= METERED_KEY=
# Interview Configurations
MAX_INTERVIEWER=2

View File

@ -3,6 +3,7 @@
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -17,11 +18,9 @@ public function __invoke(): JsonResponse
try { try {
$baseUrl = config( $baseUrl = config(
"services.metered.url", "services.metered.url",
env('METERD_URL', 'https://single-login-system.metered.live/api/v1/turn/credentials')
); );
$apiKey = config( $apiKey = config(
"services.metered.key", "services.metered.key",
env('METERED_KEY', 'ffc9146ac5c2f75d83d7c679d8553a21c8fa')
); );
$url = $baseUrl; $url = $baseUrl;
@ -30,12 +29,10 @@ public function __invoke(): JsonResponse
$url .= $separator . "apiKey=" . urlencode($apiKey); $url .= $separator . "apiKey=" . urlencode($apiKey);
} }
$data = Cache::remember('metered_credentials_cache', now()->addMinute(10), function() use($url) {
$response = Http::timeout(5)->get($url); $response = Http::timeout(5)->get($url);
if(!$response->successful())
if ($response->successful()) { {
return response()->json($response->json());
}
Log::error( Log::error(
"Metered API error response", "Metered API error response",
[ [
@ -44,15 +41,13 @@ public function __invoke(): JsonResponse
], ],
); );
return response()->json( throw new \RuntimeException('Metered API Error');
[ }
"error" => "Failed to fetch ICE servers",
"status" => $response->status(), return $response->json();
], });
$response->status() >= 400 && $response->status() < 600 return response()->json($data);
? $response->status()
: 500,
);
} catch (\Throwable $e) { } catch (\Throwable $e) {
Log::error( Log::error(
"ICE servers fetch exception: " . $e->getMessage(), "ICE servers fetch exception: " . $e->getMessage(),
@ -68,4 +63,3 @@ public function __invoke(): JsonResponse
} }
} }
} }

View File

@ -46,6 +46,7 @@ public function store(Request $request)
'assigned_interviewers' => 'nullable|array', 'assigned_interviewers' => 'nullable|array',
]); ]);
$tempPassword = 'Pass-' . rand(100000, 999999); $tempPassword = 'Pass-' . rand(100000, 999999);
$cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone); $cleanPhone = preg_replace('/[^0-9]/', '', $request->candidate_phone);
$cleanName = Str::slug($request->candidate_name); $cleanName = Str::slug($request->candidate_name);
@ -629,10 +630,20 @@ public function getPollData($id)
// Enforce expiration: ended call status if timeline is expired // Enforce expiration: ended call status if timeline is expired
if ($interview->isExpired()) { if ($interview->isExpired()) {
if ($interview->call_status === 'active') { 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); $formattedRecordings = $this->getFormattedRecordings($interview);
$starterName = $interview->callStarter ? $interview->callStarter->name : null; $starterName = $interview->callStarter ? $interview->callStarter->name : null;
@ -647,6 +658,7 @@ public function getPollData($id)
'call_started_by' => $interview->call_started_by, 'call_started_by' => $interview->call_started_by,
'starter_name' => $starterName, 'starter_name' => $starterName,
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null, 'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
'active_peers' => $validPeers,
'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled, 'enable_tab_switch_screenshot' => $effectiveScreenshotEnabled,
'global_screenshot_enabled' => $globalScreenshotsEnabled, 'global_screenshot_enabled' => $globalScreenshotsEnabled,
'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [], '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). * Update Interview Call Session Status (active, idle, ended).
*/ */
@ -688,6 +753,7 @@ public function updateCallStatus(Request $request, $id)
} elseif (in_array($status, ['ended', 'idle'])) { } elseif (in_array($status, ['ended', 'idle'])) {
$updateData['call_started_by'] = null; $updateData['call_started_by'] = null;
$updateData['call_started_at'] = null; $updateData['call_started_at'] = null;
$updateData['active_peers'] = [];
} }
$interview->update($updateData); $interview->update($updateData);

View File

@ -35,6 +35,7 @@ class Interview extends Model
'call_started_at', 'call_started_at',
'enable_tab_switch_screenshot', 'enable_tab_switch_screenshot',
'tab_switch_screenshots', 'tab_switch_screenshots',
'active_peers',
]; ];
protected $casts = [ protected $casts = [
@ -46,6 +47,7 @@ class Interview extends Model
'proctor_logs' => 'array', 'proctor_logs' => 'array',
'recordings' => 'array', 'recordings' => 'array',
'tab_switch_screenshots' => 'array', 'tab_switch_screenshots' => 'array',
'active_peers' => 'array',
]; ];
public function creator() public function creator()

11
config/interview.php Normal file
View File

@ -0,0 +1,11 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Maximum Interviewer Panelists Limit
|--------------------------------------------------------------------------
|
*/
'max_interviewer' => (int) env('MAX_INTERVIEWER', 2)
];

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -310,20 +310,23 @@
</div> </div>
</div> </div>
<!-- Remote Interviewer Video Feed (Live 2-Way Call) --> <!-- Remote Interviewer Panelist Video Grid (Multi-Party WebRTC Mesh) -->
<div class="webcam-box" style="position: relative; border-color: #6366f1;"> <div id="interviewer-mesh-grid" style="display: flex; flex-direction: column; gap: 10px; width: 100%;">
<video id="interviewer-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video> <div id="interviewer-placeholder-box" class="webcam-box" style="position: relative; border-color: #6366f1;">
<video id="interviewer-video-default" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811; display: none;"></video>
<div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px; z-index: 5;"> <div id="interviewer-video-placeholder" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; background: rgba(15,23,42,0.95); color: #94a3b8; font-size: 0.75rem; text-align: center; padding: 10px; z-index: 5;">
<div id="interviewer-avatar-circle" style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(56,189,248,0.4);"> <div id="interviewer-avatar-circle" style="width: 60px; height: 60px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.5rem; font-weight: 800; font-family: 'Outfit', sans-serif; color: white; margin-bottom: 6px; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
IV IV
</div> </div>
<div id="call-status-sub" style="font-size: 0.7rem; color: #e2e8f0; font-weight: 600;">Interviewer Video Feed</div> <div id="call-status-sub" style="font-size: 0.7rem; color: #e2e8f0; font-weight: 600;">Interviewer Panel</div>
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Waiting for panelist to join call...</div> <div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Waiting for interviewer panelist to join call...</div>
</div> </div>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;"> <div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.8); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10;">
🎙️ Interviewer / Panelist 🎙️ Interviewer / Panelist
</div> </div>
</div> </div>
</div>
<canvas id="proctor-canvas" style="display: none;" width="320" height="240"></canvas> <canvas id="proctor-canvas" style="display: none;" width="320" height="240"></canvas>
@ -381,8 +384,9 @@
</div> </div>
</div> </div>
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY --> <!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY (DISABLED: Auto-connect enabled) -->
<div id="candidate-incoming-modal" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); display: flex; align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;"> <div id="candidate-incoming-modal" style="display: none !important; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px); align-items: center; justify-content: center; z-index: 10000; opacity: 0; pointer-events: none; transition: opacity 0.3s ease;">
<div style="max-width: 440px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #10b981; border-radius: 24px; padding: 32px; text-align: center; color: white; box-shadow: 0 0 50px rgba(16, 185, 129, 0.6);"> <div style="max-width: 440px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #10b981; border-radius: 24px; padding: 32px; text-align: center; color: white; box-shadow: 0 0 50px rgba(16, 185, 129, 0.6);">
<div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;"> <div style="position: relative; width: 90px; height: 90px; margin: 0 auto 20px auto; display: flex; align-items: center; justify-content: center;">
<div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div> <div style="position: absolute; inset: -15px; border-radius: 50%; border: 2px solid rgba(16, 185, 129, 0.6); animation: ringPulse 1.4s infinite ease-out;"></div>
@ -1110,141 +1114,107 @@ class CandRingtoneEngine {
let candRingTimer = null; let candRingTimer = null;
let latestCallStartedAt = null; let latestCallStartedAt = null;
function getCallSessionKey(timestamp) { let candidateInterviewerTiles = new Map();
return 'handled_call_' + interviewId + '_' + (timestamp || latestCallStartedAt || 'active');
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) { function triggerCandidateRing(offer = null, callStartedAt = null) {
if (isCandidateCallConnected) return; // Disabled ringing modal: Auto-connect is active
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;
}
if (offer) pendingOffer = offer; if (offer) pendingOffer = offer;
if (!isCandidateCallConnected && localMediaStream) {
const badge = document.getElementById('call-status-badge'); acceptCandidateCall();
if (badge) { }
badge.innerText = '🔔 INCOMING CALL... RINGING';
badge.style.background = 'rgba(234,179,8,0.3)';
badge.style.color = '#fde047';
} }
const modal = document.getElementById('candidate-incoming-modal'); function declineCandidateCall() {}
if (modal) { function stopCandidateRing() {}
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 showCandidateJoinOption() { 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'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge && !isCandidateCallConnected) {
badge.innerText = '📞 CALL IN PROGRESS'; badge.innerText = 'READY (Waiting for Interviewer)';
badge.style.background = 'rgba(59,130,246,0.3)'; badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#93c5fd'; badge.style.color = '#34d399';
}
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';
} }
} }
async function acceptCandidateCall() { async function acceptCandidateCall() {
const sessionKey = getCallSessionKey();
sessionStorage.setItem(sessionKey, 'handled');
candidateDeclinedOrLeftCall = false; 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) { if (!localMediaStream || localMediaStream.getVideoTracks().length === 0) {
await startCandidateCameraStream(); await startCandidateCameraStream();
} }
@ -1265,31 +1235,13 @@ function showCandidateJoinOption() {
} }
// Signal candidate ready via WebRTC BroadcastChannel // 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) { if (pendingOffer) {
await handleInterviewerOffer(pendingOffer); await handleInterviewerOffer(pendingOffer);
pendingOffer = null; 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) // Automatically capture candidate system screenshot on call start (if enabled)
if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) { if (!hasCapturedCallStartScreenshot && isTabSwitchScreenshotEnabled) {
hasCapturedCallStartScreenshot = true; 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 || [ let globalIceServers = window.globalIceServers || [
{ urls: 'stun:stun.l.google.com:19302' } { urls: 'stun:stun.l.google.com:19302' }
]; ];
@ -1340,11 +1291,8 @@ function initMeteredIceServers() {
return Promise.resolve(globalIceServers); return Promise.resolve(globalIceServers);
} }
// Initiate TURN credentials pre-fetch immediately
initMeteredIceServers(); initMeteredIceServers();
let localMediaStream = null; let localMediaStream = null;
let peerInstance = null; let peerInstance = null;
let candidatePeerConnection = null; let candidatePeerConnection = null;
@ -1362,22 +1310,10 @@ function initMeteredIceServers() {
if (data.type === 'incoming_call' && data.interview_id == interviewId) { if (data.type === 'incoming_call' && data.interview_id == interviewId) {
latestCallStatus = 'active'; latestCallStatus = 'active';
triggerCandidateRing(null, data.call_started_at || null); if (!isCandidateCallConnected) acceptCandidateCall();
} else if (data.type === 'call_ended' && data.interview_id == interviewId) { } else if (data.type === 'call_ended' && data.interview_id == interviewId) {
candRingtone.stop();
if (candRingTimer) clearTimeout(candRingTimer);
isCandidateCallConnected = false; isCandidateCallConnected = false;
latestCallStatus = 'ended'; 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'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
badge.innerText = 'CALL ENDED BY HOST'; badge.innerText = 'CALL ENDED BY HOST';
@ -1390,28 +1326,27 @@ function initMeteredIceServers() {
callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
callSigChannel.onmessage = async (event) => { callSigChannel.onmessage = async (event) => {
const msg = event.data; const msg = event.data;
if (!msg) return; 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'; latestCallStatus = 'active';
if (isCandidateCallConnected && localMediaStream && peerInstance) { if (msg.peer_id && msg.peer_id.startsWith('interviewer_')) {
const interviewerPeerId = 'interviewer_' + interviewId; if (localMediaStream && peerInstance) {
try { try {
const outCall = peerInstance.call(interviewerPeerId, localMediaStream); const outCall = peerInstance.call(msg.peer_id, localMediaStream);
if (outCall) { if (outCall) {
outCall.on('stream', remoteStream => { outCall.on('stream', remoteStream => {
const remoteVideo = document.getElementById('interviewer-video'); addOrUpdateCandidateInterviewerTile(msg.peer_id, remoteStream, msg.user_name || 'Interviewer Panelist');
const placeholder = document.getElementById('interviewer-video-placeholder');
if (remoteVideo) safePlayMediaStream(remoteVideo, remoteStream, false);
if (placeholder) placeholder.style.display = 'none';
}); });
} }
} catch(e) {} } catch(e) {}
} else { }
triggerCandidateRing(msg.offer || null); }
} 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) { } else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) {
await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
@ -1420,13 +1355,14 @@ function initMeteredIceServers() {
await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
} catch(e) {} } catch(e) {}
} else if (msg.type === 'end_call_all') { } else if (msg.type === 'end_call_all') {
candRingtone.stop();
isCandidateCallConnected = false; isCandidateCallConnected = false;
latestCallStatus = 'ended'; latestCallStatus = 'ended';
const modal = document.getElementById('candidate-incoming-modal'); candidateInterviewerTiles.forEach((_, pid) => removeCandidateInterviewerTile(pid));
if (modal) { const badge = document.getElementById('call-status-badge');
modal.style.opacity = '0'; if (badge) {
modal.style.pointerEvents = 'none'; badge.innerText = 'CALL ENDED BY HOST';
badge.style.background = 'rgba(239,68,68,0.2)';
badge.style.color = '#fca5a5';
} }
candidateLeaveCall(true); candidateLeaveCall(true);
} }
@ -1447,34 +1383,8 @@ function initMeteredIceServers() {
} }
candidatePeerConnection.ontrack = (event) => { candidatePeerConnection.ontrack = (event) => {
const remoteVideo = document.getElementById('interviewer-video'); if (event.streams[0]) {
const placeholder = document.getElementById('interviewer-video-placeholder'); addOrUpdateCandidateInterviewerTile('default_pc', event.streams[0], 'Interviewer');
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();
} }
}; };
@ -1519,47 +1429,35 @@ function initMeteredIceServers() {
peerInstance.on('open', id => { peerInstance.on('open', id => {
console.log('Candidate WebRTC Peer Registered:', 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 => { peerInstance.on('call', call => {
activeCallInstance = call; activeCallInstance = call;
latestCallStatus = 'active'; latestCallStatus = 'active';
if (!isCandidateCallConnected && !candidateDeclinedOrLeftCall) { const sendStream = localMediaStream || new MediaStream();
triggerCandidateRing(); try { call.answer(sendStream); } catch(e) {}
}
call.on('stream', remoteStream => { call.on('stream', remoteStream => {
const remoteVideo = document.getElementById('interviewer-video'); addOrUpdateCandidateInterviewerTile(call.peer, remoteStream, 'Interviewer Panelist');
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';
}
}
}); });
if (localMediaStream && localMediaStream.getVideoTracks().length > 0) {
try { call.answer(localMediaStream); } catch(e) {}
}
call.on('close', () => { call.on('close', () => {
candidateLeaveCall(); removeCandidateInterviewerTile(call.peer);
}); });
}); });
peerInstance.on('error', async err => { peerInstance.on('error', async err => {
console.warn('Candidate PeerJS error:', err); console.warn('Candidate PeerJS error:', err);
if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') { if (err.type === 'peer-unavailable' || err.type === 'network' || err.type === 'webrtc') {
const meteredServers = await fetchMeteredTurnIceServers(); const meteredServers = await initMeteredIceServers();
console.log('Re-initializing Candidate PeerJS with Metered TURN Relay Servers...');
if (peerInstance) { if (peerInstance) {
try { peerInstance.destroy(); } catch(e) {} try { peerInstance.destroy(); } catch(e) {}
} }
@ -1570,6 +1468,7 @@ function initMeteredIceServers() {
}); });
} }
function startCandidateCameraStream() { function startCandidateCameraStream() {
const avatarPlaceholder = document.getElementById('webcam-avatar-placeholder'); 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(() => { setInterval(() => {
fetch(`{{ route('interview.candidate.poll', $interview->submission_unique_id) }}`, { if (!interviewId) return;
sendCandidatePeerHeartbeat('heartbeat');
fetch('/candidate/poll/' + interviewId, {
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}) })
.then(r => r.json()) .then(r => r.json())
.then(data => { .then(data => {
if (!data) return; 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) { if (data.enable_tab_switch_screenshot !== undefined) {
isTabSwitchScreenshotEnabled = Boolean(data.enable_tab_switch_screenshot); isTabSwitchScreenshotEnabled = Boolean(data.enable_tab_switch_screenshot);
} }
@ -2189,42 +2137,15 @@ function saveCanvasDrawing(force = false) {
}, 1200); }, 1200);
} }
const sessionKey = getCallSessionKey(data.call_started_at); if (!isCandidateCallConnected && localMediaStream) {
const isHandledInSession = sessionStorage.getItem(sessionKey) === 'handled'; acceptCandidateCall();
if (!isCandidateCallConnected) {
if (isHandledInSession || candidateDeclinedOrLeftCall) {
showCandidateJoinOption();
} else {
triggerCandidateRing(null, data.call_started_at);
}
} }
} else if (data.call_status === 'ended' || data.call_status === 'idle') { } else if (data.call_status === 'ended' || data.call_status === 'idle') {
latestCallStatus = data.call_status; latestCallStatus = data.call_status;
hasCapturedCallStartScreenshot = false; hasCapturedCallStartScreenshot = false;
candidateDeclinedOrLeftCall = false; candidateDeclinedOrLeftCall = false;
candRingtone.stop();
if (candRingTimer) clearTimeout(candRingTimer);
isCandidateCallConnected = false; 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'); const badge = document.getElementById('call-status-badge');
if (badge) { if (badge) {
badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY'; badge.innerText = (data.call_status === 'ended') ? 'CALL ENDED BY HOST' : 'READY';
@ -2234,7 +2155,7 @@ function saveCanvasDrawing(force = false) {
} }
}) })
.catch(() => {}); .catch(() => {});
}, 800); }, 6000);
</script> </script>
</body> </body>
</html> </html>

View File

@ -420,13 +420,13 @@
</button> </button>
@else @else
<button onclick="openReviewModal({{ $inv->id }}, '{{ addslashes($inv->candidate_name) }}', '{{ $inv->submission_unique_id }}')" class="btn-nav" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); font-weight: 600; padding: 6px 12px; margin-right: 4px; cursor: pointer;"> <button onclick="openReviewModal({{ $inv->id }}, '{{ addslashes($inv->candidate_name) }}', '{{ $inv->submission_unique_id }}')" class="btn-nav" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); font-weight: 600; padding: 6px 12px; margin-right: 4px; cursor: pointer;">
🔍 Review Code & Proctor Logs 📞 Join Call
</button> </button>
@endif @endif
<a href="{{ route('interview.room', $inv->submission_unique_id) }}" target="_blank" class="btn-nav" style="padding: 6px 10px; font-size: 0.75rem;"> <!-- <a href="{{ route('interview.room', $inv->submission_unique_id) }}" target="_blank" class="btn-nav" style="padding: 6px 10px; font-size: 0.75rem;">
Open IDE Link Open IDE Link
</a> </a> -->
</td> </td>
</tr> </tr>
@empty @empty
@ -497,7 +497,9 @@
</div> </div>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Assign Internal Interviewers / Panelists</label> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="font-size:0.75rem; color:var(--text-muted); font-weight:500;">Assign Internal Interviewers / Panelists</label>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 120px; overflow-y: auto; background: rgba(0,0,0,0.3); padding: 10px; border-radius: 8px; border: 1px solid var(--card-border);"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; max-height: 120px; overflow-y: auto; background: rgba(0,0,0,0.3); padding: 10px; border-radius: 8px; border: 1px solid var(--card-border);">
@foreach($interviewers as $usr) @foreach($interviewers as $usr)
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: white; cursor: pointer;"> <label style="display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: white; cursor: pointer;">
@ -508,6 +510,7 @@
</div> </div>
</div> </div>
<div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;"> <div style="display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;">
<button type="button" onclick="document.getElementById('create-interview-modal').classList.remove('active')" class="btn-nav">Cancel</button> <button type="button" onclick="document.getElementById('create-interview-modal').classList.remove('active')" class="btn-nav">Cancel</button>
<button type="submit" class="btn-nav" style="background: var(--accent-primary); color: white; border-color: transparent; font-weight: 600;">Create Session & Password</button> <button type="submit" class="btn-nav" style="background: var(--accent-primary); color: white; border-color: transparent; font-weight: 600;">Create Session & Password</button>
@ -590,7 +593,7 @@
</div> </div>
<!-- Video Feeds Grid: Candidate Video, Candidate Screen (with Zoom controls), Panelist Video --> <!-- Video Feeds Grid: Candidate Video, Candidate Screen (with Zoom controls), Panelist Video -->
<div style="display: grid; grid-template-columns: 1fr 1.2fr 1fr; gap: 12px; min-height: 220px;"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; min-height: 220px;">
<!-- 1. Candidate Camera Stream (with Zoom Controls & Initials Avatar) --> <!-- 1. Candidate Camera Stream (with Zoom Controls & Initials Avatar) -->
<div id="cand-video-wrapper" style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid var(--card-border); overflow: hidden; height: 220px;"> <div id="cand-video-wrapper" style="position: relative; background: #050811; border-radius: 12px; border: 1.5px solid var(--card-border); overflow: hidden; height: 220px;">
<div style="position: absolute; top: 6px; right: 6px; z-index: 20; display: flex; gap: 4px; background: rgba(0,0,0,0.7); padding: 3px 6px; border-radius: 6px; backdrop-filter: blur(4px);"> <div style="position: absolute; top: 6px; right: 6px; z-index: 20; display: flex; gap: 4px; background: rgba(0,0,0,0.7); padding: 3px 6px; border-radius: 6px; backdrop-filter: blur(4px);">
@ -660,6 +663,10 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Secondary Panelist WebRTC Mesh Grid (Multi-Party Panelist Video Grid) -->
<div id="panelist-mesh-grid" style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-top: 12px;"></div>
</div> </div>
<!-- LARGE CARD: CANDIDATE IDE & SYNCED NOTEBOOK / DRAWING INSPECTOR (Visible by default even if Screen Share is OFF) --> <!-- LARGE CARD: CANDIDATE IDE & SYNCED NOTEBOOK / DRAWING INSPECTOR (Visible by default even if Screen Share is OFF) -->
@ -844,7 +851,31 @@
</style> </style>
<script> <script>
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
// function checkMaxInterviewers(checkboxElem) {
// const checkedCount = document.querySelectorAll('input[name="assigned_interviewers[]"]:checked').length;
// if (checkedCount > window.MAX_INTERVIEWERS) {
// if (checkboxElem) checkboxElem.checked = false;
// if (typeof Swal !== 'undefined') {
// Swal.fire({
// icon: 'warning',
// title: 'Limit Exceeded',
// text: `You can select a maximum of ${window.MAX_INTERVIEWERS} internal interviewers per assessment session.`,
// confirmButtonColor: '#6366f1',
// toast: true,
// position: 'top-end',
// timer: 3500,
// showConfirmButton: false
// });
// } else {
// alert(`Maximum ${window.MAX_INTERVIEWERS} internal interviewers allowed per session.`);
// }
// }
// }
var interviewerSigChannel = null; var interviewerSigChannel = null;
let currentInterviewId = null; let currentInterviewId = null;
let interviewerPeer = null; let interviewerPeer = null;
let screenReceiverPeer = null; let screenReceiverPeer = null;
@ -1299,7 +1330,7 @@ function openReviewModal(id, name, uid, autoJoinCall = false) {
subscribeLiveSync(id); subscribeLiveSync(id);
pollReviewData(); pollReviewData();
clearInterval(sosPollInterval); clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 500); sosPollInterval = setInterval(pollReviewData, 6000);
if (autoJoinCall) { if (autoJoinCall) {
setTimeout(() => { setTimeout(() => {
@ -1308,8 +1339,30 @@ function openReviewModal(id, name, uid, autoJoinCall = false) {
} }
} }
function sendInterviewerPeerHeartbeat(action = 'heartbeat') {
if (!currentInterviewId) return;
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
fetch(`{{ route('interview.peer-heartbeat', ':id') }}`.replace(':id', currentInterviewId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
peer_id: myPeerId,
name: '{{ addslashes(Auth::user()->name) }}',
role: '{{ Auth::user()->is_admin ? "admin" : "interviewer" }}',
action: action
})
}).catch(e => {});
}
function pollReviewData() { function pollReviewData() {
if (!currentInterviewId) return; if (!currentInterviewId) return;
if (interviewerLocalStream) {
sendInterviewerPeerHeartbeat('heartbeat');
}
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', currentInterviewId), { fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', currentInterviewId), {
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
}) })
@ -1321,6 +1374,37 @@ function pollReviewData() {
}) })
.then(data => { .then(data => {
if (!data) return; if (!data) return;
if (data.active_peers && Array.isArray(data.active_peers)) {
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
const activeIds = new Set(data.active_peers.map(p => p.peer_id));
data.active_peers.forEach(p => {
if (p.peer_id && p.peer_id !== myPeerId) {
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
if (p.peer_id.startsWith('interviewer_')) {
if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
if (!panelistMeshTiles.has(p.peer_id)) {
try {
const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream);
if (outCall) {
outCall.on('stream', remoteStream => {
addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel);
});
}
} catch(e) {}
}
}
}
}
});
panelistMeshTiles.forEach((_, pid) => {
if (!activeIds.has(pid)) {
removePanelistTile(pid);
}
});
}
document.getElementById('modal-code-lang').innerText = (data.submitted_language || 'UNKNOWN').toUpperCase(); document.getElementById('modal-code-lang').innerText = (data.submitted_language || 'UNKNOWN').toUpperCase();
document.getElementById('modal-code-display').innerText = data.submitted_code || '// Candidate has not typed any code yet.'; document.getElementById('modal-code-display').innerText = data.submitted_code || '// Candidate has not typed any code yet.';
document.getElementById('modal-output-display').innerText = data.code_output || 'No execution output.'; document.getElementById('modal-output-display').innerText = data.code_output || 'No execution output.';
@ -1762,16 +1846,77 @@ function safePlayMediaStream(videoElem, stream, isSelf = false) {
} }
} }
let panelistMeshTiles = new Map();
function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist') {
if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return;
const grid = document.getElementById('panelist-mesh-grid');
if (!grid) return;
let tile = document.getElementById('panelist-tile-' + peerId);
let videoElem;
if (!tile) {
tile = document.createElement('div');
tile.id = 'panelist-tile-' + peerId;
tile.style.position = 'relative';
tile.style.background = '#050811';
tile.style.borderRadius = '12px';
tile.style.border = '1.5px solid #6366f1';
tile.style.overflow = 'hidden';
tile.style.height = '220px';
videoElem = document.createElement('video');
videoElem.id = 'panelist-video-' + peerId;
videoElem.autoplay = true;
videoElem.playsInline = true;
videoElem.style.width = '100%';
videoElem.style.height = '100%';
videoElem.style.objectFit = 'cover';
const labelDiv = document.createElement('div');
labelDiv.style.position = 'absolute';
labelDiv.style.bottom = '6px';
labelDiv.style.left = '6px';
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 = '🎙️ ' + name;
tile.appendChild(videoElem);
tile.appendChild(labelDiv);
grid.appendChild(tile);
} else {
videoElem = document.getElementById('panelist-video-' + peerId);
}
if (videoElem && stream) {
safePlayMediaStream(videoElem, stream);
}
panelistMeshTiles.set(peerId, stream);
}
function removePanelistTile(peerId) {
const tile = document.getElementById('panelist-tile-' + peerId);
if (tile) tile.remove();
panelistMeshTiles.delete(peerId);
}
async function initInterviewerPeer() { async function initInterviewerPeer() {
if (!currentInterviewId || typeof Peer === 'undefined') return; if (!currentInterviewId || typeof Peer === 'undefined') return;
await initMeteredIceServers(); await initMeteredIceServers();
const peerId = 'interviewer_' + currentInterviewId; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
if (interviewerPeer && !interviewerPeer.destroyed) { if (interviewerPeer && !interviewerPeer.destroyed) {
try { interviewerPeer.destroy(); } catch(e) {} try { interviewerPeer.destroy(); } catch(e) {}
} }
interviewerPeer = new Peer(peerId, { interviewerPeer = new Peer(myPeerId, {
config: { config: {
iceServers: globalIceServers iceServers: globalIceServers
} }
@ -1779,28 +1924,40 @@ function safePlayMediaStream(videoElem, stream, isSelf = false) {
interviewerPeer.on('open', id => { interviewerPeer.on('open', id => {
console.log('Interviewer WebRTC Peer Registered:', id); console.log('Interviewer WebRTC Peer Registered:', id);
if (interviewerSigChannel) {
interviewerSigChannel.postMessage({
type: 'peer_joined',
peer_id: id,
user_id: currentUserId,
user_name: '{{ addslashes(Auth::user()->name) }}',
role: 'interviewer'
});
}
}); });
interviewerPeer.on('call', call => { interviewerPeer.on('call', call => {
activeCall = call;
const sendStream = interviewerLocalStream || new MediaStream(); const sendStream = interviewerLocalStream || new MediaStream();
call.answer(sendStream); try { call.answer(sendStream); } catch(e) {}
call.on('stream', candRemoteStream => { call.on('stream', remoteStream => {
if (call.peer === 'cand_' + currentInterviewId) {
const candVid = document.getElementById('interviewer-cand-video'); const candVid = document.getElementById('interviewer-cand-video');
const placeholder = document.getElementById('cand-video-placeholder'); const placeholder = document.getElementById('cand-video-placeholder');
if (candVid) { if (candVid) safePlayMediaStream(candVid, remoteStream);
safePlayMediaStream(candVid, candRemoteStream);
}
if (placeholder) placeholder.style.display = 'none'; if (placeholder) placeholder.style.display = 'none';
} else {
addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist');
}
document.getElementById('interviewer-call-status').innerText = 'CONNECTED (LIVE)'; const badge = document.getElementById('interviewer-call-status');
document.getElementById('interviewer-call-status').style.background = 'rgba(16,185,129,0.3)'; if (badge) {
document.getElementById('interviewer-call-status').style.color = '#34d399'; badge.innerText = 'CONNECTED (LIVE)';
badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399';
}
}); });
}); });
// Listen for candidate screen share peer stream with STUN iceServers
const screenPeerId = 'interviewer_screen_' + currentInterviewId; const screenPeerId = 'interviewer_screen_' + currentInterviewId;
if (screenReceiverPeer && !screenReceiverPeer.destroyed) { if (screenReceiverPeer && !screenReceiverPeer.destroyed) {
try { screenReceiverPeer.destroy(); } catch(e) {} try { screenReceiverPeer.destroy(); } catch(e) {}
@ -1838,7 +1995,6 @@ function zoomCandVideo(delta) {
let interviewerPeerConnection = null; let interviewerPeerConnection = null;
interviewerSigChannel = null; interviewerSigChannel = null;
function initInterviewerSigChannel() { function initInterviewerSigChannel() {
if (!currentInterviewId) return; if (!currentInterviewId) return;
if (interviewerSigChannel) interviewerSigChannel.close(); if (interviewerSigChannel) interviewerSigChannel.close();
@ -1853,11 +2009,51 @@ function initInterviewerSigChannel() {
pollReviewData(); pollReviewData();
} }
if ((msg.type === 'offer' || msg.type === 'candidate_ready') && msg.sender === 'candidate') { const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
if (msg.offer) {
await handleCandidateOffer(msg.offer); if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') {
} else if (interviewerLocalStream) { if (msg.peer_id && msg.peer_id !== myPeerId) {
sendOfferToCandidate(); if (msg.type === 'peer_joined') {
interviewerSigChannel.postMessage({
type: 'peer_presence_ack',
peer_id: myPeerId,
user_id: currentUserId,
user_name: '{{ addslashes(Auth::user()->name) }}',
role: 'interviewer'
});
}
if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
try {
const outCall = interviewerPeer.call(msg.peer_id, interviewerLocalStream);
if (outCall) {
outCall.on('stream', remoteStream => {
if (msg.peer_id === 'cand_' + currentInterviewId) {
const candVid = document.getElementById('interviewer-cand-video');
const placeholder = document.getElementById('cand-video-placeholder');
if (candVid) safePlayMediaStream(candVid, remoteStream);
if (placeholder) placeholder.style.display = 'none';
} else {
addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist');
}
const badge = document.getElementById('interviewer-call-status');
if (badge) {
badge.innerText = 'CONNECTED (LIVE)';
badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399';
}
});
}
} catch(e) {}
}
}
} else if (msg.type === 'peer_left') {
if (msg.peer_id === 'cand_' + currentInterviewId) {
const placeholder = document.getElementById('cand-video-placeholder');
if (placeholder) placeholder.style.display = 'flex';
} else if (msg.peer_id) {
removePanelistTile(msg.peer_id);
} }
} else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) { } else if (msg.type === 'answer' && msg.sender === 'candidate' && interviewerPeerConnection) {
await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer)); await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
@ -1865,10 +2061,13 @@ function initInterviewerSigChannel() {
try { try {
await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate)); await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
} catch(e) {} } catch(e) {}
} else if (msg.type === 'end_call_all') {
leaveInterviewerCall(false);
} }
}; };
} }
async function createInterviewerPeerConnection() { async function createInterviewerPeerConnection() {
if (interviewerPeerConnection) return interviewerPeerConnection; if (interviewerPeerConnection) return interviewerPeerConnection;
await initMeteredIceServers(); await initMeteredIceServers();
@ -2045,6 +2244,7 @@ function startInterviewerCall(isRejoin = false) {
} }
function leaveInterviewerCall(isExplicitEnd = true) { function leaveInterviewerCall(isExplicitEnd = true) {
sendInterviewerPeerHeartbeat('leave');
if (currentInterviewId && isExplicitEnd) { if (currentInterviewId && isExplicitEnd) {
const endingId = currentInterviewId; const endingId = currentInterviewId;
endedCallIds.add(endingId); endedCallIds.add(endingId);

View File

@ -38,6 +38,7 @@
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes'); Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing'); Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing');
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message'); Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
Route::post('/candidate/peer-heartbeat/{id}', [InterviewController::class, 'registerPeerHeartbeat'])->name('interview.candidate.peer-heartbeat');
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll'); Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
// Public Storage & Media Stream Delivery & ICE Servers // Public Storage & Media Stream Delivery & ICE Servers
@ -76,6 +77,7 @@
Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store'); Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning'); Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll'); Route::get('/interviews/{id}/poll', [InterviewController::class, 'getPollData'])->name('interview.poll');
Route::post('/interviews/{id}/peer-heartbeat', [InterviewController::class, 'registerPeerHeartbeat'])->name('interview.peer-heartbeat');
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password'); Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate'); Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report'); Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');