diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 64ee6c5..abd0570 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -393,12 +393,125 @@ public function blockCandidate(Request $request, $id) ]); } + /** + * Serve Storage File (Recordings & Public Assets) directly with Range Support for Video Streaming. + */ + public function serveStorageFile($path) + { + $sanitizedPath = ltrim(str_replace('..', '', $path), '/'); + + $candidatePaths = [ + storage_path('app/public/' . $sanitizedPath), + storage_path('app/private/public/' . $sanitizedPath), + storage_path('app/private/' . $sanitizedPath), + storage_path('app/' . $sanitizedPath), + public_path('storage/' . $sanitizedPath), + public_path($sanitizedPath), + ]; + + // Also check with alternate extensions (.webm / .mp4) if requested file extension differs + $baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath); + if ($baseNoExt !== $sanitizedPath) { + $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm'); + $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); + $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); + $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); + } + + $filePath = null; + foreach ($candidatePaths as $cp) { + if (file_exists($cp) && is_file($cp)) { + $filePath = $cp; + break; + } + } + + if (!$filePath) { + abort(404, 'Storage file not found.'); + } + + // Read first 12 bytes to accurately detect container format (WebM vs MP4 vs Image) + $handle = @fopen($filePath, 'rb'); + $header = $handle ? fread($handle, 12) : ''; + if ($handle) fclose($handle); + + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $isWebmHeader = str_starts_with($header, "\x1A\x45\xDF\xA3"); + + if ($isWebmHeader || $extension === 'webm') { + $mimeType = 'video/webm'; + } elseif (str_contains($header, 'ftyp') || in_array($extension, ['mp4', 'm4v'])) { + $mimeType = 'video/mp4'; + } elseif ($extension === 'png') { + $mimeType = 'image/png'; + } elseif (in_array($extension, ['jpg', 'jpeg'])) { + $mimeType = 'image/jpeg'; + } else { + $mimeType = @mime_content_type($filePath) ?: 'application/octet-stream'; + } + + return response()->file($filePath, [ + 'Content-Type' => $mimeType, + 'Accept-Ranges' => 'bytes', + 'Cache-Control' => 'public, max-age=86400', + ]); + } + + /** + * Get normalized recordings list sorted in descending order of creation timestamp (newest first). + */ + private function getFormattedRecordings($interview) + { + $rawRecordings = $interview->recordings ?? []; + if (!is_array($rawRecordings) || empty($rawRecordings)) { + if (!empty($interview->recording_path)) { + $rawRecordings = [[ + 'url' => $interview->recording_path, + 'filename' => basename($interview->recording_path), + 'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(), + ]]; + } else { + $rawRecordings = []; + } + } + + $formatted = []; + foreach ($rawRecordings as $idx => $rec) { + if (is_string($rec)) { + $rec = [ + 'url' => $rec, + 'filename' => basename($rec), + 'created_at' => $interview->updated_at ? $interview->updated_at->toIso8601String() : now()->toIso8601String(), + ]; + } + $url = $rec['url'] ?? ''; + $urlPath = parse_url($url, PHP_URL_PATH) ?? $url; + $cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); + $streamUrl = url('/media-stream/' . $cleanPath); + + $rec['original_index'] = $idx; + $rec['stream_url'] = $streamUrl; + $rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx; + $formatted[] = $rec; + } + + // Sort descending by created_at timestamp (newest first) + usort($formatted, function ($a, $b) { + $timeA = isset($a['created_at']) ? strtotime($a['created_at']) : 0; + $timeB = isset($b['created_at']) ? strtotime($b['created_at']) : 0; + return $timeB <=> $timeA; + }); + + return $formatted; + } + /** * Live Polling Endpoint for Candidate Warnings & HR Monitoring. */ public function getPollData($id) { $interview = Interview::with(['warnings.sender'])->findOrFail($id); + $formattedRecordings = $this->getFormattedRecordings($interview); return response()->json([ 'status' => $interview->status, @@ -412,7 +525,7 @@ public function getPollData($id) 'blocked_ip' => $interview->blocked_ip, 'proctor_logs' => $interview->proctor_logs ?? [], 'recording_path' => $interview->recording_path, - 'recordings' => $interview->recordings ?? [], + 'recordings' => $formattedRecordings, 'warnings' => $interview->warnings, 'is_expired' => $interview->isExpired(), ]); @@ -438,9 +551,14 @@ public function uploadRecording(Request $request, $id) if ($request->hasFile('video')) { $file = $request->file('video'); - $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.mp4'; - $path = $file->storeAs('public/recordings', $filename); - $url = Storage::url($path); + $ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); + if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov'])) { + $ext = 'webm'; + } + + $filename = 'interview_' . $interview->submission_unique_id . '_' . time() . '.' . $ext; + $path = $file->storeAs('recordings', $filename, 'public'); + $url = Storage::disk('public')->url($path); $recordings = $interview->recordings ?? []; if (!is_array($recordings)) { @@ -464,7 +582,7 @@ public function uploadRecording(Request $request, $id) } /** - * Download Candidate Video Recording as MP4 File. + * Download Candidate Video Recording as File. */ public function downloadRecording(Request $request, $id) { @@ -486,11 +604,46 @@ public function downloadRecording(Request $request, $id) return back()->with('error', 'No video recording found for this candidate profile.'); } - $relativePath = str_replace('/storage/', 'public/', $targetUrl); - if (Storage::exists($relativePath)) { - $downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.mp4'; - return Storage::download($relativePath, $downloadFilename, [ - 'Content-Type' => 'video/mp4', + $urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl; + $sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); + + $candidatePaths = [ + storage_path('app/public/' . $sanitizedPath), + storage_path('app/private/public/' . $sanitizedPath), + storage_path('app/private/' . $sanitizedPath), + storage_path('app/' . $sanitizedPath), + public_path('storage/' . $sanitizedPath), + public_path($sanitizedPath), + ]; + + $baseNoExt = preg_replace('/\.(mp4|webm|m4v|mov)$/i', '', $sanitizedPath); + if ($baseNoExt !== $sanitizedPath) { + $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.webm'); + $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); + $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); + $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); + } + + $filePath = null; + foreach ($candidatePaths as $cp) { + if (file_exists($cp) && is_file($cp)) { + $filePath = $cp; + break; + } + } + + if ($filePath && file_exists($filePath)) { + $handle = @fopen($filePath, 'rb'); + $header = $handle ? fread($handle, 12) : ''; + if ($handle) fclose($handle); + + $isWebm = str_starts_with($header, "\x1A\x45\xDF\xA3") || str_ends_with($filePath, '.webm'); + $ext = $isWebm ? 'webm' : 'mp4'; + $mimeType = $isWebm ? 'video/webm' : 'video/mp4'; + + $downloadFilename = 'candidate_' . Str::slug($interview->candidate_name) . '_recording_' . ($index + 1) . '.' . $ext; + return response()->download($filePath, $downloadFilename, [ + 'Content-Type' => $mimeType, ]); } @@ -556,7 +709,7 @@ public function generateReport($id) $codeScore += 10; } } - $codeScore = min(100, $codeScore); + $interview->formatted_recordings = $this->getFormattedRecordings($interview); return view('interview.report', compact( 'interview', diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 03338fc..eade07b 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -621,8 +621,8 @@