diff --git a/app/Actions/Interview/FinalizeRecordingAction.php b/app/Actions/Interview/FinalizeRecordingAction.php new file mode 100644 index 0000000..eb2fdec --- /dev/null +++ b/app/Actions/Interview/FinalizeRecordingAction.php @@ -0,0 +1,38 @@ +id, + $sessionId, + $tempDir, + $folderName, + $totalChunks, + $duration + ); + } +} diff --git a/app/Actions/Interview/MergeRecordingChunksAction.php b/app/Actions/Interview/MergeRecordingChunksAction.php new file mode 100644 index 0000000..7a11d23 --- /dev/null +++ b/app/Actions/Interview/MergeRecordingChunksAction.php @@ -0,0 +1,201 @@ + $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 + */ + 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 $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 $parts + * @param string $tempDir + * @return void + */ + private function cleanupTempParts(array $parts, string $tempDir): void + { + foreach ($parts as $partFile) { + @unlink($partFile); + } + @rmdir($tempDir); + } +} diff --git a/app/Actions/Interview/StoreRecordingChunkAction.php b/app/Actions/Interview/StoreRecordingChunkAction.php new file mode 100644 index 0000000..82876b0 --- /dev/null +++ b/app/Actions/Interview/StoreRecordingChunkAction.php @@ -0,0 +1,34 @@ +move($tempDir, $partName); + + return $tempDir . DIRECTORY_SEPARATOR . $partName; + } +} diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 05cdb2d..a71007f 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -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. */ diff --git a/app/Http/Requests/Interview/UploadRecordingChunkRequest.php b/app/Http/Requests/Interview/UploadRecordingChunkRequest.php new file mode 100644 index 0000000..5e90ef9 --- /dev/null +++ b/app/Http/Requests/Interview/UploadRecordingChunkRequest.php @@ -0,0 +1,46 @@ +|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 + */ + public function messages(): array + { + return [ + 'session_id.required' => 'A valid recording session identifier is required.', + 'chunk_index.required' => 'Chunk index must be provided.', + ]; + } +} diff --git a/app/Http/Responses/Interview/RecordingChunkReceivedResponse.php b/app/Http/Responses/Interview/RecordingChunkReceivedResponse.php new file mode 100644 index 0000000..f07a846 --- /dev/null +++ b/app/Http/Responses/Interview/RecordingChunkReceivedResponse.php @@ -0,0 +1,36 @@ +json([ + 'success' => true, + 'chunk_index' => $this->chunkIndex, + 'is_final' => false, + 'session_id' => $this->sessionId, + ], 200); + } +} diff --git a/app/Http/Responses/Interview/RecordingFinalizedResponse.php b/app/Http/Responses/Interview/RecordingFinalizedResponse.php new file mode 100644 index 0000000..9dbf2e6 --- /dev/null +++ b/app/Http/Responses/Interview/RecordingFinalizedResponse.php @@ -0,0 +1,37 @@ +json([ + 'success' => true, + 'is_final' => true, + 'processing' => true, + 'session_id' => $this->sessionId, + 'message' => $this->message, + ], 200); + } +} diff --git a/app/Jobs/MergeRecordingChunksJob.php b/app/Jobs/MergeRecordingChunksJob.php new file mode 100644 index 0000000..34592ce --- /dev/null +++ b/app/Jobs/MergeRecordingChunksJob.php @@ -0,0 +1,98 @@ +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(), + ]); + } +} diff --git a/config/interview.php b/config/interview.php index c58a1d7..8d8c380 100644 --- a/config/interview.php +++ b/config/interview.php @@ -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'), + ], ]; diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index 0160a76..1e46e88 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -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 = ` +
+ +
+ Processing Call Recording… +
Your video recording is currently being assembled on the server. It will appear here shortly with a download link once ready.
+
+ +
`; + } + +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 = ` ${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 = ` ${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 = `
- 📹 Stored Video Sessions (${recList.length} recording${recList.length > 1 ? 's' : ''}) - 📜 Scroll to view older recordings -
`; + let recHtml = processingBannerHtml + `
`; 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 += `
-
-
- 📹 Session Recording #${recList.length - displayIdx} - 📅 ${dateStr} + recHtml += ` + `; }); + recHtml += `
`; 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 = '
No video recordings saved for this candidate yet.
'; + recContainer.innerHTML = processingBannerHtml + ` +
+
+ +
+

No video recordings saved yet

+

Recordings captured during the call will appear here for download.

+
`; } } @@ -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 = 'Saved!'; + } else { + recStopBtn.innerHTML = 'Saving ' + pct + '%'; + } +} + +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 = ' Recording (00:00) - Stop'; + } + 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 = ' Recording (00:00) - Stop'; + } 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 = 'Saving 15%'; + } + 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') { diff --git a/resources/views/components/interview/tabs/recordings-tab.blade.php b/resources/views/components/interview/tabs/recordings-tab.blade.php index a502d68..ac179b7 100644 --- a/resources/views/components/interview/tabs/recordings-tab.blade.php +++ b/resources/views/components/interview/tabs/recordings-tab.blade.php @@ -1,6 +1,11 @@