mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-09-23 13:14:08 +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) {
|
UserConfig dco_decode_user_config(dynamic raw) {
|
||||||
// Codec=Dco (DartCObject based), see doc to use other codecs
|
// Codec=Dco (DartCObject based), see doc to use other codecs
|
||||||
final arr = raw as List<dynamic>;
|
final arr = raw as List<dynamic>;
|
||||||
if (arr.length != 60)
|
if (arr.length != 61)
|
||||||
throw Exception('unexpected arr length: expect 60 but see ${arr.length}');
|
throw Exception('unexpected arr length: expect 61 but see ${arr.length}');
|
||||||
return UserConfig(
|
return UserConfig(
|
||||||
userId: dco_decode_i_64(arr[0]),
|
userId: dco_decode_i_64(arr[0]),
|
||||||
username: dco_decode_String(arr[1]),
|
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]),
|
lastChangeLogHash: dco_decode_opt_list_prim_u_8_strict(arr[46]),
|
||||||
hideChangeLog: dco_decode_bool(arr[47]),
|
hideChangeLog: dco_decode_bool(arr[47]),
|
||||||
hideMemoriesBackupPromo: dco_decode_bool(arr[48]),
|
hideMemoriesBackupPromo: dco_decode_bool(arr[48]),
|
||||||
updateFcmToken: dco_decode_bool(arr[49]),
|
hideWidgetShareExplainer: dco_decode_bool(arr[49]),
|
||||||
canUseLoginTokenForAuth: dco_decode_bool(arr[50]),
|
updateFcmToken: dco_decode_bool(arr[50]),
|
||||||
twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[51]),
|
canUseLoginTokenForAuth: dco_decode_bool(arr[51]),
|
||||||
isBackupEnabled: dco_decode_bool(arr[52]),
|
twonlySafeBackup: dco_decode_opt_box_autoadd_twonly_safe_backup(arr[52]),
|
||||||
|
isBackupEnabled: dco_decode_bool(arr[53]),
|
||||||
passwordLessRecovery:
|
passwordLessRecovery:
|
||||||
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[53]),
|
dco_decode_opt_box_autoadd_passwordless_recovery_config(arr[54]),
|
||||||
fcmToken: dco_decode_opt_String(arr[54]),
|
fcmToken: dco_decode_opt_String(arr[55]),
|
||||||
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[55]),
|
lastFcmWakeupAt: dco_decode_opt_box_autoadd_i_64(arr[56]),
|
||||||
lastServerMessageAt: 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[57]),
|
currentSetupPage: dco_decode_opt_String(arr[58]),
|
||||||
skipSetupPages: dco_decode_bool(arr[58]),
|
skipSetupPages: dco_decode_bool(arr[59]),
|
||||||
hasZoomed: 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_hideChangeLog = sse_decode_bool(deserializer);
|
||||||
var var_hideMemoriesBackupPromo = 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_updateFcmToken = sse_decode_bool(deserializer);
|
||||||
var var_canUseLoginTokenForAuth = sse_decode_bool(deserializer);
|
var var_canUseLoginTokenForAuth = sse_decode_bool(deserializer);
|
||||||
var var_twonlySafeBackup = sse_decode_opt_box_autoadd_twonly_safe_backup(
|
var var_twonlySafeBackup = sse_decode_opt_box_autoadd_twonly_safe_backup(
|
||||||
|
|
@ -8256,6 +8258,7 @@ class RustLibApiImpl extends RustLibApiImplPlatform implements RustLibApi {
|
||||||
lastChangeLogHash: var_lastChangeLogHash,
|
lastChangeLogHash: var_lastChangeLogHash,
|
||||||
hideChangeLog: var_hideChangeLog,
|
hideChangeLog: var_hideChangeLog,
|
||||||
hideMemoriesBackupPromo: var_hideMemoriesBackupPromo,
|
hideMemoriesBackupPromo: var_hideMemoriesBackupPromo,
|
||||||
|
hideWidgetShareExplainer: var_hideWidgetShareExplainer,
|
||||||
updateFcmToken: var_updateFcmToken,
|
updateFcmToken: var_updateFcmToken,
|
||||||
canUseLoginTokenForAuth: var_canUseLoginTokenForAuth,
|
canUseLoginTokenForAuth: var_canUseLoginTokenForAuth,
|
||||||
twonlySafeBackup: var_twonlySafeBackup,
|
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_opt_list_prim_u_8_strict(self.lastChangeLogHash, serializer);
|
||||||
sse_encode_bool(self.hideChangeLog, serializer);
|
sse_encode_bool(self.hideChangeLog, serializer);
|
||||||
sse_encode_bool(self.hideMemoriesBackupPromo, serializer);
|
sse_encode_bool(self.hideMemoriesBackupPromo, serializer);
|
||||||
|
sse_encode_bool(self.hideWidgetShareExplainer, serializer);
|
||||||
sse_encode_bool(self.updateFcmToken, serializer);
|
sse_encode_bool(self.updateFcmToken, serializer);
|
||||||
sse_encode_bool(self.canUseLoginTokenForAuth, serializer);
|
sse_encode_bool(self.canUseLoginTokenForAuth, serializer);
|
||||||
sse_encode_opt_box_autoadd_twonly_safe_backup(
|
sse_encode_opt_box_autoadd_twonly_safe_backup(
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,7 @@ class UserConfig {
|
||||||
Uint8List? lastChangeLogHash;
|
Uint8List? lastChangeLogHash;
|
||||||
bool hideChangeLog;
|
bool hideChangeLog;
|
||||||
bool hideMemoriesBackupPromo;
|
bool hideMemoriesBackupPromo;
|
||||||
|
bool hideWidgetShareExplainer;
|
||||||
bool updateFcmToken;
|
bool updateFcmToken;
|
||||||
bool canUseLoginTokenForAuth;
|
bool canUseLoginTokenForAuth;
|
||||||
TwonlySafeBackup? twonlySafeBackup;
|
TwonlySafeBackup? twonlySafeBackup;
|
||||||
|
|
@ -222,6 +223,7 @@ class UserConfig {
|
||||||
this.lastChangeLogHash,
|
this.lastChangeLogHash,
|
||||||
required this.hideChangeLog,
|
required this.hideChangeLog,
|
||||||
required this.hideMemoriesBackupPromo,
|
required this.hideMemoriesBackupPromo,
|
||||||
|
required this.hideWidgetShareExplainer,
|
||||||
required this.updateFcmToken,
|
required this.updateFcmToken,
|
||||||
required this.canUseLoginTokenForAuth,
|
required this.canUseLoginTokenForAuth,
|
||||||
this.twonlySafeBackup,
|
this.twonlySafeBackup,
|
||||||
|
|
@ -286,6 +288,7 @@ class UserConfig {
|
||||||
lastChangeLogHash.hashCode ^
|
lastChangeLogHash.hashCode ^
|
||||||
hideChangeLog.hashCode ^
|
hideChangeLog.hashCode ^
|
||||||
hideMemoriesBackupPromo.hashCode ^
|
hideMemoriesBackupPromo.hashCode ^
|
||||||
|
hideWidgetShareExplainer.hashCode ^
|
||||||
updateFcmToken.hashCode ^
|
updateFcmToken.hashCode ^
|
||||||
canUseLoginTokenForAuth.hashCode ^
|
canUseLoginTokenForAuth.hashCode ^
|
||||||
twonlySafeBackup.hashCode ^
|
twonlySafeBackup.hashCode ^
|
||||||
|
|
@ -359,6 +362,7 @@ class UserConfig {
|
||||||
lastChangeLogHash == other.lastChangeLogHash &&
|
lastChangeLogHash == other.lastChangeLogHash &&
|
||||||
hideChangeLog == other.hideChangeLog &&
|
hideChangeLog == other.hideChangeLog &&
|
||||||
hideMemoriesBackupPromo == other.hideMemoriesBackupPromo &&
|
hideMemoriesBackupPromo == other.hideMemoriesBackupPromo &&
|
||||||
|
hideWidgetShareExplainer == other.hideWidgetShareExplainer &&
|
||||||
updateFcmToken == other.updateFcmToken &&
|
updateFcmToken == other.updateFcmToken &&
|
||||||
canUseLoginTokenForAuth == other.canUseLoginTokenForAuth &&
|
canUseLoginTokenForAuth == other.canUseLoginTokenForAuth &&
|
||||||
twonlySafeBackup == other.twonlySafeBackup &&
|
twonlySafeBackup == other.twonlySafeBackup &&
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,4 @@ class KeyValueKeys {
|
||||||
static const String currentBackupState = 'current_backup_state';
|
static const String currentBackupState = 'current_backup_state';
|
||||||
static const String backupRecoveryState = 'backup_recovery_state';
|
static const String backupRecoveryState = 'backup_recovery_state';
|
||||||
static const String onboardingState = 'onboarding_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:drift/drift.dart' show Value;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:twonly/locator.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/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.dart';
|
import 'package:twonly/src/database/twonly.db.dart';
|
||||||
import 'package:twonly/src/model/protobuf/client/generated/data.pb.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/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/utils/log.dart';
|
||||||
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
import 'package:twonly/src/visual/helpers/media_view_sizing.helper.dart';
|
||||||
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
import 'package:twonly/src/visual/helpers/screenshot.helper.dart';
|
||||||
|
|
@ -95,16 +94,14 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
bool _widgetRecipientAvailable = false;
|
bool _widgetRecipientAvailable = false;
|
||||||
bool _sendToWidget = false;
|
bool _sendToWidget = false;
|
||||||
bool _updatingWidgetMode = false;
|
bool _updatingWidgetMode = false;
|
||||||
bool _widgetExplainerPreferenceLoaded = false;
|
bool _widgetExplainerDismissed =
|
||||||
bool _widgetExplainerDismissed = false;
|
userService.currentUser.hideWidgetShareExplainer;
|
||||||
bool _previousMediaSettingsCaptured = false;
|
bool _previousMediaSettingsCaptured = false;
|
||||||
int? _displayLimitBeforeWidget;
|
int? _displayLimitBeforeWidget;
|
||||||
bool _requiresAuthBeforeWidget = false;
|
bool _requiresAuthBeforeWidget = false;
|
||||||
|
|
||||||
bool get _showWidgetExplainer =>
|
bool get _showWidgetExplainer =>
|
||||||
_widgetRecipientAvailable &&
|
_widgetRecipientAvailable && !_widgetExplainerDismissed;
|
||||||
_widgetExplainerPreferenceLoaded &&
|
|
||||||
!_widgetExplainerDismissed;
|
|
||||||
|
|
||||||
MediaFileService get mediaService => widget.mediaFileService;
|
MediaFileService get mediaService => widget.mediaFileService;
|
||||||
MediaFile get media => widget.mediaFileService.mediaFile;
|
MediaFile get media => widget.mediaFileService.mediaFile;
|
||||||
|
|
@ -139,8 +136,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
_widgetGroupsSubscription = twonlyDB.groupsDao
|
_widgetGroupsSubscription = twonlyDB.groupsDao
|
||||||
.watchGroupsAllowedForWidgetShare()
|
.watchGroupsAllowedForWidgetShare()
|
||||||
.listen(_updateWidgetRecipientAvailability);
|
.listen(_updateWidgetRecipientAvailability);
|
||||||
unawaited(_loadWidgetExplainerPreference());
|
|
||||||
|
|
||||||
if (media.type == MediaType.image || media.type == MediaType.gif) {
|
if (media.type == MediaType.image || media.type == MediaType.gif) {
|
||||||
_loadInitialImage();
|
_loadInitialImage();
|
||||||
}
|
}
|
||||||
|
|
@ -165,23 +160,9 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
||||||
super.dispose();
|
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 {
|
Future<void> _dismissWidgetExplainer() async {
|
||||||
setState(() => _widgetExplainerDismissed = true);
|
setState(() => _widgetExplainerDismissed = true);
|
||||||
await KeyValueStore.put(
|
await UserService.update((u) => u.hideWidgetShareExplainer = true);
|
||||||
KeyValueKeys.shareImageWidgetExplainer,
|
|
||||||
const {'dismissed': true},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateWidgetRecipientAvailability(List<Group> widgetGroups) {
|
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/locator.dart';
|
||||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||||
import 'package:twonly/src/database/twonly.db.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/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';
|
||||||
|
|
@ -75,65 +76,55 @@ class _ChatAudioEntryState extends State<ChatAudioEntry> {
|
||||||
}
|
}
|
||||||
|
|
||||||
final showTime = info.displayTime || message.modifiedAt != null;
|
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(
|
return IntrinsicWidth(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: MediaQuery.sizeOf(context).width * 0.8,
|
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(
|
decoration: BoxDecoration(
|
||||||
color: info.color,
|
color: info.color,
|
||||||
borderRadius: borderRadius,
|
borderRadius: borderRadius,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
if (info.displayUserName != '')
|
if (info.text != '')
|
||||||
Padding(
|
Expanded(
|
||||||
padding: const EdgeInsets.only(bottom: 2),
|
child: BetterText(
|
||||||
child: Text(
|
text: info.text,
|
||||||
info.displayUserName,
|
textColor: info.textColor,
|
||||||
textAlign: TextAlign.left,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Expanded(
|
||||||
|
child: isDownloaded
|
||||||
|
? (_hasTempFile
|
||||||
|
? InChatAudioPlayer(
|
||||||
|
path: mediaService.tempPath.path,
|
||||||
|
message: message,
|
||||||
|
trailing: messageTime,
|
||||||
|
)
|
||||||
|
: Container())
|
||||||
|
: MessageSendStateIcon(
|
||||||
|
[message],
|
||||||
|
[mediaService.mediaFile],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Row(
|
if (!showsPlayer && messageTime != null) messageTime,
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -145,116 +136,241 @@ class InChatAudioPlayer extends StatefulWidget {
|
||||||
const InChatAudioPlayer({
|
const InChatAudioPlayer({
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.message,
|
required this.message,
|
||||||
|
this.trailing,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String path;
|
final String path;
|
||||||
final Message message;
|
final Message message;
|
||||||
|
|
||||||
|
/// Shown below the waveform, next to the remaining playback time.
|
||||||
|
final Widget? trailing;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<InChatAudioPlayer> createState() => _InChatAudioPlayerState();
|
State<InChatAudioPlayer> createState() => _InChatAudioPlayerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
|
class _InChatAudioPlayerState extends State<InChatAudioPlayer> {
|
||||||
final PlayerController _playerController = PlayerController();
|
static const double _maxWaveWidth = 170;
|
||||||
int _displayDuration = 0;
|
static const double _waveHeight = 36;
|
||||||
int _maxDuration = 0;
|
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
|
@override
|
||||||
void initState() {
|
void didChangeDependencies() {
|
||||||
super.initState();
|
super.didChangeDependencies();
|
||||||
_playerController
|
// Leaves room for the play button and the bubble padding.
|
||||||
..preparePlayer(path: widget.path)
|
_waveWidth = (MediaQuery.sizeOf(context).width * 0.8 - 80).clamp(
|
||||||
..setFinishMode(finishMode: FinishMode.pause);
|
80.0,
|
||||||
|
_maxWaveWidth,
|
||||||
|
);
|
||||||
|
if (_playback != null) return;
|
||||||
|
|
||||||
_playerController.onCompletion.listen((_) {
|
// The player outlives this widget: the message list recreates its items
|
||||||
if (mounted) {
|
// 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(() {
|
setState(() {
|
||||||
_isPlaying = false;
|
_isPlaying = false;
|
||||||
_playerController.seekTo(0);
|
_position = 0;
|
||||||
});
|
});
|
||||||
}
|
unawaited(
|
||||||
});
|
playback.controller.seekTo(0).then((_) {
|
||||||
|
// A last duration update may still arrive after the completion.
|
||||||
_playerController.onPlayerStateChanged.listen((a) async {
|
if (mounted) setState(() => _position = 0);
|
||||||
if (a == PlayerState.initialized) {
|
}),
|
||||||
_displayDuration = await _playerController.getDuration(
|
|
||||||
DurationType.max,
|
|
||||||
);
|
);
|
||||||
_maxDuration = _displayDuration;
|
}),
|
||||||
if (mounted) setState(() {});
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_playerController.onCurrentDurationChanged.listen((duration) async {
|
unawaited(_restoreState(playback));
|
||||||
if (mounted) {
|
}
|
||||||
setState(() {
|
|
||||||
_displayDuration = _maxDuration - duration;
|
/// 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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_playerController.dispose();
|
for (final subscription in _subscriptions) {
|
||||||
|
unawaited(subscription.cancel());
|
||||||
|
}
|
||||||
|
final playback = _playback;
|
||||||
|
if (playback != null) AudioPlaybackService.instance.detach(playback);
|
||||||
super.dispose();
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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: [
|
children: [
|
||||||
Padding(
|
Row(
|
||||||
padding: const EdgeInsets.only(left: 4),
|
mainAxisSize: MainAxisSize.min,
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
SizedBox(
|
||||||
children: [
|
width: _playSlotWidth,
|
||||||
GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () => unawaited(_togglePlayback()),
|
||||||
if (_isPlaying) {
|
child: ColoredBox(
|
||||||
_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,
|
|
||||||
),
|
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: FaIcon(
|
child: FaIcon(
|
||||||
_isPlaying ? FontAwesomeIcons.pause : FontAwesomeIcons.play,
|
_isPlaying
|
||||||
size: 20,
|
? FontAwesomeIcons.solidCirclePause
|
||||||
|
: FontAwesomeIcons.solidCirclePlay,
|
||||||
|
size: 28,
|
||||||
color: Colors.white,
|
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(
|
style: const TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(width: 10),
|
||||||
),
|
if (widget.trailing != null)
|
||||||
const SizedBox(width: 10),
|
SizedBox(
|
||||||
AudioFileWaveforms(
|
width: _waveWidth,
|
||||||
playerController: _playerController,
|
child: Align(
|
||||||
size: const Size(150, 40),
|
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_lastChangeLogHash = <Option<Vec<u8>>>::sse_decode(deserializer);
|
||||||
let mut var_hideChangeLog = <bool>::sse_decode(deserializer);
|
let mut var_hideChangeLog = <bool>::sse_decode(deserializer);
|
||||||
let mut var_hideMemoriesBackupPromo = <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_updateFcmToken = <bool>::sse_decode(deserializer);
|
||||||
let mut var_canUseLoginTokenForAuth = <bool>::sse_decode(deserializer);
|
let mut var_canUseLoginTokenForAuth = <bool>::sse_decode(deserializer);
|
||||||
let mut var_twonlySafeBackup =
|
let mut var_twonlySafeBackup =
|
||||||
|
|
@ -6838,6 +6839,7 @@ impl SseDecode for crate::user_config::UserConfig {
|
||||||
last_change_log_hash: var_lastChangeLogHash,
|
last_change_log_hash: var_lastChangeLogHash,
|
||||||
hide_change_log: var_hideChangeLog,
|
hide_change_log: var_hideChangeLog,
|
||||||
hide_memories_backup_promo: var_hideMemoriesBackupPromo,
|
hide_memories_backup_promo: var_hideMemoriesBackupPromo,
|
||||||
|
hide_widget_share_explainer: var_hideWidgetShareExplainer,
|
||||||
update_fcm_token: var_updateFcmToken,
|
update_fcm_token: var_updateFcmToken,
|
||||||
can_use_login_token_for_auth: var_canUseLoginTokenForAuth,
|
can_use_login_token_for_auth: var_canUseLoginTokenForAuth,
|
||||||
twonly_safe_backup: var_twonlySafeBackup,
|
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.last_change_log_hash.into_into_dart().into_dart(),
|
||||||
self.hide_change_log.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_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.update_fcm_token.into_into_dart().into_dart(),
|
||||||
self.can_use_login_token_for_auth
|
self.can_use_login_token_for_auth
|
||||||
.into_into_dart()
|
.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);
|
<Option<Vec<u8>>>::sse_encode(self.last_change_log_hash, serializer);
|
||||||
<bool>::sse_encode(self.hide_change_log, serializer);
|
<bool>::sse_encode(self.hide_change_log, serializer);
|
||||||
<bool>::sse_encode(self.hide_memories_backup_promo, 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.update_fcm_token, serializer);
|
||||||
<bool>::sse_encode(self.can_use_login_token_for_auth, serializer);
|
<bool>::sse_encode(self.can_use_login_token_for_auth, serializer);
|
||||||
<Option<crate::user_config::TwonlySafeBackup>>::sse_encode(
|
<Option<crate::user_config::TwonlySafeBackup>>::sse_encode(
|
||||||
|
|
|
||||||
|
|
@ -305,6 +305,9 @@ pub struct UserConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[frb(non_final)]
|
#[frb(non_final)]
|
||||||
pub hide_memories_backup_promo: bool,
|
pub hide_memories_backup_promo: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
#[frb(non_final)]
|
||||||
|
pub hide_widget_share_explainer: bool,
|
||||||
#[serde(default = "defaults::true_value")]
|
#[serde(default = "defaults::true_value")]
|
||||||
#[frb(non_final)]
|
#[frb(non_final)]
|
||||||
pub update_fcm_token: bool,
|
pub update_fcm_token: bool,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue