From d1506f10ef6a833234fcdbd34844ac58b0db501b Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 22 Jun 2025 01:40:10 +0800 Subject: [PATCH] :lipstick: Optimized call --- lib/pods/call.dart | 2 + lib/pods/call.g.dart | 2 +- lib/route.dart | 2 +- lib/screens/chat/call.dart | 37 +--- lib/screens/chat/chat.dart | 2 +- lib/screens/tabs.dart | 22 +++ lib/services/responsive.dart | 14 +- lib/widgets/chat/call_overlay.dart | 275 ++++++++++++++++++++++------- 8 files changed, 256 insertions(+), 100 deletions(-) diff --git a/lib/pods/call.dart b/lib/pods/call.dart index fc155fc..fb49bd2 100644 --- a/lib/pods/call.dart +++ b/lib/pods/call.dart @@ -66,6 +66,8 @@ class CallNotifier extends _$CallNotifier { Timer? _durationTimer; + Room? get room => _room; + @override CallState build() { // Subscribe to websocket updates diff --git a/lib/pods/call.g.dart b/lib/pods/call.g.dart index 2acdcea..0482763 100644 --- a/lib/pods/call.g.dart +++ b/lib/pods/call.g.dart @@ -6,7 +6,7 @@ part of 'call.dart'; // RiverpodGenerator // ************************************************************************** -String _$callNotifierHash() => r'e04cea314c823e407d49fd616d90d77491232c12'; +String _$callNotifierHash() => r'47eaba43aa2af1a107725998f4a34af2c94fbc55'; /// See also [CallNotifier]. @ProviderFor(CallNotifier) diff --git a/lib/route.dart b/lib/route.dart index 9b592d7..d350e1c 100644 --- a/lib/route.dart +++ b/lib/route.dart @@ -44,7 +44,6 @@ class AppRouter extends RootStackRouter { children: [ AutoRoute(page: ChatListRoute.page, path: ''), AutoRoute(page: ChatRoomRoute.page, path: ':id'), - AutoRoute(page: CallRoute.page, path: ':id/call'), AutoRoute(page: NewChatRoute.page, path: 'new'), AutoRoute(page: EditChatRoute.page, path: ':id/edit'), AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'), @@ -54,6 +53,7 @@ class AppRouter extends RootStackRouter { ), AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'), AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'), + AutoRoute(page: CallRoute.page, path: '/chat/:id/call'), AutoRoute(page: EventCalanderRoute.page, path: '/account/:name/calendar'), AutoRoute( page: CreatorHubShellRoute.page, diff --git a/lib/screens/chat/call.dart b/lib/screens/chat/call.dart index 95ad4cb..66706a7 100644 --- a/lib/screens/chat/call.dart +++ b/lib/screens/chat/call.dart @@ -11,6 +11,7 @@ import 'package:island/widgets/chat/call_button.dart'; import 'package:island/widgets/chat/call_overlay.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:livekit_client/livekit_client.dart'; +import 'package:material_symbols_icons/symbols.dart'; import 'package:styled_widget/styled_widget.dart'; @RoutePage() @@ -32,37 +33,9 @@ class CallScreen extends HookConsumerWidget { final viewMode = useState('grid'); return AppScaffold( + noBackground: false, appBar: AppBar( - leading: PageBackButton( - onWillPop: () { - showDialog( - context: context, - builder: (context) { - return AlertDialog( - content: const Text( - 'Do you want to leave the call or leave it in background?', - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('In Background'), - ), - TextButton( - onPressed: () async { - Navigator.of(context).pop(); - await callNotifier.disconnect(); - callNotifier.dispose(); - }, - child: const Text('Leave'), - ), - ], - ); - }, - ); - }, - ), + leading: PageBackButton(), title: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -83,7 +56,7 @@ class CallScreen extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.end, children: [ IconButton( - icon: Icon(Icons.grid_view), + icon: Icon(Symbols.grid_view), tooltip: 'Grid View', onPressed: () => viewMode.value = 'grid', color: @@ -92,7 +65,7 @@ class CallScreen extends HookConsumerWidget { : null, ), IconButton( - icon: Icon(Icons.view_agenda), + icon: Icon(Symbols.view_agenda), tooltip: 'Stage View', onPressed: () => viewMode.value = 'stage', color: diff --git a/lib/screens/chat/chat.dart b/lib/screens/chat/chat.dart index e489eeb..93a48f4 100644 --- a/lib/screens/chat/chat.dart +++ b/lib/screens/chat/chat.dart @@ -431,7 +431,7 @@ class ChatListScreen extends HookConsumerWidget { Positioned( left: 0, right: 0, - bottom: 0, + bottom: getTabbedPadding(context).bottom + 8, child: const CallOverlayBar().padding(horizontal: 16, vertical: 12), ), ], diff --git a/lib/screens/tabs.dart b/lib/screens/tabs.dart index eb09c17..db66aa9 100644 --- a/lib/screens/tabs.dart +++ b/lib/screens/tabs.dart @@ -54,6 +54,28 @@ class TabsScreen extends HookConsumerWidget { builder: (context, child, _) { final tabsRouter = AutoTabsRouter.of(context); + if (isWideScreen(context)) { + return Row( + children: [ + NavigationRail( + destinations: + destinations + .map( + (e) => NavigationRailDestination( + icon: e.icon, + label: Text(e.label), + ), + ) + .toList(), + selectedIndex: tabsRouter.activeIndex, + onDestinationSelected: tabsRouter.setActiveIndex, + ), + const VerticalDivider(width: 1), + Expanded(child: child), + ], + ); + } + return Stack( children: [ Positioned.fill(child: child), diff --git a/lib/services/responsive.dart b/lib/services/responsive.dart index 43f5cee..2555816 100644 --- a/lib/services/responsive.dart +++ b/lib/services/responsive.dart @@ -25,14 +25,22 @@ EdgeInsets getTabbedPadding( double? top, double? bottom, }) { - final bottomPadding = bottom ?? MediaQuery.of(context).padding.bottom + 16; + if (isWideScreen(context)) { + return EdgeInsets.only( + left: left ?? horizontal ?? 0, + right: right ?? horizontal ?? 0, + top: top ?? vertical ?? 0, + bottom: bottom ?? vertical ?? 0, + ); + } + final effectiveBottom = bottom ?? vertical; return EdgeInsets.only( left: left ?? horizontal ?? 0, right: right ?? horizontal ?? 0, top: top ?? vertical ?? 0, bottom: - bottom != null - ? bottomPadding + effectiveBottom != null + ? effectiveBottom + MediaQuery.of(context).padding.bottom + 16 : MediaQuery.of(context).padding.bottom + 16, ); } diff --git a/lib/widgets/chat/call_overlay.dart b/lib/widgets/chat/call_overlay.dart index 3e05ae8..8cec63c 100644 --- a/lib/widgets/chat/call_overlay.dart +++ b/lib/widgets/chat/call_overlay.dart @@ -4,10 +4,11 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:island/pods/call.dart'; -import 'package:island/pods/userinfo.dart'; import 'package:island/route.gr.dart'; import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:styled_widget/styled_widget.dart'; +import 'package:livekit_client/livekit_client.dart'; +import '../../widgets/content/sheet.dart'; class CallControlsBar extends HookConsumerWidget { const CallControlsBar({super.key}); @@ -17,76 +18,226 @@ class CallControlsBar extends HookConsumerWidget { final callState = ref.watch(callNotifierProvider); final callNotifier = ref.read(callNotifierProvider.notifier); - final userInfo = ref.watch(userInfoProvider); - - final actionButtonStyle = ButtonStyle( - minimumSize: const MaterialStatePropertyAll(Size(24, 24)), - ); - - return Card( - margin: const EdgeInsets.only(left: 12, right: 12, top: 8), + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, children: [ - Expanded( - child: Row( - children: [ - Builder( - builder: (context) { - if (callNotifier.localParticipant == null) { - return CircularProgressIndicator().center(); - } - return SizedBox( - width: 40, - height: 40, - child: - SpeakingRippleAvatar( - isSpeaking: - callNotifier.localParticipant!.isSpeaking, - audioLevel: - callNotifier.localParticipant!.audioLevel, - pictureId: userInfo.value?.profile.picture?.id, - size: 36, - ).center(), - ); - }, - ), - ], - ), + _buildCircularButtonWithDropdown( + context: context, + ref: ref, + icon: + callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, + onPressed: () => callNotifier.toggleCamera(), + backgroundColor: const Color(0xFF424242), + hasDropdown: true, + deviceType: 'videoinput', ), - IconButton( - icon: Icon( - callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, - ), - onPressed: () { - callNotifier.toggleMicrophone(); - }, - style: actionButtonStyle, + const Gap(16), + _buildCircularButton( + icon: + callState.isScreenSharing + ? Icons.stop_screen_share + : Icons.screen_share, + onPressed: () => callNotifier.toggleScreenShare(), + backgroundColor: const Color(0xFF424242), ), - IconButton( - icon: Icon( - callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, - ), - onPressed: () { - callNotifier.toggleCamera(); - }, - style: actionButtonStyle, + const Gap(16), + _buildCircularButtonWithDropdown( + context: context, + ref: ref, + icon: callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, + onPressed: () => callNotifier.toggleMicrophone(), + backgroundColor: const Color(0xFF424242), + hasDropdown: true, + deviceType: 'audioinput', ), - IconButton( - icon: Icon( - callState.isScreenSharing - ? Icons.stop_screen_share - : Icons.screen_share, - ), - onPressed: () { - callNotifier.toggleScreenShare(); - }, - style: actionButtonStyle, + const Gap(16), + _buildCircularButton( + icon: Icons.call_end, + onPressed: () => callNotifier.disconnect(), + backgroundColor: const Color(0xFFE53E3E), + iconColor: Colors.white, ), ], - ).padding(all: 16), + ), ); } + + Widget _buildCircularButton({ + required IconData icon, + required VoidCallback onPressed, + required Color backgroundColor, + Color? iconColor, + }) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle), + child: IconButton( + icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), + onPressed: onPressed, + ), + ); + } + + Widget _buildCircularButtonWithDropdown({ + required BuildContext context, + required WidgetRef ref, + required IconData icon, + required VoidCallback onPressed, + required Color backgroundColor, + required bool hasDropdown, + Color? iconColor, + String? deviceType, // 'videoinput' or 'audioinput' + }) { + return Stack( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + ), + child: IconButton( + icon: Icon(icon, color: iconColor ?? Colors.white, size: 24), + onPressed: onPressed, + ), + ), + if (hasDropdown && deviceType != null) + Positioned( + bottom: 4, + right: 4, + child: GestureDetector( + onTap: () => _showDeviceSelectionDialog(context, ref, deviceType), + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: backgroundColor.withOpacity(0.8), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withOpacity(0.3), + width: 0.5, + ), + ), + child: Icon( + Icons.arrow_drop_down, + color: Colors.white, + size: 12, + ), + ), + ), + ), + ], + ); + } + + Future _showDeviceSelectionDialog( + BuildContext context, + WidgetRef ref, + String deviceType, + ) async { + try { + final devices = await Hardware.instance.enumerateDevices( + type: deviceType, + ); + + if (!context.mounted) return; + + showModalBottomSheet( + context: context, + builder: (BuildContext dialogContext) { + return SheetScaffold( + titleText: deviceType == 'videoinput' + ? 'selectCamera'.tr() + : 'selectMicrophone'.tr(), + child: ListView.builder( + itemCount: devices.length, + itemBuilder: (context, index) { + final device = devices[index]; + return ListTile( + title: Text( + device.label.isNotEmpty + ? device.label + : '${'device'.tr()} ${index + 1}', + ), + onTap: () { + Navigator.of(dialogContext).pop(); + _switchDevice(context, ref, device, deviceType); + }, + ); + }, + ), + ); + }, + ); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${'failedToEnumerateDevices'.tr()}: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _switchDevice( + BuildContext context, + WidgetRef ref, + MediaDevice device, + String deviceType, + ) async { + try { + final callNotifier = ref.read(callNotifierProvider.notifier); + + if (deviceType == 'videoinput') { + // Switch camera device + final localParticipant = callNotifier.room?.localParticipant; + final videoTrack = + localParticipant?.videoTrackPublications.firstOrNull?.track; + + if (videoTrack is LocalVideoTrack) { + await videoTrack.switchCamera(device.deviceId); + } + } else if (deviceType == 'audioinput') { + // Switch microphone device + final localParticipant = callNotifier.room?.localParticipant; + final audioTrack = + localParticipant?.audioTrackPublications.firstOrNull?.track; + + if (audioTrack is LocalAudioTrack) { + // For audio devices, we need to restart the track with new device + await audioTrack.restartTrack( + AudioCaptureOptions(deviceId: device.deviceId), + ); + } + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '${'switchedTo'.tr()} ${device.label.isNotEmpty ? device.label : 'selectedDevice'.tr()}', + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${'failedToSwitchDevice'.tr()}: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } } class CallOverlayBar extends HookConsumerWidget {