improvement: add server renderd TORN Server cred and minor improvements #17

Merged
subhajit.pal merged 3 commits from feature/TURN-server-integration into main 2026-08-06 12:52:50 +00:00
8 changed files with 352 additions and 85 deletions

View File

@ -69,3 +69,7 @@ MICROSOFT_CLIENT_ID=
MICROSOFT_CLIENT_SECRET= MICROSOFT_CLIENT_SECRET=
MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback" MICROSOFT_REDIRECT_URI="${APP_URL}/auth/microsoft/callback"
MICROSOFT_TENANT_ID=common MICROSOFT_TENANT_ID=common
# Metered TURN Server Credentials
METERED_URL=
METERED_KEY=

View File

@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class IceServerController extends Controller
{
public function __invoke(): JsonResponse
{
if (!Auth::check() && !session()->has('candidate_interview_id')) {
return response()->json(['error' => 'Unauthenticated access.'], 401);
}
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;
if ($apiKey && !str_contains($url, "apiKey=")) {
$separator = str_contains($url, "?") ? "&" : "?";
$url .= $separator . "apiKey=" . urlencode($apiKey);
}
$response = Http::timeout(5)->get($url);
if ($response->successful()) {
return response()->json($response->json());
}
Log::error(
"Metered API error response",
[
"status" => $response->status(),
"body" => $response->body(),
],
);
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(),
);
return response()->json(
[
"error" => "An error occurred while fetching ICE servers.",
"message" => $e->getMessage(),
],
500,
);
}
}
}

View File

@ -42,4 +42,9 @@
'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')), 'tenant' => env('MICROSOFT_TENANT_ID', env('MICROSOFT_GRAPH_TENANT_ID', 'common')),
], ],
'metered' => [
'url' => env('METERED_URL'),
'key' => env('METERED_KEY'),
],
]; ];

View File

@ -1 +1,10 @@
// import { getMeteredIceServers, initMeteredIceServers, globalIceServers } from './metered.js';
window.globalIceServers = globalIceServers;
window.getMeteredIceServers = getMeteredIceServers;
window.initMeteredIceServers = initMeteredIceServers;
// Pre-fetch TURN credentials immediately on app boot
if (typeof window !== 'undefined') {
window.getMeteredIceServers();
}

54
resources/js/metered.js Normal file
View File

@ -0,0 +1,54 @@
/**
* Metered TURN Server Integration
*/
let globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' }
];
let meteredIceFetchPromise = null;
/**
* Fetch ICE servers from /ice-servers endpoint and merge with default STUN server.
* Handles network errors gracefully with fallback.
*
* @returns {Promise<Array>} Resolves with the array of ICE server objects.
*/
export function getMeteredIceServers() {
if (meteredIceFetchPromise) return meteredIceFetchPromise;
meteredIceFetchPromise = fetch('/ice-servers', {
headers: {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => {
if (!response.ok) {
throw new Error(`Failed to fetch ICE servers: HTTP status ${response.status}`);
}
return response.json();
})
.then(meteredIce => {
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
...meteredIce.slice(0, 3)
];
}
window.globalIceServers = globalIceServers;
return globalIceServers;
})
.catch(e => {
console.warn('Metered TURN fetch warning:', e);
window.globalIceServers = globalIceServers;
return globalIceServers;
});
return meteredIceFetchPromise;
}
export function initMeteredIceServers() {
return getMeteredIceServers();
}
export { globalIceServers };

View File

@ -10,13 +10,16 @@
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600&family=Instrument+Sans:wght@400;500;600;700&family=Outfit:wght@500;600;700&display=swap" rel="stylesheet">
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script> <script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<!-- Monaco Code Editor Loader & PeerJS Realtime WebRTC Call & SweetAlert2 --> <!-- PeerJS, Metered SDK, SweetAlert2 & html2canvas -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
<script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script> <script src="https://unpkg.com/peerjs@1.5.2/dist/peerjs.min.js"></script>
<script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script> <script src="//cdn.metered.ca/sdk/video/1.4.6/sdk.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script> <script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<!-- Monaco Code Editor Loader (loaded after UMD libraries to prevent define.amd conflicts) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.45.0/min/vs/loader.min.js"></script>
<style> <style>
:root { :root {
--bg-color: #080c14; --bg-color: #080c14;
@ -413,8 +416,10 @@
</div> </div>
<script> <script>
var callSigChannel = null;
let editor; let editor;
let interviewId = {{ $interview->id }}; let interviewId = {{ $interview->id }};
let mediaRecorder; let mediaRecorder;
let recordedChunks = []; let recordedChunks = [];
let previousFrameData = null; let previousFrameData = null;
@ -560,10 +565,27 @@ function submitSolution() {
let isTabSwitchScreenshotEnabled = true; let isTabSwitchScreenshotEnabled = true;
let lastTabSwitchTime = 0; let lastTabSwitchTime = 0;
let pendingNativePrompts = 0; // counter for native OS prompts
let suppressFocusViolationsUntil = 0; // trailing buffer after prompt resolves
function beginNativePrompt() {
pendingNativePrompts++;
}
function endNativePrompt() {
pendingNativePrompts = Math.max(0, pendingNativePrompts - 1);
}
function armTrailingBuffer(ms) {
suppressFocusViolationsUntil = Math.max(suppressFocusViolationsUntil, Date.now() + ms);
}
function isFocusSuppressed() {
return pendingNativePrompts > 0 || Date.now() < suppressFocusViolationsUntil;
}
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}'; let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
let screenShareVideoElem = null; let screenShareVideoElem = null;
// Auto-initialize persistent offscreen video element for screen share capture
// Auto-initialize persistent offscreen video element for screen share capture // Auto-initialize persistent offscreen video element for screen share capture
function getScreenShareVideoElement() { function getScreenShareVideoElement() {
if (!screenShareVideoElem) { if (!screenShareVideoElem) {
@ -724,6 +746,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
// 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot) // 1. Tab Switch Detection (Captures Candidate's SYSTEM DESKTOP MONITOR screenshot)
document.addEventListener('visibilitychange', function () { document.addEventListener('visibilitychange', function () {
if (isFocusSuppressed()) return;
if (document.hidden) { if (document.hidden) {
const now = Date.now(); const now = Date.now();
if (now - lastTabSwitchTime > 1000) { if (now - lastTabSwitchTime > 1000) {
@ -736,6 +759,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
// 2. Window Focus Loss / External AI Overlay Application Switch Detection // 2. Window Focus Loss / External AI Overlay Application Switch Detection
window.addEventListener('blur', function () { window.addEventListener('blur', function () {
if (isFocusSuppressed()) return;
const now = Date.now(); const now = Date.now();
if (now - lastTabSwitchTime > 1000) { if (now - lastTabSwitchTime > 1000) {
lastTabSwitchTime = now; lastTabSwitchTime = now;
@ -746,6 +770,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
// Mouse Viewport Departure (Candidate moving cursor outside window to interact with Parakeet AI or secondary app overlay) // Mouse Viewport Departure (Candidate moving cursor outside window to interact with Parakeet AI or secondary app overlay)
document.addEventListener('mouseleave', function () { document.addEventListener('mouseleave', function () {
if (isFocusSuppressed()) return;
const now = Date.now(); const now = Date.now();
if (now - lastTabSwitchTime > 2500) { if (now - lastTabSwitchTime > 2500) {
lastTabSwitchTime = now; lastTabSwitchTime = now;
@ -757,7 +782,7 @@ function uploadTabScreenshotBlob(imageData, reason = 'tab_switch') {
// 3. Code Copy-Paste & Ingestion Detection // 3. Code Copy-Paste & Ingestion Detection
document.addEventListener('paste', function (e) { document.addEventListener('paste', function (e) {
logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)'); logViolation('paste_event', 'Candidate pasted content into editor window (possible AI answer paste)');
}); }, true);
// Question Selection / Copying Detection (Candidate copying questions to feed Parakeet AI) // Question Selection / Copying Detection (Candidate copying questions to feed Parakeet AI)
document.addEventListener('copy', function (e) { document.addEventListener('copy', function (e) {
@ -1290,36 +1315,28 @@ function safePlayMediaStream(videoElem, stream, isSelf = false) {
} }
} }
// Metered TURN Server Integration (Pre-fetched for seamless WebRTC P2P + TURN relay) // Metered TURN Server Integration (Managed via window.getMeteredIceServers in app.js / metered.js)
const METERED_CREDENTIALS_URL = "https://single-login-system.metered.live/api/v1/turn/credentials?apiKey=ffc9146ac5c2f75d83d7c679d8553a21c8fa"; let globalIceServers = window.globalIceServers || [
let globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' } { urls: 'stun:stun.l.google.com:19302' }
]; ];
let meteredIceFetchPromise = null;
function initMeteredIceServers() { function initMeteredIceServers() {
if (meteredIceFetchPromise) return meteredIceFetchPromise; if (typeof window.getMeteredIceServers === 'function') {
meteredIceFetchPromise = fetch(METERED_CREDENTIALS_URL) return window.getMeteredIceServers().then(servers => {
.then(r => r.json()) if (Array.isArray(servers) && servers.length > 0) {
.then(meteredIce => { globalIceServers = servers;
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
...meteredIce.slice(0, 3)
];
} }
return globalIceServers; return globalIceServers;
})
.catch(e => {
console.warn('Metered TURN fetch warning:', e);
return globalIceServers;
}); });
return meteredIceFetchPromise; }
return Promise.resolve(globalIceServers);
} }
// Initiate TURN credentials pre-fetch immediately // Initiate TURN credentials pre-fetch immediately
initMeteredIceServers(); initMeteredIceServers();
let localMediaStream = null; let localMediaStream = null;
let peerInstance = null; let peerInstance = null;
let candidatePeerConnection = null; let candidatePeerConnection = null;
@ -1363,7 +1380,8 @@ function initMeteredIceServers() {
} }
}; };
const callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId); callSigChannel = new BroadcastChannel('webrtc_call_' + interviewId);
callSigChannel.onmessage = async (event) => { callSigChannel.onmessage = async (event) => {
const msg = event.data; const msg = event.data;
@ -1553,6 +1571,8 @@ function startCandidateCameraStream() {
autoGainControl: true autoGainControl: true
}; };
beginNativePrompt();
return navigator.mediaDevices.getUserMedia({ return navigator.mediaDevices.getUserMedia({
video: { video: {
width: { ideal: 1280 }, width: { ideal: 1280 },
@ -1566,6 +1586,7 @@ function startCandidateCameraStream() {
.catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true })) .catch(() => navigator.mediaDevices.getUserMedia({ video: false, audio: true }))
.catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false })) .catch(() => navigator.mediaDevices.getUserMedia({ video: true, audio: false }))
.then(stream => { .then(stream => {
endNativePrompt();
localMediaStream = stream; localMediaStream = stream;
const vid = document.getElementById('webcam'); const vid = document.getElementById('webcam');
if (vid) { if (vid) {
@ -1592,6 +1613,7 @@ function startCandidateCameraStream() {
return stream; return stream;
}) })
.catch(err => { .catch(err => {
endNativePrompt();
console.log('Candidate media access error:', err); console.log('Candidate media access error:', err);
if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex'; if (avatarPlaceholder) avatarPlaceholder.style.display = 'flex';
initCandidatePeer(); initCandidatePeer();
@ -1603,20 +1625,11 @@ function startCandidateCameraStream() {
// Prompt candidate to activate Entire Screen Proctoring Shield upon joining // Prompt candidate to activate Entire Screen Proctoring Shield upon joining
setTimeout(() => { setTimeout(() => {
if (!screenShareStream && typeof Swal !== 'undefined') { if (!screenShareStream) {
Swal.fire({ promptScreenShareRequired();
icon: 'info',
title: '🛡️ Parakeet AI & Overlay Detection Shield',
text: 'To ensure assessment integrity and detect external AI overlay windows (such as Parakeet AI), Entire Screen sharing is required.',
confirmButtonText: '🖥️ Enable Entire Screen Shield Now',
allowOutsideClick: false
}).then((res) => {
if (res.isConfirmed) {
startScreenShare();
}
});
} }
}, 2000); }, 1500);
function candidateLogoutAction() { function candidateLogoutAction() {
if (typeof Swal !== 'undefined') { if (typeof Swal !== 'undefined') {
@ -1741,6 +1754,17 @@ function toggleCam() {
} }
} }
function requestFullscreenAsPromise(elem) {
if (elem.requestFullscreen) {
return elem.requestFullscreen();
}
if (elem.webkitRequestFullscreen) {
elem.webkitRequestFullscreen();
return Promise.resolve();
}
return Promise.reject(new Error('Fullscreen not supported'));
}
function requestProctoringFullscreen() { function requestProctoringFullscreen() {
try { try {
const elem = document.documentElement; const elem = document.documentElement;
@ -1753,6 +1777,7 @@ function requestProctoringFullscreen() {
} }
document.addEventListener('fullscreenchange', function() { document.addEventListener('fullscreenchange', function() {
if (isFocusSuppressed()) return;
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)'); logViolation('tab_switch', 'Candidate exited full-screen proctoring mode (possible overlay AI window interaction)');
captureAndUploadTabScreenshot(); captureAndUploadTabScreenshot();
@ -1786,12 +1811,60 @@ function initLiveOverlayDetector() {
}, 2000); }, 2000);
} }
function promptScreenShareRequired(reasonMessage) {
if (screenShareStream && screenShareStream.active) return;
const messageHtml = reasonMessage ? `
<div class="text-center">
<p style="color: #ef4444; font-weight: 600; margin-bottom: 12px;">
${reasonMessage}
</p>
<p style="margin-bottom: 12px; color:1D2A36;">
Screen sharing is <strong>mandatory</strong>.
</p>
<p style="font-size: 0.85rem; color: #94a3b8;">
When prompted by your browser, select <strong>Entire Screen</strong> and click <strong>Share</strong>.
</p>
</div>
` : `
<div class="text-center">
<p style="margin-bottom: 12px; color: #1D2A36;">
Before you begin, please share your <strong>entire screen</strong>.
</p>
<p style="font-size: 0.85rem; color: #94a3b8;">
When prompted by your browser, select <strong>Entire Screen</strong> and click <strong>Share</strong>.
</p>
</div>
`;
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'warning',
title: 'Screen Sharing Required',
html: messageHtml,
confirmButtonText: 'Share Entire Screen Now',
confirmButtonColor: '#6366f1',
allowOutsideClick: false,
allowEscapeKey: false
}).then((res) => {
if (res.isConfirmed) {
startScreenShare();
}
});
} else {
alert((reasonMessage ? (reasonMessage + ' ') : '') + 'Screen sharing is required. Please select Entire Screen when prompted.');
startScreenShare();
}
}
function startScreenShare() { function startScreenShare() {
if (!navigator.mediaDevices.getDisplayMedia) { if (!navigator.mediaDevices.getDisplayMedia) {
alert('Screen sharing is not supported on your browser.'); alert('Screen sharing is not supported on your browser.');
return; return;
} }
beginNativePrompt();
navigator.mediaDevices.getDisplayMedia({ navigator.mediaDevices.getDisplayMedia({
video: { video: {
displaySurface: 'monitor' displaySurface: 'monitor'
@ -1799,25 +1872,29 @@ function startScreenShare() {
audio: false audio: false
}) })
.then(stream => { .then(stream => {
endNativePrompt();
const track = stream.getVideoTracks()[0]; const track = stream.getVideoTracks()[0];
const settings = track.getSettings ? track.getSettings() : {}; const settings = track.getSettings ? track.getSettings() : {};
// Enforce ENTIRE SCREEN share for Parakeet AI overlay detection // Enforce ENTIRE SCREEN share
if (settings.displaySurface && settings.displaySurface !== 'monitor') { if (settings.displaySurface && settings.displaySurface !== 'monitor') {
if (typeof Swal !== 'undefined') { if (typeof Swal !== 'undefined') {
Swal.fire({ Swal.fire({
icon: 'warning', icon: 'warning',
title: '⚠️ Entire Screen Share Required', title: 'Entire Screen Share Required',
text: 'You selected a single tab or window. To detect overlay AI assistants (such as Parakeet AI) and ensure proctoring integrity, you MUST select "Entire Screen".', text: 'You selected a single tab or window. You MUST select "Entire Screen".',
confirmButtonText: '🖥️ Reshare Entire Screen' confirmButtonText: 'Reshare Entire Screen',
confirmButtonColor: '#6366f1',
allowOutsideClick: false,
allowEscapeKey: false
}).then(() => { }).then(() => {
track.stop(); track.stop();
setTimeout(startScreenShare, 500); setTimeout(startScreenShare, 300);
}); });
} else { } else {
alert('Entire Screen share is required for proctoring overlay detection. Please select Entire Screen.'); alert('Entire Screen share is required. Please select Entire Screen.');
track.stop(); track.stop();
setTimeout(startScreenShare, 500); setTimeout(startScreenShare, 300);
} }
return; return;
} }
@ -1829,11 +1906,17 @@ function startScreenShare() {
const btn = document.getElementById('share-screen-btn'); const btn = document.getElementById('share-screen-btn');
if (btn) { if (btn) {
btn.innerText = '🟢 Entire Screen Sharing Active (Parakeet AI Shield ON)'; btn.innerText = '🟢 Entire Screen Sharing Active';
btn.style.background = '#10b981'; btn.style.background = '#10b981';
} }
requestProctoringFullscreen(); beginNativePrompt();
requestFullscreenAsPromise(document.documentElement)
.catch(() => {})
.finally(() => {
endNativePrompt();
armTrailingBuffer(1000);
});
// Connect screen stream to interviewer screen peer // Connect screen stream to interviewer screen peer
const screenPeer = new Peer('cand_screen_' + interviewId + '_' + Date.now()); const screenPeer = new Peer('cand_screen_' + interviewId + '_' + Date.now());
@ -1842,20 +1925,27 @@ function startScreenShare() {
}); });
track.onended = () => { track.onended = () => {
screenShareStream = null;
if (btn) { if (btn) {
btn.innerText = '🖥️ Share Screen with Interviewer'; btn.innerText = '🖥️ Share Screen with Interviewer';
btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)'; btn.style.background = 'linear-gradient(135deg, #6366f1 0%, #0078d4 100%)';
} }
logViolation('tab_switch', 'Candidate stopped screen sharing (proctoring screen share disabled)'); logViolation('tab_switch', 'Candidate stopped screen sharing (proctoring screen share disabled)');
promptScreenShareRequired('Screen sharing was stopped. You must share your Entire Screen to continue.');
}; };
initLiveOverlayDetector(); initLiveOverlayDetector();
}) })
.catch(err => { .catch(err => {
console.log('Screen sharing cancelled:', err); endNativePrompt();
console.log('Screen sharing cancelled/denied:', err);
screenShareStream = null;
logViolation('tab_switch', 'Candidate denied or cancelled screen sharing permission prompt');
promptScreenShareRequired('Screen sharing permission was denied or cancelled.');
}); });
} }
function analyzeCameraFrame() { function analyzeCameraFrame() {
const video = document.getElementById('webcam'); const video = document.getElementById('webcam');
const canvas = document.getElementById('proctor-canvas'); const canvas = document.getElementById('proctor-canvas');

View File

@ -844,6 +844,7 @@
</style> </style>
<script> <script>
var interviewerSigChannel = null;
let currentInterviewId = null; let currentInterviewId = null;
let interviewerPeer = null; let interviewerPeer = null;
let screenReceiverPeer = null; let screenReceiverPeer = null;
@ -1686,36 +1687,28 @@ function blockCandidateAction() {
} }
} }
// Metered TURN Server Integration (Pre-fetched for seamless WebRTC P2P + TURN relay) // Metered TURN Server Integration (Managed via window.getMeteredIceServers in app.js / metered.js)
const METERED_CREDENTIALS_URL = "https://single-login-system.metered.live/api/v1/turn/credentials?apiKey=ffc9146ac5c2f75d83d7c679d8553a21c8fa"; let globalIceServers = window.globalIceServers || [
let globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' } { urls: 'stun:stun.l.google.com:19302' }
]; ];
let meteredIceFetchPromise = null;
function initMeteredIceServers() { function initMeteredIceServers() {
if (meteredIceFetchPromise) return meteredIceFetchPromise; if (typeof window.getMeteredIceServers === 'function') {
meteredIceFetchPromise = fetch(METERED_CREDENTIALS_URL) return window.getMeteredIceServers().then(servers => {
.then(r => r.json()) if (Array.isArray(servers) && servers.length > 0) {
.then(meteredIce => { globalIceServers = servers;
if (Array.isArray(meteredIce) && meteredIce.length > 0) {
globalIceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
...meteredIce.slice(0, 3)
];
} }
return globalIceServers; return globalIceServers;
})
.catch(e => {
console.warn('Metered TURN fetch warning:', e);
return globalIceServers;
}); });
return meteredIceFetchPromise; }
return Promise.resolve(globalIceServers);
} }
// Initiate TURN credentials pre-fetch immediately // Initiate TURN credentials pre-fetch immediately
initMeteredIceServers(); initMeteredIceServers();
function safePlayMediaStream(videoElem, stream, isSelf = false) { function safePlayMediaStream(videoElem, stream, isSelf = false) {
if (!videoElem || !stream) return; if (!videoElem || !stream) return;
if (isSelf) { if (isSelf) {
@ -1814,7 +1807,8 @@ function zoomCandVideo(delta) {
} }
let interviewerPeerConnection = null; let interviewerPeerConnection = null;
let interviewerSigChannel = null; interviewerSigChannel = null;
function initInterviewerSigChannel() { function initInterviewerSigChannel() {
if (!currentInterviewId) return; if (!currentInterviewId) return;
@ -2155,6 +2149,7 @@ function endCallForAll() {
// --- SILENT CALL RECORDING ENGINE FOR INTERVIEWERS & ADMINS --- // --- SILENT CALL RECORDING ENGINE FOR INTERVIEWERS & ADMINS ---
let adminMediaRecorder = null; let adminMediaRecorder = null;
let adminRecordedChunks = []; let adminRecordedChunks = [];
let recAudioContext = null;
function startCallRecording() { function startCallRecording() {
if (!currentInterviewId) { if (!currentInterviewId) {
@ -2166,28 +2161,59 @@ function startCallRecording() {
let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; let candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
let streamToRecord = new MediaStream(); let streamToRecord = new MediaStream();
// Add live Candidate Video and Audio tracks // 1. Add Video Track (Candidate video or Interviewer fallback)
if (candidateStream) { if (candidateStream) {
candidateStream.getTracks().forEach(track => { candidateStream.getVideoTracks().forEach(track => {
if (track.readyState === 'live') { if (track.readyState === 'live') {
try { streamToRecord.addTrack(track); } catch(e) {} try { streamToRecord.addTrack(track); } catch(e) {}
} }
}); });
} }
// Add Interviewer Local Microphone Audio track for 2-way call recording if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) {
if (interviewerLocalStream) { interviewerLocalStream.getVideoTracks().forEach(track => {
interviewerLocalStream.getAudioTracks().forEach(track => { if (track.readyState === 'live') {
if (track.readyState === 'live' && !streamToRecord.getAudioTracks().includes(track)) {
try { streamToRecord.addTrack(track); } catch(e) {} try { streamToRecord.addTrack(track); } catch(e) {}
} }
}); });
if (streamToRecord.getVideoTracks().length === 0) { }
interviewerLocalStream.getVideoTracks().forEach(track => {
if (track.readyState === 'live') { // 2. Mix Multiple Audio Tracks into a SINGLE Audio Track via Web Audio API
try { streamToRecord.addTrack(track); } catch(e) {} // (MediaRecorder standard throws DOMException if streamToRecord has >1 audio track)
} const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
}); const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) {
try {
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
if (recAudioContext) {
try { recAudioContext.close(); } catch(e) {}
}
recAudioContext = new AudioContextClass();
const audioDestination = recAudioContext.createMediaStreamDestination();
if (candAudioTracks.length > 0) {
const candAudioStream = new MediaStream(candAudioTracks);
const candSource = recAudioContext.createMediaStreamSource(candAudioStream);
candSource.connect(audioDestination);
}
if (adminAudioTracks.length > 0) {
const adminAudioStream = new MediaStream(adminAudioTracks);
const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream);
adminSource.connect(audioDestination);
}
const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0];
if (mixedAudioTrack) {
streamToRecord.addTrack(mixedAudioTrack);
}
} catch(e) {
console.warn('Web Audio API mixing fallback:', e);
const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0];
if (fallbackAudio) {
try { streamToRecord.addTrack(fallbackAudio); } catch(err) {}
}
} }
} }
@ -2221,6 +2247,10 @@ function startCallRecording() {
}; };
adminMediaRecorder.onstop = function() { adminMediaRecorder.onstop = function() {
if (recAudioContext) {
try { recAudioContext.close(); } catch(e) {}
recAudioContext = null;
}
if (adminRecordedChunks.length === 0) return; if (adminRecordedChunks.length === 0) return;
const recMime = adminMediaRecorder.mimeType || 'video/webm'; const recMime = adminMediaRecorder.mimeType || 'video/webm';
const isMp4 = recMime.includes('mp4'); const isMp4 = recMime.includes('mp4');

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\InterviewController; use App\Http\Controllers\InterviewController;
use App\Http\Controllers\LoginController; use App\Http\Controllers\LoginController;
use App\Http\Controllers\OnboardingController; use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\IceServerController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
// Authentication & Portal landing // Authentication & Portal landing
@ -39,9 +40,11 @@
Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message'); Route::post('/candidate/send-message/{id}', [InterviewController::class, 'candidateSendMessage'])->name('interview.candidate.send-message');
Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll'); Route::get('/candidate/poll/{id}', [InterviewController::class, 'getPollData'])->name('interview.candidate.poll');
// Public Storage & Media Stream Delivery (Recordings & Video Streaming) // Public Storage & Media Stream Delivery & ICE Servers
Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream'); Route::get('/media-stream/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('media.stream');
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file'); Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
Route::get('/ice-servers', IceServerController::class)->name('ice-servers');
// User Dashboard Portal (requires authenticated user) // User Dashboard Portal (requires authenticated user)
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () { Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
@ -81,6 +84,7 @@
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot'); Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
}); });
// Admin Control Panel (requires admin role) // Admin Control Panel (requires admin role)
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () { Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session'); Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');