diff --git a/lib/controllers/chat_events_controller.dart b/lib/controllers/chat_events_controller.dart index 704de2c..60f52ef 100644 --- a/lib/controllers/chat_events_controller.dart +++ b/lib/controllers/chat_events_controller.dart @@ -131,6 +131,8 @@ class ChatEventController { } insertEvent(LocalEvent entry) { + if (entry.channelId != channel?.id) return; + final idx = currentEvents.indexWhere((x) => x.data.uuid == entry.data.uuid); if (idx != -1) { currentEvents[idx] = entry; diff --git a/lib/providers/call.dart b/lib/providers/call.dart index 47c508e..db2a2fc 100644 --- a/lib/providers/call.dart +++ b/lib/providers/call.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:get/get_rx/get_rx.dart'; import 'package:livekit_client/livekit_client.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:solian/models/call.dart'; @@ -16,6 +17,7 @@ class ChatCallProvider extends GetxController { RxBool isReady = false.obs; RxBool isMounted = false.obs; + RxBool isInitialized = false.obs; String? token; String? endpoint; @@ -151,6 +153,8 @@ class ChatCallProvider extends GetxController { void onRoomDidUpdate() => sortParticipants(); void setupRoom() { + if(isInitialized.value) return; + sortParticipants(); room.addListener(onRoomDidUpdate); WidgetsBindingCompatible.instance?.addPostFrameCallback( @@ -160,6 +164,8 @@ class ChatCallProvider extends GetxController { if (lkPlatformIsMobile()) { Hardware.instance.setSpeakerphoneOn(true); } + + isInitialized.value = true; } void setupRoomListeners({ @@ -362,6 +368,7 @@ class ChatCallProvider extends GetxController { void disposeRoom() { isMounted.value = false; + isInitialized.value = false; current.value = null; channel.value = null; room.removeListener(onRoomDidUpdate); diff --git a/lib/screens/channel/call/call.dart b/lib/screens/channel/call/call.dart index 3de6ce8..5e0f29e 100644 --- a/lib/screens/channel/call/call.dart +++ b/lib/screens/channel/call/call.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:math' as math; +import 'package:async/async.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:solian/providers/call.dart'; @@ -16,11 +17,24 @@ class CallScreen extends StatefulWidget { State createState() => _CallScreenState(); } -class _CallScreenState extends State { - Timer? timer; - String currentDuration = '00:00:00'; +class _CallScreenState extends State with TickerProviderStateMixin { + Timer? _timer; + String _currentDuration = '00:00:00'; - String parseDuration() { + bool _showControls = true; + CancelableOperation? _hideControlsOperation; + + late final AnimationController _controlsAnimationController = + AnimationController( + duration: const Duration(milliseconds: 500), + vsync: this, + ); + late final Animation _controlsAnimation = CurvedAnimation( + parent: _controlsAnimationController, + curve: Curves.fastOutSlowIn, + ); + + String _parseDuration() { final ChatCallProvider provider = Get.find(); if (provider.current.value == null) return '00:00:00'; Duration duration = @@ -34,18 +48,50 @@ class _CallScreenState extends State { return formattedTime; } - void updateDuration() { + void _updateDuration() { setState(() { - currentDuration = parseDuration(); + _currentDuration = _parseDuration(); }); } + void _toggleControls() { + if (_showControls) { + setState(() => _showControls = false); + _controlsAnimationController.animateTo(0); + _hideControlsOperation?.cancel(); + } else { + setState(() => _showControls = true); + _controlsAnimationController.animateTo(1); + _planAutoHideControls(); + } + } + + void _planAutoHideControls() { + _hideControlsOperation = CancelableOperation.fromFuture( + Future.delayed(const Duration(seconds: 3), () { + if (!mounted) return; + if (_showControls) _toggleControls(); + }), + ); + } + @override void initState() { Get.find().setupRoom(); super.initState(); - timer = Timer.periodic(const Duration(seconds: 1), (_) => updateDuration()); + _updateDuration(); + _planAutoHideControls(); + _timer = Timer.periodic( + const Duration(seconds: 1), + (_) => _updateDuration(), + ); + } + + @override + void dispose() { + _controlsAnimationController.dispose(); + super.dispose(); } @override @@ -68,80 +114,94 @@ class _CallScreenState extends State { ), const TextSpan(text: '\n'), TextSpan( - text: currentDuration, + text: _currentDuration, style: Theme.of(context).textTheme.bodySmall, ), ]), ), ), body: SafeArea( - child: Obx( - () => Stack( - children: [ - Column( - children: [ - Expanded( - child: Container( - color: Theme.of(context).colorScheme.surfaceContainer, - child: provider.focusTrack.value != null - ? InteractiveParticipantWidget( - isFixed: false, - participant: provider.focusTrack.value!, - onTap: () {}, - ) - : const SizedBox(), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + child: Obx( + () => Stack( + children: [ + Column( + children: [ + Expanded( + child: Container( + color: Theme.of(context).colorScheme.surfaceContainer, + child: provider.focusTrack.value != null + ? InteractiveParticipantWidget( + isFixed: false, + participant: provider.focusTrack.value!, + onTap: () {}, + ) + : const SizedBox(), + ), ), - ), - if (provider.room.localParticipant != null) - ControlsWidget( - provider.room, - provider.room.localParticipant!, - ), - ], - ), - Positioned( - left: 0, - right: 0, - top: 0, - child: SizedBox( - height: 128, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: math.max(0, provider.participantTracks.length), - itemBuilder: (BuildContext context, int index) { - final track = provider.participantTracks[index]; - if (track.participant.sid == - provider.focusTrack.value?.participant.sid) { - return Container(); - } - - return Padding( - padding: const EdgeInsets.only(top: 8, left: 8), - child: ClipRRect( - borderRadius: - const BorderRadius.all(Radius.circular(8)), - child: InteractiveParticipantWidget( - isFixed: true, - width: 120, - height: 120, - color: Theme.of(context).cardColor, - participant: track, - onTap: () { - if (track.participant.sid != - provider - .focusTrack.value?.participant.sid) { - provider.changeFocusTrack(track); - } - }, + if (provider.room.localParticipant != null) + SizeTransition( + sizeFactor: _controlsAnimation, + axis: Axis.vertical, + child: SizedBox( + width: MediaQuery.of(context).size.width, + child: ControlsWidget( + provider.room, + provider.room.localParticipant!, ), ), - ); - }, + ), + ], + ), + Positioned( + left: 0, + right: 0, + top: 0, + child: SizedBox( + height: 128, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: + math.max(0, provider.participantTracks.length), + itemBuilder: (BuildContext context, int index) { + final track = provider.participantTracks[index]; + if (track.participant.sid == + provider.focusTrack.value?.participant.sid) { + return Container(); + } + + return Padding( + padding: const EdgeInsets.only(top: 8, left: 8), + child: ClipRRect( + borderRadius: + const BorderRadius.all(Radius.circular(8)), + child: InteractiveParticipantWidget( + isFixed: true, + width: 120, + height: 120, + color: Theme.of(context).cardColor, + participant: track, + onTap: () { + if (track.participant.sid != + provider + .focusTrack.value?.participant.sid) { + provider.changeFocusTrack(track); + } + }, + ), + ), + ); + }, + ), ), ), - ), - ], + ], + ), ), + onTap: () { + _toggleControls(); + }, ), ), ), @@ -150,16 +210,16 @@ class _CallScreenState extends State { @override void deactivate() { - timer?.cancel(); - timer = null; + _timer?.cancel(); + _timer = null; super.deactivate(); } @override void activate() { - timer ??= Timer.periodic( + _timer ??= Timer.periodic( const Duration(seconds: 1), - (_) => updateDuration(), + (_) => _updateDuration(), ); super.activate(); } diff --git a/lib/screens/channel/channel_chat.dart b/lib/screens/channel/channel_chat.dart index a62af7e..0661a54 100644 --- a/lib/screens/channel/channel_chat.dart +++ b/lib/screens/channel/channel_chat.dart @@ -109,10 +109,15 @@ class _ChannelChatScreenState extends State { break; case 'calls.new': final payload = Call.fromJson(event.payload!); - setState(() => _ongoingCall = payload); + if (payload.channel.id == _channel!.id) { + setState(() => _ongoingCall = payload); + } break; case 'calls.end': - setState(() => _ongoingCall = null); + final payload = Call.fromJson(event.payload!); + if (payload.channel.id == _channel!.id) { + setState(() => _ongoingCall = null); + } break; } }); diff --git a/lib/widgets/chat/call/call_controls.dart b/lib/widgets/chat/call/call_controls.dart index 72a66f1..7c5eb0a 100644 --- a/lib/widgets/chat/call/call_controls.dart +++ b/lib/widgets/chat/call/call_controls.dart @@ -23,7 +23,7 @@ class ControlsWidget extends StatefulWidget { } class _ControlsWidgetState extends State { - CameraPosition position = CameraPosition.front; + CameraPosition _position = CameraPosition.front; List? _audioInputs; List? _audioOutputs; @@ -36,25 +36,25 @@ class _ControlsWidgetState extends State { @override void initState() { super.initState(); - participant.addListener(onChange); + _participant.addListener(onChange); _subscription = Hardware.instance.onDeviceChange.stream .listen((List devices) { - revertDevices(devices); + _revertDevices(devices); }); - Hardware.instance.enumerateDevices().then(revertDevices); + Hardware.instance.enumerateDevices().then(_revertDevices); _speakerphoneOn = Hardware.instance.speakerOn ?? false; } @override void dispose() { _subscription?.cancel(); - participant.removeListener(onChange); + _participant.removeListener(onChange); super.dispose(); } - LocalParticipant get participant => widget.participant; + LocalParticipant get _participant => widget.participant; - void revertDevices(List devices) async { + void _revertDevices(List devices) async { _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); _audioOutputs = devices.where((d) => d.kind == 'audiooutput').toList(); _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); @@ -63,7 +63,7 @@ class _ControlsWidgetState extends State { void onChange() => setState(() {}); - bool get isMuted => participant.isMuted; + bool get isMuted => _participant.isMuted; Future showDisconnectDialog() { return showDialog( @@ -85,7 +85,7 @@ class _ControlsWidgetState extends State { ); } - void disconnect() async { + void _disconnect() async { if (await showDisconnectDialog() != true) return; final ChatCallProvider provider = Get.find(); @@ -95,59 +95,59 @@ class _ControlsWidgetState extends State { } } - void disableAudio() async { - await participant.setMicrophoneEnabled(false); + void _disableAudio() async { + await _participant.setMicrophoneEnabled(false); } - void enableAudio() async { - await participant.setMicrophoneEnabled(true); + void _enableAudio() async { + await _participant.setMicrophoneEnabled(true); } - void disableVideo() async { - await participant.setCameraEnabled(false); + void _disableVideo() async { + await _participant.setCameraEnabled(false); } - void enableVideo() async { - await participant.setCameraEnabled(true); + void _enableVideo() async { + await _participant.setCameraEnabled(true); } - void selectAudioOutput(MediaDevice device) async { + void _selectAudioOutput(MediaDevice device) async { await widget.room.setAudioOutputDevice(device); setState(() {}); } - void selectAudioInput(MediaDevice device) async { + void _selectAudioInput(MediaDevice device) async { await widget.room.setAudioInputDevice(device); setState(() {}); } - void selectVideoInput(MediaDevice device) async { + void _selectVideoInput(MediaDevice device) async { await widget.room.setVideoInputDevice(device); setState(() {}); } - void setSpeakerphoneOn() { + void _setSpeakerphoneOn() { _speakerphoneOn = !_speakerphoneOn; Hardware.instance.setSpeakerphoneOn(_speakerphoneOn); setState(() {}); } - void toggleCamera() async { - final track = participant.videoTrackPublications.firstOrNull?.track; + void _toggleCamera() async { + final track = _participant.videoTrackPublications.firstOrNull?.track; if (track == null) return; try { - final newPosition = position.switched(); + final newPosition = _position.switched(); await track.setCameraPosition(newPosition); setState(() { - position = newPosition; + _position = newPosition; }); } catch (error) { return; } } - void enableScreenShare() async { + void _enableScreenShare() async { if (lkPlatformIsDesktop()) { try { final source = await showDialog( @@ -163,7 +163,7 @@ class _ControlsWidgetState extends State { maxFrameRate: 15.0, ), ); - await participant.publishVideoTrack(track); + await _participant.publishVideoTrack(track); } catch (e) { final message = e.toString(); context.showErrorDialog(message); @@ -177,7 +177,7 @@ class _ControlsWidgetState extends State { maxFrameRate: 30.0, ), ); - await participant.publishVideoTrack(track); + await _participant.publishVideoTrack(track); return; } @@ -188,11 +188,11 @@ class _ControlsWidgetState extends State { return; } - await participant.setScreenShareEnabled(true, captureScreenAudio: true); + await _participant.setScreenShareEnabled(true, captureScreenAudio: true); } - void disableScreenShare() async { - await participant.setScreenShareEnabled(false); + void _disableScreenShare() async { + await _participant.setScreenShareEnabled(false); } @override @@ -210,12 +210,12 @@ class _ControlsWidgetState extends State { icon: Transform.flip( flipX: true, child: const Icon(Icons.exit_to_app)), color: Theme.of(context).colorScheme.onSurface, - onPressed: disconnect, + onPressed: _disconnect, ), - if (participant.isMicrophoneEnabled()) + if (_participant.isMicrophoneEnabled()) if (lkPlatformIs(PlatformType.android)) IconButton( - onPressed: disableAudio, + onPressed: _disableAudio, icon: const Icon(Icons.mic), color: Theme.of(context).colorScheme.onSurface, tooltip: 'callMicrophoneOff'.tr, @@ -227,7 +227,7 @@ class _ControlsWidgetState extends State { return [ PopupMenuItem( value: null, - onTap: isMuted ? enableAudio : disableAudio, + onTap: isMuted ? _enableAudio : _disableAudio, child: ListTile( leading: const Icon(Icons.mic_off), title: Text(isMuted @@ -246,7 +246,7 @@ class _ControlsWidgetState extends State { : const Icon(Icons.check_box_outline_blank), title: Text(device.label), ), - onTap: () => selectAudioInput(device), + onTap: () => _selectAudioInput(device), ); }) ]; @@ -254,19 +254,19 @@ class _ControlsWidgetState extends State { ) else IconButton( - onPressed: enableAudio, + onPressed: _enableAudio, icon: const Icon(Icons.mic_off), color: Theme.of(context).colorScheme.onSurface, tooltip: 'callMicrophoneOn'.tr, ), - if (participant.isCameraEnabled()) + if (_participant.isCameraEnabled()) PopupMenuButton( icon: const Icon(Icons.videocam_sharp), itemBuilder: (BuildContext context) { return [ PopupMenuItem( value: null, - onTap: disableVideo, + onTap: _disableVideo, child: ListTile( leading: const Icon(Icons.videocam_off), title: Text('callCameraOff'.tr), @@ -283,7 +283,7 @@ class _ControlsWidgetState extends State { : const Icon(Icons.check_box_outline_blank), title: Text(device.label), ), - onTap: () => selectVideoInput(device), + onTap: () => _selectVideoInput(device), ); }) ]; @@ -291,17 +291,17 @@ class _ControlsWidgetState extends State { ) else IconButton( - onPressed: enableVideo, + onPressed: _enableVideo, icon: const Icon(Icons.videocam_off), color: Theme.of(context).colorScheme.onSurface, tooltip: 'callCameraOn'.tr, ), IconButton( - icon: Icon(position == CameraPosition.back + icon: Icon(_position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front), color: Theme.of(context).colorScheme.onSurface, - onPressed: () => toggleCamera(), + onPressed: () => _toggleCamera(), tooltip: 'callVideoFlip'.tr, ), if (!lkPlatformIs(PlatformType.iOS)) @@ -327,7 +327,7 @@ class _ControlsWidgetState extends State { : const Icon(Icons.check_box_outline_blank), title: Text(device.label), ), - onTap: () => selectAudioOutput(device), + onTap: () => _selectAudioOutput(device), ); }) ]; @@ -336,7 +336,7 @@ class _ControlsWidgetState extends State { if (!kIsWeb && lkPlatformIs(PlatformType.iOS)) IconButton( onPressed: Hardware.instance.canSwitchSpeakerphone - ? setSpeakerphoneOn + ? _setSpeakerphoneOn : null, color: Theme.of(context).colorScheme.onSurface, icon: Icon( @@ -344,18 +344,18 @@ class _ControlsWidgetState extends State { ), tooltip: 'callSpeakerphoneToggle'.tr, ), - if (participant.isScreenShareEnabled()) + if (_participant.isScreenShareEnabled()) IconButton( icon: const Icon(Icons.monitor_outlined), color: Theme.of(context).colorScheme.onSurface, - onPressed: () => disableScreenShare(), + onPressed: () => _disableScreenShare(), tooltip: 'callScreenOff'.tr, ) else IconButton( icon: const Icon(Icons.monitor), color: Theme.of(context).colorScheme.onSurface, - onPressed: () => enableScreenShare(), + onPressed: () => _enableScreenShare(), tooltip: 'callScreenOn'.tr, ), ], diff --git a/lib/widgets/chat/call/call_participant.dart b/lib/widgets/chat/call/call_participant.dart index 500f8d3..27160b9 100644 --- a/lib/widgets/chat/call/call_participant.dart +++ b/lib/widgets/chat/call/call_participant.dart @@ -219,7 +219,7 @@ class InteractiveParticipantWidget extends StatelessWidget { Widget build(BuildContext context) { return Material( color: Colors.transparent, - child: InkWell( + child: GestureDetector( child: Container( width: width, height: height, diff --git a/pubspec.lock b/pubspec.lock index e58ded2..2324e5b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "2.5.0" async: - dependency: transitive + dependency: "direct main" description: name: async sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" diff --git a/pubspec.yaml b/pubspec.yaml index 986fa1f..04a2d8b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,7 @@ dependencies: markdown_toolbar: ^0.5.0 animations: ^2.0.11 avatar_stack: ^1.2.0 + async: ^2.11.0 dev_dependencies: flutter_test: