diff --git a/app/Http/Controllers/ClientCallController.php b/app/Http/Controllers/ClientCallController.php index b23d573..e76ba1d 100644 --- a/app/Http/Controllers/ClientCallController.php +++ b/app/Http/Controllers/ClientCallController.php @@ -46,9 +46,9 @@ public function index() // Quick stats $totalClients = $clients->count(); $totalMeetings = ClientMeeting::whereIn('client_id', $clients->pluck('id'))->count(); - $angryClientsCount = $clients->where('latest_sentiment', 'angry')->count(); + $activeProjectsCount = $clients->where('status', 'active')->count(); - return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'angryClientsCount')); + return view('client_calls.index', compact('clients', 'allUsers', 'managersAndLeads', 'totalClients', 'totalMeetings', 'activeProjectsCount')); } /** @@ -101,12 +101,25 @@ public function show($id) return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); } - $client = Client::with(['responsibleUser', 'creator', 'meetings.host', 'meetings.latestNote', 'callNotes.creator'])->findOrFail($id); + $client = Client::with([ + 'responsibleUser', + 'creator', + 'meetings.host', + 'meetings.latestNote', + 'callNotes.creator', + 'callNotes.meeting' + ])->findOrFail($id); + $allUsers = User::orderBy('name', 'asc')->get(); + // Group call notes date-wise (Y-m-d) + $groupedCallNotes = $client->callNotes->groupBy(function ($note) { + return $note->created_at ? $note->created_at->format('Y-m-d') : now()->format('Y-m-d'); + }); + $canViewAngry = $client->canViewAngryAnalysis($user); - return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry')); + return view('client_calls.show', compact('client', 'allUsers', 'canViewAngry', 'groupedCallNotes')); } /** @@ -239,7 +252,9 @@ public function showRoom(Request $request, $code) $participantAvatar = $user ? ($user->avatar ?? null) : null; $isHost = $user && ((int)$meeting->host_user_id === (int)$user->id || $user->isAdmin()); - return view('client_calls.room', compact('meeting', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest')); + $client = $meeting->client; + + return view('client_calls.room', compact('meeting', 'client', 'participantName', 'participantRole', 'participantAvatar', 'isHost', 'isGuest')); } /** @@ -275,6 +290,166 @@ public function detectEmotion(Request $request, $code) return response()->json($result); } + /** + * Upload & Save Client Call Video Recording. + */ + public function uploadRecording(Request $request, $code) + { + $meeting = ClientMeeting::where('meeting_code', $code)->firstOrFail(); + + if ($request->hasFile('video') || $request->hasFile('recording')) { + $file = $request->file('video') ?? $request->file('recording'); + $ext = strtolower($file->getClientOriginalExtension() ?: 'webm'); + if (!in_array($ext, ['mp4', 'webm', 'm4v', 'mov', 'ogg'])) { + $ext = 'webm'; + } + + $subDir = 'uploads/client_recordings/' . $meeting->meeting_code; + $destinationPath = public_path($subDir); + if (!file_exists($destinationPath)) { + mkdir($destinationPath, 0777, true); + } + + $filename = 'call_recording_' . time() . '.' . $ext; + $file->move($destinationPath, $filename); + $url = asset($subDir . '/' . $filename); + $relativePath = '/' . $subDir . '/' . $filename; + + $recordings = $meeting->recordings ?? []; + if (!is_array($recordings)) { + $recordings = $meeting->recording_path ? [['url' => $meeting->recording_path, 'full_url' => asset(ltrim($meeting->recording_path, '/')), 'filename' => basename($meeting->recording_path), 'created_at' => now()->toIso8601String()]] : []; + } + $recordings[] = [ + 'url' => $relativePath, + 'full_url' => $url, + 'filename' => $filename, + 'created_at' => now()->toIso8601String(), + ]; + + $meeting->update([ + 'recording_path' => $relativePath, + 'recordings' => $recordings, + ]); + + // Sync to existing latest call note if present + $latestNote = $meeting->callNotes()->first(); + if ($latestNote) { + $latestNote->update([ + 'recording_path' => $relativePath, + 'recordings' => $recordings, + ]); + } + + return response()->json([ + 'success' => true, + 'path' => $relativePath, + 'url' => $url, + 'recordings' => $recordings, + ]); + } + + return response()->json(['success' => false, 'message' => 'No video recording payload received'], 400); + } + + /** + * Download Video Recording for a Meeting. + */ + public function downloadMeetingRecording(Request $request, $id) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + abort(403, 'Unauthorized'); + } + + $meeting = ClientMeeting::findOrFail($id); + $index = (int) $request->input('index', 0); + $recordings = $meeting->recordings ?? []; + + $targetUrl = null; + if (!empty($recordings) && isset($recordings[$index])) { + $rec = $recordings[$index]; + $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; + } + + if (!$targetUrl) { + $targetUrl = $meeting->recording_path; + } + + if (!$targetUrl) { + return back()->with('error', 'No video recording found for this meeting.'); + } + + return $this->serveRecordingDownload($targetUrl, $meeting->title ?? 'Meeting'); + } + + /** + * Download Video Recording for a Call Note. + */ + public function downloadNoteRecording(Request $request, $id) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + abort(403, 'Unauthorized'); + } + + $callNote = ClientCallNote::with(['client', 'meeting'])->findOrFail($id); + $index = (int) $request->input('index', 0); + $recordings = $callNote->recordings ?? ($callNote->meeting->recordings ?? []); + + $targetUrl = null; + if (!empty($recordings) && isset($recordings[$index])) { + $rec = $recordings[$index]; + $targetUrl = is_array($rec) ? ($rec['url'] ?? null) : $rec; + } + + if (!$targetUrl) { + $targetUrl = $callNote->recording_path ?? ($callNote->meeting->recording_path ?? null); + } + + if (!$targetUrl) { + return back()->with('error', 'No video recording found for this call note.'); + } + + $projectName = $callNote->client->project_name ?? 'ClientCall'; + return $this->serveRecordingDownload($targetUrl, $projectName); + } + + /** + * Helper to download recording file. + */ + protected function serveRecordingDownload(string $targetUrl, string $prefixName) + { + $urlPath = parse_url($targetUrl, PHP_URL_PATH) ?? $targetUrl; + $sanitizedPath = ltrim(str_replace(['/storage/', 'storage/'], '', $urlPath), '/'); + + $candidatePaths = [ + public_path($sanitizedPath), + public_path('storage/' . $sanitizedPath), + storage_path('app/public/' . $sanitizedPath), + storage_path('app/' . $sanitizedPath), + ]; + + $filePath = null; + foreach ($candidatePaths as $cp) { + if (file_exists($cp) && is_file($cp)) { + $filePath = $cp; + break; + } + } + + if ($filePath && file_exists($filePath)) { + $ext = pathinfo($filePath, PATHINFO_EXTENSION) ?: 'webm'; + $slug = Str::slug($prefixName); + $downloadFilename = "Recording-{$slug}-" . date('Y-m-d') . ".{$ext}"; + + return response()->download($filePath, $downloadFilename, [ + 'Content-Type' => ($ext === 'webm' ? 'video/webm' : 'video/mp4'), + ]); + } + + return back()->with('error', 'Recording file not found on server storage.'); + } + /** * End Call, Generate AI MoM & Notes, and Send Email to TL, Director, PM, and Admin. */ @@ -286,11 +461,26 @@ public function endCallAndGenerateMoM(Request $request, $code) $transcript = $request->input('transcript', ''); $liveNotes = $request->input('live_notes', ''); $emotionTimeline = $request->input('emotion_timeline', []); + $recordingPath = $request->input('recording_path', $meeting->recording_path); + $recordings = $request->input('recordings', $meeting->recordings ?? []); + + if ($recordingPath && empty($recordings)) { + $recordings = [ + [ + 'url' => $recordingPath, + 'full_url' => asset(ltrim($recordingPath, '/')), + 'filename' => basename($recordingPath), + 'created_at' => now()->toIso8601String(), + ] + ]; + } // Mark meeting as completed $meeting->update([ 'status' => 'completed', 'ended_at' => now(), + 'recording_path' => $recordingPath, + 'recordings' => $recordings, ]); // Generate AI MoM @@ -322,11 +512,15 @@ public function endCallAndGenerateMoM(Request $request, $code) 'sentiment_breakdown' => $aiResult['sentiment_breakdown'], 'angry_analysis' => $aiResult['angry_analysis'], 'emotion_details' => $aiResult['emotion_details'] ?? [], + 'recording_path' => $recordingPath, + 'recordings' => $recordings, ]); // Automatically send email with MoM and notes to TL, Director, PM, and Admin $emailStatus = $this->aiService->sendMoMEmails($callNote); + $redirectUrl = Auth::check() ? route('client-calls.show', $client->id) : url('/'); + if ($request->ajax() || $request->wantsJson()) { return response()->json([ 'success' => true, @@ -334,10 +528,15 @@ public function endCallAndGenerateMoM(Request $request, $code) 'client_id' => $client->id, 'detected_sentiment' => $callNote->detected_sentiment, 'email_status' => $emailStatus, - 'redirect_url' => route('client-calls.show', $client->id), + 'recording_path' => $callNote->recording_path, + 'redirect_url' => $redirectUrl, ]); } + if (!Auth::check()) { + return redirect('/')->with('success', 'Meeting ended successfully.'); + } + return redirect()->route('client-calls.show', $client->id)->with('success', 'Call ended. AI MoM & Notes generated and emailed to project stakeholders!'); } @@ -494,7 +693,9 @@ public function getEmotionDetails($noteId) 'project_name' => $client->project_name, 'detected_sentiment' => $sentiment, 'emotion_details' => $details, + 'todo_items' => $callNote->todo_items ?? $callNote->action_items ?? [], 'summary' => $callNote->summary, + 'mom_content' => $callNote->mom_content, 'created_at' => $callNote->created_at ? $callNote->created_at->format('M d, Y h:i A') : now()->format('M d, Y h:i A'), ]); } @@ -526,6 +727,77 @@ public function toggleTodo(Request $request, $noteId, $todoId) ]); } + /** + * Add a new To-Do item to the call note. + */ + public function addTodo(Request $request, $noteId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $request->validate([ + 'task' => 'required|string|max:500', + 'owner' => 'nullable|string|max:255', + 'priority' => 'nullable|in:High,Medium,Low', + 'deadline' => 'nullable|string|max:100', + ]); + + $callNote = ClientCallNote::findOrFail($noteId); + $todos = $callNote->todo_items ?? []; + + $maxId = 0; + foreach ($todos as $t) { + if (isset($t['id']) && $t['id'] > $maxId) { + $maxId = $t['id']; + } + } + + $newTodo = [ + 'id' => $maxId + 1, + 'task' => trim($request->task), + 'owner' => $request->owner ?: ($user->name ?? 'Assigned Lead'), + 'priority' => $request->priority ?: 'Medium', + 'deadline' => $request->deadline ?: now()->addDays(3)->format('M d, Y'), + 'completed' => false, + ]; + + $todos[] = $newTodo; + $callNote->update(['todo_items' => $todos]); + + return response()->json([ + 'success' => true, + 'todo' => $newTodo, + 'todo_items' => $todos, + ]); + } + + /** + * Delete a To-Do item from the call note. + */ + public function deleteTodo(Request $request, $noteId, $todoId) + { + $user = Auth::user(); + if (!$user || !$user->canAccessClientCalls()) { + return response()->json(['error' => 'Unauthorized'], 403); + } + + $callNote = ClientCallNote::findOrFail($noteId); + $todos = $callNote->todo_items ?? []; + + $filteredTodos = array_values(array_filter($todos, function ($item) use ($todoId) { + return !isset($item['id']) || (int)$item['id'] !== (int)$todoId; + })); + + $callNote->update(['todo_items' => $filteredTodos]); + + return response()->json([ + 'success' => true, + 'todo_items' => $filteredTodos, + ]); + } + /** * Get Angry Root Cause Analysis (Admin & Responsible Person ONLY). */ @@ -598,7 +870,7 @@ public function signalingHeartbeat(Request $request, $code) $activePeers = $meeting->active_peers ?? []; $now = now()->timestamp; - // Clean up stale peers (older than 25s) + // Clean up stale peers (older than 25s) foreach ($activePeers as $id => $peer) { if (isset($peer['last_seen']) && ($now - $peer['last_seen']) > 25) { unset($activePeers[$id]); diff --git a/app/Models/ClientCallNote.php b/app/Models/ClientCallNote.php index 737c65a..92f3418 100644 --- a/app/Models/ClientCallNote.php +++ b/app/Models/ClientCallNote.php @@ -24,6 +24,8 @@ class ClientCallNote extends Model 'angry_analysis', 'emotion_details', 'todo_items', + 'recording_path', + 'recordings', 'emailed_at', 'emailed_to', ]; @@ -35,6 +37,7 @@ class ClientCallNote extends Model 'angry_analysis' => 'array', 'emotion_details' => 'array', 'todo_items' => 'array', + 'recordings' => 'array', 'emailed_to' => 'array', 'emailed_at' => 'datetime', ]; diff --git a/app/Models/ClientMeeting.php b/app/Models/ClientMeeting.php index fdf1682..a1ef0c6 100644 --- a/app/Models/ClientMeeting.php +++ b/app/Models/ClientMeeting.php @@ -26,6 +26,8 @@ class ClientMeeting extends Model 'active_peers', 'peer_signals', 'chat_messages', + 'recording_path', + 'recordings', ]; protected $casts = [ @@ -37,6 +39,7 @@ class ClientMeeting extends Model 'active_peers' => 'array', 'peer_signals' => 'array', 'chat_messages' => 'array', + 'recordings' => 'array', ]; /** diff --git a/app/Services/AiClientCallService.php b/app/Services/AiClientCallService.php index 204749c..ae8d485 100644 --- a/app/Services/AiClientCallService.php +++ b/app/Services/AiClientCallService.php @@ -109,39 +109,82 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 { $apiKey = env('GEMINI_API_KEY') ?: (env('GOOGLE_API_KEY') ?: (getenv('GEMINI_API_KEY') ?: getenv('GOOGLE_API_KEY'))); - // Check for Angry / Frustrated trigger keywords in speech text - $lowerText = strtolower($speechText); - $angryTriggers = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'incompetent', 'worst']; - $sadTriggers = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless', 'sorry', 'doubt']; - $happyTriggers = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'amazing', 'impressed']; + $lowerText = strtolower(trim($speechText)); + + // 1. Check for negative polarity / negations that invert positive sentiment + $negatedHappyPhrases = [ + 'not happy', 'not satisfied', 'not good', 'not great', 'not working', + 'not impressed', 'not pleased', 'never works', 'no progress', 'displeased', + 'unhappy', 'dissatisfied', 'don\'t like', 'do not like', 'cannot accept', + 'unacceptable', 'waste of time', 'waste of money', 'broken feature', 'delays' + ]; + + $foundNegations = []; + foreach ($negatedHappyPhrases as $negPhrase) { + if (str_contains($lowerText, $negPhrase)) { + $foundNegations[] = $negPhrase; + } + } + + // 2. Angry / Frustrated trigger keywords + $angryTriggers = [ + 'angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', + 'delay', 'delays', 'delayed', 'broken', 'disaster', 'unhappy', 'frustrated', + 'mad', 'annoyed', 'horrible', 'cancel', 'lawyer', 'refund', 'fail', 'failed', + 'incompetent', 'worst', 'escalate', 'escalation', 'disgusted', 'blunder', + 'mess', 'screwed', 'unprofessional', 'missed deadline', 'not delivering' + ]; + + // 3. Sad / Concerned / Hesitant trigger keywords + $sadTriggers = [ + 'sad', 'disappointed', 'disappointment', 'worried', 'worry', 'concerned', + 'concern', 'nervous', 'scared', 'afraid', 'regret', 'loss', 'hopeless', + 'sorry', 'doubt', 'doubtful', 'hesitant', 'struggling', 'anxious', + 'over budget', 'falling behind', 'uncertain', 'at risk' + ]; + + // 4. Happy / Satisfied / Praising trigger keywords + $happyTriggers = [ + 'great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', + 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', + 'amazing', 'impressed', 'kudos', 'brilliant', 'exceeded expectations', + 'looks good', 'looks great', 'well done', 'appreciate', 'delighted', + 'seamless', 'flawless', 'outstanding' + ]; $detectedAngryWords = []; foreach ($angryTriggers as $word) { - if (str_contains($lowerText, $word)) { + if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) { $detectedAngryWords[] = $word; } } + if (!empty($foundNegations)) { + $detectedAngryWords = array_unique(array_merge($detectedAngryWords, $foundNegations)); + } $detectedSadWords = []; foreach ($sadTriggers as $word) { - if (str_contains($lowerText, $word)) { + if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) { $detectedSadWords[] = $word; } } $detectedHappyWords = []; - foreach ($happyTriggers as $word) { - if (str_contains($lowerText, $word)) { - $detectedHappyWords[] = $word; + // Only consider happy words if there are no strong negations of happiness + if (empty($foundNegations)) { + foreach ($happyTriggers as $word) { + if (preg_match('/\b' . preg_quote($word, '/') . '\b/i', $lowerText) || str_contains($lowerText, $word)) { + $detectedHappyWords[] = $word; + } } } if (!empty($detectedAngryWords)) { return [ 'emotion' => 'angry', - 'confidence' => 0.92, - 'trigger_words' => $detectedAngryWords, - 'details' => 'Detected frustration or dissatisfaction keywords in speech.', + 'confidence' => 0.94, + 'trigger_words' => array_values(array_unique($detectedAngryWords)), + 'details' => 'Detected clear frustration, complaint, or dissatisfaction cues in conversation.', 'label' => 'Frustrated / Angry đĄ' ]; } @@ -149,9 +192,9 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 if (!empty($detectedSadWords)) { return [ 'emotion' => 'sad', - 'confidence' => 0.85, - 'trigger_words' => $detectedSadWords, - 'details' => 'Detected concern or disappointment cues in speech.', + 'confidence' => 0.88, + 'trigger_words' => array_values(array_unique($detectedSadWords)), + 'details' => 'Detected concern, hesitation, or worry in speech dialogue.', 'label' => 'Concerned / Sad đ' ]; } @@ -159,9 +202,9 @@ public function detectEmotion(string $speechText = '', ?string $imageFrameBase64 if (!empty($detectedHappyWords)) { return [ 'emotion' => 'happy', - 'confidence' => 0.88, - 'trigger_words' => $detectedHappyWords, - 'details' => 'Detected positive satisfaction keywords in speech.', + 'confidence' => 0.90, + 'trigger_words' => array_values(array_unique($detectedHappyWords)), + 'details' => 'Detected positive satisfaction, appreciation, or praise in speech.', 'label' => 'Happy / Satisfied đ' ]; } @@ -507,13 +550,23 @@ protected function generateFallbackMoM(string $combinedContent, string $transcri { $lower = strtolower($combinedContent); - $angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent']; - $sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt']; - $happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed']; + $negatedHappyPhrases = ['not happy', 'not satisfied', 'not good', 'not great', 'not working', 'not impressed', 'not pleased', 'no progress', 'displeased', 'unhappy', 'dissatisfied', 'cannot accept', 'unacceptable']; + $foundNegations = array_values(array_filter($negatedHappyPhrases, fn($w) => str_contains($lower, $w))); - $foundAngry = array_values(array_filter($angryWords, fn($w) => str_contains($lower, $w))); + $angryWords = ['angry', 'furious', 'unacceptable', 'ridiculous', 'terrible', 'waste of time', 'delay', 'delays', 'delayed', 'broken', 'disaster', 'unhappy', 'frustrated', 'mad', 'annoyed', 'horrible', 'refund', 'cancel', 'fail', 'incompetent', 'unprofessional', 'missed deadline']; + $sadWords = ['sad', 'disappointed', 'worried', 'concerned', 'nervous', 'scared', 'afraid', 'regret', 'doubt', 'hesitant', 'uncertain', 'at risk']; + $happyWords = ['great', 'excellent', 'fantastic', 'awesome', 'good job', 'love it', 'happy', 'pleased', 'perfect', 'superb', 'wonderful', 'thank you', 'appreciate', 'impressed', 'kudos', 'brilliant', 'delighted']; + + $foundAngry = array_values(array_unique(array_merge( + array_filter($angryWords, fn($w) => str_contains($lower, $w)), + $foundNegations + ))); $foundSad = array_values(array_filter($sadWords, fn($w) => str_contains($lower, $w))); - $foundHappy = array_values(array_filter($happyWords, fn($w) => str_contains($lower, $w))); + + $foundHappy = []; + if (empty($foundNegations)) { + $foundHappy = array_values(array_filter($happyWords, fn($w) => str_contains($lower, $w))); + } $sentiment = 'neutral'; $angryAnalysis = [ diff --git a/database/migrations/2026_08_21_054157_add_recording_fields_to_client_meetings_and_notes_tables.php b/database/migrations/2026_08_21_054157_add_recording_fields_to_client_meetings_and_notes_tables.php new file mode 100644 index 0000000..3750274 --- /dev/null +++ b/database/migrations/2026_08_21_054157_add_recording_fields_to_client_meetings_and_notes_tables.php @@ -0,0 +1,38 @@ +string('recording_path')->nullable()->after('chat_messages'); + $table->json('recordings')->nullable()->after('recording_path'); + }); + + Schema::table('client_call_notes', function (Blueprint $table) { + $table->string('recording_path')->nullable()->after('todo_items'); + $table->json('recordings')->nullable()->after('recording_path'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('client_meetings', function (Blueprint $table) { + $table->dropColumn(['recording_path', 'recordings']); + }); + + Schema::table('client_call_notes', function (Blueprint $table) { + $table->dropColumn(['recording_path', 'recordings']); + }); + } +}; diff --git a/public/js/client-call.js b/public/js/client-call.js index 8719f6d..379d678 100644 --- a/public/js/client-call.js +++ b/public/js/client-call.js @@ -1,5 +1,7 @@ /** * SingleLogin Client Call - WebRTC Video & AI Meeting Intelligence Engine + * Features: Multi-party video grid, Live AI transcription, Real-time emotion analysis, + * In-call chat, and Full Call Recording with automated server upload. */ let localStream = null; @@ -9,6 +11,16 @@ let isAudioMuted = false; let isVideoMuted = false; let isScreenSharing = false; +// Call Recording State +let mediaRecorder = null; +let recordedChunks = []; +let isRecording = false; +let recordingStartTime = null; +let recordingTimerInterval = null; +let recordingStream = null; +let recordedVideoBlob = null; +let recordedVideoPath = null; + let accumulatedTranscript = []; let speechRecognizer = null; let emotionTimeline = []; @@ -33,6 +45,7 @@ document.addEventListener('DOMContentLoaded', async () => { window.toggleAudio = toggleAudio; window.toggleVideo = toggleVideo; window.toggleScreenShare = toggleScreenShare; + window.toggleCallRecording = toggleCallRecording; window.sendChatMessage = sendChatMessage; window.endMeetingAndGenerateMoM = endMeetingAndGenerateMoM; window.leaveCall = leaveCall; @@ -87,6 +100,7 @@ function toggleAudio() { const btn = document.getElementById('btnToggleMic'); if (btn) { btn.classList.toggle('active-off', isAudioMuted); + btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone'; } } } @@ -100,6 +114,7 @@ function toggleVideo() { const btn = document.getElementById('btnToggleVideo'); if (btn) { btn.classList.toggle('active-off', isVideoMuted); + btn.title = isVideoMuted ? 'Turn Camera On' : 'Turn Camera Off'; } } } @@ -108,7 +123,7 @@ async function toggleScreenShare() { const btn = document.getElementById('btnScreenShare'); if (!isScreenSharing) { try { - screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); + screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true }); const screenTrack = screenStream.getVideoTracks()[0]; // Replace video track for peers @@ -151,7 +166,242 @@ function stopScreenShare() { } /** + * ========================================================================= + * Call Recording Engine + * ========================================================================= + */ +async function toggleCallRecording() { + if (isRecording) { + stopRecording(); + } else { + await startRecording(); + } +} + +async function startRecording() { + try { + recordedChunks = []; + let combinedStream = null; + + // Try getting screen/tab capture with system audio + try { + if (navigator.mediaDevices.getDisplayMedia) { + if (screenStream && screenStream.active) { + combinedStream = screenStream.clone(); + } else { + const screenCapture = await navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: 'browser' }, + audio: true + }); + combinedStream = screenCapture; + } + } + } catch (e) { + console.info('Display capture not selected, recording camera stream directly:', e); + } + + if (!combinedStream && localStream) { + combinedStream = localStream.clone(); + } + + if (!combinedStream) { + alert('No camera or display stream available to record.'); + return; + } + + // Mix local microphone audio if available + if (localStream && localStream.getAudioTracks().length > 0 && window.AudioContext) { + try { + const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + const dest = audioCtx.createMediaStreamDestination(); + + // Local mic + const micSource = audioCtx.createMediaStreamSource(localStream); + micSource.connect(dest); + + // Screen audio if captured + if (combinedStream.getAudioTracks().length > 0) { + const screenAudioSource = audioCtx.createMediaStreamSource(new MediaStream([combinedStream.getAudioTracks()[0]])); + screenAudioSource.connect(dest); + } + + const mixedAudioTrack = dest.stream.getAudioTracks()[0]; + if (mixedAudioTrack) { + combinedStream.getAudioTracks().forEach(t => combinedStream.removeTrack(t)); + combinedStream.addTrack(mixedAudioTrack); + } + } catch (err) { + console.warn('Audio mixing fallback:', err); + if (combinedStream.getAudioTracks().length === 0 && localStream.getAudioTracks().length > 0) { + combinedStream.addTrack(localStream.getAudioTracks()[0].clone()); + } + } + } + + recordingStream = combinedStream; + + // Determine supported mimeTypes + let options = { mimeType: 'video/webm;codecs=vp9,opus' }; + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = { mimeType: 'video/webm;codecs=vp8,opus' }; + } + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = { mimeType: 'video/webm' }; + } + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = { mimeType: 'video/mp4' }; + } + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options = {}; + } + + mediaRecorder = new MediaRecorder(recordingStream, options); + + mediaRecorder.ondataavailable = (event) => { + if (event.data && event.data.size > 0) { + recordedChunks.push(event.data); + } + }; + + mediaRecorder.onstop = async () => { + clearInterval(recordingTimerInterval); + isRecording = false; + updateRecordingUI(false); + + if (recordingStream) { + recordingStream.getTracks().forEach(t => t.stop()); + recordingStream = null; + } + + if (recordedChunks.length > 0) { + const mimeType = mediaRecorder.mimeType || 'video/webm'; + recordedVideoBlob = new Blob(recordedChunks, { type: mimeType }); + await uploadRecordingBlob(recordedVideoBlob); + } + }; + + recordingStream.getVideoTracks().forEach(track => { + track.onended = () => { + if (isRecording) { + stopRecording(); + } + }; + }); + + mediaRecorder.start(1000); + isRecording = true; + recordingStartTime = Date.now(); + startRecordingTimer(); + updateRecordingUI(true); + showToastNotification('đ´ Call recording started'); + } catch (err) { + console.error('Error starting recording:', err); + alert('Could not start call recording: ' + (err.message || err)); + } +} + +function stopRecording() { + if (mediaRecorder && mediaRecorder.state !== 'inactive') { + mediaRecorder.stop(); + showToastNotification('âšī¸ Stopping recording & saving...'); + } +} + +function startRecordingTimer() { + const timerEl = document.getElementById('recTimer'); + recordingTimerInterval = setInterval(() => { + const elapsedSec = Math.floor((Date.now() - recordingStartTime) / 1000); + const mins = String(Math.floor(elapsedSec / 60)).padStart(2, '0'); + const secs = String(elapsedSec % 60).padStart(2, '0'); + if (timerEl) timerEl.innerText = `REC ${mins}:${secs}`; + }, 1000); +} + +function updateRecordingUI(recording) { + const badge = document.getElementById('recordingStatusBadge'); + const recIconStart = document.getElementById('recIconStart'); + const recIconStop = document.getElementById('recIconStop'); + const btn = document.getElementById('btnToggleRecord'); + + if (recording) { + if (badge) badge.style.display = 'inline-flex'; + if (recIconStart) recIconStart.style.display = 'none'; + if (recIconStop) recIconStop.style.display = 'inline'; + if (btn) { + btn.classList.add('active-rec'); + btn.title = 'Stop Call Recording'; + } + } else { + if (badge) badge.style.display = 'none'; + if (recIconStart) recIconStart.style.display = 'inline'; + if (recIconStop) recIconStop.style.display = 'none'; + if (btn) { + btn.classList.remove('active-rec'); + btn.title = 'Start Call Recording'; + } + } +} + +async function uploadRecordingBlob(blob) { + if (!blob || blob.size === 0) return; + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + const uploadUrl = config.uploadRecordingUrl; + + if (!uploadUrl) return; + + const formData = new FormData(); + const ext = blob.type.includes('mp4') ? 'mp4' : 'webm'; + formData.append('video', blob, `client_call_${config.meetingCode || 'rec'}_${Date.now()}.${ext}`); + + try { + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken, + 'Accept': 'application/json' + }, + body: formData + }); + + const data = await response.json(); + if (data && data.success) { + recordedVideoPath = data.path; + showToastNotification('â Video recording saved & uploaded successfully!'); + showDownloadPrompt(blob, ext); + } + } catch (err) { + console.error('Failed to upload recording:', err); + showToastNotification('â ī¸ Cloud upload delayed. You can download the recording locally.'); + showDownloadPrompt(blob, ext); + } +} + +function showDownloadPrompt(blob, ext) { + const modal = document.getElementById('recordingSavedModal'); + if (modal) { + const downloadBtn = document.getElementById('btnDownloadSavedRec'); + if (downloadBtn) { + const url = URL.createObjectURL(blob); + downloadBtn.href = url; + downloadBtn.download = `Meeting-${config.meetingCode || 'recording'}-${Date.now()}.${ext}`; + } + modal.style.display = 'flex'; + } +} + +function showToastNotification(msg) { + const toast = document.getElementById('toastNotification'); + if (toast) { + toast.innerText = msg; + toast.style.display = 'block'; + setTimeout(() => { toast.style.display = 'none'; }, 3500); + } +} + +/** + * ========================================================================= * Web Speech Recognition for Live Transcription & AI Note Taking + * ========================================================================= */ function initSpeechRecognition() { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; @@ -324,6 +574,7 @@ let outgoingChatMessage = null; function sendChatMessage() { const input = document.getElementById('chatInput'); + if (!input) return; const text = input.value.trim(); if (!text) return; @@ -382,6 +633,8 @@ function renderChat(messages) { function handleActivePeers(peers) { const grid = document.getElementById('videoGrid'); + if (!grid) return; + peers.forEach(peer => { if (peer.peer_id === peerId) return; @@ -391,7 +644,7 @@ function handleActivePeers(peers) { tile.id = 'tile_' + peer.peer_id; tile.className = 'video-tile'; tile.innerHTML = ` -