💄 Optimized call
This commit is contained in:
parent
006841cf82
commit
d1506f10ef
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
|
@ -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:
|
||||||
|
@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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) {
|
|
||||||
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(
|
|
||||||
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
|
callState.isCameraEnabled ? Icons.videocam : Icons.videocam_off,
|
||||||
|
onPressed: () => callNotifier.toggleCamera(),
|
||||||
|
backgroundColor: const Color(0xFF424242),
|
||||||
|
hasDropdown: true,
|
||||||
|
deviceType: 'videoinput',
|
||||||
),
|
),
|
||||||
onPressed: () {
|
const Gap(16),
|
||||||
callNotifier.toggleCamera();
|
_buildCircularButton(
|
||||||
},
|
icon:
|
||||||
style: actionButtonStyle,
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
callState.isScreenSharing
|
callState.isScreenSharing
|
||||||
? Icons.stop_screen_share
|
? Icons.stop_screen_share
|
||||||
: Icons.screen_share,
|
: Icons.screen_share,
|
||||||
|
onPressed: () => callNotifier.toggleScreenShare(),
|
||||||
|
backgroundColor: const Color(0xFF424242),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
const Gap(16),
|
||||||
callNotifier.toggleScreenShare();
|
_buildCircularButtonWithDropdown(
|
||||||
},
|
context: context,
|
||||||
style: actionButtonStyle,
|
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 {
|
class CallOverlayBar extends HookConsumerWidget {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user