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
This commit is contained in:
parent
03a36c00e6
commit
dfbbd1263d
@ -945,9 +945,14 @@ public function toggleTabScreenshot(Request $request, $id)
|
||||
*/
|
||||
public function uploadRecording(Request $request, $id)
|
||||
{
|
||||
try {
|
||||
$interview = Interview::where('id', $id)
|
||||
->orWhere('submission_unique_id', $id)
|
||||
->firstOrFail();
|
||||
->first();
|
||||
|
||||
if (!$interview) {
|
||||
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
|
||||
}
|
||||
|
||||
if ($request->hasFile('video')) {
|
||||
$file = $request->file('video');
|
||||
@ -987,7 +992,11 @@ public function uploadRecording(Request $request, $id)
|
||||
return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => false, 'message' => 'No video payload received']);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
const makeCalls = (peer) => {
|
||||
targetPeerIds.forEach(targetId => {
|
||||
|
||||
@ -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;
|
||||
|
||||
const panelistsList = [];
|
||||
const panelistStreamsList = [];
|
||||
|
||||
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';
|
||||
|
||||
panelistsList.push({
|
||||
peerId: pid,
|
||||
name: pName,
|
||||
role: pRole,
|
||||
micOn: isMicOn,
|
||||
camOn: isCamOn
|
||||
});
|
||||
|
||||
if (stream) {
|
||||
panelistStreamsList.push({ peerId: pid, stream: stream });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) {
|
||||
interviewerLocalStream.getVideoTracks().forEach(track => {
|
||||
if (track.readyState === 'live') {
|
||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
||||
return {
|
||||
candidateStream: candStream,
|
||||
candidateName: candidateName,
|
||||
candidateCamOn: isCandCamOn,
|
||||
candidateMicOn: isCandMicOn,
|
||||
selfStream: interviewerLocalStream,
|
||||
selfName: window.currentUserName || 'Interviewer',
|
||||
selfCamOn: isInterviewerCamOn,
|
||||
selfMicOn: isInterviewerMicOn,
|
||||
panelists: panelistsList,
|
||||
panelistStreams: panelistStreamsList
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
|
||||
const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
|
||||
|
||||
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();
|
||||
|
||||
if (candAudioTracks.length > 0) {
|
||||
const candAudioStream = new MediaStream(candAudioTracks);
|
||||
const candSource = recAudioContext.createMediaStreamSource(candAudioStream);
|
||||
candSource.connect(audioDestination);
|
||||
}
|
||||
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
recordingCompositor.start();
|
||||
const streamToRecord = recordingCompositor.getStream();
|
||||
|
||||
adminRecordedChunks = [];
|
||||
try {
|
||||
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() {
|
||||
|
||||
685
resources/js/recording-compositor.js
Normal file
685
resources/js/recording-compositor.js
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,10 @@
|
||||
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot" class="hidden">
|
||||
Record Call (Silent)
|
||||
</x-button>
|
||||
<x-button id="btn-stop-recording" class="hidden" onclick="stopCallRecording()" variant="danger" icon="fa-solid fa-square" class="hidden animate-pulse" title="Stop & Save Recording"></x-button>
|
||||
<x-button id="btn-stop-recording" onclick="stopCallRecording()" variant="danger" class="hidden animate-pulse" title="Stop & Save Recording">
|
||||
<i class="fa-solid fa-square text-xs mr-1.5"></i>
|
||||
<span>Recording <span id="rec-live-timer" class="font-mono text-xs font-bold">(00:00)</span> - Stop</span>
|
||||
</x-button>
|
||||
|
||||
<!-- 5. Leave Call -->
|
||||
<x-button id="btn-leave-call" onclick="leaveInterviewerCall()" variant="secondary" icon="fa-solid fa-arrow-right-from-bracket" class="hidden">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user