feat: mic & camera status of interview, candidate and peers

This commit is contained in:
kushal.saha 2026-08-11 13:20:32 +00:00
parent d1fbd99965
commit 5e0d2e4266
4 changed files with 312 additions and 89 deletions

View File

@ -93,6 +93,7 @@ let screenZoomLevel = 1.0;
let candVidZoomLevel = 1.0; let candVidZoomLevel = 1.0;
let activeViolations = []; let activeViolations = [];
let sosPollInterval = null; let sosPollInterval = null;
let peerHeartbeatTimer = null;
let acknowledgedViolationCount = 0; let acknowledgedViolationCount = 0;
let sosDismissed = false; let sosDismissed = false;
let sosAutoTriggerDisabled = false; let sosAutoTriggerDisabled = false;
@ -413,6 +414,14 @@ export function subscribeLiveSync(interviewId) {
if (drawingImg) drawingImg.style.display = 'none'; if (drawingImg) drawingImg.style.display = 'none';
if (noDrawing) noDrawing.style.display = 'block'; if (noDrawing) noDrawing.style.display = 'block';
} }
} else if (data.type === 'peer_state_changed') {
const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_'));
if (isCandPeer) {
const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true;
const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true;
updateCandidateStatusIcons(isMicOn, isCamOn);
}
pollReviewData();
} else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') { } else if (data.type === 'chat_message' || data.type === 'violation_logged' || data.type === 'violation_occurred') {
pollReviewData(); pollReviewData();
} }
@ -425,7 +434,15 @@ export function subscribeLiveSync(interviewId) {
interviewerSigChannel.onmessage = function(event) { interviewerSigChannel.onmessage = function(event) {
const data = event.data; const data = event.data;
if (!data) return; if (!data) return;
if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') { if (data.type === 'peer_state_changed') {
const isCandPeer = data.role === 'candidate' || (data.peer_id && data.peer_id.startsWith('cand_'));
if (isCandPeer) {
const isMicOn = (data.mic_on !== undefined) ? Boolean(data.mic_on) : true;
const isCamOn = (data.cam_on !== undefined) ? Boolean(data.cam_on) : true;
updateCandidateStatusIcons(isMicOn, isCamOn);
}
setTimeout(pollReviewData, 100);
} else if (data.type === 'violation_occurred' || data.type === 'violation_logged' || data.type === 'candidate_joined' || data.type === 'tab_switch') {
setTimeout(pollReviewData, 200); setTimeout(pollReviewData, 200);
} }
}; };
@ -462,6 +479,11 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) {
if (sosPollInterval) clearInterval(sosPollInterval); if (sosPollInterval) clearInterval(sosPollInterval);
sosPollInterval = setInterval(pollReviewData, 2500); sosPollInterval = setInterval(pollReviewData, 2500);
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
peerHeartbeatTimer = setInterval(() => {
sendInterviewerPeerHeartbeat('heartbeat');
}, 6000);
if (autoJoinCall) { if (autoJoinCall) {
setTimeout(() => { setTimeout(() => {
startInterviewerCall(true); startInterviewerCall(true);
@ -487,11 +509,51 @@ export function sendInterviewerPeerHeartbeat(action = 'heartbeat') {
peer_id: myPeerId, peer_id: myPeerId,
name: currentUserName, name: currentUserName,
role: currentUserRole, role: currentUserRole,
mic_on: isInterviewerMicOn,
cam_on: isInterviewerCamOn,
action: action action: action
}) })
}).catch(() => {}); }).catch(() => {});
} }
function isTrueVal(val) {
if (val === undefined || val === null) return true;
if (val === false || val === 'false' || val === 0 || val === '0') return false;
return Boolean(val);
}
export function updateCandidateStatusIcons(isMicOn, isCamOn) {
const micOn = isTrueVal(isMicOn);
const camOn = isTrueVal(isCamOn);
const candMicIcon = document.getElementById('cand-mic-status');
const candCamIcon = document.getElementById('cand-cam-status');
const candPlaceholder = document.getElementById('cand-video-placeholder');
const candStatusText = document.getElementById('cand-status-text') || (candPlaceholder ? candPlaceholder.querySelector('.status') : null);
const candVid = document.getElementById('interviewer-cand-video');
const candidateStreamConnected = candVid && candVid.srcObject && (candVid.srcObject.active || (candVid.srcObject.getTracks && candVid.srcObject.getTracks().length > 0));
if (candMicIcon && isMicOn !== undefined) {
candMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
candMicIcon.title = micOn ? 'Mic On' : 'Mic Muted';
}
if (candCamIcon && isCamOn !== undefined) {
candCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]';
candCamIcon.title = camOn ? 'Camera On' : 'Camera Off';
}
if (candPlaceholder) {
if (!camOn) {
candPlaceholder.style.display = 'flex';
if (candStatusText) candStatusText.innerText = 'Camera Turned Off';
} else if (candidateStreamConnected) {
candPlaceholder.style.display = 'none';
} else {
candPlaceholder.style.display = 'flex';
if (candStatusText) candStatusText.innerHTML = 'Waiting for candidate…';
}
}
}
export function pollReviewData() { export function pollReviewData() {
if (!currentInterviewId) return; if (!currentInterviewId) return;
if (interviewerLocalStream) { if (interviewerLocalStream) {
@ -517,8 +579,18 @@ export function pollReviewData() {
const activeIds = new Set(data.active_peers.map(p => p.peer_id)); const activeIds = new Set(data.active_peers.map(p => p.peer_id));
data.active_peers.forEach(p => { data.active_peers.forEach(p => {
const isCandPeer = p.role === 'candidate' || (p.peer_id && p.peer_id.startsWith('cand_'));
if (isCandPeer) {
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
updateCandidateStatusIcons(isMicOn, isCamOn);
}
if (p.peer_id && p.peer_id !== myPeerId) { if (p.peer_id && p.peer_id !== myPeerId) {
let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || '')); let roleLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : ('Panelist ' + (p.name || ''));
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
if (p.peer_id.startsWith('interviewer_')) { if (p.peer_id.startsWith('interviewer_')) {
if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { if (interviewerLocalStream && interviewerPeer && interviewerPeer.open) {
if (!panelistMeshTiles.has(p.peer_id)) { if (!panelistMeshTiles.has(p.peer_id)) {
@ -526,10 +598,12 @@ export function pollReviewData() {
const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream);
if (outCall) { if (outCall) {
outCall.on('stream', remoteStream => { outCall.on('stream', remoteStream => {
addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel); addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn);
}); });
} }
} catch(e) {} } catch(e) {}
} else {
addOrUpdatePanelistTile(p.peer_id, panelistMeshTiles.get(p.peer_id), roleLabel, isMicOn, isCamOn);
} }
} }
} }
@ -1035,7 +1109,7 @@ export function safePlayMediaStream(videoElem, stream, isSelf = false) {
} }
} }
export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist') { export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist', micOn = true, camOn = true) {
const currentUserId = window.currentUserId || 0; const currentUserId = window.currentUserId || 0;
if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return; if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return;
const grid = document.getElementById('panelist-mesh-grid'); const grid = document.getElementById('panelist-mesh-grid');
@ -1043,27 +1117,49 @@ export function addOrUpdatePanelistTile(peerId, stream, name = 'Panelist') {
let tile = document.getElementById('panelist-tile-' + peerId); let tile = document.getElementById('panelist-tile-' + peerId);
let videoElem; let videoElem;
const initials = getInitials(name);
if (!tile) { if (!tile) {
tile = document.createElement('div'); tile = document.createElement('div');
tile.id = 'panelist-tile-' + peerId; tile.id = 'panelist-tile-' + peerId;
tile.className = 'relative bg-[#050811] rounded-xl border-2 border-indigo-500 overflow-hidden h-[220px]'; tile.className = 'video-tile relative aspect-[16/11] bg-[#0d1220] border border-[#1b2233] rounded-[10px] overflow-hidden flex flex-col items-center justify-center transition-all';
videoElem = document.createElement('video'); tile.innerHTML = `
videoElem.id = 'panelist-video-' + peerId; <video id="panelist-video-${peerId}" autoplay playsinline class="w-full h-full object-cover origin-center transition-transform duration-200"></video>
videoElem.autoplay = true; <div id="mesh-placeholder-${peerId}" class="absolute inset-0 flex flex-col items-center justify-center bg-[#0d1220]/95 text-slate-400 text-xs z-10 p-3" style="display: ${camOn ? 'none' : 'flex'};">
videoElem.playsInline = true; <div class="av w-[58px] h-[58px] rounded-full bg-gradient-to-br from-[#5b8bff] to-[#3b63e0] flex items-center justify-center text-white font-semibold text-[19px] mb-2.5 shadow-md shadow-[#4b82f7]/20">
videoElem.className = 'w-full h-full object-cover'; ${initials}
</div>
<div class="name text-[13.5px] font-semibold text-white">${name}</div>
<div class="status text-[11.5px] text-[#5b637a] mt-1 text-center px-3">Camera turned off</div>
</div>
<span class="tile-tag absolute left-2.5 bottom-2.5 text-[10px] font-semibold tracking-wide px-2.5 py-1 rounded-md bg-black/50 text-slate-300 border border-[#1b2233] z-20 flex items-center gap-1.5 backdrop-blur-sm">
<span>${name}</span>
<span class="w-px h-2.5 bg-white/20 mx-0.5"></span>
<i id="mesh-mic-${peerId}" class="${micOn ? 'fa-solid fa-microphone text-[#21c274]' : 'fa-solid fa-microphone-slash text-[#ef4a5f]'} text-[10px]" title="${micOn ? 'Mic On' : 'Mic Muted'}"></i>
<i id="mesh-cam-${peerId}" class="${camOn ? 'fa-solid fa-video text-[#21c274]' : 'fa-solid fa-video-slash text-[#ef4a5f]'} text-[10px]" title="${camOn ? 'Camera On' : 'Camera Off'}"></i>
</span>
`;
const labelDiv = document.createElement('div');
labelDiv.className = 'absolute bottom-1.5 left-1.5 bg-indigo-600/85 px-2 py-0.5 rounded text-[0.65rem] text-white font-semibold z-10';
labelDiv.innerText = '🎙️ ' + name;
tile.appendChild(videoElem);
tile.appendChild(labelDiv);
grid.appendChild(tile); grid.appendChild(tile);
videoElem = document.getElementById('panelist-video-' + peerId);
} else { } else {
videoElem = document.getElementById('panelist-video-' + peerId); videoElem = document.getElementById('panelist-video-' + peerId);
const meshMicIcon = document.getElementById('mesh-mic-' + peerId);
const meshCamIcon = document.getElementById('mesh-cam-' + peerId);
const meshPlaceholder = document.getElementById('mesh-placeholder-' + peerId);
if (meshMicIcon) {
meshMicIcon.className = micOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
meshMicIcon.title = micOn ? 'Mic On' : 'Mic Muted';
}
if (meshCamIcon) {
meshCamIcon.className = camOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]';
meshCamIcon.title = camOn ? 'Camera On' : 'Camera Off';
}
if (meshPlaceholder) {
meshPlaceholder.style.display = camOn ? 'none' : 'flex';
}
} }
if (videoElem && stream) { if (videoElem && stream) {
@ -1114,9 +1210,10 @@ export async function initInterviewerPeer() {
call.on('stream', remoteStream => { call.on('stream', remoteStream => {
if (call.peer === 'cand_' + currentInterviewId) { if (call.peer === 'cand_' + currentInterviewId) {
const candVid = document.getElementById('interviewer-cand-video'); const candVid = document.getElementById('interviewer-cand-video');
const placeholder = document.getElementById('cand-video-placeholder');
if (candVid) safePlayMediaStream(candVid, remoteStream); if (candVid) safePlayMediaStream(candVid, remoteStream);
if (placeholder) placeholder.style.display = 'none'; const candCamIcon = document.getElementById('cand-cam-status');
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
updateCandidateStatusIcons(undefined, isCamOn);
} else { } else {
addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist'); addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist');
} }
@ -1183,9 +1280,10 @@ export function initInterviewerSigChannel() {
outCall.on('stream', remoteStream => { outCall.on('stream', remoteStream => {
if (msg.peer_id === 'cand_' + currentInterviewId) { if (msg.peer_id === 'cand_' + currentInterviewId) {
const candVid = document.getElementById('interviewer-cand-video'); const candVid = document.getElementById('interviewer-cand-video');
const placeholder = document.getElementById('cand-video-placeholder');
if (candVid) safePlayMediaStream(candVid, remoteStream); if (candVid) safePlayMediaStream(candVid, remoteStream);
if (placeholder) placeholder.style.display = 'none'; const candCamIcon = document.getElementById('cand-cam-status');
const isCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
updateCandidateStatusIcons(undefined, isCamOn);
} else { } else {
addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist'); addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist');
} }
@ -1653,22 +1751,30 @@ export function toggleInterviewerMic() {
audioTracks[0].enabled = isInterviewerMicOn; audioTracks[0].enabled = isInterviewerMicOn;
const btn = document.getElementById('btn-toggle-interviewer-mic'); const btn = document.getElementById('btn-toggle-interviewer-mic');
if (!btn) return; if (btn) {
const icon = btn.querySelector('i');
if (icon) {
icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
}
const icon = btn.querySelector('i'); btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)';
if (icon) {
icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; if (isInterviewerMicOn) {
btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
} else {
btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
}
} }
btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)'; const selfMicIcon = document.getElementById('self-mic-status');
if (selfMicIcon) {
if (isInterviewerMicOn) { selfMicIcon.className = isInterviewerMicOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]';
btn.classList.remove('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white'); selfMicIcon.title = isInterviewerMicOn ? 'Mic On' : 'Mic Muted';
btn.classList.add('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
} else {
btn.classList.remove('bg-[#0d1220]', 'text-slate-300', 'border-[#232b3d]');
btn.classList.add('bg-[#ef4a5f]', 'border-[#ef4a5f]', 'hover:bg-[#d63d51]', 'text-white');
} }
sendInterviewerPeerHeartbeat();
} }
export function toggleInterviewerCam() { export function toggleInterviewerCam() {
@ -1694,6 +1800,12 @@ export function toggleInterviewerCam() {
} }
} }
const selfCamIcon = document.getElementById('self-cam-status');
if (selfCamIcon) {
selfCamIcon.className = isInterviewerCamOn ? 'fa-solid fa-video text-[#21c274] text-[10px]' : 'fa-solid fa-video-slash text-[#ef4a5f] text-[10px]';
selfCamIcon.title = isInterviewerCamOn ? 'Camera On' : 'Camera Off';
}
if (interviewerLocalStream) { if (interviewerLocalStream) {
const videoTracks = interviewerLocalStream.getVideoTracks(); const videoTracks = interviewerLocalStream.getVideoTracks();
if (videoTracks.length > 0) { if (videoTracks.length > 0) {
@ -1710,14 +1822,17 @@ export function toggleInterviewerCam() {
if (selfPlaceholder) selfPlaceholder.style.display = 'none'; if (selfPlaceholder) selfPlaceholder.style.display = 'none';
}) })
.catch(() => {}); .catch(() => {});
sendInterviewerPeerHeartbeat();
return; return;
} }
if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex'; if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex';
sendInterviewerPeerHeartbeat();
} }
export function closeReviewModal() { export function closeReviewModal() {
if (sosPollInterval) clearInterval(sosPollInterval); if (sosPollInterval) clearInterval(sosPollInterval);
if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer);
if (liveSyncChannel) { if (liveSyncChannel) {
try { liveSyncChannel.close(); } catch(e) {} try { liveSyncChannel.close(); } catch(e) {}
liveSyncChannel = null; liveSyncChannel = null;

View File

@ -4,6 +4,8 @@
'placeholderId', 'placeholderId',
'badgeText' => '', 'badgeText' => '',
'badgeId' => null, 'badgeId' => null,
'micStatusId' => null,
'camStatusId' => null,
'borderColor' => 'border-[#1b2233]', 'borderColor' => 'border-[#1b2233]',
'showZoom' => false, 'showZoom' => false,
'showFullscreen' => false, 'showFullscreen' => false,
@ -51,7 +53,16 @@
</div> </div>
</div> </div>
<span class="tile-tag absolute left-2.5 bottom-2.5 text-[10px] font-semibold tracking-wide px-2 py-1 rounded-md bg-black/35 text-slate-400 border border-[#1b2233] z-10"> <span class="tile-tag absolute left-2.5 bottom-2.5 text-[10px] font-semibold tracking-wide px-2.5 py-1 rounded-md bg-black/50 text-slate-300 border border-[#1b2233] z-20 flex items-center gap-1.5 backdrop-blur-sm">
{{ $badgeText }} <span>{{ $badgeText }}</span>
@if($micStatusId || $camStatusId)
<span class="w-px h-2.5 bg-white/20 mx-0.5"></span>
@if($micStatusId)
<i id="{{ $micStatusId }}" class="fa-solid fa-microphone text-[#21c274] text-[10px]" title="Mic On"></i>
@endif
@if($camStatusId)
<i id="{{ $camStatusId }}" class="fa-solid fa-video text-[#21c274] text-[10px]" title="Camera On"></i>
@endif
@endif
</span> </span>
</div> </div>

View File

@ -9,6 +9,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
<!-- PeerJS, Metered SDK, SweetAlert2 & html2canvas --> <!-- PeerJS, Metered SDK, SweetAlert2 & html2canvas -->
<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>
@ -305,8 +306,11 @@
</div> </div>
<div style="font-size: 0.7rem; color: #94a3b8; margin-top: 6px;">Camera Disabled</div> <div style="font-size: 0.7rem; color: #94a3b8; margin-top: 6px;">Camera Disabled</div>
</div> </div>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10;"> <div style="position: absolute; bottom: 8px; left: 8px; background: rgba(0,0,0,0.6); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: #34d399; font-weight: 600; z-index: 10; display: flex; align-items: center; gap: 6px;">
📷 Candidate Camera <span>Candidate (You)</span>
<span style="opacity: 0.4;">|</span>
<i id="cand-self-mic-icon" class="fa-solid fa-microphone" style="color: #34d399; font-size: 10px;" title="Mic On"></i>
<i id="cand-self-cam-icon" class="fa-solid fa-video" style="color: #34d399; font-size: 10px;" title="Camera On"></i>
</div> </div>
</div> </div>
@ -1123,13 +1127,23 @@ class CandRingtoneEngine {
let candidateInterviewerTiles = new Map(); let candidateInterviewerTiles = new Map();
function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interviewer Panelist') { function isTrueVal(val) {
if (val === undefined || val === null) return true;
if (val === false || val === 'false' || val === 0 || val === '0') return false;
return Boolean(val);
}
function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interviewer Panelist', micOn = true, camOn = true) {
const micOnVal = isTrueVal(micOn);
const camOnVal = isTrueVal(camOn);
const grid = document.getElementById('interviewer-mesh-grid'); const grid = document.getElementById('interviewer-mesh-grid');
const placeholder = document.getElementById('interviewer-placeholder-box'); const placeholder = document.getElementById('interviewer-placeholder-box');
if (placeholder) placeholder.style.display = 'none'; if (placeholder) placeholder.style.display = 'none';
let tile = document.getElementById('cand-iv-tile-' + peerId); let tile = document.getElementById('cand-iv-tile-' + peerId);
let videoElem; let videoElem;
const initials = getInitials(labelName);
if (!tile) { if (!tile) {
tile = document.createElement('div'); tile = document.createElement('div');
@ -1138,33 +1152,44 @@ function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interv
tile.style.position = 'relative'; tile.style.position = 'relative';
tile.style.borderColor = '#6366f1'; tile.style.borderColor = '#6366f1';
videoElem = document.createElement('video'); tile.innerHTML = `
videoElem.id = 'cand-iv-video-' + peerId; <video id="cand-iv-video-${peerId}" autoplay playsinline style="width: 100%; height: 100%; object-fit: cover; background: #050811;"></video>
videoElem.autoplay = true; <div id="cand-iv-placeholder-${peerId}" style="position: absolute; inset: 0; display: ${camOnVal ? 'none' : 'flex'}; flex-direction: column; align-items: center; justify-content: center; background: #0f172a; color: white; z-index: 5;">
videoElem.playsInline = true; <div style="width: 58px; height: 58px; border-radius: 50%; background: linear-gradient(135deg, #38bdf8 0%, #6366f1 100%); display: flex; align-items: center; justify-content: center; font-size: 1.3rem; font-weight: 800; color: white; box-shadow: 0 0 15px rgba(56,189,248,0.4);">
videoElem.style.width = '100%'; ${initials}
videoElem.style.height = '100%'; </div>
videoElem.style.objectFit = 'cover'; <div style="font-size: 0.8rem; font-weight: 700; color: white; margin-top: 6px;">${labelName}</div>
videoElem.style.background = '#050811'; <div style="font-size: 0.65rem; color: #94a3b8; margin-top: 2px;">Camera Turned Off</div>
</div>
<div style="position: absolute; bottom: 8px; left: 8px; background: rgba(99,102,241,0.85); padding: 2px 8px; border-radius: 4px; font-size: 0.65rem; color: white; font-weight: 600; z-index: 10; display: flex; align-items: center; gap: 6px;">
<span>🎙️ ${labelName}</span>
<span style="opacity: 0.4;">|</span>
<i id="cand-iv-mic-${peerId}" class="${micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'}" style="color: ${micOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${micOnVal ? 'Mic On' : 'Mic Muted'}"></i>
<i id="cand-iv-cam-${peerId}" class="${camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash'}" style="color: ${camOnVal ? '#34d399' : '#ef4444'}; font-size: 10px;" title="${camOnVal ? 'Camera On' : 'Camera Off'}"></i>
</div>
`;
const labelDiv = document.createElement('div');
labelDiv.style.position = 'absolute';
labelDiv.style.bottom = '8px';
labelDiv.style.left = '8px';
labelDiv.style.background = 'rgba(99,102,241,0.85)';
labelDiv.style.padding = '2px 8px';
labelDiv.style.borderRadius = '4px';
labelDiv.style.fontSize = '0.65rem';
labelDiv.style.color = 'white';
labelDiv.style.fontWeight = '600';
labelDiv.style.zIndex = '10';
labelDiv.innerText = '🎙️ ' + labelName;
tile.appendChild(videoElem);
tile.appendChild(labelDiv);
if (grid) grid.appendChild(tile); if (grid) grid.appendChild(tile);
videoElem = document.getElementById('cand-iv-video-' + peerId);
} else { } else {
videoElem = document.getElementById('cand-iv-video-' + peerId); videoElem = document.getElementById('cand-iv-video-' + peerId);
const micIcon = document.getElementById('cand-iv-mic-' + peerId);
const camIcon = document.getElementById('cand-iv-cam-' + peerId);
const placeholderEl = document.getElementById('cand-iv-placeholder-' + peerId);
if (micIcon) {
micIcon.className = micOnVal ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
micIcon.style.color = micOnVal ? '#34d399' : '#ef4444';
micIcon.title = micOnVal ? 'Mic On' : 'Mic Muted';
}
if (camIcon) {
camIcon.className = camOnVal ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
camIcon.style.color = camOnVal ? '#34d399' : '#ef4444';
camIcon.title = camOnVal ? 'Camera On' : 'Camera Off';
}
if (placeholderEl) {
placeholderEl.style.display = camOnVal ? 'none' : 'flex';
}
} }
if (videoElem && stream) { if (videoElem && stream) {
@ -1337,15 +1362,20 @@ function initMeteredIceServers() {
const msg = event.data; const msg = event.data;
if (!msg) return; if (!msg) return;
if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer') { if (msg.type === 'interviewer_joined' || msg.type === 'peer_joined' || msg.type === 'peer_presence_ack' || msg.type === 'offer' || msg.type === 'peer_state_changed') {
latestCallStatus = 'active'; latestCallStatus = 'active';
if (msg.peer_id && msg.peer_id.startsWith('interviewer_')) { if (msg.peer_id && msg.peer_id.startsWith('interviewer_')) {
if (localMediaStream && peerInstance) { const isMicOn = (msg.mic_on !== undefined) ? Boolean(msg.mic_on) : true;
const isCamOn = (msg.cam_on !== undefined) ? Boolean(msg.cam_on) : true;
const existingStream = candidateInterviewerTiles.get(msg.peer_id) || null;
addOrUpdateCandidateInterviewerTile(msg.peer_id, existingStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
if (localMediaStream && peerInstance && !existingStream) {
try { try {
const outCall = peerInstance.call(msg.peer_id, localMediaStream); const outCall = peerInstance.call(msg.peer_id, localMediaStream);
if (outCall) { if (outCall) {
outCall.on('stream', remoteStream => { outCall.on('stream', remoteStream => {
addOrUpdateCandidateInterviewerTile(msg.peer_id, remoteStream, msg.user_name || 'Interviewer Panelist'); addOrUpdateCandidateInterviewerTile(msg.peer_id, remoteStream, msg.user_name || msg.name || 'Interviewer Panelist', isMicOn, isCamOn);
}); });
} }
} catch(e) {} } catch(e) {}
@ -1629,6 +1659,71 @@ function candidateLeaveCall(isEndedByHost = false) {
} }
} }
let candidateHeartbeatDebounceTimer = null;
let candidateHeartbeatIntervalTimer = null;
function debouncedCandidatePeerHeartbeat() {
if (candidateHeartbeatDebounceTimer) clearTimeout(candidateHeartbeatDebounceTimer);
sendCandidatePeerHeartbeat();
candidateHeartbeatDebounceTimer = setTimeout(() => {
sendCandidatePeerHeartbeat();
}, 300);
}
function sendCandidatePeerHeartbeat(action = 'heartbeat') {
const peerId = 'cand_' + interviewId;
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
fetch("{{ route('interview.candidate.peer-heartbeat', $interview->id) }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken
},
body: JSON.stringify({
peer_id: peerId,
name: '{{ addslashes($interview->candidate_name) }}',
role: 'candidate',
mic_on: Boolean(isMicEnabled),
cam_on: Boolean(isCamEnabled),
action: action
})
})
.then(r => r.ok ? r.json() : null)
.then(data => {
if (data && data.active_peers && Array.isArray(data.active_peers)) {
data.active_peers.forEach(p => {
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
const existingStream = candidateInterviewerTiles.get(p.peer_id) || null;
const nameLabel = p.role === 'admin' ? ('Admin ' + (p.name || '')) : (p.name || 'Interviewer Panelist');
const isMicOn = isTrueVal(p.mic_on);
const isCamOn = isTrueVal(p.cam_on);
addOrUpdateCandidateInterviewerTile(p.peer_id, existingStream, nameLabel, isMicOn, isCamOn);
}
});
}
})
.catch(() => {});
if (callSigChannel) {
callSigChannel.postMessage({
type: 'peer_state_changed',
peer_id: peerId,
role: 'candidate',
name: '{{ addslashes($interview->candidate_name) }}',
mic_on: Boolean(isMicEnabled),
cam_on: Boolean(isCamEnabled)
});
}
}
// 6-Second Steady Heartbeat Timer & Immediate Load Trigger
if (candidateHeartbeatIntervalTimer) clearInterval(candidateHeartbeatIntervalTimer);
sendCandidatePeerHeartbeat();
candidateHeartbeatIntervalTimer = setInterval(() => {
sendCandidatePeerHeartbeat();
}, 6000);
function toggleMic() { function toggleMic() {
if (!localMediaStream) return; if (!localMediaStream) return;
const audioTracks = localMediaStream.getAudioTracks(); const audioTracks = localMediaStream.getAudioTracks();
@ -1636,9 +1731,18 @@ function toggleMic() {
isMicEnabled = !isMicEnabled; isMicEnabled = !isMicEnabled;
audioTracks[0].enabled = isMicEnabled; audioTracks[0].enabled = isMicEnabled;
const btn = document.getElementById('toggle-mic-btn'); const btn = document.getElementById('toggle-mic-btn');
btn.innerText = isMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted'; if (btn) {
btn.style.background = isMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; btn.innerText = isMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted';
btn.style.borderColor = isMicEnabled ? '#10b981' : '#ef4444'; btn.style.background = isMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
btn.style.borderColor = isMicEnabled ? '#10b981' : '#ef4444';
}
const icon = document.getElementById('cand-self-mic-icon');
if (icon) {
icon.className = isMicEnabled ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash';
icon.style.color = isMicEnabled ? '#34d399' : '#ef4444';
icon.title = isMicEnabled ? 'Mic On' : 'Mic Muted';
}
debouncedCandidatePeerHeartbeat();
} }
} }
@ -1650,15 +1754,24 @@ function toggleCam() {
isCamEnabled = !isCamEnabled; isCamEnabled = !isCamEnabled;
videoTracks[0].enabled = isCamEnabled; videoTracks[0].enabled = isCamEnabled;
const btn = document.getElementById('toggle-cam-btn'); const btn = document.getElementById('toggle-cam-btn');
btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off'; if (btn) {
btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off';
btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444'; btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)';
btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444';
}
if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex'; if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex';
} }
} else { } else {
isCamEnabled = !isCamEnabled; isCamEnabled = !isCamEnabled;
if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex'; if (avatarPlaceholder) avatarPlaceholder.style.display = isCamEnabled ? 'none' : 'flex';
} }
const icon = document.getElementById('cand-self-cam-icon');
if (icon) {
icon.className = isCamEnabled ? 'fa-solid fa-video' : 'fa-solid fa-video-slash';
icon.style.color = isCamEnabled ? '#34d399' : '#ef4444';
icon.title = isCamEnabled ? 'Camera On' : 'Camera Off';
}
debouncedCandidatePeerHeartbeat();
} }
function requestFullscreenAsPromise(elem) { function requestFullscreenAsPromise(elem) {
@ -2072,24 +2185,6 @@ function saveCanvasDrawing(force = false) {
} }
} }
function sendCandidatePeerHeartbeat(action = 'heartbeat') {
if (!interviewId) return;
const candPeerId = 'cand_' + interviewId;
fetch('/candidate/peer-heartbeat/' + interviewId, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}'
},
body: JSON.stringify({
peer_id: candPeerId,
name: '{{ addslashes($interview->candidate_name) }}',
role: 'candidate',
action: action
})
}).catch(e => {});
}
setInterval(() => { setInterval(() => {
if (!interviewId) return; if (!interviewId) return;
sendCandidatePeerHeartbeat('heartbeat'); sendCandidatePeerHeartbeat('heartbeat');

View File

@ -213,7 +213,8 @@
videoId="interviewer-cand-video" videoId="interviewer-cand-video"
placeholderId="cand-video-placeholder" placeholderId="cand-video-placeholder"
badgeText="Candidate video" badgeText="Candidate video"
badgeId="cand-vid-zoom-badge" micStatusId="cand-mic-status"
camStatusId="cand-cam-status"
showZoom="true" showZoom="true"
zoomInFn="zoomCandVideo(0.25)" zoomInFn="zoomCandVideo(0.25)"
zoomOutFn="zoomCandVideo(-0.25)" zoomOutFn="zoomCandVideo(-0.25)"
@ -222,7 +223,7 @@
avatarNameId="cand-avatar-name" avatarNameId="cand-avatar-name"
avatarTitle="{{ $interview->candidate_name }}" avatarTitle="{{ $interview->candidate_name }}"
initials="{{ $candInitials }}"> initials="{{ $candInitials }}">
Waiting for candidate&hellip; <span id="cand-status-text">Waiting for candidate&hellip;</span>
</x-video-tile> </x-video-tile>
<!-- Candidate Shared Screen --> <!-- Candidate Shared Screen -->
@ -231,7 +232,6 @@
videoId="interviewer-screen-video" videoId="interviewer-screen-video"
placeholderId="screen-video-placeholder" placeholderId="screen-video-placeholder"
badgeText="Shared screen" badgeText="Shared screen"
badgeId="zoom-level-badge"
focused="true" focused="true"
showZoom="true" showZoom="true"
showFullscreen="true" showFullscreen="true"
@ -255,6 +255,8 @@
videoId="interviewer-self-video" videoId="interviewer-self-video"
placeholderId="self-video-placeholder" placeholderId="self-video-placeholder"
badgeText="Panelist self" badgeText="Panelist self"
micStatusId="self-mic-status"
camStatusId="self-cam-status"
avatarTitle="{{ Auth::user()->name }}" avatarTitle="{{ Auth::user()->name }}"
initials="{{ $usrInitials }}"> initials="{{ $usrInitials }}">
Panelist (self) Panelist (self)