From dfbbd1263dcebd744f626783fd39c3ef985e33a9 Mon Sep 17 00:00:00 2001 From: "kushal.saha" Date: Tue, 18 Aug 2026 10:56:53 +0000 Subject: [PATCH] feat(call recording): add recording compositor to merge multiple peers - current compositor has a 2 pane layout, with candidate video/screenshare on left - other peers are added on right and bellow --- app/Http/Controllers/InterviewController.php | 83 ++- resources/js/candidate-proctor.js | 7 +- resources/js/interview-call.js | 218 ++++-- resources/js/recording-compositor.js | 685 ++++++++++++++++++ .../interview/call-controls.blade.php | 5 +- 5 files changed, 889 insertions(+), 109 deletions(-) create mode 100644 resources/js/recording-compositor.js diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index de5de06..05cdb2d 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -945,49 +945,58 @@ public function toggleTabScreenshot(Request $request, $id) */ public function uploadRecording(Request $request, $id) { - $interview = Interview::where('id', $id) - ->orWhere('submission_unique_id', $id) - ->firstOrFail(); + try { + $interview = Interview::where('id', $id) + ->orWhere('submission_unique_id', $id) + ->first(); - if ($request->hasFile('video')) { - $file = $request->file('video'); - $ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); - if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) { - $ext = 'webm'; + if (!$interview) { + return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404); } - $uniqueFolder = $interview->submission_unique_id ?: $interview->id; - $subDir = 'uploads/candidate_recordings/' . $uniqueFolder; - $destinationPath = public_path($subDir); - if (!file_exists($destinationPath)) { - mkdir($destinationPath, 0777, true); + if ($request->hasFile('video')) { + $file = $request->file('video'); + $ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); + if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) { + $ext = 'webm'; + } + + $uniqueFolder = $interview->submission_unique_id ?: $interview->id; + $subDir = 'uploads/candidate_recordings/' . $uniqueFolder; + $destinationPath = public_path($subDir); + if (!file_exists($destinationPath)) { + mkdir($destinationPath, 0777, true); + } + + $filename = 'recording_' . time() . '.' . $ext; + $file->move($destinationPath, $filename); + $url = asset($subDir . '/' . $filename); + $relativePath = '/' . $subDir . '/' . $filename; + + $recordings = $interview->recordings ?? []; + if (!is_array($recordings)) { + $recordings = $interview->recording_path ? [$interview->recording_path] : []; + } + $recordings[] = [ + 'url' => $relativePath, + 'full_url' => $url, + 'filename' => $filename, + 'created_at' => now()->toIso8601String(), + ]; + + $interview->update([ + 'recording_path' => $relativePath, + 'recordings' => $recordings, + ]); + + return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]); } - $filename = 'recording_' . time() . '.' . $ext; - $file->move($destinationPath, $filename); - $url = asset($subDir . '/' . $filename); - $relativePath = '/' . $subDir . '/' . $filename; - - $recordings = $interview->recordings ?? []; - if (!is_array($recordings)) { - $recordings = $interview->recording_path ? [$interview->recording_path] : []; - } - $recordings[] = [ - 'url' => $relativePath, - 'full_url' => $url, - 'filename' => $filename, - 'created_at' => now()->toIso8601String(), - ]; - - $interview->update([ - 'recording_path' => $relativePath, - 'recordings' => $recordings, - ]); - - return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]); + return response()->json(['success' => false, 'message' => 'No video payload received (file may exceed server upload size limit).'], 422); + } catch (\Throwable $e) { + \Log::error('Recording upload failed: ' . $e->getMessage()); + return response()->json(['success' => false, 'message' => 'Server error saving recording: ' . $e->getMessage()], 500); } - - return response()->json(['success' => false, 'message' => 'No video payload received']); } /** diff --git a/resources/js/candidate-proctor.js b/resources/js/candidate-proctor.js index b9b4721..92d9303 100644 --- a/resources/js/candidate-proctor.js +++ b/resources/js/candidate-proctor.js @@ -446,19 +446,18 @@ export function broadcastCandidateScreenStream(stream) { const targetPeerIds = new Set(); - // Collect dedicated screen receiver targets from active interviewers + // Collect dedicated screen receiver targets and main interviewer peers if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) { window.latestActivePeers.forEach(p => { if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) { const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_'); targetPeerIds.add(screenTargetId); + targetPeerIds.add(p.peer_id); } }); } - if (targetPeerIds.size === 0) { - targetPeerIds.add(legacyScreenPeerId); - } + targetPeerIds.add(legacyScreenPeerId); const makeCalls = (peer) => { targetPeerIds.forEach(targetId => { diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index 71d6ec5..0160a76 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -6,6 +6,7 @@ import { initMeteredIceServers } from './metered.js'; import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js'; +import { RecordingCompositor } from './recording-compositor.js'; // --- Global Audio Ringtone Synthesizer Engine --- export class CallRingtoneEngine { @@ -2140,10 +2141,12 @@ export function endCallForAll() { } } -// MediaRecorder recording +// MediaRecorder & Recording Compositor let adminMediaRecorder = null; let adminRecordedChunks = []; -let recAudioContext = null; +let recordingCompositor = null; +let recordingTimerInterval = null; +let recordingStartTime = null; export function startCallRecording() { if (!currentInterviewId) { @@ -2156,69 +2159,66 @@ export function startCallRecording() { } const candVid = document.getElementById('interviewer-cand-video'); - const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; - const streamToRecord = new MediaStream(); + const candNameEl = document.getElementById('modal-cand-name'); + const candidateName = candNameEl ? candNameEl.innerText.trim() : 'Candidate'; - if (candidateStream) { - candidateStream.getVideoTracks().forEach(track => { - if (track.readyState === 'live') { - try { streamToRecord.addTrack(track); } catch(e) {} - } - }); - } + // 1. Initialize RecordingCompositor (1920x1080 @ 30fps) + recordingCompositor = new RecordingCompositor({ + width: 1920, + height: 1080, + fps: 30, + getParticipantsData: () => { + const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null; + const candCamIcon = document.getElementById('cand-cam-status'); + const candMicIcon = document.getElementById('cand-mic-status'); + const isCandCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true; + const isCandMicOn = candMicIcon ? candMicIcon.classList.contains('fa-microphone') : true; - if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) { - interviewerLocalStream.getVideoTracks().forEach(track => { - if (track.readyState === 'live') { - try { streamToRecord.addTrack(track); } catch(e) {} - } - }); - } + const panelistsList = []; + const panelistStreamsList = []; - const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : []; - const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : []; + panelistMeshTiles.forEach((stream, pid) => { + const peerInfo = latestActivePeers.find(p => p.peer_id === pid); + const pMicEl = document.getElementById('mesh-mic-' + pid); + const pCamEl = document.getElementById('mesh-cam-' + pid); + const isMicOn = pMicEl ? pMicEl.classList.contains('fa-microphone') : (peerInfo ? isTrueVal(peerInfo.mic_on) : true); + const isCamOn = pCamEl ? pCamEl.classList.contains('fa-video') : (peerInfo ? isTrueVal(peerInfo.cam_on) : true); + const pName = peerInfo ? (peerInfo.name || 'Panelist') : 'Panelist'; + const pRole = peerInfo ? (peerInfo.role === 'admin' ? 'Admin' : 'Panelist') : 'Panelist'; - 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(); + panelistsList.push({ + peerId: pid, + name: pName, + role: pRole, + micOn: isMicOn, + camOn: isCamOn + }); - if (candAudioTracks.length > 0) { - const candAudioStream = new MediaStream(candAudioTracks); - const candSource = recAudioContext.createMediaStreamSource(candAudioStream); - candSource.connect(audioDestination); - } + if (stream) { + panelistStreamsList.push({ peerId: pid, stream: stream }); + } + }); - 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) { - const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0]; - if (fallbackAudio) { - try { streamToRecord.addTrack(fallbackAudio); } catch(err) {} - } + return { + candidateStream: candStream, + candidateName: candidateName, + candidateCamOn: isCandCamOn, + candidateMicOn: isCandMicOn, + selfStream: interviewerLocalStream, + selfName: window.currentUserName || 'Interviewer', + selfCamOn: isInterviewerCamOn, + selfMicOn: isInterviewerMicOn, + panelists: panelistsList, + panelistStreams: panelistStreamsList + }; } - } + }); - const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live'); - if (activeTracks.length === 0) { - alert('No active video/audio stream available to record. Please start or join the call first.'); - return; - } - - adminRecordedChunks = []; try { + recordingCompositor.start(); + const streamToRecord = recordingCompositor.getStream(); + + adminRecordedChunks = []; let options = {}; if (typeof MediaRecorder.isTypeSupported === 'function') { if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) { @@ -2241,9 +2241,9 @@ export function startCallRecording() { }; adminMediaRecorder.onstop = function() { - if (recAudioContext) { - try { recAudioContext.close(); } catch(e) {} - recAudioContext = null; + if (recordingCompositor) { + recordingCompositor.stop(); + recordingCompositor = null; } if (adminRecordedChunks.length === 0) return; const recMime = adminMediaRecorder.mimeType || 'video/webm'; @@ -2256,32 +2256,57 @@ export function startCallRecording() { }; adminMediaRecorder.start(1000); + recordingStartTime = Date.now(); + // Update UI buttons & Live Timer const recStartBtn = document.getElementById('btn-start-recording'); const recStopBtn = document.getElementById('btn-stop-recording'); if (recStartBtn) recStartBtn.style.display = 'none'; if (recStopBtn) recStopBtn.style.display = 'inline-flex'; + if (recordingTimerInterval) clearInterval(recordingTimerInterval); + recordingTimerInterval = setInterval(() => { + const timerEl = document.getElementById('rec-live-timer'); + if (timerEl && recordingStartTime) { + const totalSec = Math.floor((Date.now() - recordingStartTime) / 1000); + const mins = String(Math.floor(totalSec / 60)).padStart(2, '0'); + const secs = String(totalSec % 60).padStart(2, '0'); + timerEl.innerText = `(${mins}:${secs})`; + } + }, 1000); + if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'info', - title: 'Recording Started', - text: 'Interview call recording is active (silent mode - candidate is not notified).', + title: 'Composite Recording Started', + text: 'Recording composite layout (Candidate, Screen, and Panelists in HD).', toast: true, position: 'top-end', - timer: 3000, + timer: 3500, showConfirmButton: false }); } } catch(e) { + if (recordingCompositor) { + recordingCompositor.stop(); + recordingCompositor = null; + } alert('Recording failed to initialize: ' + e.message); } } export function stopCallRecording() { + if (recordingTimerInterval) { + clearInterval(recordingTimerInterval); + recordingTimerInterval = null; + } if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') { adminMediaRecorder.stop(); } + if (recordingCompositor) { + recordingCompositor.stop(); + recordingCompositor = null; + } const recStartBtn = document.getElementById('btn-start-recording'); const recStopBtn = document.getElementById('btn-stop-recording'); if (recStartBtn) recStartBtn.style.display = 'inline-flex'; @@ -2289,7 +2314,13 @@ export function stopCallRecording() { } export function uploadRecordedBlob(blob, ext = 'webm') { - if (!currentInterviewId) return; + if (!currentInterviewId && typeof document !== 'undefined') { + currentInterviewId = document.body?.dataset?.interviewId || window.interviewId; + } + if (!currentInterviewId) { + console.error('Cannot upload recording: missing interview ID'); + return; + } const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken; const formData = new FormData(); @@ -2298,16 +2329,44 @@ export function uploadRecordedBlob(blob, ext = 'webm') { fetch(`/candidate/upload-recording/${currentInterviewId}`, { method: 'POST', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': csrfToken, + 'X-Requested-With': 'XMLHttpRequest' + }, body: formData }) - .then(r => r.json()) + .then(async r => { + const contentType = r.headers.get('content-type') || ''; + const isJson = contentType.includes('application/json'); + if (!r.ok) { + const errText = await r.text(); + let errMsg = `Upload failed (HTTP ${r.status})`; + if (isJson) { + try { + const errJson = JSON.parse(errText); + if (errJson && errJson.message) errMsg = errJson.message; + } catch(e) {} + } + throw new Error(errMsg); + } + if (isJson) { + return r.json(); + } + const text = await r.text(); + try { + return JSON.parse(text); + } catch(e) { + return { success: true }; + } + }) .then(res => { - if (res.success) { + if (res && res.success) { if (typeof window.Swal !== 'undefined') { window.Swal.fire({ icon: 'success', title: 'Recording Saved!', - text: 'Call recording saved successfully under Candidate Profile and Report.', + text: 'Call recording saved successfully to server and candidate report.', toast: true, position: 'top-end', timer: 3500, @@ -2315,9 +2374,34 @@ export function uploadRecordedBlob(blob, ext = 'webm') { }); } pollReviewData(); + } else if (res && res.message) { + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'warning', + title: 'Recording Notice', + text: res.message, + toast: true, + position: 'top-end', + timer: 4000, + showConfirmButton: false + }); + } } }) - .catch(err => console.log('Recording upload error:', err)); + .catch(err => { + console.error('Recording upload error:', err); + if (typeof window.Swal !== 'undefined') { + window.Swal.fire({ + icon: 'error', + title: 'Recording Upload Failed', + text: err.message || 'Failed to save recording to server.', + toast: true, + position: 'top-end', + timer: 4500, + showConfirmButton: false + }); + } + }); } export function toggleInterviewerMic() { diff --git a/resources/js/recording-compositor.js b/resources/js/recording-compositor.js new file mode 100644 index 0000000..5756daa --- /dev/null +++ b/resources/js/recording-compositor.js @@ -0,0 +1,685 @@ +/** + * Recording Compositor & Multi-Track Audio Mixer Engine + * + * Creates a unified 1920x1080 composite video stream from multiple WebRTC video sources + * (Candidate Webcam, Screen Share, Self Interviewer, Remote Mesh Panelists) + * matching the interviewer layout: + * - Large Main Area: Candidate Webcam (or Screen Share when active) + * - Right Sidebar: Stacked Peers (and Candidate Webcam at top when screen share is active) + * - Camera-off Avatar Placeholders with initials and badges + * - Bottom Overflow Row: Placed horizontally below main area if peers > 3 + * - AudioContext multi-track audio mixing + */ + +export class RecordingCompositor { + /** + * @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options + */ + constructor(options = {}) { + this.width = options.width || 1920; + this.height = options.height || 1080; + this.fps = options.fps || 30; + + this.canvas = document.createElement("canvas"); + this.canvas.width = this.width; + this.canvas.height = this.height; + this.ctx = this.canvas.getContext("2d", { alpha: false }); + + this.audioCtx = null; + this.audioDestination = null; + this.audioSourceNodes = []; + this.mixedAudioTrack = null; + + this.animationFrameId = null; + this.isRunning = false; + this.startTime = null; + this.speakingStates = new Map(); + + // Metadata provider function + this.getParticipantsData = options.getParticipantsData || (() => ({})); + } + + start() { + if (this.isRunning) return; + this.isRunning = true; + this.startTime = Date.now(); + + this.initAudioMixer(); + this.renderLoop(); + } + + stop() { + this.isRunning = false; + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + + if (this.audioSourceNodes.length > 0) { + this.audioSourceNodes.forEach((node) => { + try { + node.disconnect(); + } catch (e) {} + }); + this.audioSourceNodes = []; + } + + if (this.audioCtx) { + try { + this.audioCtx.close(); + } catch (e) {} + this.audioCtx = null; + } + this.audioDestination = null; + this.mixedAudioTrack = null; + } + + getStream() { + const videoTrack = this.canvas + .captureStream(this.fps) + .getVideoTracks()[0]; + const tracks = []; + if (videoTrack) tracks.push(videoTrack); + if (this.mixedAudioTrack) tracks.push(this.mixedAudioTrack); + return new MediaStream(tracks); + } + + setSpeakingState(key, isSpeaking) { + this.speakingStates.set(key, Boolean(isSpeaking)); + } + + initAudioMixer() { + try { + const AudioCtxClass = + window.AudioContext || window.webkitAudioContext; + if (!AudioCtxClass) return; + + this.audioCtx = new AudioCtxClass(); + this.audioDestination = + this.audioCtx.createMediaStreamDestination(); + + const data = this.getParticipantsData(); + const audioStreams = []; + + // 1. Candidate Audio + if ( + data.candidateStream && + data.candidateStream.getAudioTracks().length > 0 + ) { + audioStreams.push(data.candidateStream); + } + // 2. Interviewer Self Audio + if ( + data.selfStream && + data.selfStream.getAudioTracks().length > 0 + ) { + audioStreams.push(data.selfStream); + } + // 3. Panelist Mesh Audios + if (data.panelistStreams && Array.isArray(data.panelistStreams)) { + data.panelistStreams.forEach((ps) => { + if ( + ps && + ps.stream && + ps.stream.getAudioTracks().length > 0 + ) { + audioStreams.push(ps.stream); + } + }); + } + + audioStreams.forEach((stream) => { + try { + const source = + this.audioCtx.createMediaStreamSource(stream); + source.connect(this.audioDestination); + this.audioSourceNodes.push(source); + } catch (e) {} + }); + + this.mixedAudioTrack = + this.audioDestination.stream.getAudioTracks()[0] || null; + } catch (e) { + console.warn("[RecordingCompositor] Audio mixing error:", e); + } + } + + renderLoop() { + if (!this.isRunning) return; + this.renderFrame(); + this.animationFrameId = requestAnimationFrame(() => this.renderLoop()); + } + + renderFrame() { + const ctx = this.ctx; + const W = this.width; + const H = this.height; + + // Dark background + ctx.fillStyle = "#090d16"; + ctx.fillRect(0, 0, W, H); + + const data = this.getParticipantsData(); + + // 1. Candidate Feed + const candVideo = document.getElementById("interviewer-cand-video"); + const isCandCamOn = data.candidateCamOn !== false; + const isCandMicOn = data.candidateMicOn !== false; + const candidateFeed = { + id: "candidate", + type: "candidate", + name: data.candidateName || "Candidate", + initials: this.extractInitials(data.candidateName || "Candidate"), + video: candVideo, + hasVideo: isCandCamOn && this.isVideoPlaying(candVideo), + isCamOn: isCandCamOn, + isMicOn: isCandMicOn, + isSpeaking: + this.speakingStates.get("interviewer-cand-video") || false, + badge: "Candidate", + }; + + // 2. Screen Share Feed + const screenVideo = document.getElementById("interviewer-screen-video"); + const isScreenSharing = this.isScreenShareActive(screenVideo); + const screenFeed = { + id: "screen", + type: "screen", + name: "Candidate Live Screen", + initials: "SCR", + video: screenVideo, + hasVideo: isScreenSharing, + isCamOn: true, + isMicOn: false, + isSpeaking: false, + badge: "Shared Screen", + }; + + // 3. Self Interviewer Feed + const selfVideo = document.getElementById("interviewer-self-video"); + const isSelfCamOn = data.selfCamOn !== false; + const isSelfMicOn = data.selfMicOn !== false; + const selfFeed = { + id: "self", + type: "interviewer", + name: data.selfName + ? `${data.selfName} (You)` + : "Interviewer (You)", + initials: this.extractInitials(data.selfName || "IV"), + video: selfVideo, + hasVideo: isSelfCamOn && this.isVideoPlaying(selfVideo), + isCamOn: isSelfCamOn, + isMicOn: isSelfMicOn, + isSpeaking: false, + badge: "Interviewer", + }; + + // 4. Panelist Feeds + const panelistFeeds = []; + if (data.panelists && Array.isArray(data.panelists)) { + data.panelists.forEach((p) => { + const pVid = document.getElementById( + "panelist-video-" + p.peerId, + ); + const isCamOn = p.camOn !== false; + const isMicOn = p.micOn !== false; + panelistFeeds.push({ + id: p.peerId, + type: "panelist", + name: p.name || "Panelist", + initials: this.extractInitials(p.name || "Panelist"), + video: pVid, + hasVideo: isCamOn && this.isVideoPlaying(pVid), + isCamOn: isCamOn, + isMicOn: isMicOn, + isSpeaking: false, + badge: p.role || "Panelist", + }); + }); + } + + // Layout determination + let mainFeed; + let secondaryFeeds = []; + + if (isScreenSharing) { + // When screen share is active: + // Main = Screen Share + // Right Sidebar Top = Candidate Webcam + // Followed by Self and Panelists + mainFeed = screenFeed; + secondaryFeeds = [candidateFeed, selfFeed, ...panelistFeeds]; + } else { + // Normal Call: + // Main = Candidate Webcam + // Right Sidebar = Self and Panelists + mainFeed = candidateFeed; + secondaryFeeds = [selfFeed, ...panelistFeeds]; + } + + // Compute geometry with strict 16:9 aspect ratio preservation for every tile + const gap = 16; + const padX = 24; + const totalSecondary = secondaryFeeds.length; + + if (totalSecondary === 0) { + // Case 0: Only Main Feed (Full Canvas 16:9) + const mainH = 1032; + const mainW = Math.round(mainH * (16 / 9)); // 1835px + const mainX = Math.round((W - mainW) / 2); + const mainY = Math.round((H - mainH) / 2); + this.renderTile( + ctx, + mainFeed, + { x: mainX, y: mainY, w: mainW, h: mainH }, + true, + ); + } else if (totalSecondary === 1) { + // Case 1: Main + 1 Sidebar Tile (Both 16:9) + const sideW = 480; + const sideH = Math.round(sideW * (9 / 16)); // 270px + const mainW = 1376; + const mainH = Math.round(mainW * (9 / 16)); // 774px + + const mainX = padX; + const mainY = Math.round((H - mainH) / 2); + const sideX = padX + mainW + gap; + const sideY = Math.round((H - sideH) / 2); + + this.renderTile( + ctx, + mainFeed, + { x: mainX, y: mainY, w: mainW, h: mainH }, + true, + ); + this.renderTile( + ctx, + secondaryFeeds[0], + { x: sideX, y: sideY, w: sideW, h: sideH }, + false, + ); + } else if (totalSecondary === 2) { + // Case 2: Main + 2 Sidebar Tiles (All 16:9) + const sideW = 480; + const sideH = Math.round(sideW * (9 / 16)); // 270px + const mainW = 1376; + const mainH = Math.round(mainW * (9 / 16)); // 774px + + const mainX = padX; + const mainY = Math.round((H - mainH) / 2); + const sideX = padX + mainW + gap; + + const totalSideH = 2 * sideH + gap; // 556px + const sideYStart = Math.round((H - totalSideH) / 2); + + this.renderTile( + ctx, + mainFeed, + { x: mainX, y: mainY, w: mainW, h: mainH }, + true, + ); + this.renderTile( + ctx, + secondaryFeeds[0], + { x: sideX, y: sideYStart, w: sideW, h: sideH }, + false, + ); + this.renderTile( + ctx, + secondaryFeeds[1], + { x: sideX, y: sideYStart + sideH + gap, w: sideW, h: sideH }, + false, + ); + } else if (totalSecondary === 3) { + // Case 3: Main + 3 Sidebar Tiles (All 16:9) + const sideH = 330; + const sideW = Math.round(sideH * (16 / 9)); // 587px + const totalSideH = 3 * sideH + 2 * gap; // 1022px + const topY = Math.round((H - totalSideH) / 2); // 29px + + const sideX = W - padX - sideW; + const mainW = sideX - gap - padX; // 1270px + const mainH = Math.round(mainW * (9 / 16)); // 714px + + const mainX = padX; + const mainY = topY; + + this.renderTile( + ctx, + mainFeed, + { x: mainX, y: mainY, w: mainW, h: mainH }, + true, + ); + + for (let i = 0; i < 3; i++) { + const feed = secondaryFeeds[i]; + const tileY = topY + i * (sideH + gap); + this.renderTile( + ctx, + feed, + { x: sideX, y: tileY, w: sideW, h: sideH }, + false, + ); + } + } else { + // Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9) + const sideH = 330; + const sideW = Math.round(sideH * (16 / 9)); // 587px + const totalSideH = 3 * sideH + 2 * gap; // 1022px + const topY = Math.round((H - totalSideH) / 2); // 29px + + const sideX = W - padX - sideW; + const mainW = sideX - gap - padX; // 1270px + const mainH = Math.round(mainW * (9 / 16)); // 714px + + const mainX = padX; + const mainY = topY; + + // 1. Render Main + this.renderTile( + ctx, + mainFeed, + { x: mainX, y: mainY, w: mainW, h: mainH }, + true, + ); + + // 2. Render 3 Sidebar Tiles + for (let i = 0; i < 3; i++) { + const feed = secondaryFeeds[i]; + const tileY = topY + i * (sideH + gap); + this.renderTile( + ctx, + feed, + { x: sideX, y: tileY, w: sideW, h: sideH }, + false, + ); + } + + // 3. Render Bottom Overflow Tiles (each 16:9) + const overflowFeeds = secondaryFeeds.slice(3); + const numOverflow = overflowFeeds.length; + const botMaxH = totalSideH - mainH - gap; // 292px + const botYBase = topY + mainH + gap; // 759px + + // Calculate width per tile to fit in mainW + const candBotW = Math.floor( + (mainW - (numOverflow - 1) * gap) / numOverflow, + ); + const candBotH = Math.round(candBotW * (9 / 16)); + + let botW, botH; + if (candBotH > botMaxH) { + botH = botMaxH; + botW = Math.round(botH * (16 / 9)); + } else { + botW = candBotW; + botH = candBotH; + } + + const totalBotW = numOverflow * botW + (numOverflow - 1) * gap; + const botXStart = mainX + Math.round((mainW - totalBotW) / 2); + const botY = botYBase + Math.round((botMaxH - botH) / 2); + + overflowFeeds.forEach((feed, j) => { + const tileX = botXStart + j * (botW + gap); + this.renderTile( + ctx, + feed, + { x: tileX, y: botY, w: botW, h: botH }, + false, + ); + }); + } + } + + renderTile(ctx, feed, rect, isMain = false) { + const { x, y, w, h } = rect; + const radius = 12; + + ctx.save(); + this.drawRoundedClip(ctx, x, y, w, h, radius); + + // Base tile background + ctx.fillStyle = "#0d1220"; + ctx.fillRect(x, y, w, h); + + if (feed.hasVideo && feed.video) { + // Draw video frame + if (feed.type === "screen") { + this.drawVideoContain(ctx, feed.video, x, y, w, h); + } else { + this.drawVideoCover(ctx, feed.video, x, y, w, h); + } + } else { + // Draw avatar placeholder + this.drawPlaceholder(ctx, feed, x, y, w, h, isMain); + } + + ctx.restore(); + + // Sleek subtle border + ctx.save(); + ctx.lineWidth = 1; + ctx.strokeStyle = "rgba(255, 255, 255, 0.08)"; + this.drawRoundedStroke(ctx, x, y, w, h, radius); + ctx.restore(); + + // Overlay Pill Tag (Bottom-Left) + this.drawPillTag(ctx, feed, x + 10, y + h - 12); + } + + drawVideoCover(ctx, video, x, y, w, h) { + try { + const vw = video.videoWidth || 640; + const vh = video.videoHeight || 480; + + const targetRatio = w / h; + const videoRatio = vw / vh; + + let sx = 0, + sy = 0, + sw = vw, + sh = vh; + + if (videoRatio > targetRatio) { + // Video is wider than 16:9 target: crop horizontally + sw = Math.round(vh * targetRatio); + sx = Math.round((vw - sw) / 2); + } else { + // Video is taller than 16:9 target: crop vertically + sh = Math.round(vw / targetRatio); + sy = Math.round((vh - sh) / 2); + } + + ctx.drawImage(video, sx, sy, sw, sh, x, y, w, h); + } catch (e) {} + } + + drawVideoContain(ctx, video, x, y, w, h) { + try { + const vw = video.videoWidth || 1920; + const vh = video.videoHeight || 1080; + + const targetRatio = w / h; + const videoRatio = vw / vh; + + let dw = w; + let dh = h; + let dx = x; + let dy = y; + + if (videoRatio > targetRatio) { + // Video is wider than tile + dh = Math.round(w / videoRatio); + dy = Math.round(y + (h - dh) / 2); + } else { + // Video is taller than tile + dw = Math.round(h * videoRatio); + dx = Math.round(x + (w - dw) / 2); + } + + ctx.drawImage(video, 0, 0, vw, vh, dx, dy, dw, dh); + } catch (e) {} + } + + drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) { + const cx = x + w / 2; + const cy = y + h / 2 - (isMain ? 15 : 8); + const circleRadius = isMain + ? Math.min(54, h * 0.18) + : Math.min(32, h * 0.22); + + // Circular gradient avatar + const gradient = ctx.createLinearGradient( + cx - circleRadius, + cy - circleRadius, + cx + circleRadius, + cy + circleRadius, + ); + if (feed.type === "candidate") { + gradient.addColorStop(0, "#10b981"); + gradient.addColorStop(1, "#059669"); + } else { + gradient.addColorStop(0, "#5b8bff"); + gradient.addColorStop(1, "#3b63e0"); + } + + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(cx, cy, circleRadius, 0, Math.PI * 2); + ctx.fill(); + + // Avatar Initials Text + ctx.fillStyle = "#ffffff"; + ctx.font = `bold ${Math.round(circleRadius * 0.85)}px sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText(feed.initials || "IV", cx, cy); + + // Participant Name + ctx.fillStyle = "#ffffff"; + ctx.font = `600 ${isMain ? 18 : 13}px sans-serif`; + ctx.textBaseline = "top"; + ctx.fillText(feed.name, cx, cy + circleRadius + (isMain ? 14 : 8)); + + // Subtitle status + ctx.fillStyle = "#94a3b8"; + ctx.font = `400 ${isMain ? 13 : 10.5}px sans-serif`; + ctx.fillText( + feed.isCamOn ? "Connecting video..." : "Camera turned off", + cx, + cy + circleRadius + (isMain ? 36 : 24), + ); + } + + drawPillTag(ctx, feed, x, y) { + const text = feed.name; + const fontSize = 11; + ctx.font = `600 ${fontSize}px sans-serif`; + + const textMetrics = ctx.measureText(text); + const tagPaddingX = 8; + const tagW = textMetrics.width + tagPaddingX * 2; + const tagH = 20; + + const tagX = x; + const tagY = y - tagH; + + ctx.save(); + // Background + ctx.fillStyle = "rgba(9, 13, 22, 0.8)"; + this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, 5); + + // Border + ctx.strokeStyle = "rgba(255, 255, 255, 0.12)"; + ctx.lineWidth = 1; + this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, 5); + + // Name text + ctx.fillStyle = "#e2e8f0"; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillText(text, tagX + tagPaddingX, tagY + tagH / 2); + + ctx.restore(); + } + + isScreenShareActive(video) { + if (!video) return false; + const stream = video.srcObject; + if (!stream || !stream.active) return false; + const tracks = stream.getVideoTracks ? stream.getVideoTracks() : []; + if (tracks.length === 0) return false; + const hasLiveTrack = tracks.some( + (t) => t.readyState === "live" && t.enabled, + ); + if (!hasLiveTrack) return false; + + if (video.paused && typeof video.play === "function") { + video.play().catch(() => {}); + } + return true; + } + + isVideoPlaying(video) { + if (!video) return false; + const stream = video.srcObject; + if (!stream || !stream.active) return false; + const tracks = stream.getVideoTracks ? stream.getVideoTracks() : []; + if (tracks.length === 0) return false; + const hasLiveTrack = tracks.some( + (t) => t.readyState === "live" && t.enabled, + ); + if (!hasLiveTrack) return false; + + if (video.paused && typeof video.play === "function") { + video.play().catch(() => {}); + } + return Boolean( + video.videoWidth > 0 || video.readyState >= 1 || hasLiveTrack, + ); + } + + extractInitials(name) { + if (!name) return "CD"; + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return name.slice(0, 2).toUpperCase(); + } + + roundedRectPath(ctx, x, y, w, h, r) { + if (typeof ctx.roundRect === "function") { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + return; + } + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + } + + drawRoundedClip(ctx, x, y, w, h, r) { + this.roundedRectPath(ctx, x, y, w, h, r); + ctx.clip(); + } + + drawRoundedStroke(ctx, x, y, w, h, r) { + this.roundedRectPath(ctx, x, y, w, h, r); + ctx.stroke(); + } + + drawRoundedFill(ctx, x, y, w, h, r) { + this.roundedRectPath(ctx, x, y, w, h, r); + ctx.fill(); + } +} diff --git a/resources/views/components/interview/call-controls.blade.php b/resources/views/components/interview/call-controls.blade.php index 6a7e891..35804d7 100644 --- a/resources/views/components/interview/call-controls.blade.php +++ b/resources/views/components/interview/call-controls.blade.php @@ -28,7 +28,10 @@ - +