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.
List<double> waveformData;
/// Playback speed, kept here so it survives a rebuild of the widget.
double rate = 1;
int _refCount = 0;
Future<void>? _preparing;
@ -82,6 +85,11 @@ class AudioPlayback {
/// Resolves once the player is prepared and the waveform is extracted.
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) {
return _preparing ??= () async {
try {

View file

@ -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<void> _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,

View file

@ -10,6 +10,7 @@ enum MyButtonVariant {
primaryDense,
secondaryDense,
secondaryMiddle,
secondaryTiny,
error,
errorMiddle,
}
@ -169,6 +170,25 @@ class _MyButtonState extends State<MyButton> {
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,

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/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<InChatAudioPlayer> {
static const double _cursorWidth = 2.5;
static const double _cursorHeight = 20;
static const double _playSlotWidth = 34;
static const List<double> _rates = [1, 1.5, 2, 0.5];
static const _waveStyle = PlayerWaveStyle(
spacing: 4,
waveThickness: 2.5,
@ -260,6 +262,14 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
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 {
final playback = _playback;
if (playback == null) return;
@ -348,34 +358,47 @@ class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
),
],
),
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<void> _notifyMessageOpened() async {
final senderId = widget.message.senderId;
if (senderId == null) return;

View file

@ -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<MessageInput> {
class _MessageInputState extends State<MessageInput>
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<MessageInput> {
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<MessageInput> {
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<MessageInput> {
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<MessageInput> {
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<MessageInput> {
Future<void> _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<MessageInput> {
Future<void> _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<MessageInput> {
),
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<MessageInput> {
],
),
),
if (_textFieldController.text == '')
if (_textFieldController.text == '' &&
_recordingState == RecordingState.none)
IconButton(
icon: const FaIcon(FontAwesomeIcons.camera),
onPressed: () {
@ -493,9 +523,19 @@ class _MessageInputState extends State<MessageInput> {
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;