From df838de12599c2cd2edcf2b33e554db7ae49d303 Mon Sep 17 00:00:00 2001 From: otsmr Date: Wed, 2 Sep 2026 23:30:22 +0200 Subject: [PATCH] improve voice messages --- lib/core/frb_generated.dart | 30 +- lib/core/user_config.dart | 4 + lib/src/constants/keyvalue.keys.dart | 2 - lib/src/services/audio_playback.service.dart | 104 ++++++ .../views/camera/share_image_editor.view.dart | 29 +- .../entries/chat_audio_entry.dart | 344 ++++++++++++------ rust/src/frb_generated.rs | 6 + rust/src/user_config.rs | 3 + 8 files changed, 369 insertions(+), 153 deletions(-) create mode 100644 lib/src/services/audio_playback.service.dart diff --git a/lib/core/frb_generated.dart b/lib/core/frb_generated.dart index 0a48e51d..a8175f98 100644 --- a/lib/core/frb_generated.dart +++ b/lib/core/frb_generated.dart @@ -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; - 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( diff --git a/lib/core/user_config.dart b/lib/core/user_config.dart index af73ea31..6af0319f 100644 --- a/lib/core/user_config.dart +++ b/lib/core/user_config.dart @@ -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 && diff --git a/lib/src/constants/keyvalue.keys.dart b/lib/src/constants/keyvalue.keys.dart index ab4f16e9..9f3bee56 100644 --- a/lib/src/constants/keyvalue.keys.dart +++ b/lib/src/constants/keyvalue.keys.dart @@ -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'; } diff --git a/lib/src/services/audio_playback.service.dart b/lib/src/services/audio_playback.service.dart new file mode 100644 index 00000000..1c01dcb3 --- /dev/null +++ b/lib/src/services/audio_playback.service.dart @@ -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 _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> _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 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 cachedWaveform) + : waveformData = List.of(cachedWaveform); + + final String path; + final PlayerController controller = PlayerController(); + + /// Extracted waveform, empty until the extraction finished. + List waveformData; + + int _refCount = 0; + Future? _preparing; + + bool get isPlaying => controller.playerState == PlayerState.playing; + + /// Resolves once the player is prepared and the waveform is extracted. + Future get ready => _preparing ?? Future.value(); + + Future _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'); + } + }(); + } +} diff --git a/lib/src/visual/views/camera/share_image_editor.view.dart b/lib/src/visual/views/camera/share_image_editor.view.dart index 7d59863a..12f8d36a 100644 --- a/lib/src/visual/views/camera/share_image_editor.view.dart +++ b/lib/src/visual/views/camera/share_image_editor.view.dart @@ -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 { 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 { _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 { super.dispose(); } - Future _loadWidgetExplainerPreference() async { - final preference = await KeyValueStore.get( - KeyValueKeys.shareImageWidgetExplainer, - ); - if (!mounted) return; - setState(() { - _widgetExplainerDismissed = preference?['dismissed'] == true; - _widgetExplainerPreferenceLoaded = true; - }); - } - Future _dismissWidgetExplainer() async { setState(() => _widgetExplainerDismissed = true); - await KeyValueStore.put( - KeyValueKeys.shareImageWidgetExplainer, - const {'dismissed': true}, - ); + await UserService.update((u) => u.hideWidgetShareExplainer = true); } void _updateWidgetRecipientAvailability(List widgetGroups) { 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 1924fc5f..9d172b2a 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 @@ -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 { } 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 createState() => _InChatAudioPlayerState(); } class _InChatAudioPlayerState extends State { - 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> _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 _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 _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, + ), + ), + ], ), ], ); diff --git a/rust/src/frb_generated.rs b/rust/src/frb_generated.rs index f00027bd..6c1e5f87 100644 --- a/rust/src/frb_generated.rs +++ b/rust/src/frb_generated.rs @@ -6774,6 +6774,7 @@ impl SseDecode for crate::user_config::UserConfig { let mut var_lastChangeLogHash = >>::sse_decode(deserializer); let mut var_hideChangeLog = ::sse_decode(deserializer); let mut var_hideMemoriesBackupPromo = ::sse_decode(deserializer); + let mut var_hideWidgetShareExplainer = ::sse_decode(deserializer); let mut var_updateFcmToken = ::sse_decode(deserializer); let mut var_canUseLoginTokenForAuth = ::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 { >>::sse_encode(self.last_change_log_hash, serializer); ::sse_encode(self.hide_change_log, serializer); ::sse_encode(self.hide_memories_backup_promo, serializer); + ::sse_encode(self.hide_widget_share_explainer, serializer); ::sse_encode(self.update_fcm_token, serializer); ::sse_encode(self.can_use_login_token_for_auth, serializer); >::sse_encode( diff --git a/rust/src/user_config.rs b/rust/src/user_config.rs index 098f2642..87bde4a2 100644 --- a/rust/src/user_config.rs +++ b/rust/src/user_config.rs @@ -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,