// Version: 1.3.0 | Created: 2026-04-01 | Updated: 2026-04-03 // Full call screen with LiveKit video/audio. // - Remote video: full screen background // - Local video: picture-in-picture overlay (bottom right) // - Controls: mute, toggle video, end call // - Duration timer and participant name import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart' show RTCVideoViewObjectFit; import 'package:go_router/go_router.dart'; import 'package:livekit_client/livekit_client.dart'; import '../data/livekit_service.dart'; import '../domain/call_state.dart'; import 'call_controller.dart'; class CallScreen extends ConsumerStatefulWidget { const CallScreen({ super.key, required this.roomId, this.isVideo = true, }); final String roomId; final bool isVideo; @override ConsumerState createState() => _CallScreenState(); } class _CallScreenState extends ConsumerState { @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { ref.read(callControllerProvider.notifier).joinCall( Uri.decodeComponent(widget.roomId), withVideo: widget.isVideo, ); }); } @override Widget build(BuildContext context) { final callState = ref.watch(callControllerProvider); // On call ended: show error reason for 3 seconds before popping, // so the user can see WHY the call failed. ref.listen(callControllerProvider, (_, next) { if (next is CallEnded && mounted) { final reason = next.reason; if (reason != null && reason.isNotEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(reason), backgroundColor: Theme.of(context).colorScheme.error, duration: const Duration(seconds: 3), ), ); } // Delay pop so the user sees the error. final nav = GoRouter.of(context); Future.delayed(const Duration(seconds: 3), () { if (mounted) nav.pop(); }); } }); return Scaffold( backgroundColor: Colors.black, body: SafeArea( child: Stack( children: [ // Remote video — full screen const _RemoteVideoView(), // Local PiP — bottom right if (callState is CallActive && callState.isVideoEnabled) const _LocalVideoPip(), // Connecting / connecting overlay if (callState is CallConnecting) const _ConnectingOverlay(), // Call controls — pinned to bottom Positioned( left: 0, right: 0, bottom: 24, child: _CallControls(roomId: widget.roomId), ), // Participant info — top left Positioned( top: 16, left: 16, child: _CallInfo(callState: callState), ), ], ), ), ); } } // --------------------------------------------------------------------------- // Remote video view // --------------------------------------------------------------------------- /// Listens to the LiveKit Room's ChangeNotifier so it rebuilds when /// remote participants join/leave or publish/unpublish tracks. class _RemoteVideoView extends ConsumerWidget { const _RemoteVideoView(); @override Widget build(BuildContext context, WidgetRef ref) { final room = ref.watch(liveKitServiceProvider).activeRoom; if (room == null) { return const _NoVideoPlaceholder(); } // Room extends ChangeNotifier — rebuilds on every participant/track event. return ListenableBuilder( listenable: room, builder: (context, _) { final remoteParticipants = room.remoteParticipants.values.toList(); if (remoteParticipants.isEmpty) { return const _NoVideoPlaceholder(); } final firstParticipant = remoteParticipants.first; final videoPubs = firstParticipant.videoTrackPublications; if (videoPubs.isEmpty || videoPubs.first.track == null) { // Participant joined but no video track yet — show audio indicator. return _AudioOnlyPlaceholder(name: firstParticipant.identity); } final videoTrack = videoPubs.first.track!; return VideoTrackRenderer( videoTrack, fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover, ); }, ); } } // --------------------------------------------------------------------------- // Local PiP // --------------------------------------------------------------------------- class _LocalVideoPip extends ConsumerWidget { const _LocalVideoPip(); @override Widget build(BuildContext context, WidgetRef ref) { final room = ref.watch(liveKitServiceProvider).activeRoom; if (room == null) return const SizedBox.shrink(); return ListenableBuilder( listenable: room, builder: (context, _) { final local = room.localParticipant; if (local == null) return const SizedBox.shrink(); final videoPubs = local.videoTrackPublications; if (videoPubs.isEmpty || videoPubs.first.track == null) { return const SizedBox.shrink(); } final localTrack = videoPubs.first.track!; return Positioned( right: 16, bottom: 120, child: Container( width: 100, height: 150, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: Colors.white38, width: 1), ), clipBehavior: Clip.hardEdge, child: VideoTrackRenderer( localTrack, mirrorMode: VideoViewMirrorMode.mirror, ), ), ); }, ); } } // --------------------------------------------------------------------------- // Placeholders and overlays // --------------------------------------------------------------------------- class _AudioOnlyPlaceholder extends StatelessWidget { const _AudioOnlyPlaceholder({required this.name}); final String name; @override Widget build(BuildContext context) { final initials = name.isNotEmpty ? name[0].toUpperCase() : '?'; return Container( color: const Color(0xFF1A1A2E), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircleAvatar( radius: 48, backgroundColor: const Color(0xFF5C35C9), child: Text(initials, style: const TextStyle(fontSize: 36, color: Colors.white)), ), const SizedBox(height: 16), Text(name, style: const TextStyle(color: Colors.white, fontSize: 18)), const SizedBox(height: 8), Text('Audio only', style: TextStyle( color: Colors.white.withAlpha(153), fontSize: 14)), ], ), ), ); } } class _NoVideoPlaceholder extends StatelessWidget { const _NoVideoPlaceholder(); @override Widget build(BuildContext context) { return Container( color: const Color(0xFF1A1A2E), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.person_outline, size: 96, color: Colors.white.withAlpha(77), ), const SizedBox(height: 12), Text( 'Waiting for video...', style: TextStyle( color: Colors.white.withAlpha(153), fontSize: 16, ), ), ], ), ), ); } } class _ConnectingOverlay extends StatelessWidget { const _ConnectingOverlay(); @override Widget build(BuildContext context) { return Container( color: Colors.black.withAlpha(153), child: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(color: Colors.white), SizedBox(height: 16), Text( 'Connecting...', style: TextStyle(color: Colors.white, fontSize: 18), ), ], ), ), ); } } // --------------------------------------------------------------------------- // Call info (top) // --------------------------------------------------------------------------- class _CallInfo extends StatelessWidget { const _CallInfo({required this.callState}); final CallState callState; @override Widget build(BuildContext context) { final durationText = switch (callState) { CallActive(:final duration) => _formatDuration(duration), CallConnecting() => 'Connecting…', _ => '', }; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (durationText.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(8), ), child: Text( durationText, style: const TextStyle( color: Colors.white, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()], ), ), ), ], ); } String _formatDuration(Duration d) { final minutes = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = d.inSeconds.remainder(60).toString().padLeft(2, '0'); return '${d.inHours > 0 ? '${d.inHours}:' : ''}$minutes:$seconds'; } } // --------------------------------------------------------------------------- // Call controls (bottom) // --------------------------------------------------------------------------- class _CallControls extends ConsumerWidget { const _CallControls({required this.roomId}); final String roomId; @override Widget build(BuildContext context, WidgetRef ref) { final callState = ref.watch(callControllerProvider); final notifier = ref.read(callControllerProvider.notifier); final isAudioEnabled = callState is CallActive ? callState.isAudioEnabled : true; final isVideoEnabled = callState is CallActive ? callState.isVideoEnabled : true; return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // Mute button _ControlButton( icon: isAudioEnabled ? Icons.mic : Icons.mic_off, label: isAudioEnabled ? 'Mute' : 'Unmute', onTap: () => notifier.toggleAudio(), active: isAudioEnabled, ), // End call button — prominent red GestureDetector( onTap: () async { await notifier.endCall(); if (context.mounted && context.canPop()) context.pop(); }, child: Container( width: 72, height: 72, decoration: const BoxDecoration( color: Colors.red, shape: BoxShape.circle, ), child: const Icon(Icons.call_end, color: Colors.white, size: 32), ), ), // Video toggle _ControlButton( icon: isVideoEnabled ? Icons.videocam : Icons.videocam_off, label: isVideoEnabled ? 'Hide video' : 'Show video', onTap: () => notifier.toggleVideo(), active: isVideoEnabled, ), ], ); } } class _ControlButton extends StatelessWidget { const _ControlButton({ required this.icon, required this.label, required this.onTap, required this.active, }); final IconData icon; final String label; final VoidCallback onTap; final bool active; @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 56, height: 56, decoration: BoxDecoration( color: active ? Colors.white.withAlpha(51) : Colors.white.withAlpha(26), shape: BoxShape.circle, ), child: Icon(icon, color: Colors.white, size: 24), ), const SizedBox(height: 6), Text( label, style: const TextStyle(color: Colors.white70, fontSize: 11), ), ], ), ); } }