Merge pull request 'update' (#14) from subhajit_work_22_07 into main
Reviewed-on: #14
This commit is contained in:
commit
bc0d249dba
@ -430,6 +430,8 @@ public function serveStorageFile($path)
|
|||||||
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
|
$candidatePaths[] = storage_path('app/public/' . $baseNoExt . '.mp4');
|
||||||
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
|
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.webm');
|
||||||
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
|
$candidatePaths[] = storage_path('app/private/public/' . $baseNoExt . '.mp4');
|
||||||
|
$candidatePaths[] = public_path($baseNoExt . '.webm');
|
||||||
|
$candidatePaths[] = public_path($baseNoExt . '.mp4');
|
||||||
}
|
}
|
||||||
|
|
||||||
$filePath = null;
|
$filePath = null;
|
||||||
@ -464,11 +466,50 @@ public function serveStorageFile($path)
|
|||||||
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
|
$mimeType = @mime_content_type($filePath) ?: 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->file($filePath, [
|
$fileSize = filesize($filePath);
|
||||||
|
$headers = [
|
||||||
'Content-Type' => $mimeType,
|
'Content-Type' => $mimeType,
|
||||||
'Accept-Ranges' => 'bytes',
|
'Accept-Ranges' => 'bytes',
|
||||||
'Cache-Control' => 'public, max-age=86400',
|
'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), '/');
|
$cleanPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/');
|
||||||
$streamUrl = url('/media-stream/' . $cleanPath);
|
$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['original_index'] = $idx;
|
||||||
$rec['stream_url'] = $streamUrl;
|
$rec['stream_url'] = $streamUrl;
|
||||||
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
|
$rec['download_url'] = route('interview.download-recording', $interview->id) . '?index=' . $idx;
|
||||||
|
$rec['mime_type'] = $mimeType;
|
||||||
$formatted[] = $rec;
|
$formatted[] = $rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -680,7 +725,8 @@ public function uploadTabScreenshot(Request $request, $id)
|
|||||||
|
|
||||||
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
|
$reason = $request->input('reason', 'tab_switch'); // 'call_start' or 'tab_switch'
|
||||||
$filename = 'screenshot_' . $reason . '_' . time() . '_' . rand(100, 999) . '.jpg';
|
$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);
|
$destinationPath = public_path($subDir);
|
||||||
|
|
||||||
if (!file_exists($destinationPath)) {
|
if (!file_exists($destinationPath)) {
|
||||||
@ -697,9 +743,12 @@ public function uploadTabScreenshot(Request $request, $id)
|
|||||||
if (str_contains($base64Image, ',')) {
|
if (str_contains($base64Image, ',')) {
|
||||||
$base64Image = explode(',', $base64Image)[1];
|
$base64Image = explode(',', $base64Image)[1];
|
||||||
}
|
}
|
||||||
|
$base64Image = str_replace(' ', '+', $base64Image);
|
||||||
$imageData = base64_decode($base64Image);
|
$imageData = base64_decode($base64Image);
|
||||||
file_put_contents($destinationPath . '/' . $filename, $imageData);
|
if ($imageData !== false && strlen($imageData) > 0) {
|
||||||
$url = asset($subDir . '/' . $filename);
|
file_put_contents($destinationPath . '/' . $filename, $imageData);
|
||||||
|
$url = asset($subDir . '/' . $filename);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($url) {
|
if ($url) {
|
||||||
@ -744,6 +793,7 @@ public function uploadTabScreenshot(Request $request, $id)
|
|||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'url' => $relativePath,
|
'url' => $relativePath,
|
||||||
|
'full_url' => $url,
|
||||||
'ai_verdict' => $aiVerdict,
|
'ai_verdict' => $aiVerdict,
|
||||||
'total_screenshots' => count($screenshots),
|
'total_screenshots' => count($screenshots),
|
||||||
]);
|
]);
|
||||||
@ -787,7 +837,8 @@ public function uploadRecording(Request $request, $id)
|
|||||||
$ext = 'webm';
|
$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);
|
$destinationPath = public_path($subDir);
|
||||||
if (!file_exists($destinationPath)) {
|
if (!file_exists($destinationPath)) {
|
||||||
mkdir($destinationPath, 0777, true);
|
mkdir($destinationPath, 0777, true);
|
||||||
@ -796,23 +847,25 @@ public function uploadRecording(Request $request, $id)
|
|||||||
$filename = 'recording_' . time() . '.' . $ext;
|
$filename = 'recording_' . time() . '.' . $ext;
|
||||||
$file->move($destinationPath, $filename);
|
$file->move($destinationPath, $filename);
|
||||||
$url = asset($subDir . '/' . $filename);
|
$url = asset($subDir . '/' . $filename);
|
||||||
|
$relativePath = '/' . $subDir . '/' . $filename;
|
||||||
|
|
||||||
$recordings = $interview->recordings ?? [];
|
$recordings = $interview->recordings ?? [];
|
||||||
if (!is_array($recordings)) {
|
if (!is_array($recordings)) {
|
||||||
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
|
$recordings = $interview->recording_path ? [$interview->recording_path] : [];
|
||||||
}
|
}
|
||||||
$recordings[] = [
|
$recordings[] = [
|
||||||
'url' => $url,
|
'url' => $relativePath,
|
||||||
|
'full_url' => $url,
|
||||||
'filename' => $filename,
|
'filename' => $filename,
|
||||||
'created_at' => now()->toIso8601String(),
|
'created_at' => now()->toIso8601String(),
|
||||||
];
|
];
|
||||||
|
|
||||||
$interview->update([
|
$interview->update([
|
||||||
'recording_path' => $url,
|
'recording_path' => $relativePath,
|
||||||
'recordings' => $recordings,
|
'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']);
|
return response()->json(['success' => false, 'message' => 'No video payload received']);
|
||||||
|
|||||||
@ -12,6 +12,9 @@
|
|||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
$middleware->validateCsrfTokens(except: [
|
||||||
|
'candidate/*',
|
||||||
|
]);
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'role' => \App\Http\Middleware\EnsureRole::class,
|
'role' => \App\Http\Middleware\EnsureRole::class,
|
||||||
'verify-ms-session' => \App\Http\Middleware\VerifyMicrosoftSession::class,
|
'verify-ms-session' => \App\Http\Middleware\VerifyMicrosoftSession::class,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@ -562,6 +562,7 @@ function submitSolution() {
|
|||||||
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
|
let latestCallStatus = '{{ $interview->call_status ?? "idle" }}';
|
||||||
let screenShareVideoElem = null;
|
let screenShareVideoElem = null;
|
||||||
|
|
||||||
|
// Auto-initialize persistent offscreen video element for screen share capture
|
||||||
// Auto-initialize persistent offscreen video element for screen share capture
|
// Auto-initialize persistent offscreen video element for screen share capture
|
||||||
function getScreenShareVideoElement() {
|
function getScreenShareVideoElement() {
|
||||||
if (!screenShareVideoElem) {
|
if (!screenShareVideoElem) {
|
||||||
@ -570,10 +571,13 @@ function getScreenShareVideoElement() {
|
|||||||
screenShareVideoElem.muted = true;
|
screenShareVideoElem.muted = true;
|
||||||
screenShareVideoElem.playsInline = true;
|
screenShareVideoElem.playsInline = true;
|
||||||
screenShareVideoElem.style.position = 'fixed';
|
screenShareVideoElem.style.position = 'fixed';
|
||||||
screenShareVideoElem.style.top = '-9999px';
|
screenShareVideoElem.style.top = '0px';
|
||||||
screenShareVideoElem.style.left = '-9999px';
|
screenShareVideoElem.style.left = '0px';
|
||||||
screenShareVideoElem.style.width = '1px';
|
screenShareVideoElem.style.width = '320px';
|
||||||
screenShareVideoElem.style.height = '1px';
|
screenShareVideoElem.style.height = '180px';
|
||||||
|
screenShareVideoElem.style.opacity = '0.001';
|
||||||
|
screenShareVideoElem.style.pointerEvents = 'none';
|
||||||
|
screenShareVideoElem.style.zIndex = '-9999';
|
||||||
document.body.appendChild(screenShareVideoElem);
|
document.body.appendChild(screenShareVideoElem);
|
||||||
}
|
}
|
||||||
return 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)
|
// PRIORITY 1: Capture Candidate's ACTUAL Computer System Desktop Screen (Screen share stream)
|
||||||
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
if (typeof screenShareStream !== 'undefined' && screenShareStream && screenShareStream.getVideoTracks().length > 0) {
|
||||||
const screenTrack = screenShareStream.getVideoTracks()[0];
|
const screenTrack = screenShareStream.getVideoTracks()[0];
|
||||||
if (screenTrack.readyState === 'live') {
|
if (screenTrack && screenTrack.readyState === 'live') {
|
||||||
try {
|
try {
|
||||||
const vidElem = getScreenShareVideoElement();
|
const vidElem = getScreenShareVideoElement();
|
||||||
if (vidElem.srcObject !== screenShareStream) {
|
if (vidElem.srcObject !== screenShareStream) {
|
||||||
@ -606,7 +610,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
|
|||||||
let rawW = vidElem.videoWidth || 1280;
|
let rawW = vidElem.videoWidth || 1280;
|
||||||
let rawH = vidElem.videoHeight || 720;
|
let rawH = vidElem.videoHeight || 720;
|
||||||
|
|
||||||
if (rawW > 0 && rawH > 0) {
|
if (vidElem.readyState >= 2 && rawW > 0 && rawH > 0) {
|
||||||
const maxW = 1280;
|
const maxW = 1280;
|
||||||
let targetW = rawW;
|
let targetW = rawW;
|
||||||
let targetH = rawH;
|
let targetH = rawH;
|
||||||
@ -638,8 +642,8 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
|
|||||||
|
|
||||||
if (drewScreenStream) return;
|
if (drewScreenStream) return;
|
||||||
|
|
||||||
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas
|
// PRIORITY 2: Full Rendered DOM Workspace Capture via html2canvas or Composite Fallback
|
||||||
if (typeof html2canvas !== 'undefined') {
|
if (typeof html2canvas !== 'undefined' && !document.hidden) {
|
||||||
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
|
html2canvas(document.body, { logging: false, useCORS: true, allowTaint: true, scale: 0.85 }).then(canvas => {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.90)';
|
||||||
@ -656,6 +660,7 @@ function captureAndUploadTabScreenshot(reason = 'tab_switch') {
|
|||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.log('Candidate system screenshot capture exception:', e);
|
console.log('Candidate system screenshot capture exception:', e);
|
||||||
|
fallbackCompositeSnapshot(reason);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1361,6 +1361,9 @@ function pollReviewData() {
|
|||||||
|
|
||||||
recList.forEach((r, displayIdx) => {
|
recList.forEach((r, displayIdx) => {
|
||||||
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
|
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/')) {
|
if (videoUrl && videoUrl.includes('/storage/')) {
|
||||||
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
|
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 dateObj = (r && r.created_at) ? new Date(r.created_at) : new Date();
|
||||||
const dateStr = dateObj.toLocaleDateString() + ' ' + dateObj.toLocaleTimeString();
|
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 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 += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 12px; transition: all 0.2s ease;">
|
recHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 10px; padding: 12px; margin-bottom: 12px; transition: all 0.2s ease;">
|
||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||||
@ -1379,7 +1383,11 @@ function pollReviewData() {
|
|||||||
📥 Download Video
|
📥 Download Video
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<video src="${videoUrl}" controls preload="metadata" style="width: 100%; max-height: 240px; border-radius: 8px; background: #000; outline: none; border: 1px solid rgba(255,255,255,0.1);"></video>
|
<video controls preload="metadata" style="width: 100%; max-height: 240px; border-radius: 8px; background: #000; outline: none; border: 1px solid rgba(255,255,255,0.1);">
|
||||||
|
<source src="${videoUrl}" type="${mimeType}">
|
||||||
|
<source src="${videoUrl}">
|
||||||
|
Your browser does not support video playback.
|
||||||
|
</video>
|
||||||
</div>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
recContainer.innerHTML = recHtml;
|
recContainer.innerHTML = recHtml;
|
||||||
@ -1424,7 +1432,10 @@ function pollReviewData() {
|
|||||||
</div><div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">`;
|
</div><div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px;">`;
|
||||||
|
|
||||||
shotList.forEach((s, idx) => {
|
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() : '';
|
const timeStr = (s && s.timestamp) ? new Date(s.timestamp).toLocaleTimeString() : '';
|
||||||
shotHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 8px; padding: 8px; text-align: center;">
|
shotHtml += `<div style="background: rgba(255,255,255,0.04); border: 1px solid var(--card-border); border-radius: 8px; padding: 8px; text-align: center;">
|
||||||
<img src="${url}" style="width: 100%; height: 110px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);" onclick="window.open('${url}', '_blank')" title="Click to view full image" />
|
<img src="${url}" style="width: 100%; height: 110px; object-fit: cover; border-radius: 6px; cursor: pointer; border: 1px solid rgba(255,255,255,0.1);" onclick="window.open('${url}', '_blank')" title="Click to view full image" />
|
||||||
|
|||||||
@ -414,8 +414,8 @@
|
|||||||
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
|
<a href="{{ $rec['download_url'] }}" download style="font-size: 0.7rem; color: #6366f1; font-weight: 700; text-decoration: none; padding: 3px 8px; background: rgba(99,102,241,0.1); border-radius: 4px;">📥 Download MP4 Video</a>
|
||||||
</div>
|
</div>
|
||||||
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
|
<video controls preload="metadata" style="width: 100%; max-height: 220px; border-radius: 6px; background: #000; outline: none;">
|
||||||
<source src="{{ $rec['stream_url'] }}" type="video/mp4">
|
<source src="{{ $rec['stream_url'] }}" type="{{ $rec['mime_type'] ?? 'video/webm' }}">
|
||||||
<source src="{{ $rec['stream_url'] }}" type="video/webm">
|
<source src="{{ $rec['stream_url'] }}">
|
||||||
Your browser does not support video playback.
|
Your browser does not support video playback.
|
||||||
</video>
|
</video>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user