mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 13:34:07 +00:00
add speed button
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
Some checks failed
Flutter analyze & test / flutter_analyze_and_test (push) Has been cancelled
This commit is contained in:
parent
df838de125
commit
9207a34994
5 changed files with 130 additions and 29 deletions
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue