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 @@
Record Call (Silent)
-
+
+
+ Recording (00:00) - Stop
+