mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 12:54:07 +00:00
improve voice messages
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
a44e6347c9
commit
df838de125
8 changed files with 369 additions and 153 deletions
|
|
@ -7013,8 +7013,8 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
UserConfig dco_decode_user_config(dynamic raw) {
|
||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||
final arr = raw as List<dynamic>;
|
||||
if (arr.length != 60)
|
||||
throw Exception('unexpected arr length: expect 60 but see ${arr.length}');
|
||||
if (arr.length != 61)
|
||||
throw Exception('unexpected arr length: expect 61 but see ${arr.length}');
|
||||
return UserConfig(
|
||||
userId: dco_decode_i_64(arr[0]),
|
||||
username: dco_decode_String(arr[1]),
|
||||
|
|
@ -7069,18 +7069,19 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
lastChangeLogHash: dco_decode_opt_list_prim_u_8_strict(arr[46]),
|
||||
hideChangeLog: dco_decode_bool(arr[47]),
|
||||
hideMemoriesBackupPromo: dco_decode_bool(arr[48]),
|
||||
updateFcmToken: dco_decode_bool(arr[49]),
|
||||
canUseLoginTokenForAuth: dco_decode_bool(arr[50]),
|
||||
twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[51]),
|
||||
isBackupEnabled: dco_decode_bool(arr[52]),
|
||||
hideWidgetShareExplainer: dco_decode_bool(arr[49]),
|
||||
updateFcmToken: dco_decode_bool(arr[50]),
|
||||
canUseLoginTokenForAuth: dco_decode_bool(arr[51]),
|
||||
twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[52]),
|
||||
isBackupEnabled: dco_decode_bool(arr[53]),
|
||||
passwordLessRecovery:
|
||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[53]),
|
||||
fcmToken: dco_decode_opt_String(arr[54]),
|
||||
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[55]),
|
||||
lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[56]),
|
||||
currentSetupPage: dco_decode_opt_String(arr[57]),
|
||||
skipSetupPages: dco_decode_bool(arr[58]),
|
||||
hasZoomed: dco_decode_bool(arr[59]),
|
||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[54]),
|
||||
fcmToken: dco_decode_opt_String(arr[55]),
|
||||
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[56]),
|
||||
lastServerMessageAt: dco_decode_opt_box_autoadd_i_64(arr[57]),
|
||||
currentSetupPage: dco_decode_opt_String(arr[58]),
|
||||
skipSetupPages: dco_decode_bool(arr[59]),
|
||||
hasZoomed: dco_decode_bool(arr[60]),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -8189,6 +8190,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
);
|
||||
var var_hideChangeLog = sse_decode_bool(deserializer);
|
||||
var var_hideMemoriesBackupPromo = sse_decode_bool(deserializer);
|
||||
var var_hideWidgetShareExplainer = sse_decode_bool(deserializer);
|
||||
var var_updateFcmToken = sse_decode_bool(deserializer);
|
||||
var var_canUseLoginTokenForAuth = sse_decode_bool(deserializer);
|
||||
var var_twonlySafeBackup = sse_decode_opt_box_autoadd_twonly_safe_backup(
|
||||
|
|
@ -8256,6 +8258,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
lastChangeLogHash: var_lastChangeLogHash,
|
||||
hideChangeLog: var_hideChangeLog,
|
||||
hideMemoriesBackupPromo: var_hideMemoriesBackupPromo,
|
||||
hideWidgetShareExplainer: var_hideWidgetShareExplainer,
|
||||
updateFcmToken: var_updateFcmToken,
|
||||
canUseLoginTokenForAuth: var_canUseLoginTokenForAuth,
|
||||
twonlySafeBackup: var_twonlySafeBackup,
|
||||
|
|
@ -9338,6 +9341,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
|||
sse_encode_opt_list_prim_u_8_strict(self.lastChangeLogHash, serializer);
|
||||
sse_encode_bool(self.hideChangeLog, serializer);
|
||||
sse_encode_bool(self.hideMemoriesBackupPromo, serializer);
|
||||
sse_encode_bool(self.hideWidgetShareExplainer, serializer);
|
||||
sse_encode_bool(self.updateFcmToken, serializer);
|
||||
sse_encode_bool(self.canUseLoginTokenForAuth, serializer);
|
||||
sse_encode_opt_box_autoadd_twonly_safe_backup(
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ class UserConfig {
|
|||
Uint8List? lastChangeLogHash;
|
||||
bool hideChangeLog;
|
||||
bool hideMemoriesBackupPromo;
|
||||
bool hideWidgetShareExplainer;
|
||||
bool updateFcmToken;
|
||||
bool canUseLoginTokenForAuth;
|
||||
TwonlySafeBackup? twonlySafeBackup;
|
||||
|
|
@ -222,6 +223,7 @@ class UserConfig {
|
|||
this.lastChangeLogHash,
|
||||
required this.hideChangeLog,
|
||||
required this.hideMemoriesBackupPromo,
|
||||
required this.hideWidgetShareExplainer,
|
||||
required this.updateFcmToken,
|
||||
required this.canUseLoginTokenForAuth,
|
||||
this.twonlySafeBackup,
|
||||
|
|
@ -286,6 +288,7 @@ class UserConfig {
|
|||
lastChangeLogHash.hashCode ^
|
||||
hideChangeLog.hashCode ^
|
||||
hideMemoriesBackupPromo.hashCode ^
|
||||
hideWidgetShareExplainer.hashCode ^
|
||||
updateFcmToken.hashCode ^
|
||||
canUseLoginTokenForAuth.hashCode ^
|
||||
twonlySafeBackup.hashCode ^
|
||||
|
|
@ -359,6 +362,7 @@ class UserConfig {
|
|||
lastChangeLogHash == other.lastChangeLogHash &&
|
||||
hideChangeLog == other.hideChangeLog &&
|
||||
hideMemoriesBackupPromo == other.hideMemoriesBackupPromo &&
|
||||
hideWidgetShareExplainer == other.hideWidgetShareExplainer &&
|
||||
updateFcmToken == other.updateFcmToken &&
|
||||
canUseLoginTokenForAuth == other.canUseLoginTokenForAuth &&
|
||||
twonlySafeBackup == other.twonlySafeBackup &&
|
||||
|
|
|
|||
|
|
@ -2,6 +2,4 @@ class KeyValueKeys {
|
|||
static const String currentBackupState = 'current_backup_state';
|
||||
static const String backupRecoveryState = 'backup_recovery_state';
|
||||
static const String onboardingState = 'onboarding_state';
|
||||
static const String shareImageWidgetExplainer =
|
||||
'share_image_widget_explainer';
|
||||
}
|
||||
|
|
|
|||
104
lib/src/services/audio_playback.service.dart
Normal file
104
lib/src/services/audio_playback.service.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:audio_waveforms/audio_waveforms.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
||||
/// Keeps the [PlayerController] of a chat audio message alive across widget
|
||||
/// rebuilds.
|
||||
///
|
||||
/// The message list recreates the state of all of its items whenever it scrolls
|
||||
/// to a new position (sending a message does that), so a controller owned by
|
||||
/// the widget itself would be disposed and re-prepared in the middle of the
|
||||
/// playback. Controllers are cached by file path here and are only released
|
||||
/// once no widget uses them any more.
|
||||
class AudioPlaybackService {
|
||||
AudioPlaybackService._();
|
||||
|
||||
static final AudioPlaybackService instance = AudioPlaybackService._();
|
||||
|
||||
final Map<String, AudioPlayback> _playbacks = {};
|
||||
|
||||
/// Waveforms of already released players so a re-created widget can draw the
|
||||
/// waveform right away instead of waiting for the extraction again.
|
||||
final Map<String, List<double>> _waveformCache = {};
|
||||
|
||||
/// Returns the playback for [path], preparing the player on first use.
|
||||
///
|
||||
/// Every [attach] must be paired with a [detach].
|
||||
AudioPlayback attach(String path, {required int noOfSamples}) {
|
||||
final playback = _playbacks.putIfAbsent(
|
||||
path,
|
||||
() => AudioPlayback._(path, _waveformCache[path] ?? const []),
|
||||
);
|
||||
playback._refCount++;
|
||||
unawaited(playback._prepare(noOfSamples));
|
||||
return playback;
|
||||
}
|
||||
|
||||
/// Releases the playback again once nothing uses it any more.
|
||||
void detach(AudioPlayback playback) {
|
||||
playback._refCount--;
|
||||
if (playback._refCount > 0) return;
|
||||
// A rebuilding message list detaches and attaches within the same frame,
|
||||
// so only release the player when it was not picked up again.
|
||||
Timer(const Duration(milliseconds: 500), () {
|
||||
if (playback._refCount > 0) return;
|
||||
if (_playbacks[playback.path] != playback) return;
|
||||
_playbacks.remove(playback.path);
|
||||
if (playback.waveformData.isNotEmpty) {
|
||||
_waveformCache[playback.path] = playback.waveformData;
|
||||
}
|
||||
playback.controller.dispose();
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts [playback] and pauses any other audio message.
|
||||
Future<void> play(AudioPlayback playback) async {
|
||||
for (final other in _playbacks.values) {
|
||||
if (other == playback) continue;
|
||||
if (other.controller.playerState == PlayerState.playing) {
|
||||
await other.controller.pausePlayer();
|
||||
}
|
||||
}
|
||||
await playback.controller.startPlayer();
|
||||
}
|
||||
}
|
||||
|
||||
class AudioPlayback {
|
||||
AudioPlayback._(this.path, List<double> cachedWaveform)
|
||||
: waveformData = List<double>.of(cachedWaveform);
|
||||
|
||||
final String path;
|
||||
final PlayerController controller = PlayerController();
|
||||
|
||||
/// Extracted waveform, empty until the extraction finished.
|
||||
List<double> waveformData;
|
||||
|
||||
int _refCount = 0;
|
||||
Future<void>? _preparing;
|
||||
|
||||
bool get isPlaying => controller.playerState == PlayerState.playing;
|
||||
|
||||
/// Resolves once the player is prepared and the waveform is extracted.
|
||||
Future<void> get ready => _preparing ?? Future<void>.value();
|
||||
|
||||
Future<void> _prepare(int noOfSamples) {
|
||||
return _preparing ??= () async {
|
||||
try {
|
||||
await controller.preparePlayer(
|
||||
path: path,
|
||||
// The waveform is extracted below so the extraction can be awaited.
|
||||
shouldExtractWaveform: false,
|
||||
);
|
||||
await controller.setFinishMode(finishMode: FinishMode.pause);
|
||||
if (waveformData.isEmpty) {
|
||||
waveformData = await controller.waveformExtraction
|
||||
.extractWaveformData(path: path, noOfSamples: noOfSamples);
|
||||
AudioPlaybackService.instance._waveformCache[path] = waveformData;
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error('Could not prepare the audio player for $path: $e');
|
||||
}
|
||||
}();
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,11 @@ import 'dart:math' as math;
|
|||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/constants/keyvalue.keys.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/keyvalue.dart';
|
||||
import 'package:twonly/src/services/user.service.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
||||
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||
|
|
@ -95,16 +94,14 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
bool _widgetRecipientAvailable = false;
|
||||
bool _sendToWidget = false;
|
||||
bool _updatingWidgetMode = false;
|
||||
bool _widgetExplainerPreferenceLoaded = false;
|
||||
bool _widgetExplainerDismissed = false;
|
||||
bool _widgetExplainerDismissed =
|
||||
userService.currentUser.hideWidgetShareExplainer;
|
||||
bool _previousMediaSettingsCaptured = false;
|
||||
int? _displayLimitBeforeWidget;
|
||||
bool _requiresAuthBeforeWidget = false;
|
||||
|
||||
bool get _showWidgetExplainer =>
|
||||
_widgetRecipientAvailable &&
|
||||
_widgetExplainerPreferenceLoaded &&
|
||||
!_widgetExplainerDismissed;
|
||||
_widgetRecipientAvailable && !_widgetExplainerDismissed;
|
||||
|
||||
MediaFileService get mediaService => widget.mediaFileService;
|
||||
MediaFile get media => widget.mediaFileService.mediaFile;
|
||||
|
|
@ -139,8 +136,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
_widgetGroupsSubscription = twonlyDB.groupsDao
|
||||
.watchGroupsAllowedForWidgetShare()
|
||||
.listen(_updateWidgetRecipientAvailability);
|
||||
unawaited(_loadWidgetExplainerPreference());
|
||||
|
||||
if (media.type == MediaType.image || media.type == MediaType.gif) {
|
||||
_loadInitialImage();
|
||||
}
|
||||
|
|
@ -165,23 +160,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadWidgetExplainerPreference() async {
|
||||
final preference = await KeyValueStore.get(
|
||||
KeyValueKeys.shareImageWidgetExplainer,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_widgetExplainerDismissed = preference?['dismissed'] == true;
|
||||
_widgetExplainerPreferenceLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _dismissWidgetExplainer() async {
|
||||
setState(() => _widgetExplainerDismissed = true);
|
||||
await KeyValueStore.put(
|
||||
KeyValueKeys.shareImageWidgetExplainer,
|
||||
const {'dismissed': true},
|
||||
);
|
||||
await UserService.update((u) => u.hideWidgetShareExplainer = true);
|
||||
}
|
||||
|
||||
void _updateWidgetRecipientAvailability(List<Group> widgetGroups) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
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';
|
||||
|
|
@ -75,65 +76,55 @@ class _ChatAudioEntryState extends State<ChatAudioEntry> {
|
|||
}
|
||||
|
||||
final showTime = info.displayTime || message.modifiedAt != null;
|
||||
final messageTime = showTime ? FriendlyMessageTime(message: message) : null;
|
||||
final isDownloaded =
|
||||
mediaService.mediaFile.downloadState == DownloadState.ready ||
|
||||
mediaService.mediaFile.downloadState == null;
|
||||
// The player places the time below the waveform itself.
|
||||
final showsPlayer = info.text == '' && isDownloaded && _hasTempFile;
|
||||
|
||||
return IntrinsicWidth(
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||
minWidth: 280,
|
||||
// The player brings its own width, only the reactions below the
|
||||
// bubble may need more room.
|
||||
minWidth: info.minWidth,
|
||||
),
|
||||
padding: info.padding,
|
||||
// Voice messages need a bit more room than the tightly padded media
|
||||
// bubbles.
|
||||
padding: info.padding.copyWith(top: 8, bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: info.color,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (info.displayUserName != '')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2),
|
||||
child: Text(
|
||||
info.displayUserName,
|
||||
textAlign: TextAlign.left,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
if (info.text != '')
|
||||
Expanded(
|
||||
child: BetterText(
|
||||
text: info.text,
|
||||
textColor: info.textColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: isDownloaded
|
||||
? (_hasTempFile
|
||||
? InChatAudioPlayer(
|
||||
path: mediaService.tempPath.path,
|
||||
message: message,
|
||||
trailing: messageTime,
|
||||
)
|
||||
: Container())
|
||||
: MessageSendStateIcon(
|
||||
[message],
|
||||
[mediaService.mediaFile],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (info.text != '')
|
||||
Expanded(
|
||||
child: BetterText(
|
||||
text: info.text,
|
||||
textColor: info.textColor,
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child:
|
||||
mediaService.mediaFile.downloadState ==
|
||||
DownloadState.ready ||
|
||||
mediaService.mediaFile.downloadState == null
|
||||
? (_hasTempFile
|
||||
? InChatAudioPlayer(
|
||||
path: mediaService.tempPath.path,
|
||||
message: message,
|
||||
)
|
||||
: Container())
|
||||
: MessageSendStateIcon(
|
||||
[message],
|
||||
[mediaService.mediaFile],
|
||||
),
|
||||
),
|
||||
if (showTime) FriendlyMessageTime(message: message),
|
||||
],
|
||||
),
|
||||
if (!showsPlayer && messageTime != null) messageTime,
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -145,116 +136,241 @@ class InChatAudioPlayer extends StatefulWidget {
|
|||
const InChatAudioPlayer({
|
||||
required this.path,
|
||||
required this.message,
|
||||
this.trailing,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String path;
|
||||
final Message message;
|
||||
|
||||
/// Shown below the waveform, next to the remaining playback time.
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
State<InChatAudioPlayer> createState() => _InChatAudioPlayerState();
|
||||
}
|
||||
|
||||
class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
|
||||
final PlayerController _playerController = PlayerController();
|
||||
int _displayDuration = 0;
|
||||
int _maxDuration = 0;
|
||||
static const double _maxWaveWidth = 170;
|
||||
static const double _waveHeight = 36;
|
||||
static const double _cursorWidth = 2.5;
|
||||
static const double _cursorHeight = 20;
|
||||
static const double _playSlotWidth = 34;
|
||||
static const _waveStyle = PlayerWaveStyle(
|
||||
spacing: 4,
|
||||
waveThickness: 2.5,
|
||||
scaleFactor: 80,
|
||||
showSeekLine: false,
|
||||
);
|
||||
|
||||
AudioPlayback? _playback;
|
||||
final List<StreamSubscription<dynamic>> _subscriptions = [];
|
||||
|
||||
/// Width available for the waveform next to the play button and the time.
|
||||
double _waveWidth = _maxWaveWidth;
|
||||
|
||||
int _position = 0;
|
||||
int _duration = 0;
|
||||
bool _isPlaying = false;
|
||||
|
||||
/// The cursor is only shown once the message was started.
|
||||
bool get _showCursor => _isPlaying || _position > 0;
|
||||
|
||||
double get _progress =>
|
||||
_duration <= 0 ? 0 : (_position / _duration).clamp(0.0, 1.0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_playerController
|
||||
..preparePlayer(path: widget.path)
|
||||
..setFinishMode(finishMode: FinishMode.pause);
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// Leaves room for the play button and the bubble padding.
|
||||
_waveWidth = (MediaQuery.sizeOf(context).width * 0.8 - 80).clamp(
|
||||
80.0,
|
||||
_maxWaveWidth,
|
||||
);
|
||||
if (_playback != null) return;
|
||||
|
||||
_playerController.onCompletion.listen((_) {
|
||||
if (mounted) {
|
||||
// The player outlives this widget: the message list recreates its items
|
||||
// when it scrolls (e.g. after sending a message) and the playback must not
|
||||
// be interrupted by that.
|
||||
final playback = AudioPlaybackService.instance.attach(
|
||||
widget.path,
|
||||
noOfSamples: _waveStyle.getSamplesForWidth(_waveWidth) - 1,
|
||||
);
|
||||
_playback = playback;
|
||||
_isPlaying = playback.isPlaying;
|
||||
_duration = playback.controller.maxDuration;
|
||||
|
||||
_subscriptions.add(
|
||||
playback.controller.onPlayerStateChanged.listen((state) {
|
||||
if (!mounted) return;
|
||||
setState(() => _isPlaying = state == PlayerState.playing);
|
||||
}),
|
||||
);
|
||||
_subscriptions.add(
|
||||
playback.controller.onCurrentDurationChanged.listen((duration) {
|
||||
if (!mounted) return;
|
||||
setState(() => _position = duration);
|
||||
}),
|
||||
);
|
||||
_subscriptions.add(
|
||||
playback.controller.onCompletion.listen((_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isPlaying = false;
|
||||
_playerController.seekTo(0);
|
||||
_position = 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_playerController.onPlayerStateChanged.listen((a) async {
|
||||
if (a == PlayerState.initialized) {
|
||||
_displayDuration = await _playerController.getDuration(
|
||||
DurationType.max,
|
||||
unawaited(
|
||||
playback.controller.seekTo(0).then((_) {
|
||||
// A last duration update may still arrive after the completion.
|
||||
if (mounted) setState(() => _position = 0);
|
||||
}),
|
||||
);
|
||||
_maxDuration = _displayDuration;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
_playerController.onCurrentDurationChanged.listen((duration) async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_displayDuration = _maxDuration - duration;
|
||||
});
|
||||
}
|
||||
unawaited(_restoreState(playback));
|
||||
}
|
||||
|
||||
/// Restores duration and position, which are already known when the player
|
||||
/// was prepared by a previous instance of this widget.
|
||||
Future<void> _restoreState(AudioPlayback playback) async {
|
||||
await playback.ready;
|
||||
if (!mounted) return;
|
||||
final position = await playback.controller.getDuration(
|
||||
DurationType.current,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final started =
|
||||
playback.isPlaying ||
|
||||
playback.controller.playerState == PlayerState.paused;
|
||||
setState(() {
|
||||
_duration = playback.controller.maxDuration;
|
||||
_position = started && position > 0 ? position : 0;
|
||||
_isPlaying = playback.isPlaying;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_playerController.dispose();
|
||||
for (final subscription in _subscriptions) {
|
||||
unawaited(subscription.cancel());
|
||||
}
|
||||
final playback = _playback;
|
||||
if (playback != null) AudioPlaybackService.instance.detach(playback);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool _isPlaying = false;
|
||||
Future<void> _togglePlayback() async {
|
||||
final playback = _playback;
|
||||
if (playback == null) return;
|
||||
if (playback.isPlaying) {
|
||||
await playback.controller.pausePlayer();
|
||||
return;
|
||||
}
|
||||
await playback.ready;
|
||||
if (!mounted) return;
|
||||
await AudioPlaybackService.instance.play(playback);
|
||||
if (widget.message.senderId != null && widget.message.openedAt == null) {
|
||||
unawaited(_notifyMessageOpened());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
final remaining = _duration <= 0 ? 0 : _duration - _position;
|
||||
final playback = _playback;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (_isPlaying) {
|
||||
_playerController.pausePlayer();
|
||||
} else {
|
||||
_playerController.startPlayer();
|
||||
if (widget.message.senderId != null &&
|
||||
widget.message.openedAt == null) {
|
||||
unawaited(_notifyMessageOpened());
|
||||
}
|
||||
}
|
||||
setState(() {
|
||||
_isPlaying = !_isPlaying;
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: EdgeInsets.only(
|
||||
left: _isPlaying ? 2 : 0,
|
||||
top: 4,
|
||||
bottom: 4,
|
||||
),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _playSlotWidth,
|
||||
child: GestureDetector(
|
||||
onTap: () => unawaited(_togglePlayback()),
|
||||
child: ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: FaIcon(
|
||||
_isPlaying ? FontAwesomeIcons.pause : FontAwesomeIcons.play,
|
||||
size: 20,
|
||||
_isPlaying
|
||||
? FontAwesomeIcons.solidCirclePause
|
||||
: FontAwesomeIcons.solidCirclePlay,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatMsToMinSec(_displayDuration),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
width: _waveWidth,
|
||||
height: _waveHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (playback != null)
|
||||
AudioFileWaveforms(
|
||||
// Rebuilds the waveform once the extraction finished so
|
||||
// the complete waveform appears at once.
|
||||
key: ValueKey(playback.waveformData.isNotEmpty),
|
||||
playerController: playback.controller,
|
||||
waveformData: playback.waveformData,
|
||||
playerWaveStyle: _waveStyle,
|
||||
// The whole waveform stays in place, only the already
|
||||
// played part changes its color.
|
||||
waveformType: WaveformType.fitWidth,
|
||||
// Show the waveform right away instead of growing it in
|
||||
// wave by wave while it is extracted.
|
||||
continuousWaveform: false,
|
||||
animationDuration: Duration.zero,
|
||||
size: Size(_waveWidth, _waveHeight),
|
||||
),
|
||||
if (_showCursor)
|
||||
Positioned(
|
||||
top: (_waveHeight - _cursorHeight) / 2,
|
||||
height: _cursorHeight,
|
||||
left: (_progress * (_waveWidth - _cursorWidth)).clamp(
|
||||
0.0,
|
||||
_waveWidth - _cursorWidth,
|
||||
),
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: _cursorWidth,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: _playSlotWidth,
|
||||
child: Text(
|
||||
formatMsToMinSec(remaining),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
AudioFileWaveforms(
|
||||
playerController: _playerController,
|
||||
size: const Size(150, 40),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
if (widget.trailing != null)
|
||||
SizedBox(
|
||||
width: _waveWidth,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: widget.trailing,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6774,6 +6774,7 @@ impl SseDecode for crate::user_config::UserConfig {
|
|||
let mut var_lastChangeLogHash = <Option<Vec<u8>>>::sse_decode(deserializer);
|
||||
let mut var_hideChangeLog = <bool>::sse_decode(deserializer);
|
||||
let mut var_hideMemoriesBackupPromo = <bool>::sse_decode(deserializer);
|
||||
let mut var_hideWidgetShareExplainer = <bool>::sse_decode(deserializer);
|
||||
let mut var_updateFcmToken = <bool>::sse_decode(deserializer);
|
||||
let mut var_canUseLoginTokenForAuth = <bool>::sse_decode(deserializer);
|
||||
let mut var_twonlySafeBackup =
|
||||
|
|
@ -6838,6 +6839,7 @@ impl SseDecode for crate::user_config::UserConfig {
|
|||
last_change_log_hash: var_lastChangeLogHash,
|
||||
hide_change_log: var_hideChangeLog,
|
||||
hide_memories_backup_promo: var_hideMemoriesBackupPromo,
|
||||
hide_widget_share_explainer: var_hideWidgetShareExplainer,
|
||||
update_fcm_token: var_updateFcmToken,
|
||||
can_use_login_token_for_auth: var_canUseLoginTokenForAuth,
|
||||
twonly_safe_backup: var_twonlySafeBackup,
|
||||
|
|
@ -7922,6 +7924,9 @@ impl flutter_rust_bridge::IntoDart for crate::user_config::UserConfig {
|
|||
self.last_change_log_hash.into_into_dart().into_dart(),
|
||||
self.hide_change_log.into_into_dart().into_dart(),
|
||||
self.hide_memories_backup_promo.into_into_dart().into_dart(),
|
||||
self.hide_widget_share_explainer
|
||||
.into_into_dart()
|
||||
.into_dart(),
|
||||
self.update_fcm_token.into_into_dart().into_dart(),
|
||||
self.can_use_login_token_for_auth
|
||||
.into_into_dart()
|
||||
|
|
@ -8850,6 +8855,7 @@ impl SseEncode for crate::user_config::UserConfig {
|
|||
<Option<Vec<u8>>>::sse_encode(self.last_change_log_hash, serializer);
|
||||
<bool>::sse_encode(self.hide_change_log, serializer);
|
||||
<bool>::sse_encode(self.hide_memories_backup_promo, serializer);
|
||||
<bool>::sse_encode(self.hide_widget_share_explainer, serializer);
|
||||
<bool>::sse_encode(self.update_fcm_token, serializer);
|
||||
<bool>::sse_encode(self.can_use_login_token_for_auth, serializer);
|
||||
<Option<crate::user_config::TwonlySafeBackup>>::sse_encode(
|
||||
|
|
|
|||
|
|
@ -305,6 +305,9 @@ pub struct UserConfig {
|
|||
#[serde(default)]
|
||||
#[frb(non_final)]
|
||||
pub hide_memories_backup_promo: bool,
|
||||
#[serde(default)]
|
||||
#[frb(non_final)]
|
||||
pub hide_widget_share_explainer: bool,
|
||||
#[serde(default = "defaults::true_value")]
|
||||
#[frb(non_final)]
|
||||
pub update_fcm_token: bool,
|
||||
|
|
|
|||
Loading…
Reference in a new issue