feat: new interview page, MediaPipe Integration and Stun server discovery #20

Merged
subhajit.pal merged 12 commits from experimental/mediapipe-integration into main 2026-08-14 09:29:03 +00:00
4 changed files with 50 additions and 57 deletions
Showing only changes of commit ab00516c42 - Show all commits

View File

@ -663,11 +663,11 @@ public function getPollData($id)
} }
} }
// Clean up stale active_peers (> 15 seconds) // Clean up stale active_peers (> 30 seconds)
$activePeers = $interview->active_peers ?? []; $activePeers = $interview->active_peers ?? [];
$now = now()->timestamp; $now = now()->timestamp;
$validPeers = array_values(array_filter($activePeers, function ($p) use ($now) { $validPeers = array_values(array_filter($activePeers, function ($p) use ($now) {
return ($now - ($p['last_seen'] ?? 0)) < 15; return ($now - ($p['last_seen'] ?? 0)) < 30;
})); }));
if (count($validPeers) !== count($activePeers)) { if (count($validPeers) !== count($activePeers)) {
$interview->update(['active_peers' => $validPeers]); $interview->update(['active_peers' => $validPeers]);
@ -726,12 +726,13 @@ public function registerPeerHeartbeat(Request $request, $id)
$role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate'); $role = $request->input('role', $user ? ($user->is_admin ? 'admin' : 'interviewer') : 'candidate');
$userId = $user ? $user->id : 0; $userId = $user ? $user->id : 0;
$interview->refresh();
$activePeers = $interview->active_peers ?? []; $activePeers = $interview->active_peers ?? [];
$now = now()->timestamp; $now = now()->timestamp;
$updatedPeers = []; $updatedPeers = [];
foreach ($activePeers as $p) { foreach ($activePeers as $p) {
if (($now - ($p['last_seen'] ?? 0)) < 15) { if (($now - ($p['last_seen'] ?? 0)) < 30) {
if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) { if ($action === 'leave' && ($p['peer_id'] ?? '') === $peerId) {
continue; continue;
} }

View File

@ -1135,7 +1135,7 @@ class CandidateProctoring {
const objectDetector = await this.withAmdLoaderDisabled(() => const objectDetector = await this.withAmdLoaderDisabled(() =>
this.createModel(vision.ObjectDetector, fileset, { this.createModel(vision.ObjectDetector, fileset, {
modelAssetPath: OBJECT_MODEL_URL, modelAssetPath: OBJECT_MODEL_URL,
runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.15, runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.35,
}) })
); );
this.phoneDetector = new PhoneDetector(objectDetector); this.phoneDetector = new PhoneDetector(objectDetector);

View File

@ -271,26 +271,12 @@ export function saveCanvasDrawing(force = false) {
} }
// --- Candidate Chat Polling & Sending --- // --- Candidate Chat Polling & Sending ---
export function pollCandidateChat() { export function pollCandidateChat(data) {
const interviewId = document.body.dataset.interviewId;
if (!interviewId) return;
fetch(`/candidate/poll/${interviewId}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => {
if (!r.ok) return null;
const contentType = r.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) return null;
return r.json();
})
.then(data => {
if (!data) return; if (!data) return;
const chatHistory = document.getElementById('candidate-chat-history'); const chatHistory = document.getElementById('candidate-chat-history');
if (!chatHistory) return; if (!chatHistory) return;
if (data.status === 'blocked' || data.is_expired) { if (data.status === 'blocked' || data.is_expired) {
window.location.reload();
return; return;
} }
@ -318,8 +304,6 @@ export function pollCandidateChat() {
chatHistory.innerHTML = html; chatHistory.innerHTML = html;
chatHistory.scrollTop = chatHistory.scrollHeight; chatHistory.scrollTop = chatHistory.scrollHeight;
} }
})
.catch(() => {});
} }
export function sendCandidateMessage() { export function sendCandidateMessage() {
@ -345,7 +329,13 @@ export function sendCandidateMessage() {
}) })
.then(r => r.json()) .then(r => r.json())
.then(() => { .then(() => {
pollCandidateChat(); fetch(`/candidate/poll/${interviewId}`, {
headers: { 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data) pollCandidateChat(data);
});
}); });
} }
@ -972,10 +962,6 @@ export function initCandidateRoom() {
// Start Candidate Camera // Start Candidate Camera
startCandidateCameraStream(); startCandidateCameraStream();
// Chat polling
setInterval(pollCandidateChat, 600);
pollCandidateChat();
// Heartbeat & Polling // Heartbeat & Polling
if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer); if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer);
sendCandidatePeerHeartbeat(); sendCandidatePeerHeartbeat();
@ -985,7 +971,6 @@ export function initCandidateRoom() {
setInterval(() => { setInterval(() => {
if (!interviewId) return; if (!interviewId) return;
sendCandidatePeerHeartbeat('heartbeat');
fetch('/candidate/poll/' + interviewId, { fetch('/candidate/poll/' + interviewId, {
headers: { 'Accept': 'application/json' } headers: { 'Accept': 'application/json' }
@ -994,6 +979,8 @@ export function initCandidateRoom() {
.then(data => { .then(data => {
if (!data) return; if (!data) return;
pollCandidateChat(data);
if (data.active_peers && Array.isArray(data.active_peers)) { if (data.active_peers && Array.isArray(data.active_peers)) {
const activePeerIds = new Set(data.active_peers.map(p => p.peer_id)); const activePeerIds = new Set(data.active_peers.map(p => p.peer_id));

View File

@ -606,7 +606,7 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
const screenWrapper = document.getElementById('screen-wrapper'); const screenWrapper = document.getElementById('screen-wrapper');
const joinedPill = document.getElementById('candidate-joined-pill'); const joinedPill = document.getElementById('candidate-joined-pill');
if (!isJoined) { if (!isJoined && !candidateStreamConnected) {
if (joinedPill) { if (joinedPill) {
joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60'; joinedPill.className = 'pill gray inline-flex items-center gap-1.5 font-semibold text-[11px] px-2.5 py-0.5 rounded-full border border-slate-700 text-slate-400 bg-slate-800/60';
joinedPill.innerHTML = '<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> Not Joined'; joinedPill.innerHTML = '<i class="fa-solid fa-circle text-[8px] text-slate-500"></i> Not Joined';
@ -692,13 +692,18 @@ export function pollReviewData() {
const currentUserId = window.currentUserId || 0; const currentUserId = window.currentUserId || 0;
const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId; const myPeerId = 'interviewer_' + currentInterviewId + '_' + currentUserId;
const candVid = document.getElementById('interviewer-cand-video');
const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0));
const candidatePeer = (data.active_peers && Array.isArray(data.active_peers)) const candidatePeer = (data.active_peers && Array.isArray(data.active_peers))
? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_'))) ? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_')))
: null; : null;
if (candidatePeer) { const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected);
const isMicOn = isTrueVal(candidatePeer.mic_on);
const isCamOn = isTrueVal(candidatePeer.cam_on); if (isCandidateJoined) {
const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true;
const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true;
updateCandidateStatusIcons(isMicOn, isCamOn, true); updateCandidateStatusIcons(isMicOn, isCamOn, true);
} else { } else {
updateCandidateStatusIcons(false, false, false); updateCandidateStatusIcons(false, false, false);