subhajit_work_22_07 #11

Merged
subhajit.pal merged 6 commits from subhajit_work_22_07 into main 2026-07-30 12:01:56 +00:00
8 changed files with 1531 additions and 73 deletions
Showing only changes of commit 6600c95584 - Show all commits

View File

@ -505,17 +505,71 @@ private function getFormattedRecordings($interview)
return $formatted;
}
/**
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
/**
* Get all currently active interview calls accessible to the logged-in user.
*/
public function getActiveCalls(Request $request)
{
$user = Auth::user();
if (!$user) {
return response()->json(['active_calls' => [], 'current_user_id' => null]);
}
$accessible = $user->getAccessibleInterviews();
$activeCalls = [];
foreach ($accessible as $interview) {
if ($interview->call_status === 'active') {
$starterName = 'Panelist';
if ($interview->call_started_by) {
$starter = User::find($interview->call_started_by);
if ($starter) {
$starterName = $starter->name;
}
}
$activeCalls[] = [
'id' => $interview->id,
'candidate_name' => $interview->candidate_name,
'candidate_email' => $interview->candidate_email,
'submission_unique_id' => $interview->submission_unique_id,
'call_status' => $interview->call_status,
'call_started_by' => $interview->call_started_by,
'starter_name' => $starterName,
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
];
}
}
return response()->json([
'active_calls' => $activeCalls,
'current_user_id' => $user->id,
]);
}
/**
* Live Polling Endpoint for Candidate Warnings & HR Monitoring.
*/
public function getPollData($id)
{
$interview = Interview::with(['warnings.sender'])->findOrFail($id);
$interview = Interview::with(['warnings.sender', 'callStarter'])
->where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
$formattedRecordings = $this->getFormattedRecordings($interview);
$starterName = $interview->callStarter ? $interview->callStarter->name : null;
return response()->json([
'status' => $interview->status,
'call_status' => $interview->call_status ?? 'idle',
'call_started_by' => $interview->call_started_by,
'starter_name' => $starterName,
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
'enable_tab_switch_screenshot' => (bool) ($interview->enable_tab_switch_screenshot ?? true),
'tab_switch_screenshots' => $interview->tab_switch_screenshots ?? [],
'submitted_code' => $interview->submitted_code,
'submitted_language' => $interview->submitted_language,
'code_output' => $interview->code_output,
@ -536,18 +590,141 @@ public function getPollData($id)
*/
public function updateCallStatus(Request $request, $id)
{
$interview = Interview::findOrFail($id);
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
$status = $request->input('call_status', 'idle');
$interview->update(['call_status' => $status]);
return response()->json(['success' => true, 'call_status' => $status]);
$isRejoin = $request->boolean('is_rejoin', false);
$updateData = ['call_status' => $status];
$isNewCall = false;
if ($status === 'active') {
if (!$isRejoin && ($interview->call_status !== 'active' || !$interview->call_started_at)) {
$updateData['call_started_by'] = Auth::id();
$updateData['call_started_at'] = now();
$isNewCall = true;
}
} elseif (in_array($status, ['ended', 'idle'])) {
$updateData['call_started_by'] = null;
$updateData['call_started_at'] = null;
}
$interview->update($updateData);
$starterName = Auth::user() ? Auth::user()->name : 'Panelist';
return response()->json([
'success' => true,
'call_status' => $status,
'is_new_call' => $isNewCall,
'call_started_by' => $interview->call_started_by,
'starter_name' => $starterName,
'call_started_at' => $interview->call_started_at ? $interview->call_started_at->toIso8601String() : null,
]);
}
/**
* Save Candidate Silent Video Recording Blob / Snapshot Stream.
* Upload & Save Candidate Tab-Switch Screenshot under public/uploads/candidate_screenshots/{unique_id}/.
*/
public function uploadTabScreenshot(Request $request, $id)
{
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
if (isset($interview->enable_tab_switch_screenshot) && !$interview->enable_tab_switch_screenshot) {
return response()->json([
'success' => false,
'message' => 'Tab-switch screenshot capture is disabled by admin setting.'
]);
}
$url = null;
$filename = 'screenshot_tab_switch_' . time() . '_' . rand(100, 999) . '.jpg';
$subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id;
$destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true);
}
if ($request->hasFile('screenshot')) {
$file = $request->file('screenshot');
$file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename);
} elseif ($request->input('image')) {
$base64Image = $request->input('image');
if (str_contains($base64Image, ',')) {
$base64Image = explode(',', $base64Image)[1];
}
$imageData = base64_decode($base64Image);
file_put_contents($destinationPath . '/' . $filename, $imageData);
$url = asset($subDir . '/' . $filename);
}
if ($url) {
$relativePath = '/' . $subDir . '/' . $filename;
$screenshots = $interview->tab_switch_screenshots ?? [];
$screenshotEntry = [
'url' => $relativePath,
'full_url' => $url,
'filename' => $filename,
'timestamp' => now()->toIso8601String(),
'reason' => 'tab_switch',
];
$screenshots[] = $screenshotEntry;
$logs = $interview->proctor_logs ?? [];
$logs[] = [
'type' => 'tab_switch',
'details' => 'Candidate switched active browser tab (Screenshot Captured: ' . $filename . ')',
'screenshot_url' => $relativePath,
'timestamp' => now()->toIso8601String(),
];
$interview->update([
'tab_switch_screenshots' => $screenshots,
'proctor_logs' => $logs,
]);
return response()->json([
'success' => true,
'url' => $relativePath,
'total_screenshots' => count($screenshots),
]);
}
return response()->json(['success' => false, 'message' => 'No image payload received']);
}
/**
* Admin Toggle Enable/Disable Candidate Tab Switch Screenshot Capture.
*/
public function toggleTabScreenshot(Request $request, $id)
{
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
$enable = $request->boolean('enable', true);
$interview->update(['enable_tab_switch_screenshot' => $enable]);
return response()->json([
'success' => true,
'enable_tab_switch_screenshot' => $enable,
'message' => $enable ? 'Tab-switch screenshot capture ENABLED.' : 'Tab-switch screenshot capture DISABLED.'
]);
}
/**
* Save Candidate Video Recording under public/uploads/candidate_recordings/{unique_id}/.
*/
public function uploadRecording(Request $request, $id)
{
$interview = Interview::findOrFail($id);
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->firstOrFail();
if ($request->hasFile('video')) {
$file = $request->file('video');
@ -556,9 +733,15 @@ public function uploadRecording(Request $request, $id)
$ext = 'webm';
}
$filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.' . $ext;
$path = $file->storeAs('recordings', $filename, 'public');
$url = Storage::disk('public')->url($path);
$subDir = 'uploads/candidate_recordings/' . $interview->submission_unique_id;
$destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true);
}
$filename = 'recording_' . time() . '.' . $ext;
$file->move($destinationPath, $filename);
$url = asset($subDir . '/' . $filename);
$recordings = $interview->recordings ?? [];
if (!is_array($recordings)) {

View File

@ -31,14 +31,21 @@ class Interview extends Model
'blocked_ip',
'created_by',
'call_status',
'call_started_by',
'call_started_at',
'enable_tab_switch_screenshot',
'tab_switch_screenshots',
];
protected $casts = [
'scheduled_at' => 'datetime',
'expires_at' => 'datetime',
'call_started_at' => 'datetime',
'enable_tab_switch_screenshot' => 'boolean',
'assigned_interviewers' => 'array',
'proctor_logs' => 'array',
'recordings' => 'array',
'tab_switch_screenshots' => 'array',
];
public function creator()
@ -46,6 +53,11 @@ public function creator()
return $this->belongsTo(User::class, 'created_by');
}
public function callStarter()
{
return $this->belongsTo(User::class, 'call_started_by');
}
public function warnings()
{
return $this->hasMany(InterviewWarning::class, 'interview_id');

View File

@ -0,0 +1,33 @@
<?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) {
if (!Schema::hasColumn('interviews', 'call_started_by')) {
$table->unsignedBigInteger('call_started_by')->nullable()->after('call_status');
}
if (!Schema::hasColumn('interviews', 'call_started_at')) {
$table->timestamp('call_started_at')->nullable()->after('call_started_by');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->dropColumn(['call_started_by', 'call_started_at']);
});
}
};

View File

@ -0,0 +1,29 @@
<?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->boolean('enable_tab_switch_screenshot')->default(true)->after('proctor_logs');
$table->json('tab_switch_screenshots')->nullable()->after('enable_tab_switch_screenshot');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('interviews', function (Blueprint $table) {
$table->dropColumn(['enable_tab_switch_screenshot', 'tab_switch_screenshots']);
});
}
};

View File

@ -1332,5 +1332,261 @@ function initDragAndDrop(gridId, storageKey) {
});
});
</script>
@if(Auth::user()->isAdmin() || strtolower(Auth::user()->role) === 'hr' || Auth::user()->hasActiveInterviewZone())
<!-- INCOMING CALL RING MODAL FOR DASHBOARD -->
<div id="incoming-call-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 style="max-width: 460px; width: 90%; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: center; color: white;">
<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: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
📞
</div>
</div>
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
INCOMING INTERVIEW CALL
</div>
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
Candidate Name
</h3>
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
Initiated by Panelist
</div>
<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; font-size: 0.78rem; color: #cbd5e1; margin-bottom: 24px; line-height: 1.4;">
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy.
</div>
<div style="display: flex; gap: 14px; justify-content: center;">
<button id="incoming-decline-btn" onclick="declineIncomingCall()" style="flex: 1; padding: 12px 18px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 12px; cursor: pointer;">
🚫 Decline / Miss
</button>
<button id="incoming-accept-btn" onclick="acceptIncomingCall()" style="flex: 1.3; padding: 12px 18px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.9rem; border-radius: 12px; box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
📞 Accept Call
</button>
</div>
</div>
</div>
<style>
@keyframes ringPulse {
0% { transform: scale(0.85); opacity: 1; }
100% { transform: scale(1.35); opacity: 0; }
}
@keyframes acceptGlow {
0% { box-shadow: 0 0 15px rgba(16, 185, 129, 0.5); transform: scale(1); }
100% { box-shadow: 0 0 28px rgba(16, 185, 129, 0.9); transform: scale(1.03); }
}
</style>
<script>
class DashboardRingtoneEngine {
constructor() {
this.ctx = null;
this.interval = null;
this.isPlaying = false;
}
init() {
try {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
} catch(e) {}
}
start() {
this.init();
if (this.isPlaying) return;
this.isPlaying = true;
this.playTone();
if (this.interval) clearInterval(this.interval);
this.interval = setInterval(() => {
if (this.isPlaying) this.playTone();
}, 2400);
}
playTone() {
this.init();
if (!this.ctx || !this.isPlaying) return;
try {
const now = this.ctx.currentTime;
const osc1 = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc1.type = 'sine';
osc2.type = 'sine';
osc1.frequency.setValueAtTime(523.25, now);
osc2.frequency.setValueAtTime(659.25, now);
gain.gain.setValueAtTime(0.001, now);
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
gain.gain.setValueAtTime(0.35, now + 1.1);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(this.ctx.destination);
osc1.start(now);
osc2.start(now);
osc1.stop(now + 1.35);
osc2.stop(now + 1.35);
} catch(e) {}
}
stop() {
this.isPlaying = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
const dashboardRingtone = new DashboardRingtoneEngine();
['click', 'keydown', 'touchstart'].forEach(evt => {
document.addEventListener(evt, () => dashboardRingtone.init(), { passive: true });
});
let activeIncomingCall = null;
let dashboardRingTimeoutTimer = null;
const dismissedCallIds = new Set();
const currentUserId = {{ Auth::id() }};
const globalCallChannel = new BroadcastChannel('global_call_alerts');
function triggerDashboardIncomingRing(callData) {
if (dismissedCallIds.has(callData.id)) return;
if (callData.call_started_at) {
const elapsed = Date.now() - new Date(callData.call_started_at).getTime();
if (elapsed > 10000) return;
}
activeIncomingCall = callData;
document.getElementById('incoming-cand-name').innerText = callData.candidate_name;
document.getElementById('incoming-starter-info').innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
const modal = document.getElementById('incoming-call-modal');
modal.style.opacity = '1';
modal.style.pointerEvents = 'auto';
dashboardRingtone.start();
if (dashboardRingTimeoutTimer) clearTimeout(dashboardRingTimeoutTimer);
let remainingMs = 10000;
if (callData.call_started_at) {
const elapsed = Date.now() - new Date(callData.call_started_at).getTime();
remainingMs = Math.max(1000, 10000 - elapsed);
}
dashboardRingTimeoutTimer = setTimeout(() => {
declineIncomingCall(true);
}, remainingMs);
}
globalCallChannel.onmessage = (event) => {
const data = event.data;
if (!data) return;
if (data.type === 'incoming_call') {
triggerDashboardIncomingRing({
id: data.interview_id,
candidate_name: data.candidate_name,
submission_unique_id: data.submission_unique_id,
starter_name: data.starter_name,
call_started_at: data.call_started_at || new Date().toISOString()
});
} else if (data.type === 'call_ended') {
if (dashboardRingTimeoutTimer) {
clearTimeout(dashboardRingTimeoutTimer);
dashboardRingTimeoutTimer = null;
}
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
declineIncomingCall(true);
}
}
};
function pollDashboardActiveCalls() {
fetch(`{{ route('interview.active-calls') }}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (!data || !data.active_calls || data.active_calls.length === 0) {
if (activeIncomingCall) {
declineIncomingCall(true);
}
return;
}
const incoming = data.active_calls.find(call => {
if (dismissedCallIds.has(call.id)) return false;
if (call.call_started_at) {
const elapsed = Date.now() - new Date(call.call_started_at).getTime();
if (elapsed > 10000) return false;
}
return true;
});
if (incoming) {
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
triggerDashboardIncomingRing(incoming);
}
} else if (activeIncomingCall) {
declineIncomingCall(true);
}
})
.catch(() => {});
}
setInterval(pollDashboardActiveCalls, 2000);
function acceptIncomingCall() {
dashboardRingtone.stop();
if (dashboardRingTimeoutTimer) {
clearTimeout(dashboardRingTimeoutTimer);
dashboardRingTimeoutTimer = null;
}
const modal = document.getElementById('incoming-call-modal');
modal.style.opacity = '0';
modal.style.pointerEvents = 'none';
if (activeIncomingCall) {
const targetId = activeIncomingCall.id;
activeIncomingCall = null;
window.location.href = "{{ route('interview.index') }}?open_interview=" + targetId + "&join_call=1";
}
}
function declineIncomingCall(silent = false) {
dashboardRingtone.stop();
if (dashboardRingTimeoutTimer) {
clearTimeout(dashboardRingTimeoutTimer);
dashboardRingTimeoutTimer = null;
}
const modal = document.getElementById('incoming-call-modal');
modal.style.opacity = '0';
modal.style.pointerEvents = 'none';
if (activeIncomingCall) {
dismissedCallIds.add(activeIncomingCall.id);
activeIncomingCall = null;
}
}
</script>
@endif
</body>
</html>

View File

@ -337,6 +337,10 @@
<span id="call-status-badge" style="font-size: 0.65rem; background: rgba(16,185,129,0.2); color: #34d399; padding: 2px 8px; border-radius: 999px;">READY</span>
</div>
<button id="candidate-join-active-call-btn" onclick="acceptCandidateCall()" class="btn-ide" style="display: none; width: 100%; padding: 10px; margin-bottom: 10px; font-size: 0.8rem; font-weight: 700; background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none; box-shadow: 0 0 15px rgba(16,185,129,0.4); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
🟢 📞 Call in Progress &bull; Join Call Now
</button>
<div style="display: flex; gap: 8px; margin-bottom: 10px;">
<button id="toggle-mic-btn" onclick="toggleMic()" class="btn-ide" style="flex: 1; padding: 8px; font-size: 0.75rem; background: rgba(16,185,129,0.2); border-color: #10b981;">
🎤 Mic On
@ -372,6 +376,35 @@
</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;">
<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>
<div style="position: absolute; inset: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
📞
</div>
</div>
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
INCOMING INTERVIEW CALL
</div>
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.4rem; font-weight: 800; color: white; margin-bottom: 4px;">
Interviewer Panel Calling...
</h3>
<div style="font-size: 0.8rem; color: #a5b4fc; margin-bottom: 20px;">
The interviewer panel has initiated a live 2-way call.
</div>
<button onclick="acceptCandidateCall()" style="width: 100%; padding: 14px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 1rem; border-radius: 14px; box-shadow: 0 0 25px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
📞 Accept & Join Call
</button>
</div>
</div>
<script>
let editor;
let interviewId = {{ $interview->id }};
@ -485,18 +518,133 @@ function submitSolution() {
});
}
// --- ANTI-CHEAT & PROCTORING ENGINE ---
let isTabSwitchScreenshotEnabled = true;
let lastTabSwitchTime = 0;
// 1. Tab Switch Detection
function captureAndUploadTabScreenshot() {
if (!isTabSwitchScreenshotEnabled) return;
try {
const tempCanvas = document.createElement('canvas');
let w = 1280;
let h = 720;
let drewSource = false;
// Priority 1: Candidate Computer Screen Share Stream (if screen share active)
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
const screenTrack = screenShareStream.getVideoTracks()[0];
if (screenTrack.readyState === 'live') {
try {
const screenVid = document.createElement('video');
screenVid.muted = true;
screenVid.srcObject = screenShareStream;
screenVid.play().catch(e => {});
const settings = screenTrack.getSettings ? screenTrack.getSettings() : {};
w = settings.width || 1280;
h = settings.height || 720;
tempCanvas.width = w;
tempCanvas.height = h;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(screenVid, 0, 0, w, h);
drewSource = true;
} catch(e) {}
}
}
// Priority 2: Candidate Assessment Screen Workspace & Video Composite Snapshot
if (!drewSource) {
const video = document.getElementById('webcam');
w = (video && video.videoWidth > 0) ? video.videoWidth : 1280;
h = (video && video.videoHeight > 0) ? video.videoHeight : 720;
tempCanvas.width = w;
tempCanvas.height = h;
const tempCtx = tempCanvas.getContext('2d');
// Dark IDE workspace background
tempCtx.fillStyle = '#0b0f19';
tempCtx.fillRect(0, 0, w, h);
// IDE Header bar
tempCtx.fillStyle = '#1e293b';
tempCtx.fillRect(0, 0, w, 50);
tempCtx.fillStyle = '#38bdf8';
tempCtx.font = 'bold 16px sans-serif';
tempCtx.fillText('💻 Candidate Assessment Workspace • ID: ' + (typeof interviewId !== 'undefined' ? interviewId : ''), 20, 32);
// Code Editor Panel
const codeDisplay = document.querySelector('.CodeMirror') || document.getElementById('code-editor');
tempCtx.fillStyle = '#020617';
tempCtx.fillRect(20, 70, w - 240, h - 130);
tempCtx.fillStyle = '#94a3b8';
tempCtx.font = '13px monospace';
const codeText = codeDisplay ? (codeDisplay.innerText || codeDisplay.value || '') : '';
const lines = codeText.split('\n').slice(0, 30);
lines.forEach((line, i) => {
tempCtx.fillText(line.substring(0, 100), 35, 95 + (i * 18));
});
// Candidate Camera Feed Inset
if (video && video.readyState >= 2) {
try {
tempCtx.drawImage(video, w - 210, 70, 190, 140);
tempCtx.strokeStyle = '#6366f1';
tempCtx.lineWidth = 2;
tempCtx.strokeRect(w - 210, 70, 190, 140);
} catch(e) {}
}
drewSource = true;
}
// Add red banner overlay at bottom
const ctx = tempCanvas.getContext('2d');
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
ctx.fillRect(0, tempCanvas.height - 42, tempCanvas.width, 42);
ctx.fillStyle = '#ef4444';
ctx.font = 'bold 14px sans-serif';
ctx.fillText('🚨 CANDIDATE TAB SWITCH DETECTED • COMPUTER SCREEN SNAPSHOT • ' + new Date().toLocaleString(), 16, tempCanvas.height - 15);
const imageData = tempCanvas.toDataURL('image/jpeg', 0.85);
fetch(`{{ route('interview.upload-tab-screenshot', $interview->id) }}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ image: imageData })
})
.then(r => r.json())
.then(data => {
console.log('Computer screen tab-switch screenshot saved:', data);
})
.catch(err => console.log('Tab screenshot fetch error:', err));
} catch(e) {
console.log('Tab switch screenshot capture exception:', e);
}
}
// 1. Tab Switch Detection (Captures screenshot ONLY on tab switch if enabled by admin)
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
logViolation('tab_switch', 'Candidate switched browser tab or minimized window');
const now = Date.now();
if (now - lastTabSwitchTime > 1500) {
lastTabSwitchTime = now;
logViolation('tab_switch', 'Candidate switched browser tab or minimized window');
captureAndUploadTabScreenshot();
}
}
});
// 2. Window Focus Loss / External AI Overlay Window Switch Detection (Parakeet AI)
// 2. Window Focus Loss / External AI Overlay Window Switch Detection
window.addEventListener('blur', function () {
logViolation('external_ai_detected', 'External AI Assistant Suspicion: Candidate window lost focus (interacting with floating overlay window or Parakeet AI widget)');
const now = Date.now();
if (now - lastTabSwitchTime > 2000) {
lastTabSwitchTime = now;
logViolation('tab_switch', 'Candidate window lost focus / switched application window');
captureAndUploadTabScreenshot();
}
});
// 3. Code Copy-Paste & Ingestion Detection
@ -504,15 +652,16 @@ function submitSolution() {
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
});
// 4. Parakeet AI Global Hotkey Interception (Alt+Space, Cmd+Space, Ctrl+Shift+A/C/X, Win Key)
// 4. External AI Hotkey Interception (Targeting explicit overlay launchers, ignoring standard OS tab navigation like Alt+Tab, Cmd+Tab, Win+Tab)
document.addEventListener('keydown', function (e) {
// Ignore standard OS tab/window switching keys
if (e.code === 'Tab' || e.key === 'Tab') return;
const isAltSpace = e.altKey && (e.code === 'Space' || e.keyCode === 32);
const isCmdSpace = e.metaKey && (e.code === 'Space' || e.keyCode === 32);
const isCtrlShift = e.ctrlKey && e.shiftKey && ['KeyA', 'KeyC', 'KeyX', 'KeyV'].includes(e.code);
const isWinKey = e.key === 'Meta' || e.key === 'Win';
if (isAltSpace || isCmdSpace || isCtrlShift || isWinKey) {
logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay (Parakeet AI / Hotkey shortcut)`);
if (isAltSpace || isCmdSpace) {
logViolation('external_ai_detected', `External AI Hotkey Intercepted (${e.code || e.key}): Candidate activated external AI assistant overlay shortcut`);
}
});
@ -526,7 +675,6 @@ function submitSolution() {
// --- ACCURATE EYE GAZE, 10-SECOND LOWER SCREEN STARE & QUESTION REPETITION DETECTION ---
let gazeDownwardCounter = 0;
let gazeDownwardDurationMs = 0;
let hasLogged10sLowerGaze = false;
let gazeFixedStaringCounter = 0;
let previousFramePixels = null;
@ -541,46 +689,75 @@ function analyzeEyeGazeAndStaring() {
const pixels = imgData.data;
let totalDarkPixels = 0;
let lowerQuarterDarkPixels = 0;
const quarterY = Math.floor(canvas.height * 0.70);
let lowerHalfDarkPixels = 0;
let sumY = 0;
let facePixelCount = 0;
for (let y = 0; y < canvas.height; y++) {
for (let x = 0; x < canvas.width; x++) {
const halfY = Math.floor(canvas.height * 0.50);
for (let y = 0; y < canvas.height; y += 2) {
for (let x = 0; x < canvas.width; x += 2) {
const idx = (y * canvas.width + x) * 4;
const gray = (pixels[idx] + pixels[idx+1] + pixels[idx+2]) / 3;
const r = pixels[idx];
const g = pixels[idx+1];
const b = pixels[idx+2];
const gray = (r + g + b) / 3;
if (gray < 45) {
// Detect dark facial features (eyes, eyebrows, pupils)
if (gray < 65) {
totalDarkPixels++;
if (y > quarterY) lowerQuarterDarkPixels++;
if (y > halfY) lowerHalfDarkPixels++;
}
// Skin tone / face region centroid
if (r > 40 && g > 25 && b > 15 && (r > g) && (r > b) && Math.abs(r - g) > 10) {
sumY += y;
facePixelCount++;
}
}
}
const lowerRatio = totalDarkPixels > 0 ? (lowerQuarterDarkPixels / totalDarkPixels) : 0;
const lowerDarkRatio = totalDarkPixels > 0 ? (lowerHalfDarkPixels / totalDarkPixels) : 0;
const faceYCentroid = facePixelCount > 0 ? (sumY / facePixelCount) : (canvas.height / 2);
// Lowered Gaze Check (looking down at desk/screen bottom)
if (lowerRatio > 0.40) {
// Candidate is looking at lower portion of screen / desk if face Y centroid is low or lower dark ratio is high
const isLookingDownAtLowerScreen = (faceYCentroid > (canvas.height * 0.52)) || (lowerDarkRatio > 0.35);
if (isLookingDownAtLowerScreen) {
gazeDownwardCounter++;
gazeDownwardDurationMs += 400;
// 2.4s initial alert
if (gazeDownwardCounter === 6) {
logViolation('gaze_anomaly', 'Candidate lowered eye gaze toward screen bottom or desk');
}
// Fix: 10.0 seconds of accumulated/continuous lower screen gaze (triggers log + SOS alert)
if (gazeDownwardDurationMs >= 10000 && !hasLogged10sLowerGaze) {
hasLogged10sLowerGaze = true;
logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for more than 10s (suspected reading from mobile or external device)');
// 10.0s continuous lower screen stare alert (logs to proctor logs section in reviewer modal)
if (gazeDownwardDurationMs >= 10000) {
gazeDownwardDurationMs = 0; // Reset timer for next 10s window if candidate continues looking down
logViolation('gaze_lower_device', 'Candidate continuously looking at lower portion of screen for 10s (suspected reading from mobile device or cheat sheet)');
// Show soft candidate warning overlay
if (typeof Swal !== 'undefined' && !Swal.isVisible()) {
Swal.fire({
icon: 'warning',
title: '⚠️ Please Look Up at Screen',
text: 'Continuous lower screen gaze detected. Please maintain eye contact with your screen and camera.',
timer: 3500,
showConfirmButton: false,
toast: true,
position: 'top'
});
}
}
} else {
gazeDownwardCounter = Math.max(0, gazeDownwardCounter - 1);
if (gazeDownwardCounter === 0) {
gazeDownwardDurationMs = 0;
hasLogged10sLowerGaze = false;
}
}
// Fixed Screen Focus / Staring (10 seconds = 25 cycles * 400ms) Check
// Fixed Screen Focus / Staring Check (10 seconds = 25 cycles * 400ms)
if (previousFramePixels) {
let frameDeltaSum = 0;
for (let i = 0; i < pixels.length; i += 16) {
@ -591,7 +768,7 @@ function analyzeEyeGazeAndStaring() {
if (frameDelta >= 0.1 && frameDelta < 3.8) {
gazeFixedStaringCounter++;
if (gazeFixedStaringCounter === 25) { // 10.0 seconds of continuous fixed stare
logViolation('gaze_fixed_staring', 'candidate might see other screen');
logViolation('gaze_fixed_staring', 'Candidate maintaining fixed unnatural gaze (possible secondary screen/AI assistant)');
}
} else {
gazeFixedStaringCounter = Math.max(0, gazeFixedStaringCounter - 1);
@ -670,6 +847,196 @@ function logViolation(type, details) {
}
}
// --- CANDIDATE REAL-TIME CALL RINGTONE SYNTHESIZER ---
class CandRingtoneEngine {
constructor() {
this.ctx = null;
this.interval = null;
this.isPlaying = false;
}
init() {
try {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
} catch(e) {}
}
start() {
this.init();
if (this.isPlaying) return;
this.isPlaying = true;
this.playTone();
if (this.interval) clearInterval(this.interval);
this.interval = setInterval(() => {
if (this.isPlaying) this.playTone();
}, 2400);
}
playTone() {
this.init();
if (!this.ctx || !this.isPlaying) return;
try {
const now = this.ctx.currentTime;
const osc1 = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc1.type = 'sine';
osc2.type = 'sine';
osc1.frequency.setValueAtTime(523.25, now);
osc2.frequency.setValueAtTime(659.25, now);
gain.gain.setValueAtTime(0.001, now);
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
gain.gain.setValueAtTime(0.35, now + 1.1);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(this.ctx.destination);
osc1.start(now);
osc2.start(now);
osc1.stop(now + 1.35);
osc2.stop(now + 1.35);
} catch(e) {}
}
stop() {
this.isPlaying = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
const candRingtone = new CandRingtoneEngine();
['click', 'keydown', 'touchstart'].forEach(evt => {
document.addEventListener(evt, () => candRingtone.init(), { passive: true });
});
let isCandidateCallConnected = false;
let pendingOffer = null;
let candRingTimer = null;
function triggerCandidateRing(offer = null, callStartedAt = null) {
if (isCandidateCallConnected) return;
if (offer) pendingOffer = offer;
// Check if call started more than 10 seconds ago
if (callStartedAt) {
const elapsed = Date.now() - new Date(callStartedAt).getTime();
if (elapsed > 10000) {
showCandidateJoinOption();
return;
}
}
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';
}
const modal = document.getElementById('candidate-incoming-modal');
if (modal) {
modal.style.opacity = '1';
modal.style.pointerEvents = 'auto';
}
candRingtone.start();
if (candRingTimer) clearTimeout(candRingTimer);
let remainingMs = 10000;
if (callStartedAt) {
const elapsed = Date.now() - new Date(callStartedAt).getTime();
remainingMs = Math.max(1000, 10000 - elapsed);
}
candRingTimer = setTimeout(() => {
stopCandidateRing();
}, remainingMs);
}
function stopCandidateRing() {
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';
}
showCandidateJoinOption();
}
function showCandidateJoinOption() {
if (isCandidateCallConnected) 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';
}
async function acceptCandidateCall() {
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 (joinBtn) joinBtn.style.display = 'none';
isCandidateCallConnected = true;
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = '🟢 CALL CONNECTED (LIVE)';
badge.style.background = 'rgba(16,185,129,0.3)';
badge.style.color = '#34d399';
}
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'success',
title: 'Call Connected!',
text: 'Live 2-way audio & video stream connected with interviewer.',
timer: 3000,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
if (pendingOffer) {
await handleInterviewerOffer(pendingOffer);
pendingOffer = null;
} else if (localMediaStream) {
sendOfferToInterviewer();
}
}
// 4. WebRTC Real-Time 2-Way Interview Call Setup (Dual Signaling: BroadcastChannel + PeerJS)
let localMediaStream = null;
let peerInstance = null;
@ -679,6 +1046,34 @@ function logViolation(type, details) {
let isCamEnabled = true;
let activeCallInstance = null;
const globalCallChannel = new BroadcastChannel('global_call_alerts');
globalCallChannel.onmessage = (event) => {
const data = event.data;
if (!data) return;
if (data.type === 'incoming_call' && data.interview_id == interviewId) {
triggerCandidateRing(null, data.call_started_at || null);
} else if (data.type === 'call_ended' && data.interview_id == interviewId) {
candRingtone.stop();
if (candRingTimer) clearTimeout(candRingTimer);
isCandidateCallConnected = false;
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';
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = 'READY';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
}
candidateLeaveCall();
}
};
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
callSigChannel.onmessage = async (event) => {
@ -686,11 +1081,7 @@ function logViolation(type, details) {
if (!msg) return;
if (msg.type === 'interviewer_joined' || msg.type === 'offer') {
if (msg.offer && msg.sender !== 'candidate') {
await handleInterviewerOffer(msg.offer);
} else if (localMediaStream) {
sendOfferToInterviewer();
}
triggerCandidateRing(msg.offer || null);
} else if (msg.type === 'answer' && msg.sender !== 'candidate' && candidatePeerConnection) {
await candidatePeerConnection.setRemoteDescription(new RTCSessionDescription(msg.answer));
} else if (msg.type === 'ice-candidate' && msg.sender !== 'candidate' && candidatePeerConnection && msg.candidate) {
@ -698,6 +1089,13 @@ function logViolation(type, details) {
await candidatePeerConnection.addIceCandidate(new RTCIceCandidate(msg.candidate));
} catch(e) {}
} else if (msg.type === 'end_call_all') {
candRingtone.stop();
isCandidateCallConnected = false;
const modal = document.getElementById('candidate-incoming-modal');
if (modal) {
modal.style.opacity = '0';
modal.style.pointerEvents = 'none';
}
candidateLeaveCall();
}
};
@ -728,9 +1126,26 @@ function logViolation(type, details) {
}
if (placeholder) placeholder.style.display = 'none';
document.getElementById('call-status-badge').innerText = 'CONNECTED';
document.getElementById('call-status-badge').style.background = 'rgba(16,185,129,0.3)';
document.getElementById('call-status-badge').style.color = '#34d399';
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 = () => {
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 === 'disconnected' || state === 'failed' || state === 'closed') {
candidateLeaveCall();
}
};
candidatePeerConnection.onicecandidate = (event) => {
@ -793,9 +1208,12 @@ function initCandidatePeer() {
}
if (placeholder) placeholder.style.display = 'none';
document.getElementById('call-status-badge').innerText = 'CONNECTED';
document.getElementById('call-status-badge').style.background = 'rgba(16,185,129,0.3)';
document.getElementById('call-status-badge').style.color = '#34d399';
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';
}
});
call.on('close', () => {
@ -854,10 +1272,15 @@ function candidateLogoutAction() {
}
function candidateLeaveCall() {
isCandidateCallConnected = false;
if (activeCallInstance) {
try { activeCallInstance.close(); } catch(e) {}
activeCallInstance = null;
}
if (candidatePeerConnection) {
try { candidatePeerConnection.close(); } catch(e) {}
candidatePeerConnection = null;
}
const remoteVideo = document.getElementById('interviewer-video');
if (remoteVideo) remoteVideo.srcObject = null;
const placeholder = document.getElementById('interviewer-video-placeholder');
@ -865,10 +1288,28 @@ function candidateLeaveCall() {
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = 'DISCONNECTED (LEAVE)';
badge.innerText = 'CALL DISCONNECTED';
badge.style.background = 'rgba(239,68,68,0.2)';
badge.style.color = '#fca5a5';
}
const joinBtn = document.getElementById('candidate-join-active-call-btn');
if (joinBtn) {
joinBtn.innerText = '🟢 📞 Rejoin Call Now';
joinBtn.style.display = 'block';
}
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'info',
title: 'Call Disconnected',
text: 'You have dropped/left the call. Click "Rejoin Call Now" anytime to connect back.',
timer: 3000,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
}
function toggleMic() {
@ -1129,6 +1570,49 @@ function saveCanvasDrawing() {
});
}, 1000);
}
setInterval(() => {
fetch(`{{ route('interview.candidate.poll', $interview->submission_unique_id) }}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (!data) return;
if (data.enable_tab_switch_screenshot !== undefined) {
isTabSwitchScreenshotEnabled = Boolean(data.enable_tab_switch_screenshot);
}
if (data.call_status === 'active') {
if (!isCandidateCallConnected) {
const elapsed = data.call_started_at ? (Date.now() - new Date(data.call_started_at).getTime()) : 999999;
if (elapsed <= 10000) {
triggerCandidateRing(null, data.call_started_at);
} else {
showCandidateJoinOption();
}
}
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
candRingtone.stop();
if (candRingTimer) clearTimeout(candRingTimer);
isCandidateCallConnected = false;
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';
const badge = document.getElementById('call-status-badge');
if (badge) {
badge.innerText = 'READY';
badge.style.background = 'rgba(16,185,129,0.2)';
badge.style.color = '#34d399';
}
}
})
.catch(() => {});
}, 2200);
</script>
</body>
</html>

View File

@ -280,6 +280,10 @@
<span class="badge" style="background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3);">
COMPLETED
</span>
@elseif($inv->call_status === 'active')
<span class="badge" style="background: rgba(16,185,129,0.3); color: #34d399; border: 1px solid #10b981; font-weight: 800; animation: acceptGlow 1.5s infinite alternate;">
🟢 📞 CALL IN PROGRESS
</span>
@elseif($inv->isActiveTimeline())
<span class="badge" style="background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.4);">
TIMELINE LIVE
@ -331,9 +335,15 @@
📄 Report
</a>
<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
</button>
@if($inv->call_status === 'active')
<button onclick="openReviewModal({{ $inv->id }}, '{{ addslashes($inv->candidate_name) }}', '{{ $inv->submission_unique_id }}', true)" class="btn-nav" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%); color: white; border: none; font-weight: 800; padding: 6px 14px; margin-right: 4px; cursor: pointer; box-shadow: 0 0 15px rgba(16,185,129,0.6); animation: acceptGlow 1.5s infinite alternate;">
📞 Join Active Call
</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
</button>
@endif
<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
@ -583,17 +593,18 @@
<span style="font-size: 0.7rem; color: #34d399;"> Live Synced</span>
</div>
<!-- Tab Switcher for Code Solution, Notepad, Drawing, Saved Profile Video Recordings -->
<!-- Tab Switcher for Code Solution, Notepad, Drawing, Saved Profile Video Recordings, Screenshots -->
<div style="display: flex; gap: 6px;">
<button id="tab-btn-code" onclick="switchIdeTab('code')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: #6366f1; color: white; border: none; font-weight: 700;">💻 Live IDE Code</button>
<button id="tab-btn-notes" onclick="switchIdeTab('notes')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📝 Candidate Notepad</button>
<button id="tab-btn-drawing" onclick="switchIdeTab('drawing')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">🎨 Candidate Drawing</button>
<button id="tab-btn-recordings" onclick="switchIdeTab('recordings')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📹 Saved Recordings (.MP4)</button>
<button id="tab-btn-screenshots" onclick="switchIdeTab('screenshots')" class="btn-nav" style="padding: 5px 14px; font-size: 0.75rem; background: rgba(255,255,255,0.05); color: #94a3b8; border: 1px solid var(--card-border);">📸 Tab Switch Screenshots</button>
</div>
</div>
<div style="display: grid; grid-template-columns: 2.5fr 1fr; gap: 20px;">
<!-- Left Column: Tab Content (Code Solution, Notepad, Canvas Drawing, or Saved Video Recordings) -->
<!-- Left Column: Tab Content (Code Solution, Notepad, Canvas Drawing, Saved Recordings, Screenshots) -->
<div>
<!-- Tab 1: Live Code Solution & Terminal Output -->
<div id="tab-content-code" style="display: block;">
@ -626,6 +637,22 @@
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No video recordings saved for this candidate yet.</div>
</div>
</div>
<!-- Tab 5: Candidate Tab-Switch Screenshots -->
<div id="tab-content-screenshots" style="display: none;">
<div style="font-size: 0.75rem; color: #94a3b8; margin-bottom: 6px; font-weight: 600; display: flex; justify-content: space-between; align-items: center;">
<span>📸 Candidate Tab-Switch Captured Screenshots:</span>
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 0.7rem; color: #a7f3d0;">Admin Screenshot Capture:</span>
<button id="modal-toggle-screenshot-btn" onclick="toggleTabScreenshotCapture()" class="btn-nav" style="padding: 3px 10px; font-size: 0.7rem; background: #10b981; color: white; border: none; font-weight: 700; border-radius: 6px; cursor: pointer;">
📸 ENABLED
</button>
</div>
</div>
<div id="modal-screenshots-container" style="background: #050811; border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; min-height: 250px; max-height: 420px; overflow-y: auto;">
<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No tab-switch screenshots captured for this candidate yet.</div>
</div>
</div>
</div>
<!-- Right Column: Proctoring Audit & Silent Warning Form -->
@ -683,11 +710,58 @@
</div>
</div>
<!-- INCOMING CALL RING MODAL (REAL CALLING APP UI FOR PANELISTS) -->
<div id="incoming-call-modal" class="admin-modal" style="z-index: 10000; background: rgba(5, 8, 17, 0.85); backdrop-filter: blur(16px);">
<div class="admin-modal-content" style="max-width: 460px; background: linear-gradient(145deg, #0f172a 0%, #1e1b4b 100%); border: 2px solid #6366f1; border-radius: 24px; padding: 32px; box-shadow: 0 0 50px rgba(99, 102, 241, 0.6); text-align: 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: -30px; border-radius: 50%; border: 2px solid rgba(99, 102, 241, 0.4); animation: ringPulse 1.4s infinite ease-out 0.4s;"></div>
<div style="width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, #10b981 0%, #0078d4 100%); display: flex; align-items: center; justify-content: center; font-size: 2.4rem; font-weight: 800; color: white; box-shadow: 0 10px 25px rgba(16, 185, 129, 0.5); z-index: 5;">
📞
</div>
</div>
<div style="font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1.5px; color: #34d399; font-weight: 800; margin-bottom: 6px;">
INCOMING INTERVIEW CALL
</div>
<h3 id="incoming-cand-name" style="font-family: 'Outfit', sans-serif; font-size: 1.45rem; font-weight: 800; color: white; margin-bottom: 4px;">
Candidate Name
</h3>
<div id="incoming-starter-info" style="font-size: 0.82rem; color: #a5b4fc; margin-bottom: 16px;">
Initiated by Panelist
</div>
<div style="background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 12px; font-size: 0.78rem; color: #cbd5e1; margin-bottom: 24px; line-height: 1.4;">
Another interviewer has started calling the candidate. Click <strong>Accept Call</strong> to join live, or <strong>Decline</strong> if busy (you can join later anytime from the dashboard table).
</div>
<div style="display: flex; gap: 14px; justify-content: center;">
<button id="incoming-decline-btn" onclick="declineIncomingCall()" class="btn-nav" style="flex: 1; padding: 12px 18px; background: rgba(239, 68, 68, 0.2); border: 1.5px solid #ef4444; color: #fca5a5; font-weight: 700; font-size: 0.9rem; border-radius: 12px; cursor: pointer;">
🚫 Decline / Miss
</button>
<button id="incoming-accept-btn" onclick="acceptIncomingCall()" class="btn-nav" style="flex: 1.3; padding: 12px 18px; background: linear-gradient(135deg, #10b981 0%, #059669 100%); border: none; color: white; font-weight: 800; font-size: 0.9rem; border-radius: 12px; box-shadow: 0 0 20px rgba(16, 185, 129, 0.6); cursor: pointer; animation: acceptGlow 1.5s infinite alternate;">
📞 Accept Call
</button>
</div>
</div>
</div>
<style>
@keyframes sosPulse {
0% { transform: scale(1); box-shadow: 0 0 10px rgba(239,68,68,0.5); }
100% { transform: scale(1.05); box-shadow: 0 0 25px rgba(239,68,68,1); }
}
@keyframes ringPulse {
0% { transform: scale(0.85); opacity: 1; }
100% { transform: scale(1.35); opacity: 0; }
}
@keyframes acceptGlow {
0% { box-shadow: 0 0 15px rgba(16, 185, 129, 0.5); transform: scale(1); }
100% { box-shadow: 0 0 28px rgba(16, 185, 129, 0.9); transform: scale(1.03); }
}
</style>
<script>
@ -768,11 +842,59 @@ function openReportPage() {
window.open(reportUrl, '_blank');
}
let currentTabScreenshotEnabled = true;
function toggleTabScreenshotCapture() {
if (!currentInterviewId) return;
const newStatus = !currentTabScreenshotEnabled;
fetch(`{{ route('interview.toggle-tab-screenshot', ':id') }}`.replace(':id', currentInterviewId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ enable: newStatus })
})
.then(r => r.json())
.then(res => {
if (res.success) {
currentTabScreenshotEnabled = res.enable_tab_switch_screenshot;
updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'info',
title: 'Screenshot Setting Updated',
text: res.message,
timer: 2500,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
}
})
.catch(() => {});
}
function updateScreenshotToggleBtnUI(enabled) {
const btn = document.getElementById('modal-toggle-screenshot-btn');
if (btn) {
if (enabled) {
btn.innerText = '📸 ENABLED';
btn.style.background = '#10b981';
} else {
btn.innerText = '🔕 DISABLED';
btn.style.background = '#ef4444';
}
}
}
function switchIdeTab(tab) {
document.getElementById('tab-content-code').style.display = (tab === 'code') ? 'block' : 'none';
document.getElementById('tab-content-notes').style.display = (tab === 'notes') ? 'block' : 'none';
document.getElementById('tab-content-drawing').style.display = (tab === 'drawing') ? 'block' : 'none';
document.getElementById('tab-content-recordings').style.display = (tab === 'recordings') ? 'block' : 'none';
document.getElementById('tab-content-screenshots').style.display = (tab === 'screenshots') ? 'block' : 'none';
document.getElementById('tab-btn-code').style.background = (tab === 'code') ? '#6366f1' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-code').style.color = (tab === 'code') ? 'white' : '#94a3b8';
@ -785,9 +907,245 @@ function switchIdeTab(tab) {
document.getElementById('tab-btn-recordings').style.background = (tab === 'recordings') ? '#10b981' : 'rgba(255,255,255,0.05)';
document.getElementById('tab-btn-recordings').style.color = (tab === 'recordings') ? 'white' : '#94a3b8';
const shotBtn = document.getElementById('tab-btn-screenshots');
if (shotBtn) {
shotBtn.style.background = (tab === 'screenshots') ? '#f59e0b' : 'rgba(255,255,255,0.05)';
shotBtn.style.color = (tab === 'screenshots') ? 'white' : '#94a3b8';
}
}
function openReviewModal(id, name, uid) {
// --- REAL-TIME CALL RINGTONE AUDIO SYNTHESIZER ---
class CallRingtoneEngine {
constructor() {
this.ctx = null;
this.interval = null;
this.isPlaying = false;
}
init() {
try {
if (!this.ctx) {
const AudioCtx = window.AudioContext || window.webkitAudioContext;
this.ctx = new AudioCtx();
}
if (this.ctx && this.ctx.state === 'suspended') {
this.ctx.resume();
}
} catch(e) { console.log('Audio init:', e); }
}
start() {
this.init();
if (this.isPlaying) return;
this.isPlaying = true;
this.playTone();
if (this.interval) clearInterval(this.interval);
this.interval = setInterval(() => {
if (this.isPlaying) this.playTone();
}, 2400);
}
playTone() {
this.init();
if (!this.ctx || !this.isPlaying) return;
try {
const now = this.ctx.currentTime;
const osc1 = this.ctx.createOscillator();
const osc2 = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc1.type = 'sine';
osc2.type = 'sine';
// Loud, authentic phone call ring frequencies (C5 523Hz + E5 659Hz)
osc1.frequency.setValueAtTime(523.25, now);
osc2.frequency.setValueAtTime(659.25, now);
gain.gain.setValueAtTime(0.001, now);
gain.gain.linearRampToValueAtTime(0.35, now + 0.08);
gain.gain.setValueAtTime(0.35, now + 1.1);
gain.gain.exponentialRampToValueAtTime(0.001, now + 1.3);
osc1.connect(gain);
osc2.connect(gain);
gain.connect(this.ctx.destination);
osc1.start(now);
osc2.start(now);
osc1.stop(now + 1.35);
osc2.stop(now + 1.35);
} catch(e) {}
}
stop() {
this.isPlaying = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
}
const callRingtone = new CallRingtoneEngine();
// Unlock browser AudioContext on user gesture anywhere on page
['click', 'keydown', 'touchstart'].forEach(evt => {
document.addEventListener(evt, () => callRingtone.init(), { passive: true });
});
// Active Call Listening & Instant BroadcastChannel Signaling State
let activeIncomingCall = null;
let iAmTheCaller = false;
let ringTimeoutTimer = null;
const dismissedCallIds = new Set();
const endedCallIds = new Set();
const currentUserId = {{ Auth::id() }};
const globalCallChannel = new BroadcastChannel('global_call_alerts');
function triggerIncomingCallRing(callData) {
if (endedCallIds.has(callData.id) || dismissedCallIds.has(callData.id)) return;
if (iAmTheCaller && currentInterviewId == callData.id) return;
if (interviewerLocalStream && currentInterviewId == callData.id) return;
// Check ring 10-second threshold based on call_started_at
if (callData.call_started_at) {
const startedTime = new Date(callData.call_started_at).getTime();
const elapsedMs = Date.now() - startedTime;
if (elapsedMs > 10000) return;
}
activeIncomingCall = callData;
document.getElementById('incoming-cand-name').innerText = callData.candidate_name;
document.getElementById('incoming-starter-info').innerText = '📞 Call initiated by: ' + (callData.starter_name || 'Panelist');
document.getElementById('incoming-call-modal').classList.add('active');
callRingtone.start();
// Set 10-second auto-stop ring timeout
if (ringTimeoutTimer) clearTimeout(ringTimeoutTimer);
let remainingMs = 10000;
if (callData.call_started_at) {
const startedTime = new Date(callData.call_started_at).getTime();
const elapsedMs = Date.now() - startedTime;
remainingMs = Math.max(1000, 10000 - elapsedMs);
}
ringTimeoutTimer = setTimeout(() => {
declineIncomingCall(true);
}, remainingMs);
}
globalCallChannel.onmessage = (event) => {
const data = event.data;
if (!data) return;
if (data.type === 'incoming_call') {
triggerIncomingCallRing({
id: data.interview_id,
candidate_name: data.candidate_name,
submission_unique_id: data.submission_unique_id,
starter_name: data.starter_name,
call_started_at: data.call_started_at || new Date().toISOString()
});
} else if (data.type === 'call_ended') {
endedCallIds.add(data.interview_id);
dismissedCallIds.add(data.interview_id);
callRingtone.stop();
if (ringTimeoutTimer) {
clearTimeout(ringTimeoutTimer);
ringTimeoutTimer = null;
}
document.getElementById('incoming-call-modal').classList.remove('active');
if (activeIncomingCall && activeIncomingCall.id == data.interview_id) {
activeIncomingCall = null;
}
}
};
function pollGlobalActiveCalls() {
fetch(`{{ route('interview.active-calls') }}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (!data || !data.active_calls || data.active_calls.length === 0) {
if (activeIncomingCall) {
declineIncomingCall(true);
}
return;
}
const incoming = data.active_calls.find(call => {
if (endedCallIds.has(call.id)) return false;
if (iAmTheCaller && currentInterviewId == call.id) return false;
if (interviewerLocalStream && currentInterviewId == call.id) return false;
if (dismissedCallIds.has(call.id)) return false;
if (call.call_started_at) {
const elapsedMs = Date.now() - new Date(call.call_started_at).getTime();
if (elapsedMs > 10000) return false;
}
return true;
});
if (incoming) {
if (!activeIncomingCall || activeIncomingCall.id !== incoming.id) {
triggerIncomingCallRing(incoming);
}
} else if (activeIncomingCall) {
declineIncomingCall(true);
}
})
.catch(() => {});
}
// Poll for active calls every 2 seconds
setInterval(pollGlobalActiveCalls, 2000);
function acceptIncomingCall() {
callRingtone.stop();
if (ringTimeoutTimer) {
clearTimeout(ringTimeoutTimer);
ringTimeoutTimer = null;
}
document.getElementById('incoming-call-modal').classList.remove('active');
if (activeIncomingCall) {
const targetCall = activeIncomingCall;
activeIncomingCall = null;
openReviewModal(targetCall.id, targetCall.candidate_name, targetCall.submission_unique_id, true);
}
}
function declineIncomingCall(silent = false) {
callRingtone.stop();
if (ringTimeoutTimer) {
clearTimeout(ringTimeoutTimer);
ringTimeoutTimer = null;
}
document.getElementById('incoming-call-modal').classList.remove('active');
if (activeIncomingCall) {
dismissedCallIds.add(activeIncomingCall.id);
activeIncomingCall = null;
}
if (!silent && typeof Swal !== 'undefined') {
Swal.fire({
icon: 'info',
title: 'Call Missed / Declined',
text: 'You can click "Join Active Call" from the list whenever you are ready.',
timer: 3000,
showConfirmButton: false,
toast: true,
position: 'top-end'
});
}
}
function openReviewModal(id, name, uid, autoJoinCall = false) {
currentInterviewId = id;
acknowledgedViolationCount = 0;
sosDismissed = false;
@ -809,6 +1167,12 @@ function openReviewModal(id, name, uid) {
pollReviewData();
clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 2000);
if (autoJoinCall) {
setTimeout(() => {
startInterviewerCall(true);
}, 400);
}
}
function pollReviewData() {
@ -913,24 +1277,69 @@ function pollReviewData() {
chatHistory.scrollTop = chatHistory.scrollHeight;
}
// React to Global Call Status
// Update Tab Switch Screenshot Toggle UI State
currentTabScreenshotEnabled = (data.enable_tab_switch_screenshot !== undefined) ? Boolean(data.enable_tab_switch_screenshot) : true;
updateScreenshotToggleBtnUI(currentTabScreenshotEnabled);
// Render Captured Tab-Switch Screenshots Gallery
const shotContainer = document.getElementById('modal-screenshots-container');
if (shotContainer) {
const shotList = data.tab_switch_screenshots || [];
if (shotList.length > 0) {
let shotHtml = `<div style="font-size: 0.75rem; color: #f59e0b; font-weight: 700; margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center;">
<span>📸 Tab-Switch Screenshots Captured (${shotList.length})</span>
<span style="font-size: 0.65rem; color: #94a3b8;">Click image to enlarge full size</span>
</div><div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">`;
shotList.forEach((s, idx) => {
const url = typeof s === 'string' ? s : s.url;
const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : '';
shotHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 8px; padding: 8px; text-align: center;">
<img src="${url}" style="width: 100%; height: 110px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);" onclick="window.open('${url}', '_blank')" title="Click to view full image" />
<div style="font-size: 0.65rem; color: #94a3b8; margin-top: 4px;">📅 ${timeStr}</div>
<a href="${url}" target="_blank" download class="btn-nav" style="background: #f59e0b; color: white; border: none; font-size: 0.65rem; font-weight: 700; padding: 3px 8px; text-decoration: none; border-radius: 4px; display: inline-block; margin-top: 4px;">
📥 View / Download
</a>
</div>`;
});
shotHtml += `</div>`;
shotContainer.innerHTML = shotHtml;
} else {
shotContainer.innerHTML = '<div style="color: #94a3b8; font-size: 0.8rem; text-align: center; padding-top: 40px;">No tab-switch screenshots captured for this candidate yet.</div>';
}
}
// React to Global Call Status & Control Recording Button Availability
const callStatusBadge = document.getElementById('interviewer-call-status');
const recStartBtn = document.getElementById('btn-start-recording');
const recStopBtn = document.getElementById('btn-stop-recording');
if (callStatusBadge && data.call_status) {
if (data.call_status === 'active' && !interviewerLocalStream) {
callStatusBadge.innerText = 'CALL ACTIVE';
callStatusBadge.style.background = 'rgba(16,185,129,0.2)';
callStatusBadge.style.color = '#34d399';
document.getElementById('btn-start-call').innerText = '📞 Join Call';
document.getElementById('btn-start-call').style.background = '#6366f1';
document.getElementById('btn-start-call').style.display = 'inline-block';
} else if (data.call_status === 'ended') {
if (interviewerLocalStream) leaveInterviewerCall();
callStatusBadge.innerText = 'CALL ENDED';
callStatusBadge.style.background = 'rgba(239,68,68,0.2)';
callStatusBadge.style.color = '#fca5a5';
document.getElementById('btn-start-call').innerText = '📞 Restart Call';
if (data.call_status === 'active') {
if (!interviewerLocalStream) {
callStatusBadge.innerText = 'CALL ACTIVE';
callStatusBadge.style.background = 'rgba(16,185,129,0.2)';
callStatusBadge.style.color = '#34d399';
document.getElementById('btn-start-call').innerText = '📞 Join Call';
document.getElementById('btn-start-call').style.background = '#6366f1';
document.getElementById('btn-start-call').style.display = 'inline-block';
}
// Recording button ONLY available when call has started
if (recStartBtn && (!adminMediaRecorder || adminMediaRecorder.state === 'inactive')) {
recStartBtn.style.display = 'inline-flex';
}
} else if (data.call_status === 'ended' || data.call_status === 'idle') {
if (interviewerLocalStream && data.call_status === 'ended') leaveInterviewerCall();
callStatusBadge.innerText = (data.call_status === 'ended') ? 'CALL ENDED' : 'DISCONNECTED';
callStatusBadge.style.background = (data.call_status === 'ended') ? 'rgba(239,68,68,0.2)' : 'rgba(99,102,241,0.2)';
callStatusBadge.style.color = (data.call_status === 'ended') ? '#fca5a5' : '#818cf8';
document.getElementById('btn-start-call').innerText = (data.call_status === 'ended') ? '📞 Restart Call' : '📞 Start / Join Call';
document.getElementById('btn-start-call').style.background = '#10b981';
document.getElementById('btn-start-call').style.display = 'inline-block';
// Recording button HIDDEN when call is not active
if (recStartBtn) recStartBtn.style.display = 'none';
if (recStopBtn) recStopBtn.style.display = 'none';
}
}
@ -945,12 +1354,16 @@ function pollReviewData() {
if (l.type === 'gaze_lower_device') icon = '📱';
if (l.type === 'question_repeat_lower' || l.type === 'question_repetition') icon = '🗣️';
if (l.type === 'external_ai_detected') icon = '🤖';
if (l.type === 'tab_switch') icon = '📸';
if (['paste_event', 'tab_switch', 'focus_lost', 'gaze_anomaly', 'gaze_fixed_staring', 'gaze_lower_device', 'question_repeat_lower', 'question_repetition', 'external_ai_detected'].includes(l.type)) {
currentViolations.push(l);
}
let screenshotLink = l.screenshot_url ? `<a href="${l.screenshot_url}" target="_blank" style="color: #38bdf8; text-decoration: underline; margin-left: 6px;">[📸 View Screenshot]</a>` : '';
logsHtml += `<div style="margin-bottom:6px; border-bottom:1px solid rgba(255,255,255,0.05); padding-bottom:4px;">
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}
<strong>${icon} ${l.type.toUpperCase()}:</strong> ${l.details || ''}${screenshotLink}
<div style="font-size:0.65rem; color:#94a3b8;">${new Date(l.timestamp).toLocaleTimeString()}</div>
</div>`;
});
@ -1241,9 +1654,23 @@ function initInterviewerSigChannel() {
interviewerSigChannel.postMessage({ type: 'answer', answer: answer, sender: 'interviewer' });
}
function startInterviewerCall() {
function startInterviewerCall(isRejoin = false) {
if (!currentInterviewId) return;
if (!isRejoin) {
iAmTheCaller = true;
// Broadcast instant incoming call alert to all open tabs/windows
const candName = document.getElementById('modal-cand-name') ? document.getElementById('modal-cand-name').innerText : 'Candidate';
globalCallChannel.postMessage({
type: 'incoming_call',
interview_id: currentInterviewId,
candidate_name: candName,
starter_name: '{{ Auth::user()->name }}',
call_started_at: new Date().toISOString()
});
}
initInterviewerSigChannel();
// Notify backend that call session is active
@ -1253,7 +1680,7 @@ function startInterviewerCall() {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({ call_status: 'active' })
body: JSON.stringify({ call_status: 'active', is_rejoin: isRejoin })
});
const handleStream = (stream) => {
@ -1372,7 +1799,19 @@ function endCallForAll() {
}).then((result) => {
if (result.isConfirmed) {
if (currentInterviewId) {
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', currentInterviewId), {
const endingId = currentInterviewId;
endedCallIds.add(endingId);
dismissedCallIds.add(endingId);
callRingtone.stop();
document.getElementById('incoming-call-modal').classList.remove('active');
activeIncomingCall = null;
globalCallChannel.postMessage({
type: 'call_ended',
interview_id: endingId
});
fetch(`{{ route('interview.call-status', ':id') }}`.replace(':id', endingId), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -1382,6 +1821,7 @@ function endCallForAll() {
});
}
iAmTheCaller = false;
leaveInterviewerCall();
document.getElementById('interviewer-call-status').innerText = 'CALL ENDED FOR ALL';
document.getElementById('interviewer-call-status').style.background = 'rgba(239,68,68,0.2)';
@ -1603,6 +2043,24 @@ function sendSilentWarning() {
}
});
}
document.addEventListener('DOMContentLoaded', function() {
const urlParams = new URLSearchParams(window.location.search);
const openId = urlParams.get('open_interview');
const autoJoin = urlParams.get('join_call') === '1';
if (openId) {
fetch(`{{ route('interview.poll', ':id') }}`.replace(':id', openId), {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data && data.status) {
openReviewModal(parseInt(openId), data.candidate_name || 'Candidate', data.submission_unique_id || openId, autoJoin);
}
});
}
});
</script>
</body>
</html>

View File

@ -32,6 +32,7 @@
Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation');
Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording');
Route::post('/candidate/upload-tab-screenshot/{id}', [InterviewController::class, 'uploadTabScreenshot'])->name('interview.upload-tab-screenshot');
Route::post('/candidate/sync-code/{id}', [InterviewController::class, 'syncCandidateCode'])->name('interview.candidate.sync-code');
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
Route::post('/candidate/drawing/{id}', [InterviewController::class, 'saveCandidateDrawing'])->name('interview.candidate.drawing');
@ -68,6 +69,7 @@
// Candidate Assessment Management for HR & Panelists
Route::get('/interviews', [InterviewController::class, 'index'])->name('interview.index');
Route::get('/interviews/active-calls', [InterviewController::class, 'getActiveCalls'])->name('interview.active-calls');
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');
@ -76,6 +78,7 @@
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
});
// Admin Control Panel (requires admin role)