diff --git a/lib/src/services/audio_playback.service.dart b/lib/src/services/audio_playback.service.dart index 1c01dcb3..eccbce99 100644 --- a/lib/src/services/audio_playback.service.dart +++ b/lib/src/services/audio_playback.service.dart @@ -74,6 +74,9 @@ class AudioPlayback { /// Extracted waveform, empty until the extraction finished. List waveformData; + /// Playback speed, kept here so it survives a rebuild of the widget. + double rate = 1; + int _refCount = 0; Future? _preparing; @@ -82,6 +85,11 @@ class AudioPlayback { /// Resolves once the player is prepared and the waveform is extracted. Future get ready => _preparing ?? Future.value(); + Future setRate(double newRate) async { + rate = newRate; + await controller.setRate(newRate); + } + Future _prepare(int noOfSamples) { return _preparing ??= () async { try { diff --git a/lib/src/visual/components/contact_groups.comp.dart b/lib/src/visual/components/contact_groups.comp.dart index 39c3211d..720aa1db 100644 --- a/lib/src/visual/components/contact_groups.comp.dart +++ b/lib/src/visual/components/contact_groups.comp.dart @@ -171,11 +171,22 @@ class _BadgeMarqueeState extends State<_BadgeMarquee> { WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_scroll())); } + /// The scroll extent is only known once the scroll view has been laid out, + /// which has not necessarily happened when the first frame callback runs. + bool get _ready => + mounted && + _controller.hasClients && + _controller.position.hasContentDimensions; + Future _scroll() async { if (_scrolling) return; _scrolling = true; try { while (mounted && _controller.hasClients) { + if (!_ready) { + if (!await _wait(_pause)) return; + continue; + } final distance = _controller.position.maxScrollExtent; if (distance <= 0) return; final duration = Duration( @@ -188,7 +199,6 @@ class _BadgeMarqueeState extends State<_BadgeMarquee> { curve: Curves.linear, ); if (!await _wait(_pause)) return; - if (!mounted || !_controller.hasClients) return; await _controller.animateTo( 0, duration: duration, diff --git a/lib/src/visual/elements/my_button.element.dart b/lib/src/visual/elements/my_button.element.dart index 87adfe89..3efdce34 100644 --- a/lib/src/visual/elements/my_button.element.dart +++ b/lib/src/visual/elements/my_button.element.dart @@ -10,6 +10,7 @@ enum MyButtonVariant { primaryDense, secondaryDense, secondaryMiddle, + secondaryTiny, error, errorMiddle, } @@ -169,6 +170,25 @@ class _MyButtonState extends State { fontWeight: FontWeight.bold, ), ); + case MyButtonVariant.secondaryTiny: + buttonStyle = FilledButton.styleFrom( + backgroundColor: isDark ? Colors.grey[800] : Colors.grey[200], + foregroundColor: isDark ? Colors.white : Colors.black87, + disabledBackgroundColor: disabledBgColor, + disabledForegroundColor: disabledFgColor, + minimumSize: const Size(40, 23), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: 0, + textStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); case MyButtonVariant.error: buttonStyle = FilledButton.styleFrom( backgroundColor: Theme.of(context).colorScheme.errorContainer, diff --git a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart index 9d172b2a..346f92fc 100644 --- a/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart +++ b/lib/src/visual/views/chats/chat_messages_components/entries/chat_audio_entry.dart @@ -10,6 +10,7 @@ import 'package:twonly/src/services/audio_playback.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/notifications/native.notifications.dart'; import 'package:twonly/src/visual/elements/better_text.element.dart'; +import 'package:twonly/src/visual/elements/my_button.element.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart'; @@ -156,6 +157,7 @@ class _InChatAudioPlayerState extends State { static const double _cursorWidth = 2.5; static const double _cursorHeight = 20; static const double _playSlotWidth = 34; + static const List _rates = [1, 1.5, 2, 0.5]; static const _waveStyle = PlayerWaveStyle( spacing: 4, waveThickness: 2.5, @@ -260,6 +262,14 @@ class _InChatAudioPlayerState extends State { super.dispose(); } + Future _cycleRate() async { + final playback = _playback; + if (playback == null) return; + final next = _rates[(_rates.indexOf(playback.rate) + 1) % _rates.length]; + await playback.setRate(next); + if (mounted) setState(() {}); + } + Future _togglePlayback() async { final playback = _playback; if (playback == null) return; @@ -348,34 +358,47 @@ class _InChatAudioPlayerState extends State { ), ], ), - Row( - children: [ - SizedBox( - width: _playSlotWidth, - child: Text( - formatMsToMinSec(remaining), - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 10, + SizedBox( + height: 20, + child: Row( + children: [ + SizedBox( + width: _playSlotWidth, + child: Text( + formatMsToMinSec(remaining), + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 10, + ), ), ), - ), - const SizedBox(width: 10), - if (widget.trailing != null) + const SizedBox(width: 10), SizedBox( width: _waveWidth, - child: Align( - alignment: Alignment.centerRight, - child: widget.trailing, + child: Row( + children: [ + if (_isPlaying && playback != null) + MyButton( + variant: MyButtonVariant.secondaryTiny, + onPressed: () => unawaited(_cycleRate()), + child: Text(_formatRate(playback.rate)), + ), + const Spacer(), + ?widget.trailing, + ], ), ), - ], + ], + ), ), ], ); } + String _formatRate(double rate) => + rate == rate.roundToDouble() ? '${rate.toInt()}x' : '${rate}x'; + Future _notifyMessageOpened() async { final senderId = widget.message.senderId; if (senderId == null) return; diff --git a/lib/src/visual/views/chats/chat_messages_components/message_input.dart b/lib/src/visual/views/chats/chat_messages_components/message_input.dart index 84ced648..b6abe258 100644 --- a/lib/src/visual/views/chats/chat_messages_components/message_input.dart +++ b/lib/src/visual/views/chats/chat_messages_components/message_input.dart @@ -52,15 +52,25 @@ class MessageInput extends StatefulWidget { /// between two announcements. const _composingIdleTimeout = Duration(seconds: 6); +/// How far up the finger has to travel before it sits on the lock pill that +/// floats above the microphone. The pill is drawn 120px above the button and +/// is 60px tall, so its lower edge is roughly this far from the grab point. +const _lockHintOffset = 70.0; + +/// How far up the finger has to travel for the recording to actually latch. +const _lockOffset = 100.0; + enum RecordingState { none, recording, finished } -class _MessageInputState extends State { +class _MessageInputState extends State + with SingleTickerProviderStateMixin { late final TextEditingController _textFieldController; late final RecorderController recorderController; final bool isApple = Platform.isIOS; bool _emojiShowing = false; bool _showSparks = false; bool _audioRecordingLock = false; + bool _overLockIcon = false; int _currentDuration = 0; double _cancelSlideOffset = 0; Offset _recordingOffset = Offset.zero; @@ -71,6 +81,10 @@ class _MessageInputState extends State { Timer? _recordingTimer; DateTime? _recordingStartTime; + /// Pulses the microphone icon while a recording is running so the composer + /// reads as live even when the waveform is off screen. + late final AnimationController _recordingBlink; + void _sendMessage() { final text = _textFieldController.text; if (text == '') return; @@ -128,6 +142,7 @@ class _MessageInputState extends State { widget.textFieldFocus.removeListener(_handleTextFocusChange); widget.textFieldFocus.dispose(); _recordingTimer?.cancel(); + _recordingBlink.dispose(); recorderController.dispose(); _nextTypingIndicator?.cancel(); widget.composing.value = false; @@ -149,6 +164,11 @@ class _MessageInputState extends State { void _initializeControllers() { recorderController = RecorderController(); + _recordingBlink = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + lowerBound: 0.25, + ); } /// Whether the composer counts as being typed in right now. @@ -204,7 +224,9 @@ class _MessageInputState extends State { setState(() { _recordingState = RecordingState.recording; _currentDuration = 0; + _overLockIcon = false; }); + _recordingBlink.repeat(reverse: true); _recordingStartTime = clock.now(); _recordingTimer?.cancel(); _recordingTimer = Timer.periodic(const Duration(milliseconds: 100), ( @@ -232,9 +254,11 @@ class _MessageInputState extends State { Future _stopAudioRecording() async { _recordingTimer?.cancel(); _recordingTimer = null; + _recordingBlink.stop(); await HapticFeedback.heavyImpact(); setState(() { _audioRecordingLock = false; + _overLockIcon = false; _cancelSlideOffset = 0; _recordingState = RecordingState.none; }); @@ -265,8 +289,10 @@ class _MessageInputState extends State { Future _cancelAudioRecording() async { _recordingTimer?.cancel(); _recordingTimer = null; + _recordingBlink.stop(); setState(() { _audioRecordingLock = false; + _overLockIcon = false; _cancelSlideOffset = 0; _recordingState = RecordingState.none; }); @@ -422,17 +448,20 @@ class _MessageInputState extends State { ), child: Row( children: [ - const Padding( - padding: EdgeInsets.only( + Padding( + padding: const EdgeInsets.only( top: 14, bottom: 14, left: 12, right: 8, ), - child: FaIcon( - FontAwesomeIcons.microphone, - size: 20, - color: Colors.red, + child: FadeTransition( + opacity: _recordingBlink, + child: const FaIcon( + FontAwesomeIcons.microphone, + size: 20, + color: Colors.red, + ), ), ), const SizedBox(width: 10), @@ -475,7 +504,8 @@ class _MessageInputState extends State { ], ), ), - if (_textFieldController.text == '') + if (_textFieldController.text == '' && + _recordingState == RecordingState.none) IconButton( icon: const FaIcon(FontAwesomeIcons.camera), onPressed: () { @@ -493,9 +523,19 @@ class _MessageInputState extends State { GestureDetector( onLongPressMoveUpdate: (details) { if (_audioRecordingLock) return; - if (_recordingOffset.dy - - details.localPosition.dy >= - 100) { + final upwards = + _recordingOffset.dy - details.localPosition.dy; + // Nudge once when the finger reaches the lock, so + // it is clear the gesture will latch before the + // user commits to it. + if (upwards >= _lockHintOffset && !_overLockIcon) { + _overLockIcon = true; + HapticFeedback.selectionClick(); + } else if (upwards < _lockHintOffset && + _overLockIcon) { + _overLockIcon = false; + } + if (upwards >= _lockOffset) { HapticFeedback.heavyImpact(); setState(() { _audioRecordingLock = true;