add speed button
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled

This commit is contained in:
otsmr 2026-09-02 23:52:54 +02:00
parent df838de125
commit 9207a34994
5 changed files with 130 additions and 29 deletions

View file

@ -74,6 +74,9 @@ class AudioPlayback {
/// Extracted waveform, empty until the extraction finished. /// Extracted waveform, empty until the extraction finished.
List<double> waveformData; List<double> waveformData;
/// Playback speed, kept here so it survives a rebuild of the widget.
double rate = 1;
int _refCount = 0; int _refCount = 0;
Future<void>? _preparing; Future<void>? _preparing;
@ -82,6 +85,11 @@ class AudioPlayback {
/// Resolves once the player is prepared and the waveform is extracted. /// Resolves once the player is prepared and the waveform is extracted.
Future<void> get ready => _preparing ?? Future<void>.value(); Future<void> get ready => _preparing ?? Future<void>.value();
Future<void> setRate(double newRate) async {
rate = newRate;
await controller.setRate(newRate);
}
Future<void> _prepare(int noOfSamples) { Future<void> _prepare(int noOfSamples) {
return _preparing ??= () async { return _preparing ??= () async {
try { try {

View file

@ -171,11 +171,22 @@ class _BadgeMarqueeState extends State<_BadgeMarquee> {
WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_scroll())); 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<void> _scroll() async { Future<void> _scroll() async {
if (_scrolling) return; if (_scrolling) return;
_scrolling = true; _scrolling = true;
try { try {
while (mounted && _controller.hasClients) { while (mounted && _controller.hasClients) {
if (!_ready) {
if (!await _wait(_pause)) return;
continue;
}
final distance = _controller.position.maxScrollExtent; final distance = _controller.position.maxScrollExtent;
if (distance <= 0) return; if (distance <= 0) return;
final duration = Duration( final duration = Duration(
@ -188,7 +199,6 @@ class _BadgeMarqueeState extends State<_BadgeMarquee> {
curve: Curves.linear, curve: Curves.linear,
); );
if (!await _wait(_pause)) return; if (!await _wait(_pause)) return;
if (!mounted || !_controller.hasClients) return;
await _controller.animateTo( await _controller.animateTo(
0, 0,
duration: duration, duration: duration,

View file

@ -10,6 +10,7 @@ enum MyButtonVariant {
primaryDense, primaryDense,
secondaryDense, secondaryDense,
secondaryMiddle, secondaryMiddle,
secondaryTiny,
error, error,
errorMiddle, errorMiddle,
} }
@ -169,6 +170,25 @@ class _MyButtonState extends State<MyButton> {
fontWeight: FontWeight.bold, 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: case MyButtonVariant.error:
buttonStyle = FilledButton.styleFrom( buttonStyle = FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.errorContainer, backgroundColor: Theme.of(context).colorScheme.errorContainer,

View file

@ -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/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/native.notifications.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/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/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/entries/friendly_message_time.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart'; import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart';
@ -156,6 +157,7 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
static const double _cursorWidth = 2.5; static const double _cursorWidth = 2.5;
static const double _cursorHeight = 20; static const double _cursorHeight = 20;
static const double _playSlotWidth = 34; static const double _playSlotWidth = 34;
static const List<double> _rates = [1, 1.5, 2, 0.5];
static const _waveStyle = PlayerWaveStyle( static const _waveStyle = PlayerWaveStyle(
spacing: 4, spacing: 4,
waveThickness: 2.5, waveThickness: 2.5,
@ -260,6 +262,14 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
super.dispose(); super.dispose();
} }
Future<void> _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<void> _togglePlayback() async { Future<void> _togglePlayback() async {
final playback = _playback; final playback = _playback;
if (playback == null) return; if (playback == null) return;
@ -348,34 +358,47 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
), ),
], ],
), ),
Row( SizedBox(
children: [ height: 20,
SizedBox( child: Row(
width: _playSlotWidth, children: [
child: Text( SizedBox(
formatMsToMinSec(remaining), width: _playSlotWidth,
textAlign: TextAlign.center, child: Text(
style: const TextStyle( formatMsToMinSec(remaining),
color: Colors.white, textAlign: TextAlign.center,
fontSize: 10, style: const TextStyle(
color: Colors.white,
fontSize: 10,
),
), ),
), ),
), const SizedBox(width: 10),
const SizedBox(width: 10),
if (widget.trailing != null)
SizedBox( SizedBox(
width: _waveWidth, width: _waveWidth,
child: Align( child: Row(
alignment: Alignment.centerRight, children: [
child: widget.trailing, 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<void> _notifyMessageOpened() async { Future<void> _notifyMessageOpened() async {
final senderId = widget.message.senderId; final senderId = widget.message.senderId;
if (senderId == null) return; if (senderId == null) return;

View file

@ -52,15 +52,25 @@ class MessageInput extends StatefulWidget {
/// between two announcements. /// between two announcements.
const _composingIdleTimeout = Duration(seconds: 6); 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 } enum RecordingState { none, recording, finished }
class _MessageInputState extends State<MessageInput> { class _MessageInputState extends State<MessageInput>
with SingleTickerProviderStateMixin {
late final TextEditingController _textFieldController; late final TextEditingController _textFieldController;
late final RecorderController recorderController; late final RecorderController recorderController;
final bool isApple = Platform.isIOS; final bool isApple = Platform.isIOS;
bool _emojiShowing = false; bool _emojiShowing = false;
bool _showSparks = false; bool _showSparks = false;
bool _audioRecordingLock = false; bool _audioRecordingLock = false;
bool _overLockIcon = false;
int _currentDuration = 0; int _currentDuration = 0;
double _cancelSlideOffset = 0; double _cancelSlideOffset = 0;
Offset _recordingOffset = Offset.zero; Offset _recordingOffset = Offset.zero;
@ -71,6 +81,10 @@ class _MessageInputState extends State<MessageInput> {
Timer? _recordingTimer; Timer? _recordingTimer;
DateTime? _recordingStartTime; 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() { void _sendMessage() {
final text = _textFieldController.text; final text = _textFieldController.text;
if (text == '') return; if (text == '') return;
@ -128,6 +142,7 @@ class _MessageInputState extends State<MessageInput> {
widget.textFieldFocus.removeListener(_handleTextFocusChange); widget.textFieldFocus.removeListener(_handleTextFocusChange);
widget.textFieldFocus.dispose(); widget.textFieldFocus.dispose();
_recordingTimer?.cancel(); _recordingTimer?.cancel();
_recordingBlink.dispose();
recorderController.dispose(); recorderController.dispose();
_nextTypingIndicator?.cancel(); _nextTypingIndicator?.cancel();
widget.composing.value = false; widget.composing.value = false;
@ -149,6 +164,11 @@ class _MessageInputState extends State<MessageInput> {
void _initializeControllers() { void _initializeControllers() {
recorderController = RecorderController(); recorderController = RecorderController();
_recordingBlink = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
lowerBound: 0.25,
);
} }
/// Whether the composer counts as being typed in right now. /// Whether the composer counts as being typed in right now.
@ -204,7 +224,9 @@ class _MessageInputState extends State<MessageInput> {
setState(() { setState(() {
_recordingState = RecordingState.recording; _recordingState = RecordingState.recording;
_currentDuration = 0; _currentDuration = 0;
_overLockIcon = false;
}); });
_recordingBlink.repeat(reverse: true);
_recordingStartTime = clock.now(); _recordingStartTime = clock.now();
_recordingTimer?.cancel(); _recordingTimer?.cancel();
_recordingTimer = Timer.periodic(const Duration(milliseconds: 100), ( _recordingTimer = Timer.periodic(const Duration(milliseconds: 100), (
@ -232,9 +254,11 @@ class _MessageInputState extends State<MessageInput> {
Future<void> _stopAudioRecording() async { Future<void> _stopAudioRecording() async {
_recordingTimer?.cancel(); _recordingTimer?.cancel();
_recordingTimer = null; _recordingTimer = null;
_recordingBlink.stop();
await HapticFeedback.heavyImpact(); await HapticFeedback.heavyImpact();
setState(() { setState(() {
_audioRecordingLock = false; _audioRecordingLock = false;
_overLockIcon = false;
_cancelSlideOffset = 0; _cancelSlideOffset = 0;
_recordingState = RecordingState.none; _recordingState = RecordingState.none;
}); });
@ -265,8 +289,10 @@ class _MessageInputState extends State<MessageInput> {
Future<void> _cancelAudioRecording() async { Future<void> _cancelAudioRecording() async {
_recordingTimer?.cancel(); _recordingTimer?.cancel();
_recordingTimer = null; _recordingTimer = null;
_recordingBlink.stop();
setState(() { setState(() {
_audioRecordingLock = false; _audioRecordingLock = false;
_overLockIcon = false;
_cancelSlideOffset = 0; _cancelSlideOffset = 0;
_recordingState = RecordingState.none; _recordingState = RecordingState.none;
}); });
@ -422,17 +448,20 @@ class _MessageInputState extends State<MessageInput> {
), ),
child: Row( child: Row(
children: [ children: [
const Padding( Padding(
padding: EdgeInsets.only( padding: const EdgeInsets.only(
top: 14, top: 14,
bottom: 14, bottom: 14,
left: 12, left: 12,
right: 8, right: 8,
), ),
child: FaIcon( child: FadeTransition(
FontAwesomeIcons.microphone, opacity: _recordingBlink,
size: 20, child: const FaIcon(
color: Colors.red, FontAwesomeIcons.microphone,
size: 20,
color: Colors.red,
),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
@ -475,7 +504,8 @@ class _MessageInputState extends State<MessageInput> {
], ],
), ),
), ),
if (_textFieldController.text == '') if (_textFieldController.text == '' &&
_recordingState == RecordingState.none)
IconButton( IconButton(
icon: const FaIcon(FontAwesomeIcons.camera), icon: const FaIcon(FontAwesomeIcons.camera),
onPressed: () { onPressed: () {
@ -493,9 +523,19 @@ class _MessageInputState extends State<MessageInput> {
GestureDetector( GestureDetector(
onLongPressMoveUpdate: (details) { onLongPressMoveUpdate: (details) {
if (_audioRecordingLock) return; if (_audioRecordingLock) return;
if (_recordingOffset.dy - final upwards =
details.localPosition.dy >= _recordingOffset.dy - details.localPosition.dy;
100) { // 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(); HapticFeedback.heavyImpact();
setState(() { setState(() {
_audioRecordingLock = true; _audioRecordingLock = true;