feat: new interview page, MediaPipe Integration and Stun server discovery #20
@ -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 ?? [];
|
||||
$now = now()->timestamp;
|
||||
$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)) {
|
||||
$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');
|
||||
$userId = $user ? $user->id : 0;
|
||||
|
||||
$interview->refresh();
|
||||
$activePeers = $interview->active_peers ?? [];
|
||||
$now = now()->timestamp;
|
||||
|
||||
$updatedPeers = [];
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -1135,7 +1135,7 @@ class CandidateProctoring {
|
||||
const objectDetector = await this.withAmdLoaderDisabled(() =>
|
||||
this.createModel(vision.ObjectDetector, fileset, {
|
||||
modelAssetPath: OBJECT_MODEL_URL,
|
||||
runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.15,
|
||||
runningMode: 'VIDEO', maxResults: 10, scoreThreshold: 0.35,
|
||||
})
|
||||
);
|
||||
this.phoneDetector = new PhoneDetector(objectDetector);
|
||||
|
||||
@ -271,55 +271,39 @@ export function saveCanvasDrawing(force = false) {
|
||||
}
|
||||
|
||||
// --- Candidate Chat Polling & Sending ---
|
||||
export function pollCandidateChat() {
|
||||
const interviewId = document.body.dataset.interviewId;
|
||||
if (!interviewId) return;
|
||||
export function pollCandidateChat(data) {
|
||||
if (!data) return;
|
||||
const chatHistory = document.getElementById('candidate-chat-history');
|
||||
if (!chatHistory) 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;
|
||||
const chatHistory = document.getElementById('candidate-chat-history');
|
||||
if (!chatHistory) return;
|
||||
if (data.status === 'blocked' || data.is_expired) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === 'blocked' || data.is_expired) {
|
||||
window.location.reload();
|
||||
if (data.warnings && Array.isArray(data.warnings)) {
|
||||
if (data.warnings.length === 0) {
|
||||
chatHistory.innerHTML = '<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.warnings && Array.isArray(data.warnings)) {
|
||||
if (data.warnings.length === 0) {
|
||||
chatHistory.innerHTML = '<div style="color: #94a3b8; font-style: italic; text-align: center; padding-top: 40px;">No messages yet. Send a message below.</div>';
|
||||
return;
|
||||
}
|
||||
let html = '';
|
||||
data.warnings.forEach(w => {
|
||||
const isInterviewer = w.sent_by !== null;
|
||||
const senderName = isInterviewer ? (w.sender ? w.sender.name : 'Interviewer / Panelist') : 'You (Candidate)';
|
||||
const badgeColor = isInterviewer ? '#6366f1' : '#38bdf8';
|
||||
const bgColor = isInterviewer ? 'rgba(99,102,241,0.15)' : 'rgba(56,189,248,0.15)';
|
||||
|
||||
let html = '';
|
||||
data.warnings.forEach(w => {
|
||||
const isInterviewer = w.sent_by !== null;
|
||||
const senderName = isInterviewer ? (w.sender ? w.sender.name : 'Interviewer / Panelist') : 'You (Candidate)';
|
||||
const badgeColor = isInterviewer ? '#6366f1' : '#38bdf8';
|
||||
const bgColor = isInterviewer ? 'rgba(99,102,241,0.15)' : 'rgba(56,189,248,0.15)';
|
||||
|
||||
html += `<div style="margin-bottom:8px; background:${bgColor}; padding:8px 10px; border-radius:8px; border-left:3px solid ${badgeColor}; text-align: left;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||
<strong style="color:${badgeColor}; font-size:0.75rem;">${senderName}</strong>
|
||||
<span style="font-size:0.65rem; color:#94a3b8;">${new Date(w.created_at || Date.now()).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div style="color: #ffffff !important; font-size:0.85rem; font-weight:500; line-height:1.4; word-break: break-word;">${w.message}</div>
|
||||
</div>`;
|
||||
});
|
||||
chatHistory.innerHTML = html;
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
html += `<div style="margin-bottom:8px; background:${bgColor}; padding:8px 10px; border-radius:8px; border-left:3px solid ${badgeColor}; text-align: left;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||
<strong style="color:${badgeColor}; font-size:0.75rem;">${senderName}</strong>
|
||||
<span style="font-size:0.65rem; color:#94a3b8;">${new Date(w.created_at || Date.now()).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
<div style="color: #ffffff !important; font-size:0.85rem; font-weight:500; line-height:1.4; word-break: break-word;">${w.message}</div>
|
||||
</div>`;
|
||||
});
|
||||
chatHistory.innerHTML = html;
|
||||
chatHistory.scrollTop = chatHistory.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
export function sendCandidateMessage() {
|
||||
@ -345,7 +329,13 @@ export function sendCandidateMessage() {
|
||||
})
|
||||
.then(r => r.json())
|
||||
.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
|
||||
startCandidateCameraStream();
|
||||
|
||||
// Chat polling
|
||||
setInterval(pollCandidateChat, 600);
|
||||
pollCandidateChat();
|
||||
|
||||
// Heartbeat & Polling
|
||||
if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer);
|
||||
sendCandidatePeerHeartbeat();
|
||||
@ -985,7 +971,6 @@ export function initCandidateRoom() {
|
||||
|
||||
setInterval(() => {
|
||||
if (!interviewId) return;
|
||||
sendCandidatePeerHeartbeat('heartbeat');
|
||||
|
||||
fetch('/candidate/poll/' + interviewId, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
@ -994,6 +979,8 @@ export function initCandidateRoom() {
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
|
||||
pollCandidateChat(data);
|
||||
|
||||
if (data.active_peers && Array.isArray(data.active_peers)) {
|
||||
const activePeerIds = new Set(data.active_peers.map(p => p.peer_id));
|
||||
|
||||
|
||||
@ -606,7 +606,7 @@ export function updateCandidateStatusIcons(isMicOn, isCamOn, isJoined = true) {
|
||||
const screenWrapper = document.getElementById('screen-wrapper');
|
||||
const joinedPill = document.getElementById('candidate-joined-pill');
|
||||
|
||||
if (!isJoined) {
|
||||
if (!isJoined && !candidateStreamConnected) {
|
||||
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.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 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))
|
||||
? data.active_peers.find(p => p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_')))
|
||||
: null;
|
||||
|
||||
if (candidatePeer) {
|
||||
const isMicOn = isTrueVal(candidatePeer.mic_on);
|
||||
const isCamOn = isTrueVal(candidatePeer.cam_on);
|
||||
const isCandidateJoined = Boolean(candidatePeer) || Boolean(candidateStreamConnected);
|
||||
|
||||
if (isCandidateJoined) {
|
||||
const isMicOn = candidatePeer ? isTrueVal(candidatePeer.mic_on) : true;
|
||||
const isCamOn = candidatePeer ? isTrueVal(candidatePeer.cam_on) : true;
|
||||
updateCandidateStatusIcons(isMicOn, isCamOn, true);
|
||||
} else {
|
||||
updateCandidateStatusIcons(false, false, false);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user