From 5e0d2e426636ed4bddcaa0dfe72763064de9db70 Mon Sep 17 00:00:00 2001 From: "kushal.saha" Date: Tue, 11 Aug 2026 13:20:32 +0000 Subject: [PATCH] feat: mic & camera status of interview, candidate and peers --- resources/js/interview-call.js | 177 ++++++++++++--- .../views/components/video-tile.blade.php | 15 +- .../views/interview/candidate_room.blade.php | 201 +++++++++++++----- resources/views/interview/show.blade.php | 8 +- 4 files changed, 312 insertions(+), 89 deletions(-) diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index 898c5b8..b8a7014 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -93,6 +93,7 @@ let screenZoomLevel = 1.0; let candVidZoomLevel = 1.0; let activeViolations = []; let sosPollInterval = null; +let peerHeartbeatTimer = null; let acknowledgedViolationCount = 0; let sosDismissed = false; let sosAutoTriggerDisabled = false; @@ -413,6 +414,14 @@ export function subscribeLiveSync(interviewId) { if (drawingImg) drawingImg.style.display = 'none'; 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') { pollReviewData(); } @@ -425,7 +434,15 @@ export function subscribeLiveSync(interviewId) { interviewerSigChannel.onmessage = function(event) { const data = event.data; 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); } }; @@ -462,6 +479,11 @@ export function openReviewModal(id, name, uid, autoJoinCall = false) { if (sosPollInterval) clearInterval(sosPollInterval); sosPollInterval = setInterval(pollReviewData, 2500); + if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer); + peerHeartbeatTimer = setInterval(() => { + sendInterviewerPeerHeartbeat('heartbeat'); + }, 6000); + if (autoJoinCall) { setTimeout(() => { startInterviewerCall(true); @@ -487,11 +509,51 @@ export function sendInterviewerPeerHeartbeat(action = 'heartbeat') { peer_id: myPeerId, name: currentUserName, role: currentUserRole, + mic_on: isInterviewerMicOn, + cam_on: isInterviewerCamOn, action: action }) }).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() { if (!currentInterviewId) return; if (interviewerLocalStream) { @@ -517,8 +579,18 @@ export function pollReviewData() { const activeIds = new Set(data.active_peers.map(p => p.peer_id)); 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) { 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 (interviewerLocalStream && interviewerPeer && interviewerPeer.open) { if (!panelistMeshTiles.has(p.peer_id)) { @@ -526,10 +598,12 @@ export function pollReviewData() { const outCall = interviewerPeer.call(p.peer_id, interviewerLocalStream); if (outCall) { outCall.on('stream', remoteStream => { - addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel); + addOrUpdatePanelistTile(p.peer_id, remoteStream, roleLabel, isMicOn, isCamOn); }); } } 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; if (!peerId || peerId === ('interviewer_' + currentInterviewId + '_' + currentUserId)) return; 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 videoElem; + const initials = getInitials(name); if (!tile) { tile = document.createElement('div'); 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'); - videoElem.id = 'panelist-video-' + peerId; - videoElem.autoplay = true; - videoElem.playsInline = true; - videoElem.className = 'w-full h-full object-cover'; + tile.innerHTML = ` + +
+
+ ${initials} +
+
${name}
+
Camera turned off
+
+ + ${name} + + + + + `; - 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); + videoElem = document.getElementById('panelist-video-' + peerId); } else { 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) { @@ -1114,9 +1210,10 @@ export async function initInterviewerPeer() { call.on('stream', remoteStream => { if (call.peer === 'cand_' + currentInterviewId) { const candVid = document.getElementById('interviewer-cand-video'); - const placeholder = document.getElementById('cand-video-placeholder'); 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 { addOrUpdatePanelistTile(call.peer, remoteStream, 'Panelist'); } @@ -1183,9 +1280,10 @@ export function initInterviewerSigChannel() { outCall.on('stream', remoteStream => { if (msg.peer_id === 'cand_' + currentInterviewId) { const candVid = document.getElementById('interviewer-cand-video'); - const placeholder = document.getElementById('cand-video-placeholder'); 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 { addOrUpdatePanelistTile(msg.peer_id, remoteStream, msg.user_name || 'Panelist'); } @@ -1653,22 +1751,30 @@ export function toggleInterviewerMic() { audioTracks[0].enabled = isInterviewerMicOn; 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'); - if (icon) { - icon.className = isInterviewerMicOn ? 'fa-solid fa-microphone' : 'fa-solid fa-microphone-slash'; + btn.title = isInterviewerMicOn ? 'Mic On (Click to Mute)' : 'Mic Muted (Click to Unmute)'; + + 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)'; - - 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'); + const selfMicIcon = document.getElementById('self-mic-status'); + if (selfMicIcon) { + selfMicIcon.className = isInterviewerMicOn ? 'fa-solid fa-microphone text-[#21c274] text-[10px]' : 'fa-solid fa-microphone-slash text-[#ef4a5f] text-[10px]'; + selfMicIcon.title = isInterviewerMicOn ? 'Mic On' : 'Mic Muted'; } + + sendInterviewerPeerHeartbeat(); } 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) { const videoTracks = interviewerLocalStream.getVideoTracks(); if (videoTracks.length > 0) { @@ -1710,14 +1822,17 @@ export function toggleInterviewerCam() { if (selfPlaceholder) selfPlaceholder.style.display = 'none'; }) .catch(() => {}); + sendInterviewerPeerHeartbeat(); return; } if (selfPlaceholder) selfPlaceholder.style.display = isInterviewerCamOn ? 'none' : 'flex'; + sendInterviewerPeerHeartbeat(); } export function closeReviewModal() { if (sosPollInterval) clearInterval(sosPollInterval); + if (peerHeartbeatTimer) clearInterval(peerHeartbeatTimer); if (liveSyncChannel) { try { liveSyncChannel.close(); } catch(e) {} liveSyncChannel = null; diff --git a/resources/views/components/video-tile.blade.php b/resources/views/components/video-tile.blade.php index 5b1177a..bf7b7b5 100644 --- a/resources/views/components/video-tile.blade.php +++ b/resources/views/components/video-tile.blade.php @@ -4,6 +4,8 @@ 'placeholderId', 'badgeText' => '', 'badgeId' => null, + 'micStatusId' => null, + 'camStatusId' => null, 'borderColor' => 'border-[#1b2233]', 'showZoom' => false, 'showFullscreen' => false, @@ -51,7 +53,16 @@ - - {{ $badgeText }} + + {{ $badgeText }} + @if($micStatusId || $camStatusId) + + @if($micStatusId) + + @endif + @if($camStatusId) + + @endif + @endif diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index e92db91..b5b4362 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -9,6 +9,7 @@ + @@ -305,8 +306,11 @@
Camera Disabled
-
- 📷 Candidate Camera +
+ Candidate (You) + | + +
@@ -1123,13 +1127,23 @@ class CandRingtoneEngine { 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 placeholder = document.getElementById('interviewer-placeholder-box'); if (placeholder) placeholder.style.display = 'none'; let tile = document.getElementById('cand-iv-tile-' + peerId); let videoElem; + const initials = getInitials(labelName); if (!tile) { tile = document.createElement('div'); @@ -1138,33 +1152,44 @@ function addOrUpdateCandidateInterviewerTile(peerId, stream, labelName = 'Interv tile.style.position = 'relative'; tile.style.borderColor = '#6366f1'; - videoElem = document.createElement('video'); - videoElem.id = 'cand-iv-video-' + peerId; - videoElem.autoplay = true; - videoElem.playsInline = true; - videoElem.style.width = '100%'; - videoElem.style.height = '100%'; - videoElem.style.objectFit = 'cover'; - videoElem.style.background = '#050811'; + tile.innerHTML = ` + +
+
+ ${initials} +
+
${labelName}
+
Camera Turned Off
+
+
+ 🎙️ ${labelName} + | + + +
+ `; - 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); + videoElem = document.getElementById('cand-iv-video-' + peerId); } else { 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) { @@ -1337,15 +1362,20 @@ function initMeteredIceServers() { const msg = event.data; 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'; 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 { const outCall = peerInstance.call(msg.peer_id, localMediaStream); if (outCall) { 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) {} @@ -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() { if (!localMediaStream) return; const audioTracks = localMediaStream.getAudioTracks(); @@ -1636,9 +1731,18 @@ function toggleMic() { isMicEnabled = !isMicEnabled; audioTracks[0].enabled = isMicEnabled; const btn = document.getElementById('toggle-mic-btn'); - btn.innerText = isMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted'; - btn.style.background = isMicEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; - btn.style.borderColor = isMicEnabled ? '#10b981' : '#ef4444'; + if (btn) { + btn.innerText = isMicEnabled ? '🎤 Mic On' : '🔇 Mic Muted'; + 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; videoTracks[0].enabled = isCamEnabled; const btn = document.getElementById('toggle-cam-btn'); - btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off'; - btn.style.background = isCamEnabled ? 'rgba(16,185,129,0.2)' : 'rgba(239,68,68,0.2)'; - btn.style.borderColor = isCamEnabled ? '#10b981' : '#ef4444'; + if (btn) { + btn.innerText = isCamEnabled ? '📷 Cam On' : '🚫 Cam Off'; + 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'; } } else { isCamEnabled = !isCamEnabled; 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) { @@ -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(() => { if (!interviewId) return; sendCandidatePeerHeartbeat('heartbeat'); diff --git a/resources/views/interview/show.blade.php b/resources/views/interview/show.blade.php index b0b99fc..fccbd0f 100644 --- a/resources/views/interview/show.blade.php +++ b/resources/views/interview/show.blade.php @@ -213,7 +213,8 @@ videoId="interviewer-cand-video" placeholderId="cand-video-placeholder" badgeText="Candidate video" - badgeId="cand-vid-zoom-badge" + micStatusId="cand-mic-status" + camStatusId="cand-cam-status" showZoom="true" zoomInFn="zoomCandVideo(0.25)" zoomOutFn="zoomCandVideo(-0.25)" @@ -222,7 +223,7 @@ avatarNameId="cand-avatar-name" avatarTitle="{{ $interview->candidate_name }}" initials="{{ $candInitials }}"> - Waiting for candidate… + Waiting for candidate… @@ -231,7 +232,6 @@ videoId="interviewer-screen-video" placeholderId="screen-video-placeholder" badgeText="Shared screen" - badgeId="zoom-level-badge" focused="true" showZoom="true" showFullscreen="true" @@ -255,6 +255,8 @@ videoId="interviewer-self-video" placeholderId="self-video-placeholder" badgeText="Panelist self" + micStatusId="self-mic-status" + camStatusId="self-cam-status" avatarTitle="{{ Auth::user()->name }}" initials="{{ $usrInitials }}"> Panelist (self)