feat: UI Refactor, multi-people recording and noise cancellation #21

Merged
subhajit.pal merged 9 commits from feature/noise-cancellation into main 2026-08-21 11:33:03 +00:00
12 changed files with 965 additions and 62 deletions
Showing only changes of commit 121b4beaf8 - Show all commits

View File

@ -0,0 +1,38 @@
<?php
namespace App\Actions\Interview;
use App\Jobs\MergeRecordingChunksJob;
use App\Models\Interview;
class FinalizeRecordingAction
{
/**
* Finalize the recording session by dispatching the background merge job.
*
* @param Interview $interview
* @param string $sessionId
* @param int $totalChunks
* @param string|null $tempDir
* @param string|null $folderName
* @param int|null $duration
* @return void
*/
public function execute(
Interview $interview,
string $sessionId,
int $totalChunks = 0,
?string $tempDir = null,
?string $folderName = null,
?int $duration = null
): void {
MergeRecordingChunksJob::dispatch(
$interview->id,
$sessionId,
$tempDir,
$folderName,
$totalChunks,
$duration
);
}
}

View File

@ -0,0 +1,201 @@
<?php
namespace App\Actions\Interview;
use App\Models\Interview;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class MergeRecordingChunksAction
{
/**
* Execute the heavy stream merge of recording chunk parts into a single video file.
*
* @param Interview $interview
* @param string $sessionId
* @param string|null $tempDir
* @param string|null $folderName
* @param int|null $duration
* @return string|null The relative path to the merged recording file, or null on failure.
*/
public function execute(
Interview $interview,
string $sessionId,
?string $tempDir = null,
?string $folderName = null,
?int $duration = null
): ?string {
$safeSessionId = preg_replace('/[^a-zA-Z0-9_\-]/', '', $sessionId);
$tempBase = config('interview.recordings.temp_folder', 'app/temp_recordings');
$resolvedTempDir = $tempDir ?: storage_path(trim($tempBase, '/') . '/' . $safeSessionId);
if (!is_dir($resolvedTempDir)) {
Log::warning('[MergeRecordingChunksAction] Temporary chunk directory not found', [
'session_id' => $sessionId,
'temp_dir' => $resolvedTempDir,
]);
return null;
}
$parts = $this->findAndSortParts($resolvedTempDir);
if (empty($parts)) {
Log::warning('[MergeRecordingChunksAction] No chunk parts found to merge', [
'session_id' => $sessionId,
'temp_dir' => $resolvedTempDir,
]);
return null;
}
// Validate that part_000000.webm exists and starts with valid EBML container header
$firstPart = $parts[0];
$headerCheck = @file_get_contents($firstPart, false, null, 0, 4);
if ($headerCheck === false || !str_starts_with($headerCheck, "\x1a\x45\xdf\xa3")) {
Log::error('[MergeRecordingChunksAction] First chunk missing EBML container header. Possible incomplete initial chunk upload.', [
'session_id' => $sessionId,
'first_part' => $firstPart,
'header_hex' => $headerCheck ? bin2hex($headerCheck) : 'null',
]);
return null;
}
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
$uniqueFolder = $folderName ?: ($interview->submission_unique_id ?: (string) $interview->id);
$subDir = trim($baseFolder, '/') . '/' . $uniqueFolder;
$destinationDir = public_path($subDir);
$this->ensureDestinationDirectory($destinationDir);
$filename = 'recording_' . time() . '.webm';
$finalPath = $destinationDir . DIRECTORY_SEPARATOR . $filename;
$this->streamMergeFiles($parts, $finalPath);
if (!file_exists($finalPath) || filesize($finalPath) === 0) {
Log::error('[MergeRecordingChunksAction] Output recording file is empty or was not created', [
'final_path' => $finalPath,
'session_id' => $sessionId,
]);
return null;
}
$relativePath = '/' . $subDir . '/' . $filename;
$url = asset($subDir . '/' . $filename);
$this->updateInterviewRecordings($interview, $relativePath, $url, $filename, $duration);
$this->cleanupTempParts($parts, $resolvedTempDir);
Log::info('[MergeRecordingChunksAction] Recording assembled successfully', [
'interview_id' => $interview->id,
'session_id' => $sessionId,
'total_parts' => count($parts),
'duration' => $duration,
'path' => $relativePath,
]);
return $relativePath;
}
/**
* Locate and sort chunk part files in ascending numerical order.
*
* @param string $tempDir
* @return array<string>
*/
private function findAndSortParts(string $tempDir): array
{
$parts = glob($tempDir . DIRECTORY_SEPARATOR . 'part_*.webm') ?: [];
natsort($parts);
return array_values($parts);
}
/**
* Ensure that the destination directory exists and is writable.
*
* @param string $dir
* @return void
*/
private function ensureDestinationDirectory(string $dir): void
{
if (!is_dir($dir)) {
if (!mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new RuntimeException("Failed to create destination directory: {$dir}");
}
}
}
/**
* Stream merge multiple binary chunk files into a single destination file.
*
* @param array<string> $parts
* @param string $finalPath
* @return void
*/
private function streamMergeFiles(array $parts, string $finalPath): void
{
$out = fopen($finalPath, 'wb');
if ($out === false) {
throw new RuntimeException("Cannot open destination file for writing: {$finalPath}");
}
try {
foreach ($parts as $partFile) {
$in = fopen($partFile, 'rb');
if ($in === false) {
Log::warning("[MergeRecordingChunksAction] Could not read part: {$partFile}");
continue;
}
stream_copy_to_stream($in, $out);
fclose($in);
}
} finally {
fclose($out);
}
}
/**
* Update the interview model with the newly merged recording metadata.
*
* @param Interview $interview
* @param string $relativePath
* @param string $url
* @param string $filename
* @param int|null $duration
* @return void
*/
private function updateInterviewRecordings(Interview $interview, string $relativePath, string $url, string $filename, ?int $duration = null): void
{
$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(),
'duration' => $duration ?: 0,
];
$interview->update([
'recording_path' => $relativePath,
'recordings' => $recordings,
]);
}
/**
* Clean up temporary parts and remove the session directory.
*
* @param array<string> $parts
* @param string $tempDir
* @return void
*/
private function cleanupTempParts(array $parts, string $tempDir): void
{
foreach ($parts as $partFile) {
@unlink($partFile);
}
@rmdir($tempDir);
}
}

View File

@ -0,0 +1,34 @@
<?php
namespace App\Actions\Interview;
use App\Models\Interview;
use Illuminate\Http\UploadedFile;
class StoreRecordingChunkAction
{
/**
* Store an individual video recording chunk in a temporary session directory.
*
* @param Interview $interview
* @param string $sessionId
* @param int $chunkIndex
* @param UploadedFile $file
* @return string
*/
public function execute(Interview $interview, string $sessionId, int $chunkIndex, UploadedFile $file): string
{
$safeSessionId = preg_replace('/[^a-zA-Z0-9_\-]/', '', $sessionId);
$tempBase = config('interview.recordings.temp_folder', 'app/temp_recordings');
$tempDir = storage_path(trim($tempBase, '/') . '/' . $safeSessionId);
if (!file_exists($tempDir)) {
mkdir($tempDir, 0777, true);
}
$partName = sprintf('part_%06d.webm', $chunkIndex);
$file->move($tempDir, $partName);
return $tempDir . DIRECTORY_SEPARATOR . $partName;
}
}

View File

@ -2,6 +2,11 @@
namespace App\Http\Controllers;
use App\Actions\Interview\FinalizeRecordingAction;
use App\Actions\Interview\StoreRecordingChunkAction;
use App\Http\Requests\Interview\UploadRecordingChunkRequest;
use App\Http\Responses\Interview\RecordingChunkReceivedResponse;
use App\Http\Responses\Interview\RecordingFinalizedResponse;
use App\Models\Interview;
use App\Models\InterviewWarning;
use App\Models\User;
@ -587,6 +592,7 @@ private function getFormattedRecordings($interview)
$rec['stream_url'] = $streamUrl;
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
$rec['mime_type'] = $mimeType;
$rec['duration'] = isset($rec['duration']) ? (int) $rec['duration'] : 0;
$formatted[] = $rec;
}
@ -961,8 +967,9 @@ public function uploadRecording(Request $request, $id)
$ext = 'webm';
}
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
$uniqueFolder = $interview->submission_unique_id ?: $interview->id;
$subDir = 'uploads/candidate_recordings/' . $uniqueFolder;
$subDir = trim($baseFolder, '/') . '/' . $uniqueFolder;
$destinationPath = public_path($subDir);
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0777, true);
@ -999,6 +1006,64 @@ public function uploadRecording(Request $request, $id)
}
}
/**
* Handle live chunked video recording upload from the interviewer proctoring session.
*
* @param UploadRecordingChunkRequest $request
* @param int|string $id
* @param StoreRecordingChunkAction $storeChunkAction
* @param FinalizeRecordingAction $finalizeRecordingAction
* @return \Illuminate\Http\JsonResponse|\Illuminate\Contracts\Support\Responsable
*/
public function uploadRecordingChunk(
UploadRecordingChunkRequest $request,
$id,
StoreRecordingChunkAction $storeChunkAction,
FinalizeRecordingAction $finalizeRecordingAction
) {
try {
$interview = Interview::where('id', $id)
->orWhere('submission_unique_id', $id)
->first();
if (!$interview) {
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
}
$validated = $request->validated();
$sessionId = $validated['session_id'];
$chunkIndex = (int) $validated['chunk_index'];
$isFinal = filter_var($validated['is_final'] ?? false, FILTER_VALIDATE_BOOLEAN);
if ($request->hasFile('chunk')) {
$file = $request->file('chunk');
$storeChunkAction->execute($interview, $sessionId, $chunkIndex, $file);
}
if ($isFinal) {
$totalChunks = (int) ($validated['total_chunks'] ?? ($chunkIndex + 1));
$duration = isset($validated['duration']) ? (int) $validated['duration'] : null;
$finalizeRecordingAction->execute($interview, $sessionId, $totalChunks, null, null, $duration);
return new RecordingFinalizedResponse($sessionId);
}
return new RecordingChunkReceivedResponse($sessionId, $chunkIndex);
} catch (\Throwable $e) {
\Log::error('Recording chunk upload failed: ' . $e->getMessage(), [
'id' => $id,
'session_id' => $request->input('session_id'),
'chunk_index' => $request->input('chunk_index'),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'success' => false,
'message' => 'Error processing recording chunk: ' . $e->getMessage()
], 500);
}
}
/**
* Download Candidate Video Recording as File.
*/

View File

@ -0,0 +1,46 @@
<?php
namespace App\Http\Requests\Interview;
use Illuminate\Foundation\Http\FormRequest;
class UploadRecordingChunkRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'session_id' => ['required', 'string', 'max:255'],
'chunk_index' => ['required', 'integer', 'min:0'],
'is_final' => ['nullable', 'boolean'],
'total_chunks' => ['nullable', 'integer', 'min:0'],
'duration' => ['nullable', 'integer', 'min:0'],
'chunk' => ['nullable', 'file', 'max:25600'], // 25MB max per chunk
];
}
/**
* Get custom messages for validator errors.
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'session_id.required' => 'A valid recording session identifier is required.',
'chunk_index.required' => 'Chunk index must be provided.',
];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Responses\Interview;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
class RecordingChunkReceivedResponse implements Responsable
{
/**
* Create a new response instance.
*
* @param string $sessionId
* @param int $chunkIndex
*/
public function __construct(
protected string $sessionId,
protected int $chunkIndex
) {}
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function toResponse($request): JsonResponse
{
return response()->json([
'success' => true,
'chunk_index' => $this->chunkIndex,
'is_final' => false,
'session_id' => $this->sessionId,
], 200);
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Http\Responses\Interview;
use Illuminate\Contracts\Support\Responsable;
use Illuminate\Http\JsonResponse;
class RecordingFinalizedResponse implements Responsable
{
/**
* Create a new response instance.
*
* @param string $sessionId
* @param string $message
*/
public function __construct(
protected string $sessionId,
protected string $message = 'Final recording chunk received. Video assembly queued in background.'
) {}
/**
* Create an HTTP response that represents the object.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function toResponse($request): JsonResponse
{
return response()->json([
'success' => true,
'is_final' => true,
'processing' => true,
'session_id' => $this->sessionId,
'message' => $this->message,
], 200);
}
}

View File

@ -0,0 +1,98 @@
<?php
namespace App\Jobs;
use App\Actions\Interview\MergeRecordingChunksAction;
use App\Models\Interview;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\Attributes\Backoff;
use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Queue\Attributes\Tries;
use Illuminate\Queue\Attributes\UniqueFor;
use Illuminate\Queue\Attributes\WithoutRelations;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Throwable;
#[Timeout(300)]
#[Tries(3)]
#[Backoff(5, 15, 30)]
#[UniqueFor(1800)]
#[WithoutRelations]
class MergeRecordingChunksJob implements ShouldQueue, ShouldBeUnique
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*
* @param int|string $interviewId
* @param string $sessionId
* @param string|null $tempDir
* @param string|null $folderName
* @param int $totalChunks
* @param int|null $duration
*/
public function __construct(
public int|string $interviewId,
public string $sessionId,
public ?string $tempDir = null,
public ?string $folderName = null,
public int $totalChunks = 0,
public ?int $duration = null
) {}
/**
* The unique ID of the job.
*/
public function uniqueId(): string
{
return 'merge_rec_' . $this->interviewId . '_' . $this->sessionId;
}
/**
* Execute the job.
*/
public function handle(MergeRecordingChunksAction $mergeAction): void
{
Cache::lock('interview_merge_lock_' . $this->interviewId, 60)->block(15, function () use ($mergeAction) {
$interview = Interview::where('id', $this->interviewId)
->orWhere('submission_unique_id', $this->interviewId)
->first();
if (!$interview) {
Log::warning('[MergeRecordingChunksJob] Interview not found', [
'interview_id' => $this->interviewId,
'session_id' => $this->sessionId,
]);
return;
}
$mergeAction->execute(
$interview,
$this->sessionId,
$this->tempDir,
$this->folderName,
$this->duration
);
});
}
/**
* Handle a job failure.
*/
public function failed(?Throwable $exception): void
{
Log::error('[MergeRecordingChunksJob] Recording merge job failed', [
'interview_id' => $this->interviewId,
'session_id' => $this->sessionId,
'error' => $exception?->getMessage(),
'trace' => $exception?->getTraceAsString(),
]);
}
}

View File

@ -7,5 +7,18 @@
|--------------------------------------------------------------------------
|
*/
'max_interviewer' => (int) env('MAX_INTERVIEWER', 2)
'max_interviewer' => (int) env('MAX_INTERVIEWER', 2),
/*
|--------------------------------------------------------------------------
| Candidate Recordings Storage Configuration
|--------------------------------------------------------------------------
|
| Base folder and temporary chunk storage locations for session recordings.
|
*/
'recordings' => [
'base_folder' => env('INTERVIEW_RECORDINGS_FOLDER', 'uploads/candidate_recordings'),
'temp_folder' => env('INTERVIEW_RECORDINGS_TEMP_FOLDER', 'app/temp_recordings'),
],
];

View File

@ -983,6 +983,77 @@ export function pollReviewData() {
recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }];
}
// Auto-clear one-time processing notice if new recordings arrived
if (isRecordingProcessing && recList.length > recordingKnownCountBeforeProcessing) {
isRecordingProcessing = false;
recordingProcessingSessionId = null;
}
let processingBannerHtml = '';
if (isRecordingProcessing) {
processingBannerHtml = `
<div id="recording-processing-banner" class="bg-indigo-500/10 border border-indigo-500/30 text-indigo-200 text-xs rounded-xl p-3.5 mb-2.5 flex items-start gap-2.5 shadow-sm">
<i class="fa-solid fa-circle-notch fa-spin text-indigo-400 mt-0.5 text-sm shrink-0"></i>
<div class="flex-1 min-w-0">
<strong class="text-white text-xs block">Processing Call Recording&hellip;</strong>
<div class="text-[11px] text-indigo-300/80 mt-0.5">Your video recording is currently being assembled on the server. It will appear here shortly with a download link once ready.</div>
</div>
<button type="button" onclick="window.dismissRecordingProcessingBanner && window.dismissRecordingProcessingBanner()" class="text-indigo-400 hover:text-white text-sm shrink-0 leading-none">&times;</button>
</div>`;
}
function formatDurationBadgeText(sec) {
if (!sec || isNaN(sec) || sec <= 0) return null;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return m > 0 ? `${m}m ${String(s).padStart(2, '0')}s` : `${s}s`;
}
function resolveMediaDuration(videoUrl, badgeElementId, fallbackDuration = 0) {
const el = document.getElementById(badgeElementId);
if (!el) return;
if (fallbackDuration && fallbackDuration > 0) {
const text = formatDurationBadgeText(fallbackDuration);
if (text) {
el.innerHTML = `<i class="fa-regular fa-clock text-[9px]"></i> ${text}`;
el.style.display = 'inline-flex';
return;
}
}
if (!videoUrl) return;
const v = document.createElement('video');
v.preload = 'metadata';
v.src = videoUrl;
const applyDuration = (d) => {
if (!d || isNaN(d) || d <= 0) return;
const text = formatDurationBadgeText(d);
const targetEl = document.getElementById(badgeElementId);
if (text && targetEl) {
targetEl.innerHTML = `<i class="fa-regular fa-clock text-[9px]"></i> ${text}`;
targetEl.style.display = 'inline-flex';
}
};
v.onloadedmetadata = function() {
if (v.duration && !isNaN(v.duration) && v.duration !== Infinity && v.duration > 0) {
applyDuration(v.duration);
} else {
// Workaround for WebM streaming containers where duration is Infinity until seeked
v.currentTime = 1e101;
v.ontimeupdate = function() {
v.ontimeupdate = null;
if (v.currentTime && !isNaN(v.currentTime) && v.currentTime > 0) {
applyDuration(v.currentTime);
}
};
}
};
}
if (recList.length > 0) {
recList.sort((a, b) => {
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
@ -990,12 +1061,11 @@ export function pollReviewData() {
return timeB - timeA;
});
let recHtml = `<div class="text-xs text-sky-400 font-bold mb-2.5 flex justify-between items-center">
<span>📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''})</span>
<span class="text-[0.65rem] text-slate-400">📜 Scroll to view older recordings</span>
</div>`;
let recHtml = processingBannerHtml + `<div class="space-y-2">`;
recList.forEach((r, displayIdx) => {
const recNum = recList.length - displayIdx;
const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
videoUrl = '/' + videoUrl;
@ -1003,32 +1073,84 @@ export function pollReviewData() {
if (videoUrl && videoUrl.includes('/storage/')) {
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
}
const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
const dateFormatted = dateObj.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
const timeFormatted = dateObj.toLocaleTimeString(undefined, {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: true
});
const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`/interviews/${currentInterviewId}/download-recording?index=${origIdx}`);
const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm');
const badgeId = `rec-duration-badge-${origIdx}-${displayIdx}`;
const initialDuration = (r && r.duration && r.duration > 0) ? r.duration : 0;
const initialDurationText = formatDurationBadgeText(initialDuration);
recHtml += `<div class="bg-white/5 border border-slate-700/60 rounded-xl p-3 mb-3">
<div class="flex justify-between items-center mb-2">
<div>
<strong class="text-white text-xs">📹 Session Recording #${recList.length - displayIdx}</strong>
<span class="text-[0.7rem] text-slate-400 ml-2">📅 ${dateStr}</span>
recHtml += `
<div class="recording-card group bg-slate-900/80 hover:bg-slate-900 border border-slate-800 hover:border-slate-700/80 rounded-xl p-3 flex items-center justify-between gap-3 transition-all shadow-sm">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 rounded-lg bg-indigo-500/10 border border-indigo-500/20 text-indigo-400 flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform">
<i class="fa-solid fa-video text-xs"></i>
</div>
<a href="${downloadUrl}" target="_blank" download class="bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs px-3 py-1 rounded-md text-decoration-none inline-flex items-center gap-1">
📥 Download Video
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs font-semibold text-slate-100">Recording #${recNum}</span>
<span id="${badgeId}" class="${initialDurationText ? 'inline-flex' : 'hidden'} items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-md bg-indigo-500/10 text-indigo-300 border border-indigo-500/20">
<i class="fa-regular fa-clock text-[9px]"></i> ${initialDurationText || ''}
</span>
<span class="text-[9.5px] font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-400 border border-slate-700/50">WebM</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-slate-400 mt-0.5">
<span class="inline-flex items-center gap-1 text-slate-400">
<i class="fa-regular fa-calendar text-[10px] text-slate-500"></i>
${dateFormatted}
</span>
<span class="text-slate-600 font-bold"></span>
<span class="inline-flex items-center gap-1 text-slate-400">
<i class="fa-regular fa-clock text-[10px] text-slate-500"></i>
${timeFormatted}
</span>
</div>
</div>
</div>
<div class="shrink-0">
<a href="${downloadUrl}" target="_blank" download class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-emerald-300 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 hover:border-emerald-500/50 rounded-lg transition-all shadow-sm hover:shadow-emerald-950/40">
<i class="fa-solid fa-arrow-down-to-bracket text-xs"></i>
<span>Download</span>
</a>
</div>
<video controls preload="metadata" class="w-full max-h-[240px] rounded-lg bg-black outline-none border border-white/10">
<source src="${videoUrl}" type="${mimeType}">
<source src="${videoUrl}">
Your browser does not support video playback.
</video>
</div>`;
});
recHtml += `</div>`;
recContainer.innerHTML = recHtml;
// Dynamically resolve media duration for all cards
recList.forEach((r, displayIdx) => {
const origIdx = (typeof r === 'object' && r.original_index !== undefined) ? r.original_index : displayIdx;
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
videoUrl = '/' + videoUrl;
}
if (videoUrl && videoUrl.includes('/storage/')) {
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
}
const badgeId = `rec-duration-badge-${origIdx}-${displayIdx}`;
const initialDuration = (r && r.duration && r.duration > 0) ? r.duration : 0;
resolveMediaDuration(videoUrl, badgeId, initialDuration);
});
} else {
recContainer.innerHTML = '<div class="text-slate-400 text-xs text-center pt-10">No video recordings saved for this candidate yet.</div>';
recContainer.innerHTML = processingBannerHtml + `
<div class="flex flex-col items-center justify-center py-10 text-center">
<div class="w-10 h-10 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-500 mb-2.5">
<i class="fa-solid fa-video-slash text-sm"></i>
</div>
<p class="text-xs font-medium text-slate-400">No video recordings saved yet</p>
<p class="text-[11px] text-slate-500 mt-0.5 max-w-[240px]">Recordings captured during the call will appear here for download.</p>
</div>`;
}
}
@ -2096,6 +2218,10 @@ export function leaveInterviewerCall(isEndAll = false) {
const recStopBtn = document.getElementById('btn-stop-recording');
if (recStopBtn) recStopBtn.style.display = 'none';
const recUploadBtn = document.getElementById('btn-uploading-recording');
if (recUploadBtn) recUploadBtn.style.display = 'none';
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
try { adminMediaRecorder.stop(); } catch(e) {}
}
@ -2141,13 +2267,200 @@ export function endCallForAll() {
}
}
// MediaRecorder & Recording Compositor
// MediaRecorder, Chunked Uploader & Recording Compositor
let adminMediaRecorder = null;
let adminRecordedChunks = [];
let recordingCompositor = null;
let recordingTimerInterval = null;
let recordingStartTime = null;
let isRecordingProcessing = false;
let recordingProcessingSessionId = null;
let recordingKnownCountBeforeProcessing = 0;
let currentChunkIndex = 0;
let chunkUploadQueue = [];
let currentRecordingSessionId = null;
let isStopRecordingRequested = false;
let totalRecordedChunksCount = 0;
let isFinalizingRecording = false;
const MAX_CONCURRENT_UPLOADS = 2;
let activeUploadsCount = 0;
window.dismissRecordingProcessingBanner = function() {
isRecordingProcessing = false;
recordingProcessingSessionId = null;
const b = document.getElementById('recording-processing-banner');
if (b) b.remove();
};
function updateRecordingButtonProgress(isFinalStep = false) {
const total = Math.max(1, totalRecordedChunksCount || currentChunkIndex || 1);
const uploaded = chunkUploadQueue.filter(item => item.isUploaded && !item.isFinal).length;
let pct = isFinalStep ? 100 : Math.min(92, Math.max(15, Math.round((uploaded / total) * 90)));
const recStopBtn = document.getElementById('btn-stop-recording');
if (!recStopBtn) return;
if (isFinalStep) {
recStopBtn.innerHTML = '<i class="fa-solid fa-circle-check text-emerald-300 mr-1.5"></i><span class="text-emerald-200 font-bold">Saved!</span>';
} else {
recStopBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-indigo-200"></i><span>Saving <span id="rec-upload-progress-text" class="font-mono font-bold text-sky-200">' + pct + '%</span></span><span class="absolute -top-1 -right-1 flex h-2.5 w-2.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-sky-500"></span></span>';
}
}
let currentSessionDuration = 0;
function queueChunkUpload(chunkBlob, isFinal = false, duration = null) {
if (!currentRecordingSessionId) return;
if (chunkBlob && chunkBlob.size > 0) {
const index = currentChunkIndex++;
chunkUploadQueue.push({
sessionId: currentRecordingSessionId,
index: index,
blob: chunkBlob,
isFinal: false,
isUploading: false,
isUploaded: false,
retries: 0
});
}
if (isFinal) {
totalRecordedChunksCount = currentChunkIndex;
chunkUploadQueue.push({
sessionId: currentRecordingSessionId,
index: currentChunkIndex,
blob: null,
isFinal: true,
totalChunks: currentChunkIndex,
duration: duration || currentSessionDuration || 0,
isUploading: false,
isUploaded: false,
retries: 0
});
}
if (isStopRecordingRequested) {
updateRecordingButtonProgress(false);
}
triggerChunkQueueProcessing();
}
function triggerChunkQueueProcessing() {
// 1. Dispatch concurrent data uploads
while (activeUploadsCount < MAX_CONCURRENT_UPLOADS) {
const nextItem = chunkUploadQueue.find(item => !item.isUploading && !item.isUploaded && !item.isFinal);
if (!nextItem) break;
uploadSingleChunk(nextItem);
}
// 2. Dispatch finalization trigger once all data chunks are confirmed uploaded
const hasPendingDataChunks = chunkUploadQueue.some(item => !item.isUploaded && !item.isFinal);
const finalMarker = chunkUploadQueue.find(item => item.isFinal && !item.isUploading && !item.isUploaded);
if (!hasPendingDataChunks && finalMarker && activeUploadsCount === 0 && !isFinalizingRecording) {
isFinalizingRecording = true;
uploadSingleChunk(finalMarker);
}
}
async function uploadSingleChunk(item) {
item.isUploading = true;
activeUploadsCount++;
if (!currentInterviewId && typeof document !== 'undefined') {
currentInterviewId = document.body?.dataset?.interviewId || window.interviewId;
}
if (!currentInterviewId) {
console.error('[Recording Chunk] Missing currentInterviewId');
item.isUploading = false;
activeUploadsCount--;
return;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
const formData = new FormData();
formData.append('session_id', item.sessionId);
formData.append('chunk_index', item.index);
formData.append('is_final', item.isFinal ? '1' : '0');
formData.append('total_chunks', item.totalChunks || currentChunkIndex);
if (item.duration) {
formData.append('duration', item.duration);
}
formData.append('_token', csrfToken);
if (item.blob && item.blob.size > 0) {
formData.append('chunk', item.blob, `part_${item.index}.webm`);
}
try {
const response = await fetch(`/candidate/upload-recording-chunk/${currentInterviewId}`, {
method: 'POST',
headers: {
'Accept': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest'
},
body: formData
});
if (!response.ok) {
const errText = await response.text();
throw new Error(`HTTP ${response.status}: ${errText}`);
}
const resJson = await response.json();
item.isUploading = false;
item.isUploaded = true;
activeUploadsCount--;
if (isStopRecordingRequested) {
updateRecordingButtonProgress(item.isFinal);
}
if (item.isFinal) {
console.log('[Recording Chunk] Final chunk processed by server:', resJson);
isRecordingProcessing = true;
recordingProcessingSessionId = item.sessionId;
isStopRecordingRequested = false;
isFinalizingRecording = false;
updateRecordingButtonProgress(true);
pollReviewData();
setTimeout(() => {
const stopBtn = document.getElementById('btn-stop-recording');
const startBtn = document.getElementById('btn-start-recording');
if (stopBtn) {
stopBtn.style.display = 'none';
stopBtn.disabled = false;
stopBtn.className = 'hidden inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-rose-600 hover:bg-rose-700 text-white shadow-sm animate-pulse transition-all cursor-pointer';
stopBtn.innerHTML = '<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>';
}
if (startBtn) startBtn.style.display = 'inline-flex';
}, 1200);
}
triggerChunkQueueProcessing();
} catch(err) {
console.warn(`[Recording Chunk] Upload failed for chunk ${item.index} (attempt ${item.retries + 1}):`, err);
item.isUploading = false;
activeUploadsCount--;
item.retries++;
if (item.retries < 5) {
setTimeout(() => {
triggerChunkQueueProcessing();
}, item.retries * 800);
} else {
console.error(`[Recording Chunk] Critical upload failure for chunk ${item.index}. Retrying after delay...`);
setTimeout(() => {
triggerChunkQueueProcessing();
}, 2500);
}
}
}
export function startCallRecording() {
if (!currentInterviewId) {
alert('Please open an active candidate interview session first.');
@ -2162,11 +2475,11 @@ export function startCallRecording() {
const candNameEl = document.getElementById('modal-cand-name');
const candidateName = candNameEl ? candNameEl.innerText.trim() : 'Candidate';
// 1. Initialize RecordingCompositor (1920x1080 @ 30fps)
// 1. Initialize RecordingCompositor (1280x720 @ 24fps for ultra-fast, smooth streaming)
recordingCompositor = new RecordingCompositor({
width: 1920,
height: 1080,
fps: 30,
width: 1280,
height: 720,
fps: 24,
getParticipantsData: () => {
const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
const candCamIcon = document.getElementById('cand-cam-status');
@ -2218,13 +2531,29 @@ export function startCallRecording() {
recordingCompositor.start();
const streamToRecord = recordingCompositor.getStream();
adminRecordedChunks = [];
let options = {};
currentRecordingSessionId = 'rec_' + currentInterviewId + '_' + Date.now() + '_' + Math.random().toString(36).substring(2, 7);
currentChunkIndex = 0;
chunkUploadQueue = [];
isStopRecordingRequested = false;
totalRecordedChunksCount = 0;
isFinalizingRecording = false;
activeUploadsCount = 0;
currentSessionDuration = 0;
const existingCards = document.querySelectorAll('#modal-recordings-container .recording-card, #modal-recordings-container video');
recordingKnownCountBeforeProcessing = existingCards ? existingCards.length : 0;
// Controlled bitrate configuration (1.2 Mbps video, 64 kbps audio)
let options = {
videoBitsPerSecond: 1200000,
audioBitsPerSecond: 64000
};
if (typeof MediaRecorder.isTypeSupported === 'function') {
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
options = { mimeType: 'video/webm;codecs=vp8,opus' };
options.mimeType = 'video/webm;codecs=vp8,opus';
} else if (MediaRecorder.isTypeSupported('video/webm')) {
options = { mimeType: 'video/webm' };
options.mimeType = 'video/webm';
}
}
@ -2234,9 +2563,10 @@ export function startCallRecording() {
adminMediaRecorder = new MediaRecorder(streamToRecord);
}
// Real-time chunk slice handler
adminMediaRecorder.ondataavailable = function(e) {
if (e.data && e.data.size > 0) {
adminRecordedChunks.push(e.data);
queueChunkUpload(e.data, false);
}
};
@ -2245,24 +2575,24 @@ export function startCallRecording() {
recordingCompositor.stop();
recordingCompositor = null;
}
if (adminRecordedChunks.length === 0) return;
const recMime = adminMediaRecorder.mimeType || 'video/webm';
const isMp4 = recMime.includes('mp4');
const ext = isMp4 ? 'mp4' : 'webm';
const blobType = isMp4 ? 'video/mp4' : 'video/webm';
const blob = new Blob(adminRecordedChunks, { type: blobType });
uploadRecordedBlob(blob, ext);
// Signal finalization after the final slice is recorded
queueChunkUpload(null, true, currentSessionDuration);
};
adminMediaRecorder.start(1000);
// Start timeslice recording (6,000ms = 6s chunks ~800KB each for near-instant concurrent background uploads)
adminMediaRecorder.start(6000);
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 (recStopBtn) {
recStopBtn.style.display = 'inline-flex';
recStopBtn.disabled = false;
recStopBtn.className = 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold bg-rose-600 hover:bg-rose-700 text-white shadow-sm animate-pulse transition-all';
recStopBtn.innerHTML = '<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>';
}
if (recordingTimerInterval) clearInterval(recordingTimerInterval);
recordingTimerInterval = setInterval(() => {
@ -2274,18 +2604,6 @@ export function startCallRecording() {
timerEl.innerText = `(${mins}:${secs})`;
}
}, 1000);
if (typeof window.Swal !== 'undefined') {
window.Swal.fire({
icon: 'info',
title: 'Composite Recording Started',
text: 'Recording composite layout (Candidate, Screen, and Panelists in HD).',
toast: true,
position: 'top-end',
timer: 3500,
showConfirmButton: false
});
}
} catch(e) {
if (recordingCompositor) {
recordingCompositor.stop();
@ -2300,6 +2618,21 @@ export function stopCallRecording() {
clearInterval(recordingTimerInterval);
recordingTimerInterval = null;
}
isStopRecordingRequested = true;
currentSessionDuration = recordingStartTime ? Math.max(1, Math.round((Date.now() - recordingStartTime) / 1000)) : 0;
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';
recStopBtn.disabled = true;
recStopBtn.className = 'relative inline-flex items-center gap-2 px-3.5 py-1.5 text-xs font-semibold text-white bg-indigo-600 rounded-lg shadow-lg shadow-indigo-500/50 ring-2 ring-indigo-400 ring-offset-2 ring-offset-slate-900 animate-pulse transition-all';
recStopBtn.innerHTML = '<i class="fa-solid fa-circle-notch fa-spin text-indigo-200"></i><span>Saving <span id="rec-upload-progress-text" class="font-mono font-bold text-sky-200">15%</span></span><span class="absolute -top-1 -right-1 flex h-2.5 w-2.5"><span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span><span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-sky-500"></span></span>';
}
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
adminMediaRecorder.stop();
}
@ -2307,10 +2640,6 @@ export function stopCallRecording() {
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';
if (recStopBtn) recStopBtn.style.display = 'none';
}
export function uploadRecordedBlob(blob, ext = 'webm') {

View File

@ -1,6 +1,11 @@
<div id="tab-content-recordings" class="hidden">
<div class="text-[11.5px] text-slate-400 mb-2 font-medium">Saved candidate video sessions (sorted by newest first)</div>
<div id="modal-recordings-container" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 min-h-[250px] max-h-[420px] overflow-y-auto">
<div class="text-slate-500 text-xs text-center pt-10">No video recordings saved for this candidate yet.</div>
<div id="modal-recordings-container" class="bg-slate-950/70 border border-slate-800 rounded-lg p-3 min-h-[220px] max-h-[420px] overflow-y-auto space-y-2.5 custom-scrollbar">
<div class="flex flex-col items-center justify-center py-10 text-center">
<div class="w-10 h-10 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center text-slate-500 mb-2.5">
<i class="fa-solid fa-video-slash text-sm"></i>
</div>
<p class="text-xs font-medium text-slate-400">No video recordings saved yet</p>
<p class="text-[11px] text-slate-500 mt-0.5 max-w-[240px]">Recordings captured during the call will appear here for download.</p>
</div>
</div>
</div>

View File

@ -33,6 +33,7 @@
Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
Route::post('/candidate/log-violation/{id}', [InterviewController::class, 'logViolation'])->name('interview.violation');
Route::post('/candidate/upload-recording/{id}', [InterviewController::class, 'uploadRecording'])->name('interview.upload-recording');
Route::post('/candidate/upload-recording-chunk/{id}', [InterviewController::class, 'uploadRecordingChunk'])->name('interview.upload-recording-chunk');
Route::post('/candidate/upload-tab-screenshot/{id}', [InterviewController::class, 'uploadTabScreenshot'])->name('interview.upload-tab-screenshot');
Route::post('/candidate/sync-code/{id}', [InterviewController::class, 'syncCandidateCode'])->name('interview.candidate.sync-code');
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');