Compare commits
2 Commits
03a36c00e6
...
121b4beaf8
| Author | SHA1 | Date | |
|---|---|---|---|
| 121b4beaf8 | |||
| dfbbd1263d |
38
app/Actions/Interview/FinalizeRecordingAction.php
Normal file
38
app/Actions/Interview/FinalizeRecordingAction.php
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
201
app/Actions/Interview/MergeRecordingChunksAction.php
Normal file
201
app/Actions/Interview/MergeRecordingChunksAction.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Actions/Interview/StoreRecordingChunkAction.php
Normal file
34
app/Actions/Interview/StoreRecordingChunkAction.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
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\Interview;
|
||||||
use App\Models\InterviewWarning;
|
use App\Models\InterviewWarning;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -587,6 +592,7 @@ private function getFormattedRecordings($interview)
|
|||||||
$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;
|
$rec['mime_type'] = $mimeType;
|
||||||
|
$rec['duration'] = isset($rec['duration']) ? (int) $rec['duration'] : 0;
|
||||||
$formatted[] = $rec;
|
$formatted[] = $rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -945,9 +951,14 @@ public function toggleTabScreenshot(Request $request, $id)
|
|||||||
*/
|
*/
|
||||||
public function uploadRecording(Request $request, $id)
|
public function uploadRecording(Request $request, $id)
|
||||||
{
|
{
|
||||||
|
try {
|
||||||
$interview = Interview::where('id', $id)
|
$interview = Interview::where('id', $id)
|
||||||
->orWhere('submission_unique_id', $id)
|
->orWhere('submission_unique_id', $id)
|
||||||
->firstOrFail();
|
->first();
|
||||||
|
|
||||||
|
if (!$interview) {
|
||||||
|
return response()->json(['success' => false, 'message' => 'Interview session not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
if ($request->hasFile('video')) {
|
if ($request->hasFile('video')) {
|
||||||
$file = $request->file('video');
|
$file = $request->file('video');
|
||||||
@ -956,8 +967,9 @@ public function uploadRecording(Request $request, $id)
|
|||||||
$ext = 'webm';
|
$ext = 'webm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$baseFolder = config('interview.recordings.base_folder', 'uploads/candidate_recordings');
|
||||||
$uniqueFolder = $interview->submission_unique_id ?: $interview->id;
|
$uniqueFolder = $interview->submission_unique_id ?: $interview->id;
|
||||||
$subDir = 'uploads/candidate_recordings/' . $uniqueFolder;
|
$subDir = trim($baseFolder, '/') . '/' . $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);
|
||||||
@ -987,7 +999,69 @@ public function uploadRecording(Request $request, $id)
|
|||||||
return response()->json(['success' => true, 'path' => $relativePath, 'url' => $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 (file may exceed server upload size limit).'], 422);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::error('Recording upload failed: ' . $e->getMessage());
|
||||||
|
return response()->json(['success' => false, 'message' => 'Server error saving recording: ' . $e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
46
app/Http/Requests/Interview/UploadRecordingChunkRequest.php
Normal file
46
app/Http/Requests/Interview/UploadRecordingChunkRequest.php
Normal 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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Responses/Interview/RecordingFinalizedResponse.php
Normal file
37
app/Http/Responses/Interview/RecordingFinalizedResponse.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/Jobs/MergeRecordingChunksJob.php
Normal file
98
app/Jobs/MergeRecordingChunksJob.php
Normal 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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@ -446,19 +446,18 @@ export function broadcastCandidateScreenStream(stream) {
|
|||||||
|
|
||||||
const targetPeerIds = new Set();
|
const targetPeerIds = new Set();
|
||||||
|
|
||||||
// Collect dedicated screen receiver targets from active interviewers
|
// Collect dedicated screen receiver targets and main interviewer peers
|
||||||
if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) {
|
if (typeof window.latestActivePeers !== 'undefined' && Array.isArray(window.latestActivePeers)) {
|
||||||
window.latestActivePeers.forEach(p => {
|
window.latestActivePeers.forEach(p => {
|
||||||
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
|
if (p.peer_id && (p.peer_id.startsWith('interviewer_') || p.role === 'admin' || p.role === 'interviewer')) {
|
||||||
const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_');
|
const screenTargetId = p.peer_id.replace('interviewer_', 'interviewer_screen_');
|
||||||
targetPeerIds.add(screenTargetId);
|
targetPeerIds.add(screenTargetId);
|
||||||
|
targetPeerIds.add(p.peer_id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetPeerIds.size === 0) {
|
|
||||||
targetPeerIds.add(legacyScreenPeerId);
|
targetPeerIds.add(legacyScreenPeerId);
|
||||||
}
|
|
||||||
|
|
||||||
const makeCalls = (peer) => {
|
const makeCalls = (peer) => {
|
||||||
targetPeerIds.forEach(targetId => {
|
targetPeerIds.forEach(targetId => {
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { initMeteredIceServers } from './metered.js';
|
import { initMeteredIceServers } from './metered.js';
|
||||||
import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js';
|
import { globalAudioMonitor, AudioActivityMonitor, SPEAKING_ACTIVE_CLASSES } from './audio-monitor.js';
|
||||||
|
import { RecordingCompositor } from './recording-compositor.js';
|
||||||
|
|
||||||
// --- Global Audio Ringtone Synthesizer Engine ---
|
// --- Global Audio Ringtone Synthesizer Engine ---
|
||||||
export class CallRingtoneEngine {
|
export class CallRingtoneEngine {
|
||||||
@ -982,6 +983,77 @@ export function pollReviewData() {
|
|||||||
recList = [{ url: recPath, created_at: new Date().toISOString(), original_index: 0 }];
|
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…</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">×</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) {
|
if (recList.length > 0) {
|
||||||
recList.sort((a, b) => {
|
recList.sort((a, b) => {
|
||||||
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
const timeA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||||
@ -989,12 +1061,11 @@ export function pollReviewData() {
|
|||||||
return timeB - timeA;
|
return timeB - timeA;
|
||||||
});
|
});
|
||||||
|
|
||||||
let recHtml = `<div class="text-xs text-sky-400 font-bold mb-2.5 flex justify-between items-center">
|
let recHtml = processingBannerHtml + `<div class="space-y-2">`;
|
||||||
<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>`;
|
|
||||||
|
|
||||||
recList.forEach((r, displayIdx) => {
|
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);
|
let videoUrl = typeof r === 'string' ? r : (r.stream_url || r.url || recPath);
|
||||||
if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
|
if (videoUrl && !videoUrl.startsWith('http') && !videoUrl.startsWith('/')) {
|
||||||
videoUrl = '/' + videoUrl;
|
videoUrl = '/' + videoUrl;
|
||||||
@ -1002,32 +1073,84 @@ export function pollReviewData() {
|
|||||||
if (videoUrl && videoUrl.includes('/storage/')) {
|
if (videoUrl && videoUrl.includes('/storage/')) {
|
||||||
videoUrl = videoUrl.replace('/storage/', '/media-stream/');
|
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 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 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">
|
recHtml += `
|
||||||
<div class="flex justify-between items-center mb-2">
|
<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>
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<strong class="text-white text-xs">📹 Session Recording #${recList.length - displayIdx}</strong>
|
<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">
|
||||||
<span class="text-[0.7rem] text-slate-400 ml-2">📅 ${dateStr}</span>
|
<i class="fa-solid fa-video text-xs"></i>
|
||||||
</div>
|
</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">
|
<div class="min-w-0">
|
||||||
📥 Download Video
|
<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>
|
</a>
|
||||||
</div>
|
</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>`;
|
</div>`;
|
||||||
});
|
});
|
||||||
|
recHtml += `</div>`;
|
||||||
recContainer.innerHTML = 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 {
|
} 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>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2095,6 +2218,10 @@ export function leaveInterviewerCall(isEndAll = false) {
|
|||||||
|
|
||||||
const recStopBtn = document.getElementById('btn-stop-recording');
|
const recStopBtn = document.getElementById('btn-stop-recording');
|
||||||
if (recStopBtn) recStopBtn.style.display = 'none';
|
if (recStopBtn) recStopBtn.style.display = 'none';
|
||||||
|
|
||||||
|
const recUploadBtn = document.getElementById('btn-uploading-recording');
|
||||||
|
if (recUploadBtn) recUploadBtn.style.display = 'none';
|
||||||
|
|
||||||
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
|
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
|
||||||
try { adminMediaRecorder.stop(); } catch(e) {}
|
try { adminMediaRecorder.stop(); } catch(e) {}
|
||||||
}
|
}
|
||||||
@ -2140,10 +2267,199 @@ export function endCallForAll() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MediaRecorder recording
|
// MediaRecorder, Chunked Uploader & Recording Compositor
|
||||||
let adminMediaRecorder = null;
|
let adminMediaRecorder = null;
|
||||||
let adminRecordedChunks = [];
|
let recordingCompositor = null;
|
||||||
let recAudioContext = 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() {
|
export function startCallRecording() {
|
||||||
if (!currentInterviewId) {
|
if (!currentInterviewId) {
|
||||||
@ -2156,75 +2472,88 @@ export function startCallRecording() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const candVid = document.getElementById('interviewer-cand-video');
|
const candVid = document.getElementById('interviewer-cand-video');
|
||||||
const candidateStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
const candNameEl = document.getElementById('modal-cand-name');
|
||||||
const streamToRecord = new MediaStream();
|
const candidateName = candNameEl ? candNameEl.innerText.trim() : 'Candidate';
|
||||||
|
|
||||||
if (candidateStream) {
|
// 1. Initialize RecordingCompositor (1280x720 @ 24fps for ultra-fast, smooth streaming)
|
||||||
candidateStream.getVideoTracks().forEach(track => {
|
recordingCompositor = new RecordingCompositor({
|
||||||
if (track.readyState === 'live') {
|
width: 1280,
|
||||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
height: 720,
|
||||||
|
fps: 24,
|
||||||
|
getParticipantsData: () => {
|
||||||
|
const candStream = (candVid && candVid.srcObject) ? candVid.srcObject : null;
|
||||||
|
const candCamIcon = document.getElementById('cand-cam-status');
|
||||||
|
const candMicIcon = document.getElementById('cand-mic-status');
|
||||||
|
const isCandCamOn = candCamIcon ? candCamIcon.classList.contains('fa-video') : true;
|
||||||
|
const isCandMicOn = candMicIcon ? candMicIcon.classList.contains('fa-microphone') : true;
|
||||||
|
|
||||||
|
const panelistsList = [];
|
||||||
|
const panelistStreamsList = [];
|
||||||
|
|
||||||
|
panelistMeshTiles.forEach((stream, pid) => {
|
||||||
|
const peerInfo = latestActivePeers.find(p => p.peer_id === pid);
|
||||||
|
const pMicEl = document.getElementById('mesh-mic-' + pid);
|
||||||
|
const pCamEl = document.getElementById('mesh-cam-' + pid);
|
||||||
|
const isMicOn = pMicEl ? pMicEl.classList.contains('fa-microphone') : (peerInfo ? isTrueVal(peerInfo.mic_on) : true);
|
||||||
|
const isCamOn = pCamEl ? pCamEl.classList.contains('fa-video') : (peerInfo ? isTrueVal(peerInfo.cam_on) : true);
|
||||||
|
const pName = peerInfo ? (peerInfo.name || 'Panelist') : 'Panelist';
|
||||||
|
const pRole = peerInfo ? (peerInfo.role === 'admin' ? 'Admin' : 'Panelist') : 'Panelist';
|
||||||
|
|
||||||
|
panelistsList.push({
|
||||||
|
peerId: pid,
|
||||||
|
name: pName,
|
||||||
|
role: pRole,
|
||||||
|
micOn: isMicOn,
|
||||||
|
camOn: isCamOn
|
||||||
|
});
|
||||||
|
|
||||||
|
if (stream) {
|
||||||
|
panelistStreamsList.push({ peerId: pid, stream: stream });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (streamToRecord.getVideoTracks().length === 0 && interviewerLocalStream) {
|
return {
|
||||||
interviewerLocalStream.getVideoTracks().forEach(track => {
|
candidateStream: candStream,
|
||||||
if (track.readyState === 'live') {
|
candidateName: candidateName,
|
||||||
try { streamToRecord.addTrack(track); } catch(e) {}
|
candidateCamOn: isCandCamOn,
|
||||||
|
candidateMicOn: isCandMicOn,
|
||||||
|
selfStream: interviewerLocalStream,
|
||||||
|
selfName: window.currentUserName || 'Interviewer',
|
||||||
|
selfCamOn: isInterviewerCamOn,
|
||||||
|
selfMicOn: isInterviewerMicOn,
|
||||||
|
panelists: panelistsList,
|
||||||
|
panelistStreams: panelistStreamsList
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const candAudioTracks = candidateStream ? candidateStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
|
|
||||||
const adminAudioTracks = interviewerLocalStream ? interviewerLocalStream.getAudioTracks().filter(t => t.readyState === 'live') : [];
|
|
||||||
|
|
||||||
if (candAudioTracks.length > 0 || adminAudioTracks.length > 0) {
|
|
||||||
try {
|
try {
|
||||||
const AudioContextClass = window.AudioContext || window.webkitAudioContext;
|
recordingCompositor.start();
|
||||||
if (recAudioContext) {
|
const streamToRecord = recordingCompositor.getStream();
|
||||||
try { recAudioContext.close(); } catch(e) {}
|
|
||||||
}
|
|
||||||
recAudioContext = new AudioContextClass();
|
|
||||||
const audioDestination = recAudioContext.createMediaStreamDestination();
|
|
||||||
|
|
||||||
if (candAudioTracks.length > 0) {
|
currentRecordingSessionId = 'rec_' + currentInterviewId + '_' + Date.now() + '_' + Math.random().toString(36).substring(2, 7);
|
||||||
const candAudioStream = new MediaStream(candAudioTracks);
|
currentChunkIndex = 0;
|
||||||
const candSource = recAudioContext.createMediaStreamSource(candAudioStream);
|
chunkUploadQueue = [];
|
||||||
candSource.connect(audioDestination);
|
isStopRecordingRequested = false;
|
||||||
}
|
totalRecordedChunksCount = 0;
|
||||||
|
isFinalizingRecording = false;
|
||||||
|
activeUploadsCount = 0;
|
||||||
|
currentSessionDuration = 0;
|
||||||
|
|
||||||
if (adminAudioTracks.length > 0) {
|
const existingCards = document.querySelectorAll('#modal-recordings-container .recording-card, #modal-recordings-container video');
|
||||||
const adminAudioStream = new MediaStream(adminAudioTracks);
|
recordingKnownCountBeforeProcessing = existingCards ? existingCards.length : 0;
|
||||||
const adminSource = recAudioContext.createMediaStreamSource(adminAudioStream);
|
|
||||||
adminSource.connect(audioDestination);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mixedAudioTrack = audioDestination.stream.getAudioTracks()[0];
|
// Controlled bitrate configuration (1.2 Mbps video, 64 kbps audio)
|
||||||
if (mixedAudioTrack) {
|
let options = {
|
||||||
streamToRecord.addTrack(mixedAudioTrack);
|
videoBitsPerSecond: 1200000,
|
||||||
}
|
audioBitsPerSecond: 64000
|
||||||
} catch(e) {
|
};
|
||||||
const fallbackAudio = candAudioTracks[0] || adminAudioTracks[0];
|
|
||||||
if (fallbackAudio) {
|
|
||||||
try { streamToRecord.addTrack(fallbackAudio); } catch(err) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTracks = streamToRecord.getTracks().filter(t => t.readyState === 'live');
|
|
||||||
if (activeTracks.length === 0) {
|
|
||||||
alert('No active video/audio stream available to record. Please start or join the call first.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
adminRecordedChunks = [];
|
|
||||||
try {
|
|
||||||
let options = {};
|
|
||||||
if (typeof MediaRecorder.isTypeSupported === 'function') {
|
if (typeof MediaRecorder.isTypeSupported === 'function') {
|
||||||
if (MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')) {
|
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')) {
|
} else if (MediaRecorder.isTypeSupported('video/webm')) {
|
||||||
options = { mimeType: 'video/webm' };
|
options.mimeType = 'video/webm';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2234,62 +2563,93 @@ export function startCallRecording() {
|
|||||||
adminMediaRecorder = new MediaRecorder(streamToRecord);
|
adminMediaRecorder = new MediaRecorder(streamToRecord);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Real-time chunk slice handler
|
||||||
adminMediaRecorder.ondataavailable = function(e) {
|
adminMediaRecorder.ondataavailable = function(e) {
|
||||||
if (e.data && e.data.size > 0) {
|
if (e.data && e.data.size > 0) {
|
||||||
adminRecordedChunks.push(e.data);
|
queueChunkUpload(e.data, false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
adminMediaRecorder.onstop = function() {
|
adminMediaRecorder.onstop = function() {
|
||||||
if (recAudioContext) {
|
if (recordingCompositor) {
|
||||||
try { recAudioContext.close(); } catch(e) {}
|
recordingCompositor.stop();
|
||||||
recAudioContext = null;
|
recordingCompositor = null;
|
||||||
}
|
}
|
||||||
if (adminRecordedChunks.length === 0) return;
|
// Signal finalization after the final slice is recorded
|
||||||
const recMime = adminMediaRecorder.mimeType || 'video/webm';
|
queueChunkUpload(null, true, currentSessionDuration);
|
||||||
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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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 recStartBtn = document.getElementById('btn-start-recording');
|
||||||
const recStopBtn = document.getElementById('btn-stop-recording');
|
const recStopBtn = document.getElementById('btn-stop-recording');
|
||||||
if (recStartBtn) recStartBtn.style.display = 'none';
|
if (recStartBtn) recStartBtn.style.display = 'none';
|
||||||
if (recStopBtn) recStopBtn.style.display = 'inline-flex';
|
if (recStopBtn) {
|
||||||
|
recStopBtn.style.display = 'inline-flex';
|
||||||
if (typeof window.Swal !== 'undefined') {
|
recStopBtn.disabled = false;
|
||||||
window.Swal.fire({
|
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';
|
||||||
icon: 'info',
|
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>';
|
||||||
title: 'Recording Started',
|
|
||||||
text: 'Interview call recording is active (silent mode - candidate is not notified).',
|
|
||||||
toast: true,
|
|
||||||
position: 'top-end',
|
|
||||||
timer: 3000,
|
|
||||||
showConfirmButton: false
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (recordingTimerInterval) clearInterval(recordingTimerInterval);
|
||||||
|
recordingTimerInterval = setInterval(() => {
|
||||||
|
const timerEl = document.getElementById('rec-live-timer');
|
||||||
|
if (timerEl && recordingStartTime) {
|
||||||
|
const totalSec = Math.floor((Date.now() - recordingStartTime) / 1000);
|
||||||
|
const mins = String(Math.floor(totalSec / 60)).padStart(2, '0');
|
||||||
|
const secs = String(totalSec % 60).padStart(2, '0');
|
||||||
|
timerEl.innerText = `(${mins}:${secs})`;
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
|
if (recordingCompositor) {
|
||||||
|
recordingCompositor.stop();
|
||||||
|
recordingCompositor = null;
|
||||||
|
}
|
||||||
alert('Recording failed to initialize: ' + e.message);
|
alert('Recording failed to initialize: ' + e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopCallRecording() {
|
export function stopCallRecording() {
|
||||||
|
if (recordingTimerInterval) {
|
||||||
|
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') {
|
if (adminMediaRecorder && adminMediaRecorder.state !== 'inactive') {
|
||||||
adminMediaRecorder.stop();
|
adminMediaRecorder.stop();
|
||||||
}
|
}
|
||||||
const recStartBtn = document.getElementById('btn-start-recording');
|
if (recordingCompositor) {
|
||||||
const recStopBtn = document.getElementById('btn-stop-recording');
|
recordingCompositor.stop();
|
||||||
if (recStartBtn) recStartBtn.style.display = 'inline-flex';
|
recordingCompositor = null;
|
||||||
if (recStopBtn) recStopBtn.style.display = 'none';
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uploadRecordedBlob(blob, ext = 'webm') {
|
export function uploadRecordedBlob(blob, ext = 'webm') {
|
||||||
if (!currentInterviewId) return;
|
if (!currentInterviewId && typeof document !== 'undefined') {
|
||||||
|
currentInterviewId = document.body?.dataset?.interviewId || window.interviewId;
|
||||||
|
}
|
||||||
|
if (!currentInterviewId) {
|
||||||
|
console.error('Cannot upload recording: missing interview ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || window.csrfToken;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@ -2298,16 +2658,44 @@ export function uploadRecordedBlob(blob, ext = 'webm') {
|
|||||||
|
|
||||||
fetch(`/candidate/upload-recording/${currentInterviewId}`, {
|
fetch(`/candidate/upload-recording/${currentInterviewId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfToken,
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
body: formData
|
body: formData
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(async r => {
|
||||||
|
const contentType = r.headers.get('content-type') || '';
|
||||||
|
const isJson = contentType.includes('application/json');
|
||||||
|
if (!r.ok) {
|
||||||
|
const errText = await r.text();
|
||||||
|
let errMsg = `Upload failed (HTTP ${r.status})`;
|
||||||
|
if (isJson) {
|
||||||
|
try {
|
||||||
|
const errJson = JSON.parse(errText);
|
||||||
|
if (errJson && errJson.message) errMsg = errJson.message;
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
if (isJson) {
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
const text = await r.text();
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch(e) {
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
})
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.success) {
|
if (res && res.success) {
|
||||||
if (typeof window.Swal !== 'undefined') {
|
if (typeof window.Swal !== 'undefined') {
|
||||||
window.Swal.fire({
|
window.Swal.fire({
|
||||||
icon: 'success',
|
icon: 'success',
|
||||||
title: 'Recording Saved!',
|
title: 'Recording Saved!',
|
||||||
text: 'Call recording saved successfully under Candidate Profile and Report.',
|
text: 'Call recording saved successfully to server and candidate report.',
|
||||||
toast: true,
|
toast: true,
|
||||||
position: 'top-end',
|
position: 'top-end',
|
||||||
timer: 3500,
|
timer: 3500,
|
||||||
@ -2315,9 +2703,34 @@ export function uploadRecordedBlob(blob, ext = 'webm') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
pollReviewData();
|
pollReviewData();
|
||||||
|
} else if (res && res.message) {
|
||||||
|
if (typeof window.Swal !== 'undefined') {
|
||||||
|
window.Swal.fire({
|
||||||
|
icon: 'warning',
|
||||||
|
title: 'Recording Notice',
|
||||||
|
text: res.message,
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
timer: 4000,
|
||||||
|
showConfirmButton: false
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.log('Recording upload error:', err));
|
.catch(err => {
|
||||||
|
console.error('Recording upload error:', err);
|
||||||
|
if (typeof window.Swal !== 'undefined') {
|
||||||
|
window.Swal.fire({
|
||||||
|
icon: 'error',
|
||||||
|
title: 'Recording Upload Failed',
|
||||||
|
text: err.message || 'Failed to save recording to server.',
|
||||||
|
toast: true,
|
||||||
|
position: 'top-end',
|
||||||
|
timer: 4500,
|
||||||
|
showConfirmButton: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleInterviewerMic() {
|
export function toggleInterviewerMic() {
|
||||||
|
|||||||
685
resources/js/recording-compositor.js
Normal file
685
resources/js/recording-compositor.js
Normal file
@ -0,0 +1,685 @@
|
|||||||
|
/**
|
||||||
|
* Recording Compositor & Multi-Track Audio Mixer Engine
|
||||||
|
*
|
||||||
|
* Creates a unified 1920x1080 composite video stream from multiple WebRTC video sources
|
||||||
|
* (Candidate Webcam, Screen Share, Self Interviewer, Remote Mesh Panelists)
|
||||||
|
* matching the interviewer layout:
|
||||||
|
* - Large Main Area: Candidate Webcam (or Screen Share when active)
|
||||||
|
* - Right Sidebar: Stacked Peers (and Candidate Webcam at top when screen share is active)
|
||||||
|
* - Camera-off Avatar Placeholders with initials and badges
|
||||||
|
* - Bottom Overflow Row: Placed horizontally below main area if peers > 3
|
||||||
|
* - AudioContext multi-track audio mixing
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class RecordingCompositor {
|
||||||
|
/**
|
||||||
|
* @param {{width:number, height:number, fps: number, getParticipantsData: () => {}}} options
|
||||||
|
*/
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.width = options.width || 1920;
|
||||||
|
this.height = options.height || 1080;
|
||||||
|
this.fps = options.fps || 30;
|
||||||
|
|
||||||
|
this.canvas = document.createElement("canvas");
|
||||||
|
this.canvas.width = this.width;
|
||||||
|
this.canvas.height = this.height;
|
||||||
|
this.ctx = this.canvas.getContext("2d", { alpha: false });
|
||||||
|
|
||||||
|
this.audioCtx = null;
|
||||||
|
this.audioDestination = null;
|
||||||
|
this.audioSourceNodes = [];
|
||||||
|
this.mixedAudioTrack = null;
|
||||||
|
|
||||||
|
this.animationFrameId = null;
|
||||||
|
this.isRunning = false;
|
||||||
|
this.startTime = null;
|
||||||
|
this.speakingStates = new Map();
|
||||||
|
|
||||||
|
// Metadata provider function
|
||||||
|
this.getParticipantsData = options.getParticipantsData || (() => ({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.isRunning) return;
|
||||||
|
this.isRunning = true;
|
||||||
|
this.startTime = Date.now();
|
||||||
|
|
||||||
|
this.initAudioMixer();
|
||||||
|
this.renderLoop();
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.isRunning = false;
|
||||||
|
if (this.animationFrameId) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioSourceNodes.length > 0) {
|
||||||
|
this.audioSourceNodes.forEach((node) => {
|
||||||
|
try {
|
||||||
|
node.disconnect();
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
this.audioSourceNodes = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioCtx) {
|
||||||
|
try {
|
||||||
|
this.audioCtx.close();
|
||||||
|
} catch (e) {}
|
||||||
|
this.audioCtx = null;
|
||||||
|
}
|
||||||
|
this.audioDestination = null;
|
||||||
|
this.mixedAudioTrack = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStream() {
|
||||||
|
const videoTrack = this.canvas
|
||||||
|
.captureStream(this.fps)
|
||||||
|
.getVideoTracks()[0];
|
||||||
|
const tracks = [];
|
||||||
|
if (videoTrack) tracks.push(videoTrack);
|
||||||
|
if (this.mixedAudioTrack) tracks.push(this.mixedAudioTrack);
|
||||||
|
return new MediaStream(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSpeakingState(key, isSpeaking) {
|
||||||
|
this.speakingStates.set(key, Boolean(isSpeaking));
|
||||||
|
}
|
||||||
|
|
||||||
|
initAudioMixer() {
|
||||||
|
try {
|
||||||
|
const AudioCtxClass =
|
||||||
|
window.AudioContext || window.webkitAudioContext;
|
||||||
|
if (!AudioCtxClass) return;
|
||||||
|
|
||||||
|
this.audioCtx = new AudioCtxClass();
|
||||||
|
this.audioDestination =
|
||||||
|
this.audioCtx.createMediaStreamDestination();
|
||||||
|
|
||||||
|
const data = this.getParticipantsData();
|
||||||
|
const audioStreams = [];
|
||||||
|
|
||||||
|
// 1. Candidate Audio
|
||||||
|
if (
|
||||||
|
data.candidateStream &&
|
||||||
|
data.candidateStream.getAudioTracks().length > 0
|
||||||
|
) {
|
||||||
|
audioStreams.push(data.candidateStream);
|
||||||
|
}
|
||||||
|
// 2. Interviewer Self Audio
|
||||||
|
if (
|
||||||
|
data.selfStream &&
|
||||||
|
data.selfStream.getAudioTracks().length > 0
|
||||||
|
) {
|
||||||
|
audioStreams.push(data.selfStream);
|
||||||
|
}
|
||||||
|
// 3. Panelist Mesh Audios
|
||||||
|
if (data.panelistStreams && Array.isArray(data.panelistStreams)) {
|
||||||
|
data.panelistStreams.forEach((ps) => {
|
||||||
|
if (
|
||||||
|
ps &&
|
||||||
|
ps.stream &&
|
||||||
|
ps.stream.getAudioTracks().length > 0
|
||||||
|
) {
|
||||||
|
audioStreams.push(ps.stream);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
audioStreams.forEach((stream) => {
|
||||||
|
try {
|
||||||
|
const source =
|
||||||
|
this.audioCtx.createMediaStreamSource(stream);
|
||||||
|
source.connect(this.audioDestination);
|
||||||
|
this.audioSourceNodes.push(source);
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mixedAudioTrack =
|
||||||
|
this.audioDestination.stream.getAudioTracks()[0] || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("[RecordingCompositor] Audio mixing error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoop() {
|
||||||
|
if (!this.isRunning) return;
|
||||||
|
this.renderFrame();
|
||||||
|
this.animationFrameId = requestAnimationFrame(() => this.renderLoop());
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFrame() {
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const W = this.width;
|
||||||
|
const H = this.height;
|
||||||
|
|
||||||
|
// Dark background
|
||||||
|
ctx.fillStyle = "#090d16";
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
const data = this.getParticipantsData();
|
||||||
|
|
||||||
|
// 1. Candidate Feed
|
||||||
|
const candVideo = document.getElementById("interviewer-cand-video");
|
||||||
|
const isCandCamOn = data.candidateCamOn !== false;
|
||||||
|
const isCandMicOn = data.candidateMicOn !== false;
|
||||||
|
const candidateFeed = {
|
||||||
|
id: "candidate",
|
||||||
|
type: "candidate",
|
||||||
|
name: data.candidateName || "Candidate",
|
||||||
|
initials: this.extractInitials(data.candidateName || "Candidate"),
|
||||||
|
video: candVideo,
|
||||||
|
hasVideo: isCandCamOn && this.isVideoPlaying(candVideo),
|
||||||
|
isCamOn: isCandCamOn,
|
||||||
|
isMicOn: isCandMicOn,
|
||||||
|
isSpeaking:
|
||||||
|
this.speakingStates.get("interviewer-cand-video") || false,
|
||||||
|
badge: "Candidate",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Screen Share Feed
|
||||||
|
const screenVideo = document.getElementById("interviewer-screen-video");
|
||||||
|
const isScreenSharing = this.isScreenShareActive(screenVideo);
|
||||||
|
const screenFeed = {
|
||||||
|
id: "screen",
|
||||||
|
type: "screen",
|
||||||
|
name: "Candidate Live Screen",
|
||||||
|
initials: "SCR",
|
||||||
|
video: screenVideo,
|
||||||
|
hasVideo: isScreenSharing,
|
||||||
|
isCamOn: true,
|
||||||
|
isMicOn: false,
|
||||||
|
isSpeaking: false,
|
||||||
|
badge: "Shared Screen",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Self Interviewer Feed
|
||||||
|
const selfVideo = document.getElementById("interviewer-self-video");
|
||||||
|
const isSelfCamOn = data.selfCamOn !== false;
|
||||||
|
const isSelfMicOn = data.selfMicOn !== false;
|
||||||
|
const selfFeed = {
|
||||||
|
id: "self",
|
||||||
|
type: "interviewer",
|
||||||
|
name: data.selfName
|
||||||
|
? `${data.selfName} (You)`
|
||||||
|
: "Interviewer (You)",
|
||||||
|
initials: this.extractInitials(data.selfName || "IV"),
|
||||||
|
video: selfVideo,
|
||||||
|
hasVideo: isSelfCamOn && this.isVideoPlaying(selfVideo),
|
||||||
|
isCamOn: isSelfCamOn,
|
||||||
|
isMicOn: isSelfMicOn,
|
||||||
|
isSpeaking: false,
|
||||||
|
badge: "Interviewer",
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Panelist Feeds
|
||||||
|
const panelistFeeds = [];
|
||||||
|
if (data.panelists && Array.isArray(data.panelists)) {
|
||||||
|
data.panelists.forEach((p) => {
|
||||||
|
const pVid = document.getElementById(
|
||||||
|
"panelist-video-" + p.peerId,
|
||||||
|
);
|
||||||
|
const isCamOn = p.camOn !== false;
|
||||||
|
const isMicOn = p.micOn !== false;
|
||||||
|
panelistFeeds.push({
|
||||||
|
id: p.peerId,
|
||||||
|
type: "panelist",
|
||||||
|
name: p.name || "Panelist",
|
||||||
|
initials: this.extractInitials(p.name || "Panelist"),
|
||||||
|
video: pVid,
|
||||||
|
hasVideo: isCamOn && this.isVideoPlaying(pVid),
|
||||||
|
isCamOn: isCamOn,
|
||||||
|
isMicOn: isMicOn,
|
||||||
|
isSpeaking: false,
|
||||||
|
badge: p.role || "Panelist",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layout determination
|
||||||
|
let mainFeed;
|
||||||
|
let secondaryFeeds = [];
|
||||||
|
|
||||||
|
if (isScreenSharing) {
|
||||||
|
// When screen share is active:
|
||||||
|
// Main = Screen Share
|
||||||
|
// Right Sidebar Top = Candidate Webcam
|
||||||
|
// Followed by Self and Panelists
|
||||||
|
mainFeed = screenFeed;
|
||||||
|
secondaryFeeds = [candidateFeed, selfFeed, ...panelistFeeds];
|
||||||
|
} else {
|
||||||
|
// Normal Call:
|
||||||
|
// Main = Candidate Webcam
|
||||||
|
// Right Sidebar = Self and Panelists
|
||||||
|
mainFeed = candidateFeed;
|
||||||
|
secondaryFeeds = [selfFeed, ...panelistFeeds];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute geometry with strict 16:9 aspect ratio preservation for every tile
|
||||||
|
const gap = 16;
|
||||||
|
const padX = 24;
|
||||||
|
const totalSecondary = secondaryFeeds.length;
|
||||||
|
|
||||||
|
if (totalSecondary === 0) {
|
||||||
|
// Case 0: Only Main Feed (Full Canvas 16:9)
|
||||||
|
const mainH = 1032;
|
||||||
|
const mainW = Math.round(mainH * (16 / 9)); // 1835px
|
||||||
|
const mainX = Math.round((W - mainW) / 2);
|
||||||
|
const mainY = Math.round((H - mainH) / 2);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
mainFeed,
|
||||||
|
{ x: mainX, y: mainY, w: mainW, h: mainH },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} else if (totalSecondary === 1) {
|
||||||
|
// Case 1: Main + 1 Sidebar Tile (Both 16:9)
|
||||||
|
const sideW = 480;
|
||||||
|
const sideH = Math.round(sideW * (9 / 16)); // 270px
|
||||||
|
const mainW = 1376;
|
||||||
|
const mainH = Math.round(mainW * (9 / 16)); // 774px
|
||||||
|
|
||||||
|
const mainX = padX;
|
||||||
|
const mainY = Math.round((H - mainH) / 2);
|
||||||
|
const sideX = padX + mainW + gap;
|
||||||
|
const sideY = Math.round((H - sideH) / 2);
|
||||||
|
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
mainFeed,
|
||||||
|
{ x: mainX, y: mainY, w: mainW, h: mainH },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
secondaryFeeds[0],
|
||||||
|
{ x: sideX, y: sideY, w: sideW, h: sideH },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else if (totalSecondary === 2) {
|
||||||
|
// Case 2: Main + 2 Sidebar Tiles (All 16:9)
|
||||||
|
const sideW = 480;
|
||||||
|
const sideH = Math.round(sideW * (9 / 16)); // 270px
|
||||||
|
const mainW = 1376;
|
||||||
|
const mainH = Math.round(mainW * (9 / 16)); // 774px
|
||||||
|
|
||||||
|
const mainX = padX;
|
||||||
|
const mainY = Math.round((H - mainH) / 2);
|
||||||
|
const sideX = padX + mainW + gap;
|
||||||
|
|
||||||
|
const totalSideH = 2 * sideH + gap; // 556px
|
||||||
|
const sideYStart = Math.round((H - totalSideH) / 2);
|
||||||
|
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
mainFeed,
|
||||||
|
{ x: mainX, y: mainY, w: mainW, h: mainH },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
secondaryFeeds[0],
|
||||||
|
{ x: sideX, y: sideYStart, w: sideW, h: sideH },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
secondaryFeeds[1],
|
||||||
|
{ x: sideX, y: sideYStart + sideH + gap, w: sideW, h: sideH },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
} else if (totalSecondary === 3) {
|
||||||
|
// Case 3: Main + 3 Sidebar Tiles (All 16:9)
|
||||||
|
const sideH = 330;
|
||||||
|
const sideW = Math.round(sideH * (16 / 9)); // 587px
|
||||||
|
const totalSideH = 3 * sideH + 2 * gap; // 1022px
|
||||||
|
const topY = Math.round((H - totalSideH) / 2); // 29px
|
||||||
|
|
||||||
|
const sideX = W - padX - sideW;
|
||||||
|
const mainW = sideX - gap - padX; // 1270px
|
||||||
|
const mainH = Math.round(mainW * (9 / 16)); // 714px
|
||||||
|
|
||||||
|
const mainX = padX;
|
||||||
|
const mainY = topY;
|
||||||
|
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
mainFeed,
|
||||||
|
{ x: mainX, y: mainY, w: mainW, h: mainH },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const feed = secondaryFeeds[i];
|
||||||
|
const tileY = topY + i * (sideH + gap);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
feed,
|
||||||
|
{ x: sideX, y: tileY, w: sideW, h: sideH },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Case 4: Main + 3 Sidebar Tiles + Bottom Overflow Row (All 16:9)
|
||||||
|
const sideH = 330;
|
||||||
|
const sideW = Math.round(sideH * (16 / 9)); // 587px
|
||||||
|
const totalSideH = 3 * sideH + 2 * gap; // 1022px
|
||||||
|
const topY = Math.round((H - totalSideH) / 2); // 29px
|
||||||
|
|
||||||
|
const sideX = W - padX - sideW;
|
||||||
|
const mainW = sideX - gap - padX; // 1270px
|
||||||
|
const mainH = Math.round(mainW * (9 / 16)); // 714px
|
||||||
|
|
||||||
|
const mainX = padX;
|
||||||
|
const mainY = topY;
|
||||||
|
|
||||||
|
// 1. Render Main
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
mainFeed,
|
||||||
|
{ x: mainX, y: mainY, w: mainW, h: mainH },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Render 3 Sidebar Tiles
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const feed = secondaryFeeds[i];
|
||||||
|
const tileY = topY + i * (sideH + gap);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
feed,
|
||||||
|
{ x: sideX, y: tileY, w: sideW, h: sideH },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Render Bottom Overflow Tiles (each 16:9)
|
||||||
|
const overflowFeeds = secondaryFeeds.slice(3);
|
||||||
|
const numOverflow = overflowFeeds.length;
|
||||||
|
const botMaxH = totalSideH - mainH - gap; // 292px
|
||||||
|
const botYBase = topY + mainH + gap; // 759px
|
||||||
|
|
||||||
|
// Calculate width per tile to fit in mainW
|
||||||
|
const candBotW = Math.floor(
|
||||||
|
(mainW - (numOverflow - 1) * gap) / numOverflow,
|
||||||
|
);
|
||||||
|
const candBotH = Math.round(candBotW * (9 / 16));
|
||||||
|
|
||||||
|
let botW, botH;
|
||||||
|
if (candBotH > botMaxH) {
|
||||||
|
botH = botMaxH;
|
||||||
|
botW = Math.round(botH * (16 / 9));
|
||||||
|
} else {
|
||||||
|
botW = candBotW;
|
||||||
|
botH = candBotH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBotW = numOverflow * botW + (numOverflow - 1) * gap;
|
||||||
|
const botXStart = mainX + Math.round((mainW - totalBotW) / 2);
|
||||||
|
const botY = botYBase + Math.round((botMaxH - botH) / 2);
|
||||||
|
|
||||||
|
overflowFeeds.forEach((feed, j) => {
|
||||||
|
const tileX = botXStart + j * (botW + gap);
|
||||||
|
this.renderTile(
|
||||||
|
ctx,
|
||||||
|
feed,
|
||||||
|
{ x: tileX, y: botY, w: botW, h: botH },
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTile(ctx, feed, rect, isMain = false) {
|
||||||
|
const { x, y, w, h } = rect;
|
||||||
|
const radius = 12;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
this.drawRoundedClip(ctx, x, y, w, h, radius);
|
||||||
|
|
||||||
|
// Base tile background
|
||||||
|
ctx.fillStyle = "#0d1220";
|
||||||
|
ctx.fillRect(x, y, w, h);
|
||||||
|
|
||||||
|
if (feed.hasVideo && feed.video) {
|
||||||
|
// Draw video frame
|
||||||
|
if (feed.type === "screen") {
|
||||||
|
this.drawVideoContain(ctx, feed.video, x, y, w, h);
|
||||||
|
} else {
|
||||||
|
this.drawVideoCover(ctx, feed.video, x, y, w, h);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Draw avatar placeholder
|
||||||
|
this.drawPlaceholder(ctx, feed, x, y, w, h, isMain);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Sleek subtle border
|
||||||
|
ctx.save();
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.08)";
|
||||||
|
this.drawRoundedStroke(ctx, x, y, w, h, radius);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Overlay Pill Tag (Bottom-Left)
|
||||||
|
this.drawPillTag(ctx, feed, x + 10, y + h - 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawVideoCover(ctx, video, x, y, w, h) {
|
||||||
|
try {
|
||||||
|
const vw = video.videoWidth || 640;
|
||||||
|
const vh = video.videoHeight || 480;
|
||||||
|
|
||||||
|
const targetRatio = w / h;
|
||||||
|
const videoRatio = vw / vh;
|
||||||
|
|
||||||
|
let sx = 0,
|
||||||
|
sy = 0,
|
||||||
|
sw = vw,
|
||||||
|
sh = vh;
|
||||||
|
|
||||||
|
if (videoRatio > targetRatio) {
|
||||||
|
// Video is wider than 16:9 target: crop horizontally
|
||||||
|
sw = Math.round(vh * targetRatio);
|
||||||
|
sx = Math.round((vw - sw) / 2);
|
||||||
|
} else {
|
||||||
|
// Video is taller than 16:9 target: crop vertically
|
||||||
|
sh = Math.round(vw / targetRatio);
|
||||||
|
sy = Math.round((vh - sh) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(video, sx, sy, sw, sh, x, y, w, h);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawVideoContain(ctx, video, x, y, w, h) {
|
||||||
|
try {
|
||||||
|
const vw = video.videoWidth || 1920;
|
||||||
|
const vh = video.videoHeight || 1080;
|
||||||
|
|
||||||
|
const targetRatio = w / h;
|
||||||
|
const videoRatio = vw / vh;
|
||||||
|
|
||||||
|
let dw = w;
|
||||||
|
let dh = h;
|
||||||
|
let dx = x;
|
||||||
|
let dy = y;
|
||||||
|
|
||||||
|
if (videoRatio > targetRatio) {
|
||||||
|
// Video is wider than tile
|
||||||
|
dh = Math.round(w / videoRatio);
|
||||||
|
dy = Math.round(y + (h - dh) / 2);
|
||||||
|
} else {
|
||||||
|
// Video is taller than tile
|
||||||
|
dw = Math.round(h * videoRatio);
|
||||||
|
dx = Math.round(x + (w - dw) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(video, 0, 0, vw, vh, dx, dy, dw, dh);
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPlaceholder(ctx, feed, x, y, w, h, isMain = false) {
|
||||||
|
const cx = x + w / 2;
|
||||||
|
const cy = y + h / 2 - (isMain ? 15 : 8);
|
||||||
|
const circleRadius = isMain
|
||||||
|
? Math.min(54, h * 0.18)
|
||||||
|
: Math.min(32, h * 0.22);
|
||||||
|
|
||||||
|
// Circular gradient avatar
|
||||||
|
const gradient = ctx.createLinearGradient(
|
||||||
|
cx - circleRadius,
|
||||||
|
cy - circleRadius,
|
||||||
|
cx + circleRadius,
|
||||||
|
cy + circleRadius,
|
||||||
|
);
|
||||||
|
if (feed.type === "candidate") {
|
||||||
|
gradient.addColorStop(0, "#10b981");
|
||||||
|
gradient.addColorStop(1, "#059669");
|
||||||
|
} else {
|
||||||
|
gradient.addColorStop(0, "#5b8bff");
|
||||||
|
gradient.addColorStop(1, "#3b63e0");
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(cx, cy, circleRadius, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Avatar Initials Text
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = `bold ${Math.round(circleRadius * 0.85)}px sans-serif`;
|
||||||
|
ctx.textAlign = "center";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(feed.initials || "IV", cx, cy);
|
||||||
|
|
||||||
|
// Participant Name
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = `600 ${isMain ? 18 : 13}px sans-serif`;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillText(feed.name, cx, cy + circleRadius + (isMain ? 14 : 8));
|
||||||
|
|
||||||
|
// Subtitle status
|
||||||
|
ctx.fillStyle = "#94a3b8";
|
||||||
|
ctx.font = `400 ${isMain ? 13 : 10.5}px sans-serif`;
|
||||||
|
ctx.fillText(
|
||||||
|
feed.isCamOn ? "Connecting video..." : "Camera turned off",
|
||||||
|
cx,
|
||||||
|
cy + circleRadius + (isMain ? 36 : 24),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPillTag(ctx, feed, x, y) {
|
||||||
|
const text = feed.name;
|
||||||
|
const fontSize = 11;
|
||||||
|
ctx.font = `600 ${fontSize}px sans-serif`;
|
||||||
|
|
||||||
|
const textMetrics = ctx.measureText(text);
|
||||||
|
const tagPaddingX = 8;
|
||||||
|
const tagW = textMetrics.width + tagPaddingX * 2;
|
||||||
|
const tagH = 20;
|
||||||
|
|
||||||
|
const tagX = x;
|
||||||
|
const tagY = y - tagH;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
// Background
|
||||||
|
ctx.fillStyle = "rgba(9, 13, 22, 0.8)";
|
||||||
|
this.drawRoundedFill(ctx, tagX, tagY, tagW, tagH, 5);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.12)";
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
this.drawRoundedStroke(ctx, tagX, tagY, tagW, tagH, 5);
|
||||||
|
|
||||||
|
// Name text
|
||||||
|
ctx.fillStyle = "#e2e8f0";
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.textAlign = "left";
|
||||||
|
ctx.fillText(text, tagX + tagPaddingX, tagY + tagH / 2);
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
isScreenShareActive(video) {
|
||||||
|
if (!video) return false;
|
||||||
|
const stream = video.srcObject;
|
||||||
|
if (!stream || !stream.active) return false;
|
||||||
|
const tracks = stream.getVideoTracks ? stream.getVideoTracks() : [];
|
||||||
|
if (tracks.length === 0) return false;
|
||||||
|
const hasLiveTrack = tracks.some(
|
||||||
|
(t) => t.readyState === "live" && t.enabled,
|
||||||
|
);
|
||||||
|
if (!hasLiveTrack) return false;
|
||||||
|
|
||||||
|
if (video.paused && typeof video.play === "function") {
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVideoPlaying(video) {
|
||||||
|
if (!video) return false;
|
||||||
|
const stream = video.srcObject;
|
||||||
|
if (!stream || !stream.active) return false;
|
||||||
|
const tracks = stream.getVideoTracks ? stream.getVideoTracks() : [];
|
||||||
|
if (tracks.length === 0) return false;
|
||||||
|
const hasLiveTrack = tracks.some(
|
||||||
|
(t) => t.readyState === "live" && t.enabled,
|
||||||
|
);
|
||||||
|
if (!hasLiveTrack) return false;
|
||||||
|
|
||||||
|
if (video.paused && typeof video.play === "function") {
|
||||||
|
video.play().catch(() => {});
|
||||||
|
}
|
||||||
|
return Boolean(
|
||||||
|
video.videoWidth > 0 || video.readyState >= 1 || hasLiveTrack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractInitials(name) {
|
||||||
|
if (!name) return "CD";
|
||||||
|
const parts = name.trim().split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||||
|
}
|
||||||
|
return name.slice(0, 2).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
roundedRectPath(ctx, x, y, w, h, r) {
|
||||||
|
if (typeof ctx.roundRect === "function") {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(x, y, w, h, r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.lineTo(x + w - r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + r, r);
|
||||||
|
ctx.lineTo(x + w, y + h - r);
|
||||||
|
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
|
||||||
|
ctx.lineTo(x + r, y + h);
|
||||||
|
ctx.arcTo(x, y + h, x, y + h - r, r);
|
||||||
|
ctx.lineTo(x, y + r);
|
||||||
|
ctx.arcTo(x, y, x + r, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRoundedClip(ctx, x, y, w, h, r) {
|
||||||
|
this.roundedRectPath(ctx, x, y, w, h, r);
|
||||||
|
ctx.clip();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRoundedStroke(ctx, x, y, w, h, r) {
|
||||||
|
this.roundedRectPath(ctx, x, y, w, h, r);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawRoundedFill(ctx, x, y, w, h, r) {
|
||||||
|
this.roundedRectPath(ctx, x, y, w, h, r);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,7 +28,10 @@
|
|||||||
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot" class="hidden">
|
<x-button id="btn-start-recording" onclick="startCallRecording()" variant="secondary" icon="fa-solid fa-circle-dot" class="hidden">
|
||||||
Record Call (Silent)
|
Record Call (Silent)
|
||||||
</x-button>
|
</x-button>
|
||||||
<x-button id="btn-stop-recording" class="hidden" onclick="stopCallRecording()" variant="danger" icon="fa-solid fa-square" class="hidden animate-pulse" title="Stop & Save Recording"></x-button>
|
<x-button id="btn-stop-recording" onclick="stopCallRecording()" variant="danger" class="hidden animate-pulse" title="Stop & Save Recording">
|
||||||
|
<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>
|
||||||
|
</x-button>
|
||||||
|
|
||||||
<!-- 5. Leave Call -->
|
<!-- 5. Leave Call -->
|
||||||
<x-button id="btn-leave-call" onclick="leaveInterviewerCall()" variant="secondary" icon="fa-solid fa-arrow-right-from-bracket" class="hidden">
|
<x-button id="btn-leave-call" onclick="leaveInterviewerCall()" variant="secondary" icon="fa-solid fa-arrow-right-from-bracket" class="hidden">
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
<div id="tab-content-recordings" class="hidden">
|
<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-[220px] max-h-[420px] overflow-y-auto space-y-2.5 custom-scrollbar">
|
||||||
<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="flex flex-col items-center justify-center py-10 text-center">
|
||||||
<div class="text-slate-500 text-xs text-center pt-10">No video recordings saved for this candidate yet.</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
Route::post('/candidate/submit-code/{id}', [InterviewController::class, 'submitCode'])->name('interview.submit');
|
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/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/{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/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/sync-code/{id}', [InterviewController::class, 'syncCandidateCode'])->name('interview.candidate.sync-code');
|
||||||
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
|
Route::post('/candidate/notes/{id}', [InterviewController::class, 'saveCandidateNotes'])->name('interview.candidate.notes');
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user