Merge pull request 'feat: multiple interviewer call support' (#19) from feature/multiple-interviewer into main
Reviewed-on: #19
This commit is contained in:
commit
2225e227e0
@ -73,3 +73,6 @@ MICROSOFT_TENANT_ID=common
|
||||
# Metered TURN Server Credentials
|
||||
METERED_URL=
|
||||
METERED_KEY=
|
||||
|
||||
# Interview Configurations
|
||||
MAX_INTERVIEWER=2
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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()
|
||||
|
||||
11
config/interview.php
Normal file
11
config/interview.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maximum Interviewer Panelists Limit
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
*/
|
||||
'max_interviewer' => (int) env('MAX_INTERVIEWER', 2)
|
||||
];
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -310,21 +310,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Interviewer Video Feed (Live 2-Way Call) -->
|
||||
<div class="webcam-box" style="position: relative; border-color: #6366f1;">
|
||||
<video id="interviewer-video" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></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-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
|
||||
<!-- Remote Interviewer Panelist Video Grid (Multi-Party WebRTC Mesh) -->
|
||||
<div id="interviewer-mesh-grid" style="display: flex; flex-direction: column; gap: 10px; width: 100%;">
|
||||
<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-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
|
||||
</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 interviewer panelist to join call...</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;">
|
||||
🎙️ Interviewer / Panelist
|
||||
</div>
|
||||
<div id="call-status-sub" style="font-size: 0.7rem; color: #e2e8f0; font-weight: 600;">Interviewer Video Feed</div>
|
||||
<div style="font-size: 0.65rem; color: #38bdf8; margin-top: 2px;">Waiting for panelist to join call...</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;">
|
||||
🎙️ Interviewer / Panelist
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<canvas id="proctor-canvas" style="display: none;" width="320" height="240"></canvas>
|
||||
|
||||
<!-- Candidate Profile Info -->
|
||||
@ -381,8 +384,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY -->
|
||||
<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;">
|
||||
<!-- CANDIDATE INCOMING CALL MODAL / RINGING OVERLAY (DISABLED: Auto-connect enabled) -->
|
||||
<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="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>
|
||||
@ -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);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -420,13 +420,13 @@
|
||||
</button>
|
||||
@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;">
|
||||
🔍 Review Code & Proctor Logs
|
||||
📞 Join Call
|
||||
</button>
|
||||
@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
|
||||
</a>
|
||||
</a> -->
|
||||
</td>
|
||||
</tr>
|
||||
@empty
|
||||
@ -497,7 +497,9 @@
|
||||
</div>
|
||||
|
||||
<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);">
|
||||
@foreach($interviewers as $usr)
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.75rem; color: white; cursor: pointer;">
|
||||
@ -508,6 +510,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<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="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>
|
||||
|
||||
<!-- 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) -->
|
||||
<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);">
|
||||
@ -660,6 +663,10 @@
|
||||
</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>
|
||||
|
||||
<!-- LARGE CARD: CANDIDATE IDE & SYNCED NOTEBOOK / DRAWING INSPECTOR (Visible by default even if Screen Share is OFF) -->
|
||||
@ -844,7 +851,31 @@
|
||||
</style>
|
||||
|
||||
<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;
|
||||
|
||||
let currentInterviewId = null;
|
||||
let interviewerPeer = null;
|
||||
let screenReceiverPeer = null;
|
||||
@ -1299,7 +1330,7 @@ function openReviewModal(id, name, uid, autoJoinCall = false) {
|
||||
subscribeLiveSync(id);
|
||||
pollReviewData();
|
||||
clearInterval(sosPollInterval);
|
||||
sosPollInterval = setInterval(pollReviewData, 500);
|
||||
sosPollInterval = setInterval(pollReviewData, 6000);
|
||||
|
||||
if (autoJoinCall) {
|
||||
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() {
|
||||
if (!currentInterviewId) return;
|
||||
if (interviewerLocalStream) {
|
||||
sendInterviewerPeerHeartbeat('heartbeat');
|
||||
}
|
||||
|
||||
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', currentInterviewId), {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
})
|
||||
@ -1321,6 +1374,37 @@ function pollReviewData() {
|
||||
})
|
||||
.then(data => {
|
||||
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-display').innerText = data.submitted_code || '// Candidate has not typed any code yet.';
|
||||
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() {
|
||||
if (!currentInterviewId || typeof Peer === 'undefined') return;
|
||||
await initMeteredIceServers();
|
||||
|
||||
const peerId = 'interviewer_' + currentInterviewId;
|
||||
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
|
||||
if (interviewerPeer && !interviewerPeer.destroyed) {
|
||||
try { interviewerPeer.destroy(); } catch(e) {}
|
||||
}
|
||||
|
||||
interviewerPeer = new Peer(peerId, {
|
||||
interviewerPeer = new Peer(myPeerId, {
|
||||
config: {
|
||||
iceServers: globalIceServers
|
||||
}
|
||||
@ -1779,28 +1924,40 @@ function safePlayMediaStream(videoElem, stream, isSelf = false) {
|
||||
|
||||
interviewerPeer.on('open', 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 => {
|
||||
activeCall = call;
|
||||
const sendStream = interviewerLocalStream || new MediaStream();
|
||||
call.answer(sendStream);
|
||||
try { call.answer(sendStream); } catch(e) {}
|
||||
|
||||
call.on('stream', candRemoteStream => {
|
||||
const candVid = document.getElementById('interviewer-cand-video');
|
||||
const placeholder = document.getElementById('cand-video-placeholder');
|
||||
if (candVid) {
|
||||
safePlayMediaStream(candVid, candRemoteStream);
|
||||
call.on('stream', remoteStream => {
|
||||
if (call.peer === '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(call.peer, remoteStream, 'Panelist');
|
||||
}
|
||||
if (placeholder) placeholder.style.display = 'none';
|
||||
|
||||
document.getElementById('interviewer-call-status').innerText = 'CONNECTED (LIVE)';
|
||||
document.getElementById('interviewer-call-status').style.background = 'rgba(16,185,129,0.3)';
|
||||
document.getElementById('interviewer-call-status').style.color = '#34d399';
|
||||
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';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for candidate screen share peer stream with STUN iceServers
|
||||
const screenPeerId = 'interviewer_screen_' + currentInterviewId;
|
||||
if (screenReceiverPeer && !screenReceiverPeer.destroyed) {
|
||||
try { screenReceiverPeer.destroy(); } catch(e) {}
|
||||
@ -1838,7 +1995,6 @@ function zoomCandVideo(delta) {
|
||||
let interviewerPeerConnection = null;
|
||||
interviewerSigChannel = null;
|
||||
|
||||
|
||||
function initInterviewerSigChannel() {
|
||||
if (!currentInterviewId) return;
|
||||
if (interviewerSigChannel) interviewerSigChannel.close();
|
||||
@ -1853,11 +2009,51 @@ function initInterviewerSigChannel() {
|
||||
pollReviewData();
|
||||
}
|
||||
|
||||
if ((msg.type === 'offer' || msg.type === 'candidate_ready') && msg.sender === 'candidate') {
|
||||
if (msg.offer) {
|
||||
await handleCandidateOffer(msg.offer);
|
||||
} else if (interviewerLocalStream) {
|
||||
sendOfferToCandidate();
|
||||
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
|
||||
|
||||
if (msg.type === 'peer_joined' || msg.type === 'candidate_ready' || msg.type === 'peer_presence_ack' || msg.type === 'interviewer_joined') {
|
||||
if (msg.peer_id && msg.peer_id !== myPeerId) {
|
||||
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) {
|
||||
await interviewerPeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
|
||||
@ -1865,10 +2061,13 @@ function initInterviewerSigChannel() {
|
||||
try {
|
||||
await interviewerPeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
|
||||
} catch(e) {}
|
||||
} else if (msg.type === 'end_call_all') {
|
||||
leaveInterviewerCall(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async function createInterviewerPeerConnection() {
|
||||
if (interviewerPeerConnection) return interviewerPeerConnection;
|
||||
await initMeteredIceServers();
|
||||
@ -2045,6 +2244,7 @@ function startInterviewerCall(isRejoin = false) {
|
||||
}
|
||||
|
||||
function leaveInterviewerCall(isExplicitEnd = true) {
|
||||
sendInterviewerPeerHeartbeat('leave');
|
||||
if (currentInterviewId && isExplicitEnd) {
|
||||
const endingId = currentInterviewId;
|
||||
endedCallIds.add(endingId);
|
||||
|
||||
@ -38,6 +38,7 @@
|
||||
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/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');
|
||||
|
||||
// Public Storage & Media Stream Delivery & ICE Servers
|
||||
@ -76,6 +77,7 @@
|
||||
Route::post('/interviews', [InterviewController::class, 'store'])->name('interview.store');
|
||||
Route::post('/interviews/{id}/warning', [InterviewController::class, 'sendWarning'])->name('interview.warning');
|
||||
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}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
|
||||
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user