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 = ` -
${peer.peer_name.charAt(0).toUpperCase()}
+
${(peer.peer_name || 'U').charAt(0).toUpperCase()}
${escapeHtml(peer.peer_name)}
@@ -417,6 +670,12 @@ async function endMeetingAndGenerateMoM() { return; } + // Stop recording if running and allow a moment to complete + if (isRecording && mediaRecorder && mediaRecorder.state !== 'inactive') { + stopRecording(); + await new Promise(resolve => setTimeout(resolve, 800)); + } + const liveNotes = document.getElementById('liveNotesInput')?.value || ''; const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); @@ -425,7 +684,7 @@ async function endMeetingAndGenerateMoM() { const btn = document.querySelector('.btn-end-call'); if (btn) { btn.disabled = true; - btn.innerText = 'Generating AI MoM & Dispatching Emails...'; + btn.innerText = 'Generating AI MoM & Saving Notes...'; } try { @@ -439,7 +698,8 @@ async function endMeetingAndGenerateMoM() { body: JSON.stringify({ transcript: transcriptText, live_notes: liveNotes, - emotion_timeline: emotionTimeline + emotion_timeline: emotionTimeline, + recording_path: recordedVideoPath }) }); @@ -450,20 +710,23 @@ async function endMeetingAndGenerateMoM() { window.location.href = config.redirectUrl; } } catch (e) { - alert('Meeting ended. Redirecting to Client Hub...'); + alert('Meeting completed. Redirecting to Client Hub...'); window.location.href = config.redirectUrl; } } function leaveCall() { if (confirm('Leave this meeting?')) { + if (isRecording) { + stopRecording(); + } window.location.href = config.redirectUrl || '/'; } } function escapeHtml(str) { if (!str) return ''; - return str.replace(/[&<>"']/g, function(m) { + return String(str).replace(/[&<>"']/g, function(m) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; }); } diff --git a/resources/js/client-call.js b/resources/js/client-call.js index 96e1e5b..2d4b4ed 100644 --- a/resources/js/client-call.js +++ b/resources/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; @@ -86,6 +99,7 @@ function toggleAudio() { const btn = document.getElementById('btnToggleMic'); if (btn) { btn.classList.toggle('active-off', isAudioMuted); + btn.title = isAudioMuted ? 'Unmute Microphone' : 'Mute Microphone'; } } } @@ -99,6 +113,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'; } } } @@ -107,7 +122,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]; for (let id in peerConnections) { @@ -149,7 +164,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; @@ -318,6 +568,7 @@ let outgoingChatMessage = null; function sendChatMessage() { const input = document.getElementById('chatInput'); + if (!input) return; const text = input.value.trim(); if (!text) return; @@ -374,6 +625,8 @@ function renderChat(messages) { function handleActivePeers(peers) { const grid = document.getElementById('videoGrid'); + if (!grid) return; + peers.forEach(peer => { if (peer.peer_id === peerId) return; @@ -383,7 +636,7 @@ function handleActivePeers(peers) { tile.id = 'tile_' + peer.peer_id; tile.className = 'video-tile'; tile.innerHTML = ` -
${peer.peer_name.charAt(0).toUpperCase()}
+
${(peer.peer_name || 'U').charAt(0).toUpperCase()}
${escapeHtml(peer.peer_name)}
@@ -408,6 +661,12 @@ async function endMeetingAndGenerateMoM() { return; } + // Stop recording if running and allow a moment to complete + if (isRecording && mediaRecorder && mediaRecorder.state !== 'inactive') { + stopRecording(); + await new Promise(resolve => setTimeout(resolve, 800)); + } + const liveNotes = document.getElementById('liveNotesInput')?.value || ''; const transcriptText = accumulatedTranscript.map(l => `[${l.time}] ${l.speaker}: ${l.text}`).join("\n"); const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); @@ -415,7 +674,7 @@ async function endMeetingAndGenerateMoM() { const btn = document.querySelector('.btn-end-call'); if (btn) { btn.disabled = true; - btn.innerText = 'Generating AI MoM & Dispatching Emails...'; + btn.innerText = 'Generating AI MoM & Saving Notes...'; } try { @@ -429,7 +688,8 @@ async function endMeetingAndGenerateMoM() { body: JSON.stringify({ transcript: transcriptText, live_notes: liveNotes, - emotion_timeline: emotionTimeline + emotion_timeline: emotionTimeline, + recording_path: recordedVideoPath }) }); @@ -440,20 +700,23 @@ async function endMeetingAndGenerateMoM() { window.location.href = config.redirectUrl; } } catch (e) { - alert('Meeting ended. Redirecting to Client Hub...'); + alert('Meeting completed. Redirecting to Client Hub...'); window.location.href = config.redirectUrl; } } function leaveCall() { if (confirm('Leave this meeting?')) { + if (isRecording) { + stopRecording(); + } window.location.href = config.redirectUrl || '/'; } } function escapeHtml(str) { if (!str) return ''; - return str.replace(/[&<>"']/g, function(m) { + return String(str).replace(/[&<>"']/g, function(m) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]; }); } diff --git a/resources/views/client_calls/guest_join.blade.php b/resources/views/client_calls/guest_join.blade.php index 55279b0..51c46df 100644 --- a/resources/views/client_calls/guest_join.blade.php +++ b/resources/views/client_calls/guest_join.blade.php @@ -118,6 +118,10 @@ font-size: 0.75rem; color: var(--text-muted); } + @media (max-width: 480px) { + .join-card { padding: 1.75rem 1.25rem; } + .meeting-title { font-size: 1.25rem; } + } diff --git a/resources/views/client_calls/index.blade.php b/resources/views/client_calls/index.blade.php index b79ea36..ca90a8f 100644 --- a/resources/views/client_calls/index.blade.php +++ b/resources/views/client_calls/index.blade.php @@ -3,11 +3,12 @@ + Client Calls & Projects Hub | SingleLogin - + diff --git a/resources/views/client_calls/logs.blade.php b/resources/views/client_calls/logs.blade.php index be970f9..8693e48 100644 --- a/resources/views/client_calls/logs.blade.php +++ b/resources/views/client_calls/logs.blade.php @@ -8,7 +8,7 @@ - + diff --git a/resources/views/client_calls/room.blade.php b/resources/views/client_calls/room.blade.php index d730574..db4fc6c 100644 --- a/resources/views/client_calls/room.blade.php +++ b/resources/views/client_calls/room.blade.php @@ -28,15 +28,19 @@ justify-content: space-between; align-items: center; padding: 0.75rem 1.5rem; - background: rgba(17, 24, 39, 0.7); - backdrop-filter: blur(12px); + background: rgba(17, 24, 39, 0.75); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); border-bottom: 1px solid rgba(255, 255, 255, 0.1); z-index: 20; + flex-wrap: wrap; + gap: 10px; } .header-left { display: flex; align-items: center; gap: 12px; + flex-wrap: wrap; } .live-tag { background: #ef4444; @@ -46,6 +50,7 @@ border-radius: 999px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.5px; animation: pulse-live 2s infinite; } @keyframes pulse-live { @@ -61,11 +66,38 @@ } .call-timer { font-family: monospace; - font-size: 0.9rem; + font-size: 0.85rem; color: #9ca3af; background: rgba(255, 255, 255, 0.06); padding: 3px 8px; border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.08); + } + + /* Live Recording Status Badge */ + .rec-badge { + display: inline-flex; + align-items: center; + gap: 6px; + background: rgba(239, 68, 68, 0.2); + border: 1px solid rgba(239, 68, 68, 0.5); + color: #f87171; + padding: 3px 10px; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; + } + .rec-dot { + width: 8px; + height: 8px; + background: #ef4444; + border-radius: 50%; + animation: pulse-rec 1s infinite alternate; + } + @keyframes pulse-rec { + 0% { opacity: 0.3; transform: scale(0.85); } + 100% { opacity: 1; transform: scale(1.2); } } /* Real-Time Live Emotion Indicator Badge */ @@ -116,7 +148,7 @@ width: 100%; height: 100%; max-height: 100%; - grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); justify-items: center; align-items: center; } @@ -127,8 +159,8 @@ border-radius: 16px; width: 100%; height: 100%; - min-height: 240px; - max-height: 520px; + min-height: 220px; + max-height: 540px; overflow: hidden; display: flex; align-items: center; @@ -148,7 +180,7 @@ position: absolute; bottom: 12px; left: 12px; - background: rgba(0, 0, 0, 0.6); + background: rgba(0, 0, 0, 0.65); backdrop-filter: blur(6px); padding: 4px 10px; border-radius: 8px; @@ -159,6 +191,7 @@ gap: 6px; color: white; z-index: 10; + border: 1px solid rgba(255, 255, 255, 0.1); } .video-avatar-placeholder { @@ -173,6 +206,7 @@ font-size: 2rem; font-weight: 800; font-family: 'Outfit', sans-serif; + box-shadow: 0 4px 14px rgba(79, 70, 229, 0.4); } /* Collapsible Side Panel (AI Notes & Chat) */ @@ -210,6 +244,16 @@ color: #9ca3af; cursor: pointer; font-size: 1.2rem; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + } + .panel-close-btn:hover { + color: white; + background: rgba(255, 255, 255, 0.1); } .panel-tabs { @@ -226,6 +270,7 @@ font-size: 0.8rem; font-weight: 600; cursor: pointer; + transition: all 0.2s ease; } .panel-tab.active { color: #818cf8; @@ -280,6 +325,9 @@ outline: none; resize: none; } + .notes-textarea:focus { + border-color: #6366f1; + } /* Chat messages */ .chat-feed { @@ -318,18 +366,23 @@ font-size: 0.8rem; outline: none; } + .chat-input:focus { + border-color: #6366f1; + } /* Bottom Controls Bar */ .call-footer { display: flex; justify-content: center; align-items: center; - padding: 1rem 1.5rem; - background: rgba(17, 24, 39, 0.85); - backdrop-filter: blur(12px); + padding: 0.85rem 1.5rem; + background: rgba(17, 24, 39, 0.88); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); border-top: 1px solid rgba(255, 255, 255, 0.1); gap: 12px; z-index: 20; + flex-wrap: wrap; } .ctrl-btn { @@ -352,11 +405,23 @@ .ctrl-btn.active-off { background: #ef4444 !important; border-color: #ef4444 !important; + color: white !important; } .ctrl-btn.active-on { background: #4f46e5 !important; border-color: #4f46e5 !important; } + .ctrl-btn.active-rec { + background: rgba(239, 68, 68, 0.25) !important; + border-color: #ef4444 !important; + color: #f87171 !important; + animation: pulse-rec-btn 1.5s infinite; + } + @keyframes pulse-rec-btn { + 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.5); } + 70% { box-shadow: 0 0 0 8px rgba(239, 68, 68, 0); } + 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } + } .btn-end-call { padding: 0 1.25rem; @@ -377,23 +442,78 @@ .btn-end-call:hover { background: #dc2626; transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(239, 68, 68, 0.6); } + /* Toast & Modal */ .toast-notify { position: fixed; - bottom: 80px; + bottom: 85px; left: 50%; transform: translateX(-50%); background: rgba(17, 24, 39, 0.95); border: 1px solid #4f46e5; padding: 10px 20px; - border-radius: 8px; + border-radius: 10px; color: white; font-size: 0.85rem; display: none; z-index: 100; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); } + + /* Modal Overlay */ + .modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(11, 15, 25, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: none; + justify-content: center; + align-items: center; + z-index: 120; + padding: 1.5rem; + } + .modal-card { + background: rgba(17, 24, 39, 0.96); + backdrop-filter: blur(20px); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 20px; + width: 100%; + max-width: 480px; + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.6); + padding: 1.75rem; + text-align: center; + } + + /* Responsive Breakpoints */ + @media (max-width: 768px) { + .call-header { padding: 0.5rem 1rem; } + .meeting-title { font-size: 0.95rem; max-width: 150px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .emotion-live-indicator { font-size: 0.72rem; padding: 3px 8px; } + .side-panel { + position: absolute; + top: 0; right: 0; bottom: 0; + width: 100%; + max-width: 320px; + background: rgba(17, 24, 39, 0.97); + backdrop-filter: blur(20px); + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.7); + } + .ctrl-btn { width: 42px; height: 42px; } + .btn-end-call { height: 42px; font-size: 0.8rem; padding: 0 1rem; } + .call-footer { gap: 8px; padding: 0.65rem 0.75rem; } + .video-grid { + grid-template-columns: 1fr; + } + } + @media (max-width: 480px) { + .meeting-title { max-width: 110px; } + .call-timer { font-size: 0.75rem; padding: 2px 6px; } + .ctrl-btn { width: 38px; height: 38px; } + .btn-end-call { height: 38px; font-size: 0.75rem; padding: 0 0.8rem; } + } @@ -402,13 +522,19 @@
LIVE -
{{ $meeting->title }}
+
{{ $meeting->title }}
00:00
+ + +
-
+
-
+
😐 Client Emotion: Neutral
@@ -440,12 +566,12 @@
✨ AI Meeting Assistant
- +
- +
@@ -466,7 +592,7 @@
@if($isHost) - @endif @@ -502,6 +628,17 @@ + + + +
+ + + @@ -536,9 +692,10 @@ isHost: {{ $isHost ? 'true' : 'false' }}, isGuest: {{ $isGuest ? 'true' : 'false' }}, detectEmotionUrl: '{{ route('client-calls.detect-emotion', $meeting->meeting_code) }}', + uploadRecordingUrl: '{{ route('client-calls.upload-recording', $meeting->meeting_code) }}', endCallUrl: '{{ route('client-calls.end-call', $meeting->meeting_code) }}', heartbeatUrl: '{{ route('client-calls.heartbeat', $meeting->meeting_code) }}', - redirectUrl: '{{ route('client-calls.show', $client->id ?? $meeting->client_id) }}' + redirectUrl: '{{ $isGuest ? url('/') : route('client-calls.show', $client->id ?? $meeting->client_id) }}' }; @@ -548,9 +705,11 @@ diff --git a/resources/views/client_calls/show.blade.php b/resources/views/client_calls/show.blade.php index a7fec40..46c04d5 100644 --- a/resources/views/client_calls/show.blade.php +++ b/resources/views/client_calls/show.blade.php @@ -3,11 +3,12 @@ + {{ $client->project_name }} | Client Call Hub | SingleLogin - + diff --git a/routes/web.php b/routes/web.php index 364d43e..f93cff8 100644 --- a/routes/web.php +++ b/routes/web.php @@ -98,14 +98,19 @@ Route::get('/client-calls/notes/{id}/download-todos', [ClientCallController::class, 'downloadTodos'])->name('client-calls.download-todos'); Route::get('/client-calls/notes/{id}/emotion-details', [ClientCallController::class, 'getEmotionDetails'])->name('client-calls.emotion-details'); Route::post('/client-calls/notes/{id}/toggle-todo/{todoId}', [ClientCallController::class, 'toggleTodo'])->name('client-calls.toggle-todo'); + Route::post('/client-calls/notes/{id}/add-todo', [ClientCallController::class, 'addTodo'])->name('client-calls.add-todo'); + Route::delete('/client-calls/notes/{id}/delete-todo/{todoId}', [ClientCallController::class, 'deleteTodo'])->name('client-calls.delete-todo'); Route::get('/client-calls/notes/{id}/angry-analysis', [ClientCallController::class, 'getAngryAnalysis'])->name('client-calls.angry-analysis'); Route::post('/client-calls/notes/{id}/resend-email', [ClientCallController::class, 'resendMoMEmail'])->name('client-calls.resend-email'); + Route::get('/client-calls/meetings/{id}/download-recording', [ClientCallController::class, 'downloadMeetingRecording'])->name('client-calls.download-meeting-recording'); + Route::get('/client-calls/notes/{id}/download-recording', [ClientCallController::class, 'downloadNoteRecording'])->name('client-calls.download-note-recording'); }); // Client Video Call Room (Accessible to authenticated internal members & external guest clients via link) Route::get('/client-calls/room/{code}', [ClientCallController::class, 'showRoom'])->name('client-calls.room'); Route::post('/client-calls/room/{code}/join-guest', [ClientCallController::class, 'joinGuest'])->name('client-calls.join-guest'); Route::post('/client-calls/room/{code}/detect-emotion', [ClientCallController::class, 'detectEmotion'])->name('client-calls.detect-emotion'); +Route::post('/client-calls/room/{code}/upload-recording', [ClientCallController::class, 'uploadRecording'])->name('client-calls.upload-recording'); Route::post('/client-calls/room/{code}/end-call', [ClientCallController::class, 'endCallAndGenerateMoM'])->name('client-calls.end-call'); Route::post('/client-calls/room/{code}/heartbeat', [ClientCallController::class, 'signalingHeartbeat'])->name('client-calls.heartbeat'); diff --git a/tests/Feature/ClientCallFeatureTest.php b/tests/Feature/ClientCallFeatureTest.php index e19be4c..c81da8e 100644 --- a/tests/Feature/ClientCallFeatureTest.php +++ b/tests/Feature/ClientCallFeatureTest.php @@ -458,4 +458,328 @@ public function test_can_view_call_logs_and_download_mom_and_todos(): void $this->assertTrue($note->fresh()->todo_items[0]['completed']); } + + public function test_client_list_does_not_display_sentiment_badges_on_cards(): void + { + $client = Client::create([ + 'client_name' => 'Alice Wonder', + 'client_email' => 'alice@wonderland.com', + 'project_name' => 'Wonder App', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + 'latest_sentiment' => 'angry', + ]); + + $response = $this->actingAs($this->pm) + ->get(route('client-calls.index')); + + $response->assertStatus(200) + ->assertSee('Wonder App') + ->assertSee('Alice Wonder') + ->assertDontSee('sentiment-angry') + ->assertDontSee('sentiment-happy'); + } + + public function test_client_hub_shows_date_wise_calls_section_and_tasks(): void + { + $client = Client::create([ + 'client_name' => 'David Warner', + 'client_email' => 'david@cricket.io', + 'project_name' => 'Live Score System', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Initial Sprint Review', + 'meeting_code' => 'meet-score-1', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'summary' => 'Reviewed live scoring latency.', + 'mom_content' => 'Full MoM details here.', + 'detected_sentiment' => 'happy', + 'todo_items' => [ + ['id' => 1, 'task' => 'Optimize websocket latency', 'owner' => 'Dev Team', 'priority' => 'High', 'deadline' => 'Aug 24, 2026', 'completed' => false], + ], + ]); + + // Access Client Hub / Show page + $response = $this->actingAs($this->pm) + ->get(route('client-calls.show', $client->id)); + + $response->assertStatus(200) + ->assertSee('Live Score System') + ->assertSee('Date-wise Call History & MoM Details', false) + ->assertSee('Initial Sprint Review') + ->assertSee('Optimize websocket latency') + ->assertSee('Happy / Satisfied'); + } + + public function test_add_and_delete_todo_items(): void + { + $client = Client::create([ + 'client_name' => 'Emma Watson', + 'client_email' => 'emma@cinema.com', + 'project_name' => 'Film Portal', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Post-Production Sync', + 'meeting_code' => 'meet-film-1', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'summary' => 'Sync on release roadmap.', + 'mom_content' => 'MoM Content.', + 'detected_sentiment' => 'neutral', + 'todo_items' => [ + ['id' => 1, 'task' => 'First Task', 'owner' => 'PM', 'priority' => 'Medium', 'deadline' => 'TBD', 'completed' => false] + ], + ]); + + // 1. Add new To-Do + $addRes = $this->actingAs($this->pm) + ->postJson(route('client-calls.add-todo', $note->id), [ + 'task' => 'Deploy video CDN endpoints', + 'owner' => 'Alex Dev', + 'priority' => 'High', + 'deadline' => 'Aug 26, 2026', + ]); + + $addRes->assertStatus(200) + ->assertJson([ + 'success' => true, + 'todo' => [ + 'task' => 'Deploy video CDN endpoints', + 'owner' => 'Alex Dev', + 'priority' => 'High', + ] + ]); + + $this->assertCount(2, $note->fresh()->todo_items); + + // 2. Delete the first To-Do + $delRes = $this->actingAs($this->pm) + ->deleteJson(route('client-calls.delete-todo', ['id' => $note->id, 'todoId' => 1])); + + $delRes->assertStatus(200) + ->assertJson(['success' => true]); + + $this->assertCount(1, $note->fresh()->todo_items); + $this->assertEquals('Deploy video CDN endpoints', $note->fresh()->todo_items[0]['task']); + } + + public function test_upload_recording_endpoint_saves_file_and_updates_database(): void + { + \Illuminate\Support\Facades\Storage::fake('public'); + + $client = Client::create([ + 'client_name' => 'Sara Connor', + 'client_email' => 'sara@cyberdyne.com', + 'project_name' => 'AI Defense System', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Weekly Sync with Cyberdyne', + 'meeting_code' => 'meet-cyber-rec-1', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'summary' => 'Ongoing call notes', + 'mom_content' => 'MoM in progress', + 'detected_sentiment' => 'happy', + ]); + + $fakeVideo = \Illuminate\Http\UploadedFile::fake()->create('call_recording.webm', 2048, 'video/webm'); + + $response = $this->post(route('client-calls.upload-recording', $meeting->meeting_code), [ + 'video' => $fakeVideo, + ]); + + $response->assertStatus(200) + ->assertJson([ + 'success' => true, + ]); + + $meeting->refresh(); + $note->refresh(); + + $this->assertNotNull($meeting->recording_path); + $this->assertNotEmpty($meeting->recordings); + $this->assertNotNull($note->recording_path); + $this->assertNotEmpty($note->recordings); + $this->assertFileExists(public_path(ltrim($meeting->recording_path, '/'))); + + // Clean up created test recording file + @unlink(public_path(ltrim($meeting->recording_path, '/'))); + } + + public function test_meeting_and_note_recording_downloads(): void + { + $client = Client::create([ + 'client_name' => 'Thomas Anderson', + 'client_email' => 'neo@matrix.io', + 'project_name' => 'Matrix Simulator', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Matrix Review', + 'meeting_code' => 'meet-matrix-99', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + ]); + + // Create a dummy video file in public storage + $dir = public_path('uploads/client_recordings/' . $meeting->meeting_code); + if (!file_exists($dir)) { + mkdir($dir, 0777, true); + } + $testFilePath = $dir . '/test_rec.webm'; + file_put_contents($testFilePath, 'dummy webm content'); + + $relPath = 'uploads/client_recordings/' . $meeting->meeting_code . '/test_rec.webm'; + $meeting->update(['recording_path' => $relPath]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'summary' => 'Discussion on simulated physics.', + 'mom_content' => 'MoM details here.', + 'recording_path' => $relPath, + ]); + + // 1. Download meeting recording + $resMeeting = $this->actingAs($this->pm) + ->get(route('client-calls.download-meeting-recording', $meeting->id)); + $resMeeting->assertStatus(200); + + // 2. Download note recording + $resNote = $this->actingAs($this->pm) + ->get(route('client-calls.download-note-recording', $note->id)); + $resNote->assertStatus(200); + + // Clean up + @unlink($testFilePath); + @rmdir($dir); + } + + public function test_ending_meeting_links_recording_path_to_call_notes(): void + { + Mail::fake(); + + $client = Client::create([ + 'client_name' => 'Bruce Wayne', + 'client_email' => 'bruce@waynecorp.com', + 'project_name' => 'Batmobile OS', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Batmobile Specs Call', + 'meeting_code' => 'meet-bat-101', + 'host_user_id' => $this->pm->id, + 'status' => 'in_progress', + ]); + + $response = $this->actingAs($this->pm) + ->postJson(route('client-calls.end-call', $meeting->meeting_code), [ + 'transcript' => '[10:00 AM] PM: Testing client recording flow.', + 'live_notes' => 'Armor upgrades agreed.', + 'emotion_timeline' => [['emotion' => 'happy', 'time' => time()]], + 'recording_path' => 'uploads/client_recordings/meet-bat-101/rec.webm', + ]); + + $response->assertStatus(200) + ->assertJsonStructure(['success', 'redirect_url', 'note_id']); + + $note = ClientCallNote::where('client_meeting_id', $meeting->id)->first(); + $this->assertNotNull($note); + $this->assertEquals('uploads/client_recordings/meet-bat-101/rec.webm', $note->recording_path); + $this->assertNotEmpty($note->recordings); + + $meeting->refresh(); + $this->assertEquals('uploads/client_recordings/meet-bat-101/rec.webm', $meeting->recording_path); + } + + public function test_client_hub_and_logs_pages_render_recording_actions_when_present(): void + { + $client = Client::create([ + 'client_name' => 'Diana Prince', + 'client_email' => 'diana@themyscira.org', + 'project_name' => 'Shield Security', + 'responsible_user_id' => $this->pm->id, + 'created_by' => $this->admin->id, + 'status' => 'active', + ]); + + $meeting = ClientMeeting::create([ + 'client_id' => $client->id, + 'title' => 'Defense Protocol Sync', + 'meeting_code' => 'meet-shield-202', + 'host_user_id' => $this->pm->id, + 'status' => 'completed', + 'recording_path' => 'uploads/client_recordings/meet-shield-202/rec.webm', + ]); + + $note = ClientCallNote::create([ + 'client_meeting_id' => $meeting->id, + 'client_id' => $client->id, + 'created_by' => $this->pm->id, + 'summary' => 'Reviewed aegis defense protocols.', + 'mom_content' => 'Full MoM details here.', + 'detected_sentiment' => 'happy', + 'recording_path' => 'uploads/client_recordings/meet-shield-202/rec.webm', + ]); + + // 1. Check Show page renders Video Call Recording + $showRes = $this->actingAs($this->pm) + ->get(route('client-calls.show', $client->id)); + $showRes->assertStatus(200) + ->assertSee('Video Call Recording') + ->assertSee('Watch Recording') + ->assertSee('Download Video'); + + // 2. Check Logs page renders Recorded badge & Play Video button + $logsRes = $this->actingAs($this->pm) + ->get(route('client-calls.logs')); + $logsRes->assertStatus(200) + ->assertSee('Recorded') + ->assertSee('Play Video') + ->assertSee('Download Video Recording', false); + } }