diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index e7eb740..3fe228a 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -430,6 +430,8 @@ public function serveStorageFile($path) $candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm'); $candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4'); + $candidatePaths[] = public_path($baseNoExt . '.webm'); + $candidatePaths[] = public_path($baseNoExt . '.mp4'); } $filePath = null; @@ -464,11 +466,50 @@ public function serveStorageFile($path) $mimeType = @mime_content_type($filePath) ?: 'application/octet-stream'; } - return response()->file($filePath, [ + $fileSize = filesize($filePath); + $headers = [ 'Content-Type' => $mimeType, 'Accept-Ranges' => 'bytes', 'Cache-Control' => 'public, max-age=86400', - ]); + ]; + + // Enable HTTP 206 Partial Content Range support required for HTML5 video playback/seeking + $range = request()->header('Range'); + if ($range && preg_match('/bytes=(\d+)-(\d+)?/', $range, $matches)) { + $start = (int) $matches[1]; + $end = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : ($fileSize - 1); + + if ($start > $end || $start >= $fileSize) { + return response('', 416, ['Content-Range' => "bytes */{$fileSize}"]); + } + + $end = min($end, $fileSize - 1); + $length = $end - $start + 1; + + $headers['Content-Range'] = "bytes {$start}-{$end}/{$fileSize}"; + $headers['Content-Length'] = (string) $length; + + return response()->stream(function () use ($filePath, $start, $length) { + $file = fopen($filePath, 'rb'); + if ($file) { + fseek($file, $start); + $bufferSize = 1024 * 64; + $bytesLeft = $length; + while ($bytesLeft > 0 && !feof($file)) { + $readSize = min($bufferSize, $bytesLeft); + $data = fread($file, $readSize); + if ($data === false) break; + echo $data; + flush(); + $bytesLeft -= strlen($data); + } + fclose($file); + } + }, 206, $headers); + } + + $headers['Content-Length'] = (string) $fileSize; + return response()->file($filePath, $headers); } /** @@ -503,9 +544,13 @@ private function getFormattedRecordings($interview) $cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); $streamUrl = url('/media-stream/' . $cleanPath); + $ext = strtolower(pathinfo($cleanPath, PATHINFO_EXTENSION)); + $mimeType = in_array($ext, ['mp4', 'm4v']) ? 'video/mp4' : 'video/webm'; + $rec['original_index'] = $idx; $rec['stream_url'] = $streamUrl; $rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx; + $rec['mime_type'] = $mimeType; $formatted[] = $rec; } @@ -680,7 +725,8 @@ public function uploadTabScreenshot(Request $request, $id) $reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch' $filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg'; - $subDir = 'uploads/candidate_screenshots/' . $interview->submission_unique_id; + $uniqueFolder = $interview->submission_unique_id ?: $interview->id; + $subDir = 'uploads/candidate_screenshots/' . $uniqueFolder; $destinationPath = public_path($subDir); if (!file_exists($destinationPath)) { @@ -697,9 +743,12 @@ public function uploadTabScreenshot(Request $request, $id) if (str_contains($base64Image, ',')) { $base64Image = explode(',', $base64Image)[1]; } + $base64Image = str_replace(' ', '+', $base64Image); $imageData = base64_decode($base64Image); - file_put_contents($destinationPath . '/' . $filename, $imageData); - $url = asset($subDir . '/' . $filename); + if ($imageData !== false && strlen($imageData) > 0) { + file_put_contents($destinationPath . '/' . $filename, $imageData); + $url = asset($subDir . '/' . $filename); + } } if ($url) { @@ -744,6 +793,7 @@ public function uploadTabScreenshot(Request $request, $id) return response()->json([ 'success' => true, 'url' => $relativePath, + 'full_url' => $url, 'ai_verdict' => $aiVerdict, 'total_screenshots' => count($screenshots), ]); @@ -787,7 +837,8 @@ public function uploadRecording(Request $request, $id) $ext = 'webm'; } - $subDir = 'uploads/candidate_recordings/' . $interview->submission_unique_id; + $uniqueFolder = $interview->submission_unique_id ?: $interview->id; + $subDir = 'uploads/candidate_recordings/' . $uniqueFolder; $destinationPath = public_path($subDir); if (!file_exists($destinationPath)) { mkdir($destinationPath, 0777, true); @@ -796,23 +847,25 @@ public function uploadRecording(Request $request, $id) $filename = 'recording_' . time() . '.' . $ext; $file->move($destinationPath, $filename); $url = asset($subDir . '/' . $filename); + $relativePath = '/' . $subDir . '/' . $filename; $recordings = $interview->recordings ?? []; if (!is_array($recordings)) { $recordings = $interview->recording_path ? [$interview->recording_path] : []; } $recordings[] = [ - 'url' => $url, + 'url' => $relativePath, + 'full_url' => $url, 'filename' => $filename, 'created_at' => now()->toIso8601String(), ]; $interview->update([ - 'recording_path' => $url, + 'recording_path' => $relativePath, 'recordings' => $recordings, ]); - return response()->json(['success' => true, 'path' => $url, 'recordings' => $recordings]); + return response()->json(['success' => true, 'path' => $relativePath, 'url' => $url, 'recordings' => $recordings]); } return response()->json(['success' => false, 'message' => 'No video payload received']); diff --git a/bootstrap/app.php b/bootstrap/app.php index 9c3efb3..b75c580 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -12,6 +12,9 @@ health: '/up', ) ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'candidate/*', + ]); $middleware->alias([ 'role' => \App\Http\Middleware\EnsureRole::class, 'verify-ms-session' => \App\Http\Middleware\VerifyMicrosoftSession::class, diff --git a/public/uploads/candidate_screenshots/anwesha_9163855564_1785765309/screenshot_tab_switch_1785767670_537.jpg b/public/uploads/candidate_screenshots/anwesha_9163855564_1785765309/screenshot_tab_switch_1785767670_537.jpg new file mode 100644 index 0000000..e4f6795 Binary files /dev/null and b/public/uploads/candidate_screenshots/anwesha_9163855564_1785765309/screenshot_tab_switch_1785767670_537.jpg differ diff --git a/resources/views/interview/candidate_room.blade.php b/resources/views/interview/candidate_room.blade.php index 051bd1f..4b75d5f 100644 --- a/resources/views/interview/candidate_room.blade.php +++ b/resources/views/interview/candidate_room.blade.php @@ -562,6 +562,7 @@ function submitSolution() { let latestCallStatus = '{{ $interview->call_status ?? "idle" }}'; let screenShareVideoElem = null; + // Auto-initialize persistent offscreen video element for screen share capture // Auto-initialize persistent offscreen video element for screen share capture function getScreenShareVideoElement() { if (!screenShareVideoElem) { @@ -570,10 +571,13 @@ function getScreenShareVideoElement() { screenShareVideoElem.muted = true; screenShareVideoElem.playsInline = true; screenShareVideoElem.style.position = 'fixed'; - screenShareVideoElem.style.top = '-9999px'; - screenShareVideoElem.style.left = '-9999px'; - screenShareVideoElem.style.width = '1px'; - screenShareVideoElem.style.height = '1px'; + screenShareVideoElem.style.top = '0px'; + screenShareVideoElem.style.left = '0px'; + screenShareVideoElem.style.width = '320px'; + screenShareVideoElem.style.height = '180px'; + screenShareVideoElem.style.opacity = '0.001'; + screenShareVideoElem.style.pointerEvents = 'none'; + screenShareVideoElem.style.zIndex = '-9999'; document.body.appendChild(screenShareVideoElem); } return screenShareVideoElem; @@ -595,7 +599,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') { // PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream) if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) { const screenTrack = screenShareStream.getVideoTracks()[0]; - if (screenTrack.readyState === 'live') { + if (screenTrack && screenTrack.readyState === 'live') { try { const vidElem = getScreenShareVideoElement(); if (vidElem.srcObject !== screenShareStream) { @@ -606,7 +610,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') { let rawW = vidElem.videoWidth || 1280; let rawH = vidElem.videoHeight || 720; - if (rawW > 0 && rawH > 0) { + if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) { const maxW = 1280; let targetW = rawW; let targetH = rawH; @@ -638,8 +642,8 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') { if (drewScreenStream) return; - // PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas - if (typeof html2canvas !== 'undefined') { + // PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas or Composite Fallback + if (typeof html2canvas !== 'undefined' && !document.hidden) { html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => { const ctx = canvas.getContext('2d'); ctx.fillStyle = 'rgba(15, 23, 42, 0.90)'; @@ -656,6 +660,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') { } } catch(e) { console.log('Candidate system screenshot capture exception:', e); + fallbackCompositeSnapshot(reason); } } diff --git a/resources/views/interview/index.blade.php b/resources/views/interview/index.blade.php index 9bf09db..934792f 100644 --- a/resources/views/interview/index.blade.php +++ b/resources/views/interview/index.blade.php @@ -1361,6 +1361,9 @@ function pollReviewData() { recList.forEach((r, 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/'); } @@ -1368,6 +1371,7 @@ function pollReviewData() { const dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date(); const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString(); const downloadUrl = (typeof r === 'object' && r.download_url) ? r.download_url : (`{{ route('interview.download-recording', ':id') }}`.replace(':id', currentInterviewId) + `?index=${origIdx}`); + const mimeType = (typeof r === 'object' && r.mime_type) ? r.mime_type : ((videoUrl && videoUrl.includes('.mp4')) ? 'video/mp4' : 'video/webm'); recHtml += `
@@ -1379,7 +1383,11 @@ function pollReviewData() { 📥 Download Video
- +
`; }); recContainer.innerHTML = recHtml; @@ -1424,7 +1432,10 @@ function pollReviewData() {
`; shotList.forEach((s, idx) => { - const url = typeof s === 'string' ? s : s.url; + let url = typeof s === 'string' ? s : (s.url || s.full_url); + if (url && !url.startsWith('http') && !url.startsWith('/')) { + url = '/' + url; + } const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : ''; shotHtml += `
diff --git a/resources/views/interview/report.blade.php b/resources/views/interview/report.blade.php index b80df7c..4b4121c 100644 --- a/resources/views/interview/report.blade.php +++ b/resources/views/interview/report.blade.php @@ -414,8 +414,8 @@ 📥 Download MP4 Video