diff --git a/lib/providers/call.dart b/lib/providers/call.dart index 0eea97b..bbc9fbb 100644 --- a/lib/providers/call.dart +++ b/lib/providers/call.dart @@ -17,6 +17,10 @@ class ChatCallProvider extends GetxController { RxBool isReady = false.obs; RxBool isMounted = false.obs; RxBool isInitialized = false.obs; + RxBool isBusy = false.obs; + + RxString lastDuration = '00:00:00'.obs; + Timer? lastDurationUpdateTimer; String? token; String? endpoint; @@ -38,6 +42,34 @@ class ChatCallProvider extends GetxController { RxList participantTracks = RxList.empty(growable: true); Rx focusTrack = Rx(null); + void _updateDuration() { + if (current.value == null) { + lastDuration.value = '00:00:00'; + return; + } + + Duration duration = DateTime.now().difference(current.value!.createdAt); + + String twoDigits(int n) => n.toString().padLeft(2, '0'); + String formattedTime = '${twoDigits(duration.inHours)}:' + '${twoDigits(duration.inMinutes.remainder(60))}:' + '${twoDigits(duration.inSeconds.remainder(60))}'; + lastDuration.value = formattedTime; + } + + void enableDurationUpdater() { + _updateDuration(); + lastDurationUpdateTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _updateDuration(), + ); + } + + void disableDurationUpdater() { + lastDurationUpdateTimer?.cancel(); + lastDurationUpdateTimer = null; + } + Future checkPermissions() async { if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) { return; @@ -119,8 +151,6 @@ class ChatCallProvider extends GetxController { void joinRoom(String url, String token) async { if (isMounted.value) { return; - } else { - isMounted.value = true; } try { @@ -134,6 +164,8 @@ class ChatCallProvider extends GetxController { ); } catch (e) { rethrow; + } finally { + isMounted.value = true; } } @@ -165,6 +197,7 @@ class ChatCallProvider extends GetxController { Hardware.instance.setSpeakerphoneOn(true); } + isBusy.value = false; isInitialized.value = true; } diff --git a/lib/screens/channel/call/call.dart b/lib/screens/channel/call/call.dart index 14f80fc..a690f20 100644 --- a/lib/screens/channel/call/call.dart +++ b/lib/screens/channel/call/call.dart @@ -12,16 +12,15 @@ import 'package:solian/widgets/chat/call/call_participant.dart'; import 'package:livekit_client/livekit_client.dart' as livekit; class CallScreen extends StatefulWidget { - const CallScreen({super.key}); + final bool hideAppBar; + + const CallScreen({super.key, this.hideAppBar = false}); @override State createState() => _CallScreenState(); } class _CallScreenState extends State with TickerProviderStateMixin { - Timer? _timer; - String _currentDuration = '00:00:00'; - int _layoutMode = 0; bool _showControls = true; @@ -37,26 +36,6 @@ class _CallScreenState extends State with TickerProviderStateMixin { curve: Curves.fastOutSlowIn, ); - String _parseDuration() { - final ChatCallProvider provider = Get.find(); - if (provider.current.value == null) return '00:00:00'; - Duration duration = - DateTime.now().difference(provider.current.value!.createdAt); - - String twoDigits(int n) => n.toString().padLeft(2, '0'); - String formattedTime = '${twoDigits(duration.inHours)}:' - '${twoDigits(duration.inMinutes.remainder(60))}:' - '${twoDigits(duration.inSeconds.remainder(60))}'; - - return formattedTime; - } - - void _updateDuration() { - setState(() { - _currentDuration = _parseDuration(); - }); - } - void _switchLayout() { if (_layoutMode < 1) { setState(() => _layoutMode++); @@ -191,15 +170,15 @@ class _CallScreenState extends State with TickerProviderStateMixin { @override void initState() { - Get.find().setupRoom(); super.initState(); - _updateDuration(); - _planAutoHideControls(); - _timer = Timer.periodic( - const Duration(seconds: 1), - (_) => _updateDuration(), - ); + Future.delayed(Duration.zero, () { + Get.find() + ..setupRoom() + ..enableDurationUpdater(); + + _planAutoHideControls(); + }); } @override @@ -210,30 +189,34 @@ class _CallScreenState extends State with TickerProviderStateMixin { @override Widget build(BuildContext context) { - final ChatCallProvider provider = Get.find(); + final ChatCallProvider ctrl = Get.find(); return Material( color: Theme.of(context).colorScheme.surface, child: Scaffold( - appBar: AppBar( - leading: AppBarLeadingButton.adaptive(context), - centerTitle: true, - toolbarHeight: SolianTheme.toolbarHeight(context), - title: RichText( - textAlign: TextAlign.center, - text: TextSpan(children: [ - TextSpan( - text: 'call'.tr, - style: Theme.of(context).textTheme.titleLarge, + appBar: widget.hideAppBar + ? null + : AppBar( + leading: AppBarLeadingButton.adaptive(context), + centerTitle: true, + toolbarHeight: SolianTheme.toolbarHeight(context), + title: Obx( + () => RichText( + textAlign: TextAlign.center, + text: TextSpan(children: [ + TextSpan( + text: 'call'.tr, + style: Theme.of(context).textTheme.titleLarge, + ), + const TextSpan(text: '\n'), + TextSpan( + text: ctrl.lastDuration.value, + style: Theme.of(context).textTheme.bodySmall, + ), + ]), + ), + ), ), - const TextSpan(text: '\n'), - TextSpan( - text: _currentDuration, - style: Theme.of(context).textTheme.bodySmall, - ), - ]), - ), - ), body: SafeArea( child: GestureDetector( behavior: HitTestBehavior.translucent, @@ -259,13 +242,20 @@ class _CallScreenState extends State with TickerProviderStateMixin { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Text(call.room.serverRegion ?? 'unknown'), - const SizedBox(width: 6), - Text(call.room.serverVersion ?? 'unknown') - ], - ), + Obx(() { + return Row( + children: [ + Text( + call.channel.value!.name, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 6), + Text(call.lastDuration.value) + ], + ); + }), Row( children: [ Text( @@ -332,7 +322,6 @@ class _CallScreenState extends State with TickerProviderStateMixin { Expanded( child: Material( color: Theme.of(context).colorScheme.surfaceContainerLow, - elevation: 2, child: Builder( builder: (context) { switch (_layoutMode) { @@ -345,15 +334,15 @@ class _CallScreenState extends State with TickerProviderStateMixin { ), ), ), - if (provider.room.localParticipant != null) + if (ctrl.room.localParticipant != null) SizeTransition( sizeFactor: _controlsAnimation, axis: Axis.vertical, child: SizedBox( width: MediaQuery.of(context).size.width, child: ControlsWidget( - provider.room, - provider.room.localParticipant!, + ctrl.room, + ctrl.room.localParticipant!, ), ), ), @@ -370,17 +359,13 @@ class _CallScreenState extends State with TickerProviderStateMixin { @override void deactivate() { - _timer?.cancel(); - _timer = null; + Get.find().disableDurationUpdater(); super.deactivate(); } @override void activate() { - _timer ??= Timer.periodic( - const Duration(seconds: 1), - (_) => _updateDuration(), - ); + Get.find().enableDurationUpdater(); super.activate(); } } diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index d113108..c2a623c 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -11,9 +11,11 @@ import 'package:solian/models/channel.dart'; import 'package:solian/models/event.dart'; import 'package:solian/models/packet.dart'; import 'package:solian/providers/auth.dart'; +import 'package:solian/providers/call.dart'; import 'package:solian/providers/content/channel.dart'; import 'package:solian/providers/websocket.dart'; import 'package:solian/router.dart'; +import 'package:solian/screens/channel/call/call.dart'; import 'package:solian/screens/channel/channel_detail.dart'; import 'package:solian/theme.dart'; import 'package:solian/widgets/app_bar_leading.dart'; @@ -39,7 +41,7 @@ class ChannelChatScreen extends StatefulWidget { } class _ChannelChatScreenState extends State - with WidgetsBindingObserver { + with WidgetsBindingObserver, TickerProviderStateMixin { DateTime? _isOutOfSyncSince; bool _isBusy = false; @@ -238,77 +240,104 @@ class _ChannelChatScreenState extends State ); } - return Column( + return Row( children: [ - if (_ongoingCall != null) - ChannelCallIndicator( - channel: _channel!, - ongoingCall: _ongoingCall!, - ), Expanded( - child: ChatEventList( - scope: widget.realm, - channel: _channel!, - chatController: _chatController, - onEdit: (item) { - setState(() => _messageToEditing = item); - }, - onReply: (item) { - setState(() => _messageToReplying = item); - }, - ), - ), - if (_isOutOfSyncSince != null) - ListTile( - contentPadding: const EdgeInsets.only(left: 16, right: 8), - tileColor: Theme.of(context).colorScheme.surfaceContainerLow, - leading: const Icon(Icons.history_toggle_off), - title: Text('messageOutOfSync'.tr), - subtitle: Text('messageOutOfSyncCaption'.tr), - trailing: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - setState(() => _isOutOfSyncSince = null); - }, - ), - onTap: _isBusy - ? null - : () { - _keepUpdateWithServer(); + child: Column( + children: [ + if (_ongoingCall != null) + ChannelCallIndicator( + channel: _channel!, + ongoingCall: _ongoingCall!, + onJoin: () { + if (!SolianTheme.isLargeScreen(context)) { + final ChatCallProvider call = Get.find(); + call.gotoScreen(context); + } }, - ), - Obx(() { - if (_chatController.isLoading.isTrue) { - return const LinearProgressIndicator().animate().slideY(); - } else { - return const SizedBox(); - } - }), - ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), - child: SafeArea( - child: ChatMessageInput( - edit: _messageToEditing, - reply: _messageToReplying, - realm: widget.realm, - placeholder: placeholder, - channel: _channel!, - onSent: (Event item) { - setState(() { - _chatController.addPendingEvent(item); - }); - }, - onReset: () { - setState(() { - _messageToReplying = null; - _messageToEditing = null; - }); - }, + ), + Expanded( + child: ChatEventList( + scope: widget.realm, + channel: _channel!, + chatController: _chatController, + onEdit: (item) { + setState(() => _messageToEditing = item); + }, + onReply: (item) { + setState(() => _messageToReplying = item); + }, + ), ), - ), + if (_isOutOfSyncSince != null) + ListTile( + contentPadding: const EdgeInsets.only(left: 16, right: 8), + tileColor: + Theme.of(context).colorScheme.surfaceContainerLow, + leading: const Icon(Icons.history_toggle_off), + title: Text('messageOutOfSync'.tr), + subtitle: Text('messageOutOfSyncCaption'.tr), + trailing: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + setState(() => _isOutOfSyncSince = null); + }, + ), + onTap: _isBusy + ? null + : () { + _keepUpdateWithServer(); + }, + ), + Obx(() { + if (_chatController.isLoading.isTrue) { + return const LinearProgressIndicator().animate().slideY(); + } else { + return const SizedBox(); + } + }), + ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 50, sigmaY: 50), + child: SafeArea( + child: ChatMessageInput( + edit: _messageToEditing, + reply: _messageToReplying, + realm: widget.realm, + placeholder: placeholder, + channel: _channel!, + onSent: (Event item) { + setState(() { + _chatController.addPendingEvent(item); + }); + }, + onReset: () { + setState(() { + _messageToReplying = null; + _messageToEditing = null; + }); + }, + ), + ), + ), + ), + ], ), ), + Obx(() { + final ChatCallProvider call = Get.find(); + if (call.isMounted.value && SolianTheme.isLargeScreen(context)) { + return const Expanded( + child: Row(children: [ + VerticalDivider(width: 0.3, thickness: 0.3), + Expanded( + child: CallScreen(hideAppBar: true), + ), + ]), + ); + } + return const SizedBox(); + }), ], ); }), diff --git a/lib/translations/en_us.dart b/lib/translations/en_us.dart index 432e927..cbfbeb6 100644 --- a/lib/translations/en_us.dart +++ b/lib/translations/en_us.dart @@ -269,6 +269,7 @@ const i18nEnglish = { 'callOngoing': 'A call is ongoing...', 'callOngoingEmpty': 'A call is on hold...', 'callOngoingParticipants': '@count people are calling...', + 'callOngoingJoined': 'Call last @duration', 'callJoin': 'Join', 'callResume': 'Resume', 'callMicrophone': 'Microphone', diff --git a/lib/translations/zh_cn.dart b/lib/translations/zh_cn.dart index 946997f..ecb73d2 100644 --- a/lib/translations/zh_cn.dart +++ b/lib/translations/zh_cn.dart @@ -247,6 +247,7 @@ const i18nSimplifiedChinese = { 'callOngoing': '一则通话正在进行中…', 'callOngoingEmpty': '一则通话待机中…', 'callOngoingParticipants': '@count 人正在进行通话…', + 'callOngoingJoined': '通话进行 @duration', 'callJoin': '加入', 'callResume': '恢复', 'callMicrophone': '麦克风', diff --git a/lib/widgets/channel/channel_call_indicator.dart b/lib/widgets/channel/channel_call_indicator.dart index d065bcb..ef57ead 100644 --- a/lib/widgets/channel/channel_call_indicator.dart +++ b/lib/widgets/channel/channel_call_indicator.dart @@ -9,14 +9,20 @@ import 'package:solian/models/call.dart'; import 'package:solian/models/channel.dart'; import 'package:solian/platform.dart'; import 'package:solian/providers/call.dart'; +import 'package:solian/theme.dart'; import 'package:solian/widgets/chat/call/call_prejoin.dart'; class ChannelCallIndicator extends StatelessWidget { final Channel channel; final Call ongoingCall; + final Function onJoin; - const ChannelCallIndicator( - {super.key, required this.channel, required this.ongoingCall}); + const ChannelCallIndicator({ + super.key, + required this.channel, + required this.ongoingCall, + required this.onJoin, + }); void _showCallPrejoin(BuildContext context) { showModalBottomSheet( @@ -40,48 +46,72 @@ class ChannelCallIndicator extends StatelessWidget { dividerColor: Colors.transparent, content: Row( children: [ - if (ongoingCall.participants.isEmpty) Text('callOngoingEmpty'.tr), - if (ongoingCall.participants.isNotEmpty) - Text('callOngoingParticipants'.trParams({ - 'count': ongoingCall.participants.length.toString(), - })), + Obx(() { + if (call.isInitialized.value) { + return Text('callOngoingJoined'.trParams({ + 'duration': call.lastDuration.value, + })); + } else if (ongoingCall.participants.isEmpty) { + return Text('callOngoingEmpty'.tr); + } else { + return Text('callOngoingParticipants'.trParams({ + 'count': ongoingCall.participants.length.toString(), + })); + } + }), const SizedBox(width: 6), - if (ongoingCall.participants.isNotEmpty) - Container( - height: 28, - constraints: const BoxConstraints(maxWidth: 120), - child: AvatarStack( + Obx(() { + if (call.isInitialized.value) { + return const SizedBox(); + } + if (ongoingCall.participants.isNotEmpty) { + return Container( height: 28, - borderWidth: 0, - avatars: ongoingCall.participants.map((x) { - final userinfo = Account.fromJson(jsonDecode(x['metadata'])); - return PlatformInfo.canCacheImage - ? CachedNetworkImageProvider(userinfo.avatar) - as ImageProvider - : NetworkImage(userinfo.avatar) as ImageProvider; - }).toList(), - ), - ), + constraints: const BoxConstraints(maxWidth: 120), + child: AvatarStack( + height: 28, + borderWidth: 0, + avatars: ongoingCall.participants.map((x) { + final userinfo = + Account.fromJson(jsonDecode(x['metadata'])); + return PlatformInfo.canCacheImage + ? CachedNetworkImageProvider(userinfo.avatar) + as ImageProvider + : NetworkImage(userinfo.avatar) as ImageProvider; + }).toList(), + ), + ); + } + return const SizedBox(); + }) ], ), actions: [ Obx(() { - if (call.current.value == null) { + if (call.isBusy.value) { + return const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 3), + ).paddingAll(16); + } else if (call.current.value == null) { return TextButton( onPressed: () => _showCallPrejoin(context), child: Text('callJoin'.tr), ); - } else if (call.channel.value?.id == channel.id) { + } else if (call.channel.value?.id == channel.id && + !SolianTheme.isLargeScreen(context)) { return TextButton( - onPressed: () => call.gotoScreen(context), + onPressed: () => onJoin(), child: Text('callResume'.tr), ); - } else { + } else if (!SolianTheme.isLargeScreen(context)) { return TextButton( onPressed: null, child: Text('callJoin'.tr), ); } + return const SizedBox(); }) ], ); diff --git a/lib/widgets/chat/call/call_controls.dart b/lib/widgets/chat/call/call_controls.dart index 75b364d..1c341b6 100644 --- a/lib/widgets/chat/call/call_controls.dart +++ b/lib/widgets/chat/call/call_controls.dart @@ -88,10 +88,12 @@ class _ControlsWidgetState extends State { void _disconnect() async { if (await showDisconnectDialog() != true) return; - final ChatCallProvider provider = Get.find(); - if (provider.current.value != null) { - provider.disposeRoom(); - Navigator.pop(context); + final ChatCallProvider call = Get.find(); + if (call.current.value != null) { + call.disposeRoom(); + if (Navigator.canPop(context)) { + Navigator.pop(context); + } } } @@ -209,8 +211,7 @@ class _ControlsWidgetState extends State { runSpacing: 5, children: [ IconButton( - icon: Transform.flip( - flipX: true, child: const Icon(Icons.exit_to_app)), + icon: const Icon(Icons.exit_to_app), color: Theme.of(context).colorScheme.onSurface, onPressed: _disconnect, ), diff --git a/lib/widgets/chat/call/call_prejoin.dart b/lib/widgets/chat/call/call_prejoin.dart index 82beabe..61efc36 100644 --- a/lib/widgets/chat/call/call_prejoin.dart +++ b/lib/widgets/chat/call/call_prejoin.dart @@ -25,22 +25,23 @@ class ChatCallPrejoinPopup extends StatefulWidget { class _ChatCallPrejoinPopupState extends State { bool _isBusy = false; - void performJoin() async { + void _performJoin() async { final AuthProvider auth = Get.find(); - final ChatCallProvider provider = Get.find(); + final ChatCallProvider call = Get.find(); if (auth.isAuthorized.isFalse) return; setState(() => _isBusy = true); - provider.setCall(widget.ongoingCall, widget.channel); + call.setCall(widget.ongoingCall, widget.channel); + call.isBusy.value = true; try { - final resp = await provider.getRoomToken(); + final resp = await call.getRoomToken(); final token = resp.$1; final endpoint = resp.$2; - provider.initRoom(); - provider.setupRoomListeners( + call.initRoom(); + call.setupRoomListeners( onDisconnected: (reason) { context.showSnackbar( 'callDisconnected'.trParams({'reason': reason.toString()}), @@ -48,11 +49,9 @@ class _ChatCallPrejoinPopupState extends State { }, ); - provider.joinRoom(endpoint, token); + call.joinRoom(endpoint, token); - provider.gotoScreen(context).then((_) { - Navigator.pop(context); - }); + Navigator.pop(context); } catch (e) { context.showErrorDialog(e); } @@ -62,9 +61,9 @@ class _ChatCallPrejoinPopupState extends State { @override void initState() { - final ChatCallProvider provider = Get.find(); - provider.checkPermissions().then((_) { - provider.initHardware(); + final ChatCallProvider call = Get.find(); + call.checkPermissions().then((_) { + call.initHardware(); }); super.initState(); @@ -169,7 +168,7 @@ class _ChatCallPrejoinPopupState extends State { backgroundColor: Theme.of(context).colorScheme.primaryContainer, ), - onPressed: _isBusy ? null : performJoin, + onPressed: _isBusy ? null : _performJoin, child: Text('callJoin'.tr), ), ], diff --git a/lib/widgets/chat/call/chat_call_indicator.dart b/lib/widgets/chat/call/chat_call_indicator.dart index 6167915..da52ece 100644 --- a/lib/widgets/chat/call/chat_call_indicator.dart +++ b/lib/widgets/chat/call/chat_call_indicator.dart @@ -7,10 +7,10 @@ class ChatCallCurrentIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - final ChatCallProvider provider = Get.find(); + final ChatCallProvider call = Get.find(); return Obx(() { - if (provider.current.value == null || provider.channel.value == null) { + if (call.current.value == null || call.channel.value == null) { return const SizedBox(); } @@ -18,11 +18,8 @@ class ChatCallCurrentIndicator extends StatelessWidget { tileColor: Theme.of(context).colorScheme.surfaceContainerHigh, contentPadding: const EdgeInsets.symmetric(horizontal: 32), leading: const Icon(Icons.call), - title: Text(provider.channel.value!.name), + title: Text(call.channel.value!.name), subtitle: Text('callAlreadyOngoing'.tr), - onTap: () { - provider.gotoScreen(context); - }, ); }); } diff --git a/lib/widgets/chat/chat_event.dart b/lib/widgets/chat/chat_event.dart index 02114de..6e7da10 100644 --- a/lib/widgets/chat/chat_event.dart +++ b/lib/widgets/chat/chat_event.dart @@ -296,8 +296,8 @@ class ChatEvent extends StatelessWidget { ), ], ).paddingSymmetric(horizontal: 12), - _buildLinkExpansion().paddingOnly(left: 52), - _buildAttachment(context).paddingOnly(left: 56), + _buildLinkExpansion().paddingOnly(left: 52, right: 8), + _buildAttachment(context).paddingOnly(left: 56, right: 8), ], ); }