💄 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;
Room? get room => _room;
@override
CallState build() {
// Subscribe to websocket updates

View File

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

View File

@ -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,

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_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<String>('grid');
return AppScaffold(
noBackground: false,
appBar: AppBar(
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'),
),
],
);
},
);
},
),
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:

View File

@ -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),
),
],

View File

@ -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),

View File

@ -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,
);
}

View File

@ -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(),
);
},
),
],
),
),
IconButton(
icon: Icon(
callState.isMicrophoneEnabled ? Icons.mic : Icons.mic_off,
),
onPressed: () {
callNotifier.toggleMicrophone();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
_buildCircularButtonWithDropdown(
context: context,
ref: ref,
icon:
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
onPressed: () => callNotifier.toggleCamera(),
backgroundColor: const Color(0xFF424242),
hasDropdown: true,
deviceType: 'videoinput',
),
onPressed: () {
callNotifier.toggleCamera();
},
style: actionButtonStyle,
),
IconButton(
icon: Icon(
const Gap(16),
_buildCircularButton(
icon:
callState.isScreenSharing
? Icons.stop_screen_share
: Icons.screen_share,
onPressed: () => callNotifier.toggleScreenShare(),
backgroundColor: const Color(0xFF424242),
),
onPressed: () {
callNotifier.toggleScreenShare();
},
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',
),
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<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 {