import 'dart:async'; import 'dart:developer'; import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:provider/provider.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:test_sa/extensions/context_extension.dart'; import 'package:test_sa/extensions/int_extensions.dart'; import 'package:test_sa/extensions/text_extensions.dart'; import 'package:test_sa/extensions/widget_extensions.dart'; import 'package:test_sa/modules/cx_module/chat/chat_provider.dart'; import 'package:test_sa/modules/cx_module/chat/model/call_session.dart'; import 'package:test_sa/new_views/app_style/app_color.dart'; import 'package:test_sa/new_views/app_style/app_themes.dart'; import 'package:test_sa/modules/cx_module/chat/call/widgets/draggable_local_preview.dart'; class VideoCallPage extends StatefulWidget { const VideoCallPage({Key? key}) : super(key: key); @override State createState() => _VideoCallPageState(); } class _VideoCallPageState extends State { Timer? _streamCheckTimer; bool _isLocalVideoFullscreen = false; @override void initState() { super.initState(); if (kDebugMode) { log('🎬 [VIDEO CALL PAGE] Page initialized', name: 'VideoCallPage'); } WidgetsBinding.instance.addPostFrameCallback((_) { final chatProvider = context.read(); // Add listener to detect when call ends chatProvider.addListener(_checkCallStatus); // Start periodic check to ensure remote stream gets assigned _startStreamCheckTimer(); }); } void _startStreamCheckTimer() { // Check every 500ms if remote stream needs to be assigned _streamCheckTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) { final chatProvider = context.read(); final webrtc = chatProvider.webrtcService; if (webrtc == null || !mounted) { timer.cancel(); return; } // If we have remote stream but renderer doesn't have it, assign it if (webrtc.remoteStream != null && webrtc.remoteRenderer != null && webrtc.remoteRenderer!.srcObject == null) { try { webrtc.remoteRenderer!.srcObject = webrtc.remoteStream; // Force UI rebuild if (mounted) { setState(() {}); } } catch (e) { if (kDebugMode) { log('⚠️ [VIDEO CALL PAGE] Stream assignment failed: $e', name: 'VideoCallPage'); } } } // If call is connected and remote stream is assigned, slow down checks if (chatProvider.callStatus == CallStatus.connected && webrtc.remoteRenderer?.srcObject != null) { timer.cancel(); // Switch to slower monitoring (every 5 seconds) Timer.periodic(const Duration(seconds: 5), (slowTimer) { if (!mounted) { slowTimer.cancel(); } }); } }); } void _checkCallStatus() { final chatProvider = context.read(); // If call ended, navigate back if (chatProvider.callStatus == CallStatus.idle && mounted) { chatProvider.removeListener(_checkCallStatus); Navigator.of(context).pop(); } } @override void dispose() { // Cancel stream check timer _streamCheckTimer?.cancel(); _streamCheckTimer = null; // Clean up listener try { context.read().removeListener(_checkCallStatus); } catch (e) { // Provider might already be disposed if (kDebugMode) { log('⚠️ [VIDEO CALL PAGE] Error removing listener: $e', name: 'VideoCallPage'); } } super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppColor.backgroundTabBarDark, body: SafeArea( top: false, child: Selector( selector: (_, provider) => provider.currentCall, builder: (context, session, _) { if (session == null) { return const Center( child: Text( 'No active call', style: TextStyle(color: Colors.white), ), ); } return Stack( children: [ // Background video (fullscreen) _isLocalVideoFullscreen ? _buildFullscreenLocalVideo(context) : _buildRemoteVideo(context), // Small preview video (top-right corner) _isLocalVideoFullscreen ? _buildSmallRemoteVideo(context) : _buildLocalVideo(context), _buildTopBar(context, session), _buildBottomControls(context, session), ], ); }, ), ), ); } // Fullscreen local video (when tapped) Widget _buildFullscreenLocalVideo(BuildContext context) { final chatProvider = context.read(); return Positioned.fill( child: GestureDetector( onTap: () { setState(() { _isLocalVideoFullscreen = false; // Switch back to remote fullscreen }); }, child: Consumer( builder: (context, chatProvider, _) { if (!chatProvider.isCameraOn) { // Show avatar when camera is off return Container( color: Colors.grey[800], child: const Center( child: Icon( Icons.videocam_off, color: Colors.white, size: 64, ), ), ); } if (chatProvider.webrtcService?.localRenderer != null) { return RTCVideoView( chatProvider.webrtcService!.localRenderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: true, ); } return Container( color: Colors.black, child: const Center( child: CircularProgressIndicator(color: Colors.white54), ), ); }, ), ), ); } // Small remote video (when local is fullscreen) Widget _buildSmallRemoteVideo(BuildContext context) { return Positioned( top: MediaQuery.of(context).padding.top + 80, right: 16, child: GestureDetector( onTap: () { setState(() { _isLocalVideoFullscreen = false; // Switch back to remote fullscreen }); }, child: Consumer( builder: (context, chatProvider, _) { final remoteStream = chatProvider.webrtcService?.remoteStream; final remoteRenderer = chatProvider.webrtcService?.remoteRenderer; if (!chatProvider.isPeerCameraOn) { // Show avatar placeholder return Container( width: 120, height: 160, decoration: BoxDecoration( color: AppColor.backgroundTabBarDark, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Icon(Icons.videocam_off, color: Colors.white, size: 32), 8.height, Text( chatProvider.currentCall?.peerName.split(' ').first ?? '', style: AppTextStyles.bodyText2.copyWith( color: Colors.white, fontSize: 12, ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), ); } if (remoteRenderer != null && remoteStream != null && remoteRenderer.srcObject != null) { return Container( width: 120, height: 160, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: RTCVideoView( remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false, ), ), ); } return Container( width: 120, height: 160, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), child: const Center( child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 2), ), ); }, ), ), ); } Widget _buildRemoteVideo(BuildContext context) { return Positioned.fill( child: GestureDetector( onTap: () { setState(() { _isLocalVideoFullscreen = true; // Switch to local fullscreen }); }, child: Consumer( builder: (context, chatProvider, _) { final remoteRenderer = chatProvider.webrtcService?.remoteRenderer; final remoteStream = chatProvider.webrtcService?.remoteStream; if (remoteRenderer != null) { if (remoteRenderer.srcObject == null && remoteStream != null) { // Renderer exists but srcObject is null, assign it WidgetsBinding.instance.addPostFrameCallback((_) { final renderer = chatProvider.webrtcService?.remoteRenderer; final stream = chatProvider.webrtcService?.remoteStream; if (renderer != null && stream != null && renderer.srcObject == null) { renderer.srcObject = stream; // Force UI rebuild if (mounted) { setState(() {}); } } }); } // If renderer has stream OR we just assigned it, show the video view final hasRendererStream = remoteRenderer.srcObject != null; if (hasRendererStream) { return RTCVideoView( remoteRenderer, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: false, ); } } // Fallback while waiting for remote stream return Container( color: Colors.black, child: const Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(color: Colors.white54), SizedBox(height: 16), Text( 'Waiting for video...', style: TextStyle(color: Colors.white54), ), ], ), ), ); }, ), ), ); } Widget _buildRemoteAvatarPlaceholder(BuildContext context) { return Selector( selector: (_, provider) => provider.currentCall, builder: (context, session, _) { if (session == null) return const SizedBox.shrink(); return Container( color: AppColor.backgroundTabBarDark, child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Stack( alignment: Alignment.center, children: [ Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( shape: BoxShape.circle, color: AppColor.whiteF8d, border: Border.all(color: AppColor.white10.withAlpha(51), width: 1), ), child: ClipOval( child: session.peerAvatar != null ? CachedNetworkImage( imageUrl: session.peerAvatar!, fit: BoxFit.cover, placeholder: (context, url) => Center( child: CircularProgressIndicator( color: Colors.white.withAlpha(128), ), ), errorWidget: (context, url, error) => 'call_user_avatar'.toSvgAsset(height: 48, width: 48), ) : 'call_user_avatar'.toSvgAsset(height: 48, width: 48), ), ), // Show mute indicator badge when peer is muted Selector( selector: (_, provider) => provider.isPeerMuted, builder: (context, isPeerMuted, _) { if (!isPeerMuted) return const SizedBox.shrink(); return Positioned( bottom: 0, right: 0, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: AppColor.red30, shape: BoxShape.circle, border: Border.all(color: AppColor.white10, width: 2), ), child: 'mic_disable'.toSvgAsset( width: 16, height: 16, color: AppColor.white10, ), ), ); }, ), ], ), 24.height, Text( session.peerName, style: AppTextStyles.heading2.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), 8.height, Text( 'Camera is off', style: AppTextStyles.heading5.copyWith( color: AppColor.neutral100, fontWeight: FontWeight.w400, ), ), // Show mute status when peer is muted Selector( selector: (_, provider) => provider.isPeerMuted, builder: (context, isPeerMuted, _) { if (!isPeerMuted) return const SizedBox.shrink(); return Column( children: [ 8.height, Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: AppColor.red30.withOpacity(0.3), borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ 'mic_disable'.toSvgAsset( width: 14, height: 14, color: AppColor.white10, ), 6.width, Text( 'Microphone is off', style: AppTextStyles.bodyText2.copyWith( color: AppColor.white10, fontSize: 12, ), ), ], ), ), ], ); }, ), ], ), )); }, ); } Widget _buildLocalVideo(BuildContext context) { final chatProvider = context.read(); return Positioned( top: MediaQuery.of(context).padding.top + 80, right: 16, child: GestureDetector( onTap: () { setState(() { _isLocalVideoFullscreen = true; // Make local video fullscreen }); }, child: Selector( selector: (_, provider) => provider.isCameraOn, builder: (context, isCameraOn, _) { if (!isCameraOn) { return Container( width: 120, height: 160, decoration: BoxDecoration( color: Colors.grey[800], borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), child: const Icon( Icons.videocam_off, color: Colors.white, size: 32, ), ); } if (chatProvider.webrtcService?.localRenderer != null) { return Container( width: 120, height: 160, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: RTCVideoView( chatProvider.webrtcService!.localRenderer!, objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, mirror: true, ), ), ); } return Container( width: 120, height: 160, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white, width: 2), ), child: const Center( child: CircularProgressIndicator(color: Colors.white54, strokeWidth: 2), ), ); }, ), ), ); } Widget _buildTopBar(BuildContext context, CallSession session) { return Positioned( top: 0, left: 0, right: 0, child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.black.withOpacity(0.7), Colors.transparent, ], ), ), padding: EdgeInsets.only( top: MediaQuery.of(context).padding.top + 8, left: 16, right: 16, bottom: 24, ), child: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), onPressed: () => Navigator.pop(context), ), Expanded( child: Column( children: [ Text( session.peerName, style: AppTextStyles.bodyText2.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), ), 4.height, _buildCallStatusAndDuration(), ], ), ), const SizedBox(width: 48), ], ), ), ); } Widget _buildCallStatusAndDuration() { return Selector( selector: (_, provider) => provider.callDuration, builder: (context, duration, _) { if (duration.inSeconds == 0) { return Selector( selector: (_, provider) => provider.callStatus, builder: (context, status, _) { String statusText; switch (status) { case CallStatus.outgoingRinging: statusText = 'Calling...'; break; case CallStatus.connecting: statusText = 'Connecting...'; break; default: statusText = ''; } return Text( statusText, style: AppTextStyles.bodyText.copyWith( color: Colors.white70, ), ); }, ); } String twoDigits(int n) => n.toString().padLeft(2, '0'); final minutes = twoDigits(duration.inMinutes.remainder(60)); final seconds = twoDigits(duration.inSeconds.remainder(60)); return Text( '$minutes:$seconds', style: AppTextStyles.bodyText.copyWith( color: Colors.white, fontFeatures: [const FontFeature.tabularFigures()], ), ); }, ); } Widget _buildBottomControls(BuildContext context, CallSession session) { final chatProvider = context.read(); return Positioned( bottom: 0, left: 0, right: 0, child: Container( decoration: BoxDecoration( gradient: const LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [ Color.fromRGBO(65, 67, 82, 0.7), Color.fromRGBO(105, 109, 133, 0.7), Color.fromRGBO(65, 67, 82, 0.7), ], stops: [0.0, 0.5, 1.0], ), borderRadius: BorderRadius.circular(10), border: Border.all(color: AppColor.white10.withAlpha(51)), ), padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, children: [ // Name with mute indicator on the right Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( session.peerName, style: AppTextStyles.heading2.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), // Mute indicator badge (top-right of name) Selector( selector: (_, provider) => provider.isPeerMuted, builder: (context, isPeerMuted, _) { if (!isPeerMuted) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(left: 8), child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColor.red30, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColor.white10.withOpacity(0.3), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ 'mic_disable'.toSvgAsset( width: 14, height: 14, color: AppColor.white10, ), 4.width, Text( 'Muted', style: AppTextStyles.bodyText2.copyWith( color: AppColor.white10, fontSize: 12, fontWeight: FontWeight.w600, ), ), ], ), ), ); }, ), ], ), 16.height, _buildCallStatusOrDuration(context), 16.height, Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Selector( selector: (_, provider) => provider.isMuted, builder: (context, isMuted, _) { return _buildVideoControlButton( icon: isMuted ? 'mic_disable' : 'mic_enable', isActive: isMuted, onPressed: () => chatProvider.toggleMute(), ); }, ), // Camera toggle Selector( selector: (_, provider) => provider.isCameraOn, builder: (context, isCameraOn, _) { return _buildVideoControlButton( icon: isCameraOn ? 'video_enable' : 'video_disable', isActive: !isCameraOn, onPressed: () => chatProvider.toggleCamera(), ); }, ), // Switch camera _buildVideoControlButton( icon: 'toggle_camera', isActive: false, onPressed: () => chatProvider.switchCamera(), ), // Speaker Selector( selector: (_, provider) => provider.isSpeakerOn, builder: (context, isSpeakerOn, _) { return _buildVideoControlButton( icon: 'speaker_enable', isActive: isSpeakerOn, onPressed: () => chatProvider.toggleSpeaker(), ); }, ), _buildVideoControlButton( icon: 'end_call', isHangUp: true, showBorder: false, onPressed: () { chatProvider.hangUp(); // Removed Navigator.pop() - automatic listener will handle navigation }, ), ], ), ], ), ).paddingOnly(bottom: 16, start: 16, end: 16), ); } Widget _buildCallStatusOrDuration(BuildContext context) { return Selector( selector: (_, provider) => provider.callStatus, builder: (context, status, _) { String statusText; switch (status) { case CallStatus.outgoingRinging: statusText = 'Calling...'; break; case CallStatus.incomingRinging: statusText = 'Incoming call...'; break; case CallStatus.connecting: statusText = 'Connecting...'; break; case CallStatus.connected: return _buildDuration(context); default: statusText = ''; } return Text( statusText, style: AppTextStyles.heading5.copyWith( color: AppColor.neutral100, fontWeight: FontWeight.w400, ), ); }, ); } Widget _buildDuration(BuildContext context) { return Selector( selector: (_, provider) => provider.callDuration, builder: (context, duration, _) { if (duration.inSeconds == 0) { return const SizedBox.shrink(); } String twoDigits(int n) => n.toString().padLeft(2, '0'); final minutes = twoDigits(duration.inMinutes.remainder(60)); final seconds = twoDigits(duration.inSeconds.remainder(60)); return Text( '$minutes:$seconds', style: AppTextStyles.heading5.copyWith( color: AppColor.neutral100, fontWeight: FontWeight.w400, fontFeatures: [const FontFeature.tabularFigures()], ), ); }, ); } Widget _buildVideoControlButton({ required String icon, bool? isActive, bool isHangUp = false, bool showBorder = true, required VoidCallback onPressed, }) { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: isHangUp ? AppColor.red30 : isActive != null && isActive ? AppColor.primary10 : AppColor.black2E, shape: BoxShape.circle, border: showBorder ? BoxBorder.all(color: AppColor.white10.withOpacity(0.2)) : null), child: icon.toSvgAsset(width: 24, height: 24, color: AppColor.white10), ).onPress(() { onPressed(); }); } }