💄 Optimized call

This commit is contained in:
LittleSheep 2025-06-22 01:40:10 +08:00
parent 006841cf82
commit d1506f10ef
8 changed files with 256 additions and 100 deletions

View File

@ -66,6 +66,8 @@ class CallNotifier extends _$CallNotifier {
Timer? _durationTimer; Timer? _durationTimer;
Room? get room => _room;
@override @override
CallState build() { CallState build() {
// Subscribe to websocket updates // Subscribe to websocket updates

View File

@ -6,7 +6,7 @@ part of 'call.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$callNotifierHash() => r'e04cea314c823e407d49fd616d90d77491232c12'; String _$callNotifierHash() => r'47eaba43aa2af1a107725998f4a34af2c94fbc55';
/// See also [CallNotifier]. /// See also [CallNotifier].
@ProviderFor(CallNotifier) @ProviderFor(CallNotifier)

View File

@ -44,7 +44,6 @@ class AppRouter extends RootStackRouter {
children: [ children: [
AutoRoute(page: ChatListRoute.page, path: ''), AutoRoute(page: ChatListRoute.page, path: ''),
AutoRoute(page: ChatRoomRoute.page, path: ':id'), AutoRoute(page: ChatRoomRoute.page, path: ':id'),
AutoRoute(page: CallRoute.page, path: ':id/call'),
AutoRoute(page: NewChatRoute.page, path: 'new'), AutoRoute(page: NewChatRoute.page, path: 'new'),
AutoRoute(page: EditChatRoute.page, path: ':id/edit'), AutoRoute(page: EditChatRoute.page, path: ':id/edit'),
AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'), AutoRoute(page: ChatDetailRoute.page, path: ':id/detail'),
@ -54,6 +53,7 @@ class AppRouter extends RootStackRouter {
), ),
AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'), AutoRoute(page: PostComposeRoute.page, path: '/posts/compose'),
AutoRoute(page: PostEditRoute.page, path: '/posts/:id/edit'), 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: EventCalanderRoute.page, path: '/account/:name/calendar'),
AutoRoute( AutoRoute(
page: CreatorHubShellRoute.page, page: CreatorHubShellRoute.page,

View File

@ -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_overlay.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:livekit_client/livekit_client.dart'; import 'package:livekit_client/livekit_client.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@RoutePage() @RoutePage()
@ -32,37 +33,9 @@ class CallScreen extends HookConsumerWidget {
final viewMode = useState<String>('grid'); final viewMode = useState<String>('grid');
return AppScaffold( return AppScaffold(
noBackground: false,
appBar: AppBar( appBar: AppBar(
leading: PageBackButton( leading: PageBackButton(),
onWillPop: () {
showDialog<void>(
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'),
),
],
);
},
);
},
),
title: Column( title: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
@ -83,7 +56,7 @@ class CallScreen extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
IconButton( IconButton(
icon: Icon(Icons.grid_view), icon: Icon(Symbols.grid_view),
tooltip: 'Grid View', tooltip: 'Grid View',
onPressed: () => viewMode.value = 'grid', onPressed: () => viewMode.value = 'grid',
color: color:
@ -92,7 +65,7 @@ class CallScreen extends HookConsumerWidget {
: null, : null,
), ),
IconButton( IconButton(
icon: Icon(Icons.view_agenda), icon: Icon(Symbols.view_agenda),
tooltip: 'Stage View', tooltip: 'Stage View',
onPressed: () => viewMode.value = 'stage', onPressed: () => viewMode.value = 'stage',
color: color:

View File

@ -431,7 +431,7 @@ class ChatListScreen extends HookConsumerWidget {
Positioned( Positioned(
left: 0, left: 0,
right: 0, right: 0,
bottom: 0, bottom: getTabbedPadding(context).bottom + 8,
child: const CallOverlayBar().padding(horizontal: 16, vertical: 12), child: const CallOverlayBar().padding(horizontal: 16, vertical: 12),
), ),
], ],

View File

@ -54,6 +54,28 @@ class TabsScreen extends HookConsumerWidget {
builder: (context, child, _) { builder: (context, child, _) {
final tabsRouter = AutoTabsRouter.of(context); 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( return Stack(
children: [ children: [
Positioned.fill(child: child), Positioned.fill(child: child),

View File

@ -25,14 +25,22 @@ EdgeInsets getTabbedPadding(
double? top, double? top,
double? bottom, 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( return EdgeInsets.only(
left: left ?? horizontal ?? 0, left: left ?? horizontal ?? 0,
right: right ?? horizontal ?? 0, right: right ?? horizontal ?? 0,
top: top ?? vertical ?? 0, top: top ?? vertical ?? 0,
bottom: bottom:
bottom != null effectiveBottom != null
? bottomPadding ? effectiveBottom + MediaQuery.of(context).padding.bottom + 16
: MediaQuery.of(context).padding.bottom + 16, : MediaQuery.of(context).padding.bottom + 16,
); );
} }

View File

@ -4,10 +4,11 @@ import 'package:flutter/material.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/call.dart'; import 'package:island/pods/call.dart';
import 'package:island/pods/userinfo.dart';
import 'package:island/route.gr.dart'; import 'package:island/route.gr.dart';
import 'package:island/widgets/chat/call_participant_tile.dart'; import 'package:island/widgets/chat/call_participant_tile.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
import 'package:livekit_client/livekit_client.dart';
import '../../widgets/content/sheet.dart';
class CallControlsBar extends HookConsumerWidget { class CallControlsBar extends HookConsumerWidget {
const CallControlsBar({super.key}); const CallControlsBar({super.key});
@ -17,76 +18,226 @@ class CallControlsBar extends HookConsumerWidget {
final callState = ref.watch(callNotifierProvider); final callState = ref.watch(callNotifierProvider);
final callNotifier = ref.read(callNotifierProvider.notifier); final callNotifier = ref.read(callNotifierProvider.notifier);
final userInfo = ref.watch(userInfoProvider); return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
final actionButtonStyle = ButtonStyle(
minimumSize: const MaterialStatePropertyAll(Size(24, 24)),
);
return Card(
margin: const EdgeInsets.only(left: 12, right: 12, top: 8),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Expanded( _buildCircularButtonWithDropdown(
child: Row( context: context,
children: [ ref: ref,
Builder( icon:
builder: (context) { callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
if (callNotifier.localParticipant == null) { onPressed: () => callNotifier.toggleCamera(),
return CircularProgressIndicator().center(); backgroundColor: const Color(0xFF424242),
} hasDropdown: true,
return SizedBox( deviceType: 'videoinput',
width: 40,
height: 40,
child:
SpeakingRippleAvatar(
isSpeaking:
callNotifier.localParticipant!.isSpeaking,
audioLevel:
callNotifier.localParticipant!.audioLevel,
pictureId: userInfo.value?.profile.picture?.id,
size: 36,
).center(),
);
},
),
],
),
), ),
IconButton( const Gap(16),
icon: Icon( _buildCircularButton(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off, icon:
), callState.isScreenSharing
onPressed: () { ? Icons.stop_screen_share
callNotifier.toggleMicrophone(); : Icons.screen_share,
}, onPressed: () => callNotifier.toggleScreenShare(),
style: actionButtonStyle, backgroundColor: const Color(0xFF424242),
), ),
IconButton( const Gap(16),
icon: Icon( _buildCircularButtonWithDropdown(
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off, context: context,
), ref: ref,
onPressed: () { icon: callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
callNotifier.toggleCamera(); onPressed: () => callNotifier.toggleMicrophone(),
}, backgroundColor: const Color(0xFF424242),
style: actionButtonStyle, hasDropdown: true,
deviceType: 'audioinput',
), ),
IconButton( const Gap(16),
icon: Icon( _buildCircularButton(
callState.isScreenSharing icon: Icons.call_end,
? Icons.stop_screen_share onPressed: () => callNotifier.disconnect(),
: Icons.screen_share, backgroundColor: const Color(0xFFE53E3E),
), iconColor: Colors.white,
onPressed: () {
callNotifier.toggleScreenShare();
},
style: actionButtonStyle,
), ),
], ],
).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<void> _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<void> _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 { class CallOverlayBar extends HookConsumerWidget {