From fff3951e49b409642ef0c663d64415ea9f26f098 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Wed, 10 Dec 2025 10:49:48 +0700 Subject: [PATCH 01/26] TW-2766: Displayed audio player if user goes to list of chats or to another chat --- lib/pages/chat/chat_audio_player_widget.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index a9bdddb257..eb3e71ba7c 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -151,7 +151,6 @@ class ChatAudioPlayerWidget extends StatelessWidget { Future _handleCloseAudioPlayer() async { matrix?.voiceMessageEvent.value = null; - matrix?.cancelAudioPlayerAutoDispose(); await matrix?.audioPlayer.stop(); await matrix?.audioPlayer.dispose(); matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; From 89795de1a5eae3c334b8bda6d818db46d0437090 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Fri, 19 Dec 2025 10:03:30 +0700 Subject: [PATCH 02/26] fixup! fixup! TW-2766: Displayed audio player if user goes to list of chats or to another chat --- lib/pages/chat/chat_audio_player_widget.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index eb3e71ba7c..a9bdddb257 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -151,6 +151,7 @@ class ChatAudioPlayerWidget extends StatelessWidget { Future _handleCloseAudioPlayer() async { matrix?.voiceMessageEvent.value = null; + matrix?.cancelAudioPlayerAutoDispose(); await matrix?.audioPlayer.stop(); await matrix?.audioPlayer.dispose(); matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; From 9bd409c8de860af30ec9321fcaaf9e46a29213b3 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Tue, 23 Dec 2025 16:59:10 +0700 Subject: [PATCH 03/26] TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/chat.dart | 18 +- lib/pages/chat/chat_audio_player_widget.dart | 43 +- .../audio_message/audio_player_widget.dart | 110 +--- .../mixins/event_filter_mixin.dart | 601 ++++++++++++++++++ lib/widgets/matrix.dart | 122 +++- 5 files changed, 781 insertions(+), 113 deletions(-) create mode 100644 lib/presentation/mixins/event_filter_mixin.dart diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 64530eeb82..b04d3fba29 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -3125,13 +3125,13 @@ class ChatController extends State return; } disposeAudioMixin(); - matrix?.audioPlayer.stop(); - matrix?.audioPlayer.clearAudioSources(); + matrix?.audioPlayer?.stop(); + matrix?.audioPlayer?.clearAudioSources(); matrix?.voiceMessageEvent.value = null; } void initAudioPlayer() { - if (matrix?.audioPlayer.playing == true) { + if (matrix?.audioPlayer?.playing == true) { if (!PlatformInfos.isMobile) { matrix?.audioPlayer ?..stop() @@ -3140,12 +3140,14 @@ class ChatController extends State // On mobile, keep audio playing and return early return; } - if (matrix?.voiceMessageEvent != null) { - matrix?.voiceMessageEvent.value = null; - } + if (!PlatformInfos.isMobile) { + if (matrix?.voiceMessageEvent != null) { + matrix?.voiceMessageEvent.value = null; + } - if (matrix?.currentAudioStatus.value != AudioPlayerStatus.notDownloaded) { - matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + if (matrix?.currentAudioStatus.value != AudioPlayerStatus.notDownloaded) { + matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + } } } diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index a9bdddb257..7ca405f5dc 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -31,10 +31,16 @@ class ChatAudioPlayerWidget extends StatelessWidget { @override Widget build(BuildContext context) { + // Return empty if matrix is not available + if (matrix == null) { + return const SizedBox.shrink(); + } + final defaultAudioStatus = ValueNotifier( AudioPlayerStatus.notDownloaded, ); final defaultEvent = ValueNotifier(null); + return ValueListenableBuilder( valueListenable: matrix?.currentAudioStatus ?? defaultAudioStatus, builder: (context, status, _) { @@ -47,11 +53,11 @@ class ChatAudioPlayerWidget extends StatelessWidget { final audioPlayer = matrix?.audioPlayer; return StreamBuilder( stream: StreamGroup.merge([ - matrix?.audioPlayer.positionStream.asBroadcastStream() ?? + matrix?.audioPlayer?.positionStream.asBroadcastStream() ?? Stream.value(Duration.zero), - matrix?.audioPlayer.playerStateStream.asBroadcastStream() ?? + matrix?.audioPlayer?.playerStateStream.asBroadcastStream() ?? Stream.value(Duration.zero), - matrix?.audioPlayer.speedStream.asBroadcastStream() ?? + matrix?.audioPlayer?.speedStream.asBroadcastStream() ?? Stream.value(Duration.zero), ]), builder: (context, snapshot) { @@ -152,8 +158,8 @@ class ChatAudioPlayerWidget extends StatelessWidget { Future _handleCloseAudioPlayer() async { matrix?.voiceMessageEvent.value = null; matrix?.cancelAudioPlayerAutoDispose(); - await matrix?.audioPlayer.stop(); - await matrix?.audioPlayer.dispose(); + await matrix?.audioPlayer?.stop(); + await matrix?.audioPlayer?.dispose(); matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; } @@ -169,8 +175,8 @@ class ChatAudioPlayerWidget extends StatelessWidget { Future _handlePlayAudioAgain(BuildContext context) async { File? file; MatrixFile? matrixFile; - await matrix?.audioPlayer.stop(); - await matrix?.audioPlayer.dispose(); + await matrix?.audioPlayer?.stop(); + await matrix?.audioPlayer?.dispose(); matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; final currentEvent = matrix?.voiceMessageEvent.value; @@ -200,13 +206,16 @@ class ChatAudioPlayerWidget extends StatelessWidget { matrix?.currentAudioStatus.value = AudioPlayerStatus.downloaded; } catch (e, s) { - Logs().v('Could not download audio file', e, s); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toLocalizedString(context)), - ), - ); - rethrow; + Logs().e('Could not download audio file', e, s); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toLocalizedString(context)), + ), + ); + } + matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + return; } if (!context.mounted) return; @@ -222,10 +231,10 @@ class ChatAudioPlayerWidget extends StatelessWidget { matrix!.audioPlayer = AudioPlayer(); if (file != null) { - await matrix?.audioPlayer.setFilePath(file.path); + await matrix?.audioPlayer?.setFilePath(file.path); } else if (matrixFile != null) { await matrix?.audioPlayer - .setAudioSource(MatrixFileAudioSource(matrixFile)); + ?.setAudioSource(MatrixFileAudioSource(matrixFile)); } else { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -238,7 +247,7 @@ class ChatAudioPlayerWidget extends StatelessWidget { // Set up auto-dispose listener managed globally in MatrixState matrix?.setupAudioPlayerAutoDispose(); - matrix?.audioPlayer.play().onError((e, s) { + matrix?.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index aa195575d2..beaca15a80 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -6,6 +6,7 @@ import 'package:fluffychat/pages/chat/events/audio_message/audio_player_style.da import 'package:fluffychat/pages/chat/events/message/message_style.dart'; import 'package:fluffychat/pages/chat/seen_by_row.dart'; import 'package:fluffychat/presentation/mixins/audio_mixin.dart'; +import 'package:fluffychat/presentation/mixins/event_filter_mixin.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/download_file_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; @@ -16,14 +17,12 @@ import 'package:fluffychat/widgets/file_widget/file_tile_widget.dart'; import 'package:fluffychat/widgets/file_widget/message_file_tile_style.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:just_audio/just_audio.dart'; import 'package:linagora_design_flutter/linagora_design_flutter.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/generated/l10n/app_localizations.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; @@ -49,7 +48,7 @@ class AudioPlayerWidget extends StatefulWidget { enum AudioPlayerStatus { notDownloaded, downloading, downloaded } class AudioPlayerState extends State - with AudioMixin, AutomaticKeepAliveClientMixin { + with AudioMixin, AutomaticKeepAliveClientMixin, EventFilterMixin { final List _calculatedWaveform = []; final ValueNotifier _durationNotifier = @@ -89,18 +88,18 @@ class AudioPlayerState extends State ScaffoldMessenger.of(matrix.context).clearMaterialBanners(); }); if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { - if (matrix.audioPlayer.isAtEndPosition) { + if (matrix.audioPlayer?.isAtEndPosition == true) { matrix.voiceMessageEvent.value = null; - await matrix.audioPlayer.stop(); - await matrix.audioPlayer.dispose(); + await matrix.audioPlayer?.stop(); + await matrix.audioPlayer?.dispose(); matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; await _onButtonTap(); return; } - if (matrix.audioPlayer.playing == true) { - matrix.audioPlayer.pause(); + if (matrix.audioPlayer?.playing == true) { + matrix.audioPlayer?.pause(); } else { - matrix.audioPlayer.play().onError((e, s) { + matrix.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -115,66 +114,15 @@ class AudioPlayerState extends State return; } - matrix.voiceMessageEvent.value = widget.event; - await matrix.audioPlayer.stop(); - await matrix.audioPlayer.dispose(); - File? file; - MatrixFile? matrixFile; - - matrix.currentAudioStatus.value = AudioPlayerStatus.downloading; - try { - matrixFile = await widget.event.downloadAndDecryptAttachment(); - - if (!kIsWeb) { - final tempDir = await getTemporaryDirectory(); - final fileName = Uri.encodeComponent( - widget.event.attachmentOrThumbnailMxcUrl()!.pathSegments.last, - ); - file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); - - await file.writeAsBytes(matrixFile.bytes); - - if (Platform.isIOS && - matrixFile.mimeType.toLowerCase() == 'audio/ogg') { - file = await handleOggAudioFileIniOS(file); - } - } - - matrix.currentAudioStatus.value = AudioPlayerStatus.downloaded; - } catch (e, s) { - Logs().v('Could not download audio file', e, s); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toLocalizedString(context)), - ), - ); - rethrow; - } - if (matrix.voiceMessageEvent.value?.eventId != widget.event.eventId) return; - matrix.audioPlayer = AudioPlayer(); - matrix.voiceMessageEvent.value = widget.event; - - if (file != null) { - matrix.audioPlayer.setFilePath(file.path); - } else { - await matrix.audioPlayer - .setAudioSource(MatrixFileAudioSource(matrixFile)); - } + final audioPending = await initAudioEventsUpToClicked( + client: matrix.client, + room: widget.event.room, + clickedEvent: widget.event, + ); - // Set up auto-dispose listener managed globally in MatrixState - matrix.setupAudioPlayerAutoDispose(); + matrix.voiceMessageEvents.value = audioPending.events; - matrix.audioPlayer.play().onError((e, s) { - Logs().e('Could not play audio file', e, s); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e?.toLocalizedString(context) ?? - L10n.of(context)!.couldNotPlayAudioFile, - ), - ), - ); - }); + matrix.autoPlayAudio(currentEvent: widget.event); } Future handleOggAudioFileIniOS(File file) async { @@ -231,21 +179,23 @@ class AudioPlayerState extends State @override void dispose() { if (!PlatformInfos.isMobile) { - // Stop and dispose audio player asynchronously to avoid blocking dispose - matrix.audioPlayer.stop().then((_) { - matrix.audioPlayer.dispose(); - }).catchError((error) { - Logs().e('Error disposing audio player', error); - }); - - // Schedule value updates for after the current frame to avoid - // setState() during widget tree lock - WidgetsBinding.instance.addPostFrameCallback((_) { - if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { + // Only dispose if this event is currently playing + if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { + // Stop and dispose audio player asynchronously to avoid blocking + // dispose + matrix.audioPlayer?.stop().then((_) { + matrix.audioPlayer?.dispose(); + }).catchError((error) { + Logs().e('Error disposing audio player', error); + }); + + // Schedule value updates for after the current frame to avoid + // setState() during widget tree lock + WidgetsBinding.instance.addPostFrameCallback((_) { matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; matrix.voiceMessageEvent.value = null; - } - }); + }); + } } super.dispose(); diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart new file mode 100644 index 0000000000..36dcaa15ec --- /dev/null +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -0,0 +1,601 @@ +import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; +import 'package:matrix/matrix.dart'; + +/// Mixin providing reusable logic for filtering and managing events by type. +/// +/// This mixin provides utilities to: +/// - Filter events by message type (Image, Video, Audio, File, etc.) +/// - Decrypt encrypted events +/// - Fetch and expand event context with pagination +/// - Load more events in both directions +/// +/// Usage: +/// ```dart +/// class MyController with EventFilterMixin { +/// Future loadEvents() async { +/// final context = await getInitialEventContext( +/// client: client, +/// roomId: room.id, +/// eventId: event.eventId, +/// limit: 100, +/// ); +/// } +/// } +/// ``` +mixin EventFilterMixin { + /// Filters a list of events to include only image and video events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.Image] or [MessageTypes.Video]. + List filterMediaEvents(List events) { + return events.where((event) => event.isVideoOrImage).toList(); + } + + /// Filters a list of events to include only image events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.Image]. + List filterImageEvents(List events) { + return events + .where((event) => event.messageType == MessageTypes.Image) + .toList(); + } + + /// Filters a list of events to include only video events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.Video]. + List filterVideoEvents(List events) { + return events + .where((event) => event.messageType == MessageTypes.Video) + .toList(); + } + + /// Filters a list of events to include only audio events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.Audio]. + List filterAudioEvents(List events) { + return events + .where((event) => event.messageType == MessageTypes.Audio) + .toList(); + } + + /// Filters a list of events to include only file events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.File]. + List filterFileEvents(List events) { + return events + .where((event) => event.messageType == MessageTypes.File) + .toList(); + } + + /// Filters a list of events to include only sticker events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.Sticker]. + List filterStickerEvents(List events) { + return events + .where((event) => event.messageType == MessageTypes.Sticker) + .toList(); + } + + /// Filters a list of events to include only text events. + /// + /// Returns a new list containing only events with messageType of + /// [MessageTypes.Text]. + List filterTextEvents(List events) { + return events + .where((event) => event.messageType == MessageTypes.Text) + .toList(); + } + + /// Filters events by custom message types. + /// + /// Allows filtering by multiple message types at once. + /// + /// Example: + /// ```dart + /// final mediaAndFiles = filterEventsByTypes( + /// events, + /// [MessageTypes.Image, MessageTypes.Video, MessageTypes.File], + /// ); + /// ``` + List filterEventsByTypes( + List events, + List types, + ) { + return events.where((event) => types.contains(event.messageType)).toList(); + } + + /// Decrypts a list of encrypted events. + /// + /// If encryption is not enabled on the client, returns the original events. + /// For each encrypted event, attempts to decrypt it using the client's + /// encryption engine. If decryption fails, logs the error and returns the + /// original encrypted event. + /// + /// Returns a list of decrypted events (or original events if decryption + /// fails or is not available). + Future> decryptEvents( + List events, + Client? client, + ) async { + if (client?.encryption == null) return events; + + return await Future.wait( + events.map( + (event) async { + try { + if (event.type != EventTypes.Encrypted) { + return event; + } + + return await client!.encryption!.decryptRoomEvent(event); + } catch (e) { + Logs().e('Error decrypting event id ${event.eventId}', e); + return event; + } + }, + ), + ); + } + + /// Fetches the initial event context for a given event. + /// + /// Retrieves events before and after the target event from the server. + /// Returns null if the client is null or if the request fails. + /// + /// Parameters: + /// - [client]: The Matrix client instance + /// - [roomId]: The room ID + /// - [eventId]: The target event ID + /// - [limit]: Maximum number of events to retrieve (default: 100) + Future getInitialEventContext({ + required Client? client, + required String roomId, + required String eventId, + int limit = 100, + }) async { + if (client == null) return null; + + try { + return await client.getEventContext( + roomId, + eventId, + limit: limit, + ); + } catch (e) { + Logs().e('getInitialEventContext: Error getting event context', e); + return null; + } + } + + /// Converts MatrixEvents to Events for a given room. + /// + /// Maps a list of MatrixEvent objects to Event objects. + List convertMatrixEventsToEvents( + List matrixEvents, + Room room, + ) { + return matrixEvents + .map((matrixEvent) => Event.fromMatrixEvent(matrixEvent, room)) + .toList(); + } + + /// Builds initial event list from event context. + /// + /// Combines events before, the target event, and events after from an + /// EventContext. Returns the events in chronological order (oldest to + /// newest). + List buildInitialEventList( + EventContext eventContext, + Room room, + ) { + return [ + ...(eventContext.eventsAfter?.reversed.toList() ?? []), + if (eventContext.event != null) eventContext.event!, + ...(eventContext.eventsBefore ?? []), + ].map((matrixEvent) => Event.fromMatrixEvent(matrixEvent, room)).toList(); + } + + /// Expands events by fetching more events in forward and/or backward + /// directions. + /// + /// Fetches events from the server using pagination tokens. + /// + /// Parameters: + /// - [client]: The Matrix client instance + /// - [roomId]: The room ID + /// - [backwardToken]: Token for fetching older events + /// - [forwardToken]: Token for fetching newer events + /// - [limit]: Number of events to fetch per direction (default: 10) + /// + /// Returns a record containing the backward and forward responses. + Future< + ({ + GetRoomEventsResponse? backward, + GetRoomEventsResponse? forward, + })> expandEvents({ + required Client? client, + required String roomId, + String? backwardToken, + String? forwardToken, + int limit = 10, + }) async { + if (client == null) return (backward: null, forward: null); + + try { + final result = await Future.wait([ + if (backwardToken != null) + client.getRoomEvents( + roomId, + Direction.b, + limit: limit, + from: backwardToken, + ), + if (forwardToken != null) + client.getRoomEvents( + roomId, + Direction.f, + limit: limit, + from: forwardToken, + ), + ]); + + // Use positional indexing since Future.wait preserves order + GetRoomEventsResponse? backwardResponse; + GetRoomEventsResponse? forwardResponse; + + if (backwardToken != null && forwardToken != null) { + // Both tokens: result[0] = backward, result[1] = forward + backwardResponse = result.isNotEmpty ? result[0] : null; + forwardResponse = result.length > 1 ? result[1] : null; + } else if (backwardToken != null) { + // Only backward: result[0] = backward + backwardResponse = result.isNotEmpty ? result[0] : null; + } else if (forwardToken != null) { + // Only forward: result[0] = forward + forwardResponse = result.isNotEmpty ? result[0] : null; + } + + return (backward: backwardResponse, forward: forwardResponse); + } catch (e) { + Logs().e('expandEvents: Error expanding events', e); + return (backward: null, forward: null); + } + } + + /// Loads and processes initial events with automatic expansion. + /// + /// This method: + /// 1. Fetches the initial event context + /// 2. Builds the initial event list + /// 3. Decrypts events if needed + /// 4. Filters events by specified message types + /// 5. Automatically expands to get at least [minEventsTarget] events + /// + /// Parameters: + /// - [messageTypes]: List of message types to filter. If null, filters for + /// media events (images and videos) by default. + /// + /// Returns a record containing the filtered events and pagination tokens. + Future< + ({ + List events, + String? forwardToken, + String? backwardToken, + })> initMediaEvents({ + required Client? client, + required Room room, + required String eventId, + List? messageTypes, + int initialLimit = 100, + int minEventsTarget = 7, + int maxExpandIterations = 10, + int expandLimit = 10, + }) async { + if (client == null) { + return (events: [], forwardToken: null, backwardToken: null); + } + + final mustDecrypt = room.encrypted && client.encryptionEnabled == true; + + try { + final initialEventContext = await getInitialEventContext( + client: client, + roomId: room.id, + eventId: eventId, + limit: initialLimit, + ); + + if (initialEventContext == null) { + return (events: [], forwardToken: null, backwardToken: null); + } + + List initialFilteredEvents = + buildInitialEventList(initialEventContext, room); + + if (mustDecrypt) { + initialFilteredEvents = + await decryptEvents(initialFilteredEvents, client); + } + + initialFilteredEvents = messageTypes != null + ? filterEventsByTypes(initialFilteredEvents, messageTypes) + : filterMediaEvents(initialFilteredEvents); + + String? forwardToken = initialEventContext.end; + String? backwardToken = initialEventContext.start; + int loadMoreCount = 0; + + // Expand until we have enough events or run out of tokens + while (initialFilteredEvents.length < minEventsTarget && + loadMoreCount < maxExpandIterations && + (forwardToken != null || backwardToken != null)) { + loadMoreCount++; + + final expandResult = await expandEvents( + client: client, + roomId: room.id, + forwardToken: forwardToken, + backwardToken: backwardToken, + limit: expandLimit, + ); + + forwardToken = expandResult.forward?.end; + backwardToken = expandResult.backward?.end; + + List forwardEvents = convertMatrixEventsToEvents( + expandResult.forward?.chunk ?? [], + room, + ); + + List backwardEvents = convertMatrixEventsToEvents( + expandResult.backward?.chunk ?? [], + room, + ); + + if (mustDecrypt) { + final decrypted = await Future.wait([ + decryptEvents(forwardEvents, client), + decryptEvents(backwardEvents, client), + ]); + forwardEvents = decrypted[0]; + backwardEvents = decrypted[1]; + } + + initialFilteredEvents = [ + ...(messageTypes != null + ? filterEventsByTypes( + backwardEvents.reversed.toList(), + messageTypes, + ) + : filterMediaEvents(backwardEvents.reversed.toList())), + ...initialFilteredEvents, + ...(messageTypes != null + ? filterEventsByTypes( + forwardEvents.reversed.toList(), + messageTypes, + ) + : filterMediaEvents(forwardEvents.reversed.toList())), + ]; + } + + return ( + events: initialFilteredEvents, + forwardToken: forwardToken, + backwardToken: backwardToken, + ); + } catch (e) { + Logs().e('initMediaEvents: Error initializing events', e); + return (events: [], forwardToken: null, backwardToken: null); + } + } + + /// Loads more events in a specific direction. + /// + /// Fetches additional events from the server, decrypts them if necessary, + /// and filters events by specified message types. + /// + /// Parameters: + /// - [client]: The Matrix client instance + /// - [room]: The room object + /// - [direction]: Direction to load (forward or backward) + /// - [token]: Pagination token for the direction + /// - [messageTypes]: List of message types to filter. If null, filters for + /// media events (images and videos) by default. + /// - [limit]: Number of events to fetch (default: 10) + /// + /// Returns a record containing the loaded events and new pagination token. + Future< + ({ + List events, + String? newToken, + })> loadMoreEvents({ + required Client? client, + required Room room, + required Direction direction, + required String? token, + List? messageTypes, + int limit = 10, + }) async { + if (client == null || token == null) { + return (events: [], newToken: null); + } + + final mustDecrypt = room.encrypted && client.encryptionEnabled == true; + + try { + final expandResult = await expandEvents( + client: client, + roomId: room.id, + backwardToken: direction == Direction.b ? token : null, + forwardToken: direction == Direction.f ? token : null, + limit: limit, + ); + + final response = direction == Direction.b + ? expandResult.backward + : expandResult.forward; + + if (response == null) { + return (events: [], newToken: null); + } + + List loadMoreEvents = + convertMatrixEventsToEvents(response.chunk, room); + + if (mustDecrypt) { + loadMoreEvents = await decryptEvents(loadMoreEvents, client); + } + + loadMoreEvents = messageTypes != null + ? filterEventsByTypes(loadMoreEvents, messageTypes) + : filterMediaEvents(loadMoreEvents); + + return ( + events: loadMoreEvents, + newToken: response.end, + ); + } catch (e) { + Logs().e('loadMoreEvents: Error loading more events', e); + return (events: [], newToken: null); + } + } + + /// Checks if an event is a media event (image or video). + bool isMediaEvent(Event event) { + return event.isVideoOrImage; + } + + /// Checks if an event is an attachment (file, image, video, or audio). + bool isAttachmentEvent(Event event) { + return [ + MessageTypes.File, + MessageTypes.Image, + MessageTypes.Video, + MessageTypes.Audio, + ].contains(event.messageType); + } + + /// Filters events to include only attachment events. + /// + /// Returns events that are files, images, videos, or audio. + List filterAttachmentEvents(List events) { + return events.where((event) => isAttachmentEvent(event)).toList(); + } + + /// Groups events by message type. + /// + /// Returns a map where keys are message type strings and values are lists + /// of events of that type. + /// + /// Example: + /// ```dart + /// final grouped = groupEventsByType(events); + /// final images = grouped[MessageTypes.Image] ?? []; + /// final videos = grouped[MessageTypes.Video] ?? []; + /// ``` + Map> groupEventsByType(List events) { + final Map> grouped = {}; + + for (final event in events) { + final type = event.messageType; + if (!grouped.containsKey(type)) { + grouped[type] = []; + } + grouped[type]!.add(event); + } + + return grouped; + } + + /// Gets audio events from the clicked event onwards for playlist. + /// + /// Returns all audio events from the clicked event onwards (for auto-play). + /// Events are returned in chronological order from clicked to newest. + /// + /// Example: + /// ```dart + /// // If audioEvents = [a, b, c, d, e] (chronological order) + /// // and user clicks on c + /// final result = getAudioEventsUpToClicked(audioEvents, clickedEvent); + /// // returns [c, d, e] (for sequential playback) + /// ``` + /// + /// Parameters: + /// - [audioEvents]: The list of audio events in chronological order + /// - [clickedEvent]: The event that was clicked + /// + /// Returns a list of audio events from the clicked position onwards. + List getAudioEventsUpToClicked( + List audioEvents, + Event clickedEvent, + ) { + final reversedEvents = audioEvents.reversed.toList(); + final clickedIndex = reversedEvents.indexWhere( + (event) => event.eventId == clickedEvent.eventId, + ); + + if (clickedIndex == -1) { + return []; + } + + // Return from clicked event onwards for sequential playback + return reversedEvents.sublist(clickedIndex); + } + + /// Loads and processes initial audio events with automatic expansion. + /// + /// Similar to [initMediaEvents] but specifically for audio files. + /// Returns audio events up to and including the clicked event. + /// + /// Example: + /// ```dart + /// final result = await initAudioEventsUpToClicked( + /// client: client, + /// room: room, + /// clickedEvent: event, + /// ); + /// ``` + Future< + ({ + List events, + String? forwardToken, + String? backwardToken, + })> initAudioEventsUpToClicked({ + required Client? client, + required Room room, + required Event clickedEvent, + int initialLimit = 100, + int minEventsTarget = 20, + int maxExpandIterations = 10, + int expandLimit = 10, + }) async { + // First, get all audio events including and after the clicked event + final result = await initMediaEvents( + client: client, + room: room, + eventId: clickedEvent.eventId, + messageTypes: [MessageTypes.Audio], + initialLimit: initialLimit, + minEventsTarget: minEventsTarget, + maxExpandIterations: maxExpandIterations, + expandLimit: expandLimit, + ); + + // Find the clicked event and return only events up to it + final filteredEvents = getAudioEventsUpToClicked( + result.events, + clickedEvent, + ); + + return ( + events: filteredEvents, + forwardToken: result.forwardToken, + backwardToken: result.backwardToken, + ); + } +} diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 7f598cf739..72f1202528 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -11,6 +11,7 @@ import 'package:fluffychat/domain/repository/federation_configurations_repositor import 'package:fluffychat/domain/repository/user_info/user_info_repository.dart'; import 'package:fluffychat/domain/usecase/room/create_support_chat_interactor.dart'; import 'package:fluffychat/event/twake_event_types.dart'; +import 'package:fluffychat/pages/chat/events/audio_message/audio_play_extension.dart'; import 'package:fluffychat/pages/chat/events/audio_message/audio_player_widget.dart'; import 'package:fluffychat/presentation/mixins/init_config_mixin.dart'; import 'package:fluffychat/presentation/model/client_login_state_event.dart'; @@ -18,6 +19,8 @@ import 'package:fluffychat/widgets/layouts/agruments/logout_body_args.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:just_audio/just_audio.dart'; import 'package:linagora_design_flutter/cozy_config_manager/cozy_config_manager.dart'; +import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:universal_html/html.dart' as html hide File; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -94,9 +97,12 @@ class MatrixState extends State with WidgetsBindingObserver, ReceiveSharingIntentMixin, InitConfigMixin { final _contactsManager = getIt.get(); - AudioPlayer audioPlayer = AudioPlayer(); + AudioPlayer? audioPlayer; + final ValueNotifier voiceMessageEvent = ValueNotifier(null); + final ValueNotifier> voiceMessageEvents = ValueNotifier([]); + final ValueNotifier currentAudioStatus = ValueNotifier(AudioPlayerStatus.notDownloaded); @@ -1176,23 +1182,123 @@ class MatrixState extends State /// This method should be called after setting up a new audio source. /// It automatically cleans up the audio player and resets state when playback finishes. void setupAudioPlayerAutoDispose() { - final currentEvent = voiceMessageEvent.value; _audioPlayerStateSubscription?.cancel(); _audioPlayerStateSubscription = - audioPlayer.playerStateStream.listen((state) { + audioPlayer?.playerStateStream.listen((state) async { if (state.processingState == ProcessingState.completed) { - // Guard against clearing state for a different audio - if (voiceMessageEvent.value?.eventId != currentEvent?.eventId) { + Logs().d( + 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', + ); + + if (voiceMessageEvents.value.isEmpty) { return; } + + // Remove the completed message from the list + final updatedVoiceMessageEvent = voiceMessageEvents.value + .where((e) => e.eventId != voiceMessageEvent.value?.eventId) + .toList(); + + Logs().d( + 'setupAudioPlayerAutoDispose: Remaining audio message - $updatedVoiceMessageEvent', + ); + + voiceMessageEvents.value = updatedVoiceMessageEvent; voiceMessageEvent.value = null; - audioPlayer.stop(); - audioPlayer.dispose(); currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + + // Check if there are more messages to play + if (voiceMessageEvents.value.isEmpty) { + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + audioPlayer = null; + return; + } + + final nextAudioMessage = voiceMessageEvents.value.first; + autoPlayAudio( + currentEvent: nextAudioMessage, + ); } }); } + Future autoPlayAudio({ + required Event currentEvent, + }) async { + voiceMessageEvent.value = currentEvent; + File? file; + MatrixFile? matrixFile; + + currentAudioStatus.value = AudioPlayerStatus.downloading; + try { + matrixFile = await currentEvent.downloadAndDecryptAttachment(); + + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + final fileName = Uri.encodeComponent( + currentEvent.attachmentOrThumbnailMxcUrl()!.pathSegments.last, + ); + file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); + + await file.writeAsBytes(matrixFile.bytes ?? []); + + if (Platform.isIOS && + matrixFile.mimeType.toLowerCase() == 'audio/ogg') { + file = await handleOggAudioFileIniOS(file); + } + } + + currentAudioStatus.value = AudioPlayerStatus.downloaded; + } catch (e, s) { + Logs().e('Could not download audio file', e, s); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toLocalizedString(context)), + ), + ); + } + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + return; + } + if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; + + // Initialize audio player before use + audioPlayer = AudioPlayer(); + voiceMessageEvent.value = currentEvent; + + if (file != null) { + audioPlayer?.setFilePath(file.path); + } else { + await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); + } + + // Set up auto-dispose listener managed globally in MatrixState + setupAudioPlayerAutoDispose(); + + audioPlayer?.play().onError((e, s) { + Logs().e('Could not play audio file', e, s); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e?.toLocalizedString(context) ?? + L10n.of(context)!.couldNotPlayAudioFile, + ), + ), + ); + }); + } + + Future handleOggAudioFileIniOS(File file) async { + Logs().v('Convert ogg audio file for iOS...'); + final convertedFile = File('${file.path}.caf'); + if (await convertedFile.exists() == false) { + OpusCaf().convertOpusToCaf(file.path, convertedFile.path); + } + return convertedFile; + } + @override void didChangeAppLifecycleState(AppLifecycleState state) { Logs().i('didChangeAppLifecycleState: AppLifecycleState = $state'); @@ -1303,7 +1409,7 @@ class MatrixState extends State linuxNotifications?.close(); showQrCodeDownload.dispose(); _audioPlayerStateSubscription?.cancel(); - audioPlayer.dispose(); + audioPlayer?.dispose(); voiceMessageEvent.dispose(); _presenceSubscription?.cancel(); onLatestPresenceChanged.close(); From e8b68912eec4f45805694124d21498af3c4a4775 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Tue, 6 Jan 2026 12:05:44 +0700 Subject: [PATCH 04/26] fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/chat_audio_player_widget.dart | 2 ++ .../audio_message/audio_player_widget.dart | 30 +++++++++---------- .../mixins/event_filter_mixin.dart | 16 +++++----- lib/widgets/matrix.dart | 7 ++++- 4 files changed, 32 insertions(+), 23 deletions(-) diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index 7ca405f5dc..6210928c1d 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -220,6 +220,7 @@ class ChatAudioPlayerWidget extends StatelessWidget { if (!context.mounted) return; if (matrix == null) { + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(L10n.of(context)!.couldNotPlayAudioFile), @@ -249,6 +250,7 @@ class ChatAudioPlayerWidget extends StatelessWidget { matrix?.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index beaca15a80..3b443abae2 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -179,23 +179,23 @@ class AudioPlayerState extends State @override void dispose() { if (!PlatformInfos.isMobile) { - // Only dispose if this event is currently playing - if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { - // Stop and dispose audio player asynchronously to avoid blocking - // dispose - matrix.audioPlayer?.stop().then((_) { - matrix.audioPlayer?.dispose(); - }).catchError((error) { - Logs().e('Error disposing audio player', error); - }); - - // Schedule value updates for after the current frame to avoid - // setState() during widget tree lock - WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) async { + // Only dispose if this event is currently playing + if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { + // Stop and dispose audio player asynchronously to avoid blocking + // dispose + final playerToDispose = matrix.audioPlayer; + if (playerToDispose != null) { + await playerToDispose.stop(); + await playerToDispose.dispose(); + } + + // Schedule value updates for after the current frame to avoid + // setState() during widget tree lock matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; matrix.voiceMessageEvent.value = null; - }); - } + } + }); } super.dispose(); diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart index 36dcaa15ec..acc91f6ff3 100644 --- a/lib/presentation/mixins/event_filter_mixin.dart +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -366,20 +366,23 @@ mixin EventFilterMixin { backwardEvents = decrypted[1]; } + final backwardEventsReversed = backwardEvents.reversed.toList(); + final forwardEventsReversed = forwardEvents.reversed.toList(); + initialFilteredEvents = [ ...(messageTypes != null ? filterEventsByTypes( - backwardEvents.reversed.toList(), + backwardEventsReversed, messageTypes, ) - : filterMediaEvents(backwardEvents.reversed.toList())), + : filterMediaEvents(backwardEventsReversed)), ...initialFilteredEvents, ...(messageTypes != null ? filterEventsByTypes( - forwardEvents.reversed.toList(), + forwardEventsReversed, messageTypes, ) - : filterMediaEvents(forwardEvents.reversed.toList())), + : filterMediaEvents(forwardEventsReversed)), ]; } @@ -534,8 +537,7 @@ mixin EventFilterMixin { List audioEvents, Event clickedEvent, ) { - final reversedEvents = audioEvents.reversed.toList(); - final clickedIndex = reversedEvents.indexWhere( + final clickedIndex = audioEvents.indexWhere( (event) => event.eventId == clickedEvent.eventId, ); @@ -544,7 +546,7 @@ mixin EventFilterMixin { } // Return from clicked event onwards for sequential playback - return reversedEvents.sublist(clickedIndex); + return audioEvents.sublist(clickedIndex); } /// Loads and processes initial audio events with automatic expansion. diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 72f1202528..1ca18051a7 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -1236,8 +1236,12 @@ class MatrixState extends State if (!kIsWeb) { final tempDir = await getTemporaryDirectory(); + final mxcUrl = currentEvent.attachmentOrThumbnailMxcUrl(); + if (mxcUrl == null) { + throw Exception('Event has no attachment URL'); + } final fileName = Uri.encodeComponent( - currentEvent.attachmentOrThumbnailMxcUrl()!.pathSegments.last, + mxcUrl.pathSegments.last, ); file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); @@ -1279,6 +1283,7 @@ class MatrixState extends State audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( From 2fa6daaca27c250f0796eaa780b87bb9557a978c Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Tue, 6 Jan 2026 12:37:07 +0700 Subject: [PATCH 05/26] fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/chat_audio_player_widget.dart | 14 +++++++------- lib/presentation/mixins/event_filter_mixin.dart | 5 +++-- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index 6210928c1d..8ab7cf5e02 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -29,6 +29,11 @@ class ChatAudioPlayerWidget extends StatelessWidget { super.key, }); + static final _defaultAudioStatus = ValueNotifier( + AudioPlayerStatus.notDownloaded, + ); + static final _defaultEvent = ValueNotifier(null); + @override Widget build(BuildContext context) { // Return empty if matrix is not available @@ -36,16 +41,11 @@ class ChatAudioPlayerWidget extends StatelessWidget { return const SizedBox.shrink(); } - final defaultAudioStatus = ValueNotifier( - AudioPlayerStatus.notDownloaded, - ); - final defaultEvent = ValueNotifier(null); - return ValueListenableBuilder( - valueListenable: matrix?.currentAudioStatus ?? defaultAudioStatus, + valueListenable: matrix?.currentAudioStatus ?? _defaultAudioStatus, builder: (context, status, _) { return ValueListenableBuilder( - valueListenable: matrix?.voiceMessageEvent ?? defaultEvent, + valueListenable: matrix?.voiceMessageEvent ?? _defaultEvent, builder: (context, hasEvent, _) { if (hasEvent == null) { return const SizedBox.shrink(); diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart index acc91f6ff3..951f67654c 100644 --- a/lib/presentation/mixins/event_filter_mixin.dart +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -537,7 +537,8 @@ mixin EventFilterMixin { List audioEvents, Event clickedEvent, ) { - final clickedIndex = audioEvents.indexWhere( + final reversedEvents = audioEvents.reversed.toList(); + final clickedIndex = reversedEvents.indexWhere( (event) => event.eventId == clickedEvent.eventId, ); @@ -546,7 +547,7 @@ mixin EventFilterMixin { } // Return from clicked event onwards for sequential playback - return audioEvents.sublist(clickedIndex); + return reversedEvents.sublist(clickedIndex); } /// Loads and processes initial audio events with automatic expansion. From f7a753098c51f2ce4697114a7b26e7aa99ae5463 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 12 Jan 2026 14:04:01 +0700 Subject: [PATCH 06/26] fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/widgets/matrix.dart | 51 ++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 1ca18051a7..0487c3af0e 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -167,6 +167,8 @@ class MatrixState extends State if (index != -1) { if (index == _activeClient) return SetActiveClientState.success; _activeClient = index; + // Clean up audio player when switching accounts + await cleanupAudioPlayer(); // TODO: Multi-client VoiP support createVoipPlugin(); await _setUpToMServicesWhenChangingActiveClient(newClient); @@ -561,6 +563,8 @@ class MatrixState extends State Client currentClient, ) async { waitForFirstSync = false; + // Clean up audio player when logging out + await cleanupAudioPlayer(); await _cancelSubs(currentClient.clientName); widget.clients.remove(currentClient); await ClientManager.removeClientNameFromStore(currentClient.clientName); @@ -1147,6 +1151,8 @@ class MatrixState extends State Future _handleLastLogout() async { waitForFirstSync = false; + // Clean up audio player when logging out + await cleanupAudioPlayer(); matrixState.reSyncContacts(); await matrixState.cancelListenSynchronizeContacts(); if (PlatformInfos.isMobile) { @@ -1216,7 +1222,7 @@ class MatrixState extends State } final nextAudioMessage = voiceMessageEvents.value.first; - autoPlayAudio( + await autoPlayAudio( currentEvent: nextAudioMessage, ); } @@ -1249,7 +1255,10 @@ class MatrixState extends State if (Platform.isIOS && matrixFile.mimeType.toLowerCase() == 'audio/ogg') { - file = await handleOggAudioFileIniOS(file); + final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); + if (oggAudioFileIniOS != null) { + file = oggAudioFileIniOS; + } } } @@ -1295,13 +1304,18 @@ class MatrixState extends State }); } - Future handleOggAudioFileIniOS(File file) async { - Logs().v('Convert ogg audio file for iOS...'); - final convertedFile = File('${file.path}.caf'); - if (await convertedFile.exists() == false) { - OpusCaf().convertOpusToCaf(file.path, convertedFile.path); + Future handleOggAudioFileIniOS(File file) async { + try { + Logs().v('Convert ogg audio file for iOS...'); + final convertedFile = File('${file.path}.caf'); + if (await convertedFile.exists() == false) { + OpusCaf().convertOpusToCaf(file.path, convertedFile.path); + } + return convertedFile; + } catch (e, s) { + Logs().e('Could not convert ogg audio file for iOS', e, s); + return null; } - return convertedFile; } @override @@ -1392,6 +1406,27 @@ class MatrixState extends State _audioPlayerStateSubscription = null; } + /// Cleans up the audio player and clears playing lists. + /// + /// Should be called when logging out or switching accounts to ensure + /// audio playback is properly stopped and state is reset. + Future cleanupAudioPlayer() async { + try { + await audioPlayer?.pause(); + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + audioPlayer = null; + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = null; + voiceMessageEvents.value = []; + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + Logs().d('MatrixState::cleanupAudioPlayer: Audio player cleaned up'); + } catch (e) { + Logs().e('MatrixState::cleanupAudioPlayer: Error - $e'); + } + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); From b0b98e536c7c3271bfc537a211ecf29fe1cec278 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 12 Jan 2026 14:10:16 +0700 Subject: [PATCH 07/26] fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/widgets/matrix.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 0487c3af0e..0e19be6ee1 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -1282,7 +1282,7 @@ class MatrixState extends State voiceMessageEvent.value = currentEvent; if (file != null) { - audioPlayer?.setFilePath(file.path); + await audioPlayer?.setFilePath(file.path); } else { await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); } @@ -1450,6 +1450,7 @@ class MatrixState extends State showQrCodeDownload.dispose(); _audioPlayerStateSubscription?.cancel(); audioPlayer?.dispose(); + voiceMessageEvents.dispose(); voiceMessageEvent.dispose(); _presenceSubscription?.cancel(); onLatestPresenceChanged.close(); From e8380606d41a4954b1f44fb6134e38536b1f8450 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 12 Jan 2026 14:37:36 +0700 Subject: [PATCH 08/26] fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/widgets/matrix.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 0e19be6ee1..eb822d17fd 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -1251,6 +1251,10 @@ class MatrixState extends State ); file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); + if (matrixFile.bytes?.isEmpty == true) { + throw Exception('Downloaded file has no content'); + } + await file.writeAsBytes(matrixFile.bytes ?? []); if (Platform.isIOS && @@ -1278,6 +1282,9 @@ class MatrixState extends State if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; // Initialize audio player before use + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + audioPlayer = AudioPlayer(); voiceMessageEvent.value = currentEvent; From 9832fe610011a9f2733fa1cae58d2539d2408b2b Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 12 Jan 2026 14:47:08 +0700 Subject: [PATCH 09/26] fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/widgets/matrix.dart | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index eb822d17fd..b39cb10ad3 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -1215,6 +1215,8 @@ class MatrixState extends State // Check if there are more messages to play if (voiceMessageEvents.value.isEmpty) { + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = null; await audioPlayer?.stop(); await audioPlayer?.dispose(); audioPlayer = null; @@ -1317,6 +1319,13 @@ class MatrixState extends State final convertedFile = File('${file.path}.caf'); if (await convertedFile.exists() == false) { OpusCaf().convertOpusToCaf(file.path, convertedFile.path); + // Verify conversion succeeded + if (await convertedFile.exists() == false) { + Logs().w( + 'MatrixState::handleOggAudioFileIniOS: OGG to CAF conversion failed - converted file does not exist', + ); + return null; + } } return convertedFile; } catch (e, s) { From 242258ec7f8cdec158b1144ae3f33726e667a0b4 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 12 Jan 2026 14:53:39 +0700 Subject: [PATCH 10/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/widgets/matrix.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index b39cb10ad3..95e250cf8c 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -1301,6 +1301,9 @@ class MatrixState extends State audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); + // Reset state on playback error + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( From 4f02ae7254fd4088b10be8d9383844f882ff399c Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Thu, 15 Jan 2026 16:54:21 +0700 Subject: [PATCH 11/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/chat_audio_player_widget.dart | 79 +++--- .../audio_message/audio_player_widget.dart | 20 +- lib/presentation/mixins/audio_mixin.dart | 25 +- .../mixins/event_filter_mixin.dart | 183 +------------- lib/widgets/matrix.dart | 205 +-------------- lib/widgets/mixins/audio_player_mixin.dart | 235 ++++++++++++++++++ 6 files changed, 301 insertions(+), 446 deletions(-) create mode 100644 lib/widgets/mixins/audio_player_mixin.dart diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index 8ab7cf5e02..216682bb5e 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/resource/image_paths.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/string_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mixins/audio_player_mixin.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -16,10 +17,9 @@ import 'package:fluffychat/generated/l10n/app_localizations.dart'; import 'package:just_audio/just_audio.dart'; import 'package:linagora_design_flutter/linagora_design_flutter.dart'; import 'package:matrix/matrix.dart'; -import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; import 'package:path_provider/path_provider.dart'; -class ChatAudioPlayerWidget extends StatelessWidget { +class ChatAudioPlayerWidget extends StatefulWidget { final MatrixState? matrix; final bool enableBorder; @@ -34,30 +34,40 @@ class ChatAudioPlayerWidget extends StatelessWidget { ); static final _defaultEvent = ValueNotifier(null); + @override + State createState() => _ChatAudioPlayerWidgetState(); +} + +class _ChatAudioPlayerWidgetState extends State + with AudioPlayerMixin { @override Widget build(BuildContext context) { // Return empty if matrix is not available - if (matrix == null) { + if (widget.matrix == null) { return const SizedBox.shrink(); } return ValueListenableBuilder( - valueListenable: matrix?.currentAudioStatus ?? _defaultAudioStatus, + valueListenable: widget.matrix?.currentAudioStatus ?? + ChatAudioPlayerWidget._defaultAudioStatus, builder: (context, status, _) { return ValueListenableBuilder( - valueListenable: matrix?.voiceMessageEvent ?? _defaultEvent, + valueListenable: widget.matrix?.voiceMessageEvent ?? + ChatAudioPlayerWidget._defaultEvent, builder: (context, hasEvent, _) { if (hasEvent == null) { return const SizedBox.shrink(); } - final audioPlayer = matrix?.audioPlayer; + final audioPlayer = widget.matrix?.audioPlayer; return StreamBuilder( stream: StreamGroup.merge([ - matrix?.audioPlayer?.positionStream.asBroadcastStream() ?? + widget.matrix?.audioPlayer?.positionStream + .asBroadcastStream() ?? Stream.value(Duration.zero), - matrix?.audioPlayer?.playerStateStream.asBroadcastStream() ?? + widget.matrix?.audioPlayer?.playerStateStream + .asBroadcastStream() ?? Stream.value(Duration.zero), - matrix?.audioPlayer?.speedStream.asBroadcastStream() ?? + widget.matrix?.audioPlayer?.speedStream.asBroadcastStream() ?? Stream.value(Duration.zero), ]), builder: (context, snapshot) { @@ -73,7 +83,7 @@ class ChatAudioPlayerWidget extends StatelessWidget { constraints: const BoxConstraints(maxHeight: 40), decoration: BoxDecoration( color: LinagoraSysColors.material().onPrimary, - border: enableBorder + border: widget.enableBorder ? Border( top: BorderSide( color: LinagoraStateLayer( @@ -156,31 +166,22 @@ class ChatAudioPlayerWidget extends StatelessWidget { } Future _handleCloseAudioPlayer() async { - matrix?.voiceMessageEvent.value = null; - matrix?.cancelAudioPlayerAutoDispose(); - await matrix?.audioPlayer?.stop(); - await matrix?.audioPlayer?.dispose(); - matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - } - - Future _handleOggAudioFileIniOS(File file) async { - Logs().v('Convert ogg audio file for iOS...'); - final convertedFile = File('${file.path}.caf'); - if (await convertedFile.exists() == false) { - OpusCaf().convertOpusToCaf(file.path, convertedFile.path); - } - return convertedFile; + widget.matrix?.voiceMessageEvent.value = null; + widget.matrix?.cancelAudioPlayerAutoDispose(); + await widget.matrix?.audioPlayer?.stop(); + await widget.matrix?.audioPlayer?.dispose(); + widget.matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; } Future _handlePlayAudioAgain(BuildContext context) async { File? file; MatrixFile? matrixFile; - await matrix?.audioPlayer?.stop(); - await matrix?.audioPlayer?.dispose(); - matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - final currentEvent = matrix?.voiceMessageEvent.value; + await widget.matrix?.audioPlayer?.stop(); + await widget.matrix?.audioPlayer?.dispose(); + widget.matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + final currentEvent = widget.matrix?.voiceMessageEvent.value; - matrix?.currentAudioStatus.value = AudioPlayerStatus.downloading; + widget.matrix?.currentAudioStatus.value = AudioPlayerStatus.downloading; try { matrixFile = await currentEvent?.downloadAndDecryptAttachment(); @@ -200,11 +201,11 @@ class ChatAudioPlayerWidget extends StatelessWidget { if (Platform.isIOS && matrixFile?.mimeType.toLowerCase() == 'audio/ogg') { - file = await _handleOggAudioFileIniOS(file); + file = await handleOggAudioFileIniOS(file); } } - matrix?.currentAudioStatus.value = AudioPlayerStatus.downloaded; + widget.matrix?.currentAudioStatus.value = AudioPlayerStatus.downloaded; } catch (e, s) { Logs().e('Could not download audio file', e, s); if (context.mounted) { @@ -214,12 +215,12 @@ class ChatAudioPlayerWidget extends StatelessWidget { ), ); } - matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + widget.matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; return; } if (!context.mounted) return; - if (matrix == null) { + if (widget.matrix == null) { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -229,12 +230,12 @@ class ChatAudioPlayerWidget extends StatelessWidget { return; } - matrix!.audioPlayer = AudioPlayer(); + widget.matrix!.audioPlayer = AudioPlayer(); if (file != null) { - await matrix?.audioPlayer?.setFilePath(file.path); + await widget.matrix?.audioPlayer?.setFilePath(file.path); } else if (matrixFile != null) { - await matrix?.audioPlayer + await widget.matrix?.audioPlayer ?.setAudioSource(MatrixFileAudioSource(matrixFile)); } else { ScaffoldMessenger.of(context).showSnackBar( @@ -246,9 +247,9 @@ class ChatAudioPlayerWidget extends StatelessWidget { } // Set up auto-dispose listener managed globally in MatrixState - matrix?.setupAudioPlayerAutoDispose(); + widget.matrix?.setupAudioPlayerAutoDispose(); - matrix?.audioPlayer?.play().onError((e, s) { + widget.matrix?.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( @@ -263,7 +264,7 @@ class ChatAudioPlayerWidget extends StatelessWidget { } Future _handlePlayOrPauseAudioPlayer(BuildContext context) async { - final audioPlayer = matrix?.audioPlayer; + final audioPlayer = widget.matrix?.audioPlayer; if (audioPlayer == null) return; if (audioPlayer.isAtEndPosition) { await _handlePlayAudioAgain(context); diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index 3b443abae2..e89eef4121 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:async/async.dart'; import 'package:fluffychat/pages/chat/events/audio_message/audio_play_extension.dart'; import 'package:fluffychat/pages/chat/events/audio_message/audio_player_style.dart'; @@ -16,6 +15,7 @@ import 'package:fluffychat/widgets/file_widget/circular_loading_download_widget. import 'package:fluffychat/widgets/file_widget/file_tile_widget.dart'; import 'package:fluffychat/widgets/file_widget/message_file_tile_style.dart'; import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/widgets/mixins/audio_player_mixin.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -25,7 +25,6 @@ import 'package:matrix/matrix.dart'; import 'package:fluffychat/generated/l10n/app_localizations.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; class AudioPlayerWidget extends StatefulWidget { final Color color; @@ -48,7 +47,11 @@ class AudioPlayerWidget extends StatefulWidget { enum AudioPlayerStatus { notDownloaded, downloading, downloaded } class AudioPlayerState extends State - with AudioMixin, AutomaticKeepAliveClientMixin, EventFilterMixin { + with + AudioMixin, + AutomaticKeepAliveClientMixin, + EventFilterMixin, + AudioPlayerMixin { final List _calculatedWaveform = []; final ValueNotifier _durationNotifier = @@ -125,15 +128,6 @@ class AudioPlayerState extends State matrix.autoPlayAudio(currentEvent: widget.event); } - Future handleOggAudioFileIniOS(File file) async { - Logs().v('Convert ogg audio file for iOS...'); - final convertedFile = File('${file.path}.caf'); - if (await convertedFile.exists() == false) { - OpusCaf().convertOpusToCaf(file.path, convertedFile.path); - } - return convertedFile; - } - @override void initState() { super.initState(); @@ -234,7 +228,7 @@ class AudioPlayerState extends State final wavePosition = (currentPosition / maxPosition) * calculateWaveCountAuto( minWaves: AudioPlayerStyle.minWaveCount, - maxWaves: AudioPlayerStyle.maxWaveCount(context), + maxWaves: _calculatedWaveform.length, durationInSeconds: _durationNotifier.value.inSeconds, ); diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index fc002ab59e..12e318e98b 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -395,25 +395,20 @@ mixin AudioMixin { if (eventWaveForm == null || eventWaveForm.isEmpty || waveCount <= 0) { return null; } - if (waveCount == 1) return [eventWaveForm[eventWaveForm.length ~/ 2]]; - // If we need more data points than we have, generate fake data by repeating the waveform - if (waveCount > eventWaveForm.length) { - final List result = []; - for (int i = 0; i < waveCount; i++) { - // Cycle through the original waveform to generate fake data - final int value = eventWaveForm[i % eventWaveForm.length]; - result.add(value); - } - // Apply value clamping - return result.map((i) => i == 0 ? 1 : (i > 1024 ? 1024 : i)).toList(); + // Ensure the result will not exceed waveCount + // Use the minimum of waveCount and eventWaveForm.length + final int effectiveWaveCount = min(waveCount, eventWaveForm.length); + + if (effectiveWaveCount == 1) { + return [eventWaveForm[eventWaveForm.length ~/ 2]]; } - // Use interpolation-based sampling instead of insert/remove loops + // Use interpolation-based sampling final List result = []; - final double step = (eventWaveForm.length - 1) / (waveCount - 1); + final double step = (eventWaveForm.length - 1) / (effectiveWaveCount - 1); - for (int i = 0; i < waveCount; i++) { + for (int i = 0; i < effectiveWaveCount; i++) { final double exactIndex = i * step; final int lowerIndex = exactIndex.floor(); final int upperIndex = @@ -432,7 +427,7 @@ mixin AudioMixin { result.add(sampledValue); } - // Apply the same value clamping as the original function + // Apply value clamping return result.map((i) => i == 0 ? 1 : (i > 1024 ? 1024 : i)).toList(); } diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart index 951f67654c..f65a12798a 100644 --- a/lib/presentation/mixins/event_filter_mixin.dart +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -31,66 +31,6 @@ mixin EventFilterMixin { return events.where((event) => event.isVideoOrImage).toList(); } - /// Filters a list of events to include only image events. - /// - /// Returns a new list containing only events with messageType of - /// [MessageTypes.Image]. - List filterImageEvents(List events) { - return events - .where((event) => event.messageType == MessageTypes.Image) - .toList(); - } - - /// Filters a list of events to include only video events. - /// - /// Returns a new list containing only events with messageType of - /// [MessageTypes.Video]. - List filterVideoEvents(List events) { - return events - .where((event) => event.messageType == MessageTypes.Video) - .toList(); - } - - /// Filters a list of events to include only audio events. - /// - /// Returns a new list containing only events with messageType of - /// [MessageTypes.Audio]. - List filterAudioEvents(List events) { - return events - .where((event) => event.messageType == MessageTypes.Audio) - .toList(); - } - - /// Filters a list of events to include only file events. - /// - /// Returns a new list containing only events with messageType of - /// [MessageTypes.File]. - List filterFileEvents(List events) { - return events - .where((event) => event.messageType == MessageTypes.File) - .toList(); - } - - /// Filters a list of events to include only sticker events. - /// - /// Returns a new list containing only events with messageType of - /// [MessageTypes.Sticker]. - List filterStickerEvents(List events) { - return events - .where((event) => event.messageType == MessageTypes.Sticker) - .toList(); - } - - /// Filters a list of events to include only text events. - /// - /// Returns a new list containing only events with messageType of - /// [MessageTypes.Text]. - List filterTextEvents(List events) { - return events - .where((event) => event.messageType == MessageTypes.Text) - .toList(); - } - /// Filters events by custom message types. /// /// Allows filtering by multiple message types at once. @@ -397,124 +337,6 @@ mixin EventFilterMixin { } } - /// Loads more events in a specific direction. - /// - /// Fetches additional events from the server, decrypts them if necessary, - /// and filters events by specified message types. - /// - /// Parameters: - /// - [client]: The Matrix client instance - /// - [room]: The room object - /// - [direction]: Direction to load (forward or backward) - /// - [token]: Pagination token for the direction - /// - [messageTypes]: List of message types to filter. If null, filters for - /// media events (images and videos) by default. - /// - [limit]: Number of events to fetch (default: 10) - /// - /// Returns a record containing the loaded events and new pagination token. - Future< - ({ - List events, - String? newToken, - })> loadMoreEvents({ - required Client? client, - required Room room, - required Direction direction, - required String? token, - List? messageTypes, - int limit = 10, - }) async { - if (client == null || token == null) { - return (events: [], newToken: null); - } - - final mustDecrypt = room.encrypted && client.encryptionEnabled == true; - - try { - final expandResult = await expandEvents( - client: client, - roomId: room.id, - backwardToken: direction == Direction.b ? token : null, - forwardToken: direction == Direction.f ? token : null, - limit: limit, - ); - - final response = direction == Direction.b - ? expandResult.backward - : expandResult.forward; - - if (response == null) { - return (events: [], newToken: null); - } - - List loadMoreEvents = - convertMatrixEventsToEvents(response.chunk, room); - - if (mustDecrypt) { - loadMoreEvents = await decryptEvents(loadMoreEvents, client); - } - - loadMoreEvents = messageTypes != null - ? filterEventsByTypes(loadMoreEvents, messageTypes) - : filterMediaEvents(loadMoreEvents); - - return ( - events: loadMoreEvents, - newToken: response.end, - ); - } catch (e) { - Logs().e('loadMoreEvents: Error loading more events', e); - return (events: [], newToken: null); - } - } - - /// Checks if an event is a media event (image or video). - bool isMediaEvent(Event event) { - return event.isVideoOrImage; - } - - /// Checks if an event is an attachment (file, image, video, or audio). - bool isAttachmentEvent(Event event) { - return [ - MessageTypes.File, - MessageTypes.Image, - MessageTypes.Video, - MessageTypes.Audio, - ].contains(event.messageType); - } - - /// Filters events to include only attachment events. - /// - /// Returns events that are files, images, videos, or audio. - List filterAttachmentEvents(List events) { - return events.where((event) => isAttachmentEvent(event)).toList(); - } - - /// Groups events by message type. - /// - /// Returns a map where keys are message type strings and values are lists - /// of events of that type. - /// - /// Example: - /// ```dart - /// final grouped = groupEventsByType(events); - /// final images = grouped[MessageTypes.Image] ?? []; - /// final videos = grouped[MessageTypes.Video] ?? []; - /// ``` - Map> groupEventsByType(List events) { - final Map> grouped = {}; - - for (final event in events) { - final type = event.messageType; - if (!grouped.containsKey(type)) { - grouped[type] = []; - } - grouped[type]!.add(event); - } - - return grouped; - } - /// Gets audio events from the clicked event onwards for playlist. /// /// Returns all audio events from the clicked event onwards (for auto-play). @@ -537,8 +359,7 @@ mixin EventFilterMixin { List audioEvents, Event clickedEvent, ) { - final reversedEvents = audioEvents.reversed.toList(); - final clickedIndex = reversedEvents.indexWhere( + final clickedIndex = audioEvents.indexWhere( (event) => event.eventId == clickedEvent.eventId, ); @@ -547,7 +368,7 @@ mixin EventFilterMixin { } // Return from clicked event onwards for sequential playback - return reversedEvents.sublist(clickedIndex); + return audioEvents.sublist(clickedIndex); } /// Loads and processes initial audio events with automatic expansion. diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 95e250cf8c..5566055bb8 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -11,16 +11,12 @@ import 'package:fluffychat/domain/repository/federation_configurations_repositor import 'package:fluffychat/domain/repository/user_info/user_info_repository.dart'; import 'package:fluffychat/domain/usecase/room/create_support_chat_interactor.dart'; import 'package:fluffychat/event/twake_event_types.dart'; -import 'package:fluffychat/pages/chat/events/audio_message/audio_play_extension.dart'; -import 'package:fluffychat/pages/chat/events/audio_message/audio_player_widget.dart'; import 'package:fluffychat/presentation/mixins/init_config_mixin.dart'; import 'package:fluffychat/presentation/model/client_login_state_event.dart'; import 'package:fluffychat/widgets/layouts/agruments/logout_body_args.dart'; +import 'package:fluffychat/widgets/mixins/audio_player_mixin.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; -import 'package:just_audio/just_audio.dart'; import 'package:linagora_design_flutter/cozy_config_manager/cozy_config_manager.dart'; -import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:universal_html/html.dart' as html hide File; import 'package:adaptive_dialog/adaptive_dialog.dart'; @@ -94,20 +90,13 @@ class Matrix extends StatefulWidget { } class MatrixState extends State - with WidgetsBindingObserver, ReceiveSharingIntentMixin, InitConfigMixin { + with + WidgetsBindingObserver, + ReceiveSharingIntentMixin, + InitConfigMixin, + AudioPlayerMixin { final _contactsManager = getIt.get(); - AudioPlayer? audioPlayer; - - final ValueNotifier voiceMessageEvent = ValueNotifier(null); - - final ValueNotifier> voiceMessageEvents = ValueNotifier([]); - - final ValueNotifier currentAudioStatus = - ValueNotifier(AudioPlayerStatus.notDownloaded); - - StreamSubscription? _audioPlayerStateSubscription; - int _activeClient = -1; String? activeBundle; Store store = Store(); @@ -1183,160 +1172,6 @@ class MatrixState extends State showQrCodeDownload.value = show; } - /// Sets up the audio player with auto-dispose listener when playback completes. - /// - /// This method should be called after setting up a new audio source. - /// It automatically cleans up the audio player and resets state when playback finishes. - void setupAudioPlayerAutoDispose() { - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = - audioPlayer?.playerStateStream.listen((state) async { - if (state.processingState == ProcessingState.completed) { - Logs().d( - 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', - ); - - if (voiceMessageEvents.value.isEmpty) { - return; - } - - // Remove the completed message from the list - final updatedVoiceMessageEvent = voiceMessageEvents.value - .where((e) => e.eventId != voiceMessageEvent.value?.eventId) - .toList(); - - Logs().d( - 'setupAudioPlayerAutoDispose: Remaining audio message - $updatedVoiceMessageEvent', - ); - - voiceMessageEvents.value = updatedVoiceMessageEvent; - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - - // Check if there are more messages to play - if (voiceMessageEvents.value.isEmpty) { - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = null; - await audioPlayer?.stop(); - await audioPlayer?.dispose(); - audioPlayer = null; - return; - } - - final nextAudioMessage = voiceMessageEvents.value.first; - await autoPlayAudio( - currentEvent: nextAudioMessage, - ); - } - }); - } - - Future autoPlayAudio({ - required Event currentEvent, - }) async { - voiceMessageEvent.value = currentEvent; - File? file; - MatrixFile? matrixFile; - - currentAudioStatus.value = AudioPlayerStatus.downloading; - try { - matrixFile = await currentEvent.downloadAndDecryptAttachment(); - - if (!kIsWeb) { - final tempDir = await getTemporaryDirectory(); - final mxcUrl = currentEvent.attachmentOrThumbnailMxcUrl(); - if (mxcUrl == null) { - throw Exception('Event has no attachment URL'); - } - final fileName = Uri.encodeComponent( - mxcUrl.pathSegments.last, - ); - file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); - - if (matrixFile.bytes?.isEmpty == true) { - throw Exception('Downloaded file has no content'); - } - - await file.writeAsBytes(matrixFile.bytes ?? []); - - if (Platform.isIOS && - matrixFile.mimeType.toLowerCase() == 'audio/ogg') { - final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); - if (oggAudioFileIniOS != null) { - file = oggAudioFileIniOS; - } - } - } - - currentAudioStatus.value = AudioPlayerStatus.downloaded; - } catch (e, s) { - Logs().e('Could not download audio file', e, s); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toLocalizedString(context)), - ), - ); - } - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - return; - } - if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; - - // Initialize audio player before use - await audioPlayer?.stop(); - await audioPlayer?.dispose(); - - audioPlayer = AudioPlayer(); - voiceMessageEvent.value = currentEvent; - - if (file != null) { - await audioPlayer?.setFilePath(file.path); - } else { - await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); - } - - // Set up auto-dispose listener managed globally in MatrixState - setupAudioPlayerAutoDispose(); - - audioPlayer?.play().onError((e, s) { - Logs().e('Could not play audio file', e, s); - // Reset state on playback error - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e?.toLocalizedString(context) ?? - L10n.of(context)!.couldNotPlayAudioFile, - ), - ), - ); - }); - } - - Future handleOggAudioFileIniOS(File file) async { - try { - Logs().v('Convert ogg audio file for iOS...'); - final convertedFile = File('${file.path}.caf'); - if (await convertedFile.exists() == false) { - OpusCaf().convertOpusToCaf(file.path, convertedFile.path); - // Verify conversion succeeded - if (await convertedFile.exists() == false) { - Logs().w( - 'MatrixState::handleOggAudioFileIniOS: OGG to CAF conversion failed - converted file does not exist', - ); - return null; - } - } - return convertedFile; - } catch (e, s) { - Logs().e('Could not convert ogg audio file for iOS', e, s); - return null; - } - } - @override void didChangeAppLifecycleState(AppLifecycleState state) { Logs().i('didChangeAppLifecycleState: AppLifecycleState = $state'); @@ -1420,32 +1255,6 @@ class MatrixState extends State } } - void cancelAudioPlayerAutoDispose() { - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = null; - } - - /// Cleans up the audio player and clears playing lists. - /// - /// Should be called when logging out or switching accounts to ensure - /// audio playback is properly stopped and state is reset. - Future cleanupAudioPlayer() async { - try { - await audioPlayer?.pause(); - await audioPlayer?.stop(); - await audioPlayer?.dispose(); - audioPlayer = null; - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = null; - voiceMessageEvents.value = []; - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - Logs().d('MatrixState::cleanupAudioPlayer: Audio player cleaned up'); - } catch (e) { - Logs().e('MatrixState::cleanupAudioPlayer: Error - $e'); - } - } - @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -1467,12 +1276,12 @@ class MatrixState extends State showToMBootstrap.dispose(); linuxNotifications?.close(); showQrCodeDownload.dispose(); - _audioPlayerStateSubscription?.cancel(); audioPlayer?.dispose(); voiceMessageEvents.dispose(); voiceMessageEvent.dispose(); _presenceSubscription?.cancel(); onLatestPresenceChanged.close(); + disposeAudioPlayer(); super.dispose(); } diff --git a/lib/widgets/mixins/audio_player_mixin.dart b/lib/widgets/mixins/audio_player_mixin.dart new file mode 100644 index 0000000000..074c998fd3 --- /dev/null +++ b/lib/widgets/mixins/audio_player_mixin.dart @@ -0,0 +1,235 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:fluffychat/generated/l10n/app_localizations.dart'; +import 'package:fluffychat/pages/chat/events/audio_message/audio_play_extension.dart'; +import 'package:fluffychat/pages/chat/events/audio_message/audio_player_widget.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:matrix/matrix.dart'; +import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Mixin that provides audio player functionality for voice messages. +/// +/// This mixin manages audio playback state, auto-play queue, and cleanup. +/// It should be used with State classes that need audio playback capabilities. +mixin AudioPlayerMixin on State { + AudioPlayer? audioPlayer; + + final ValueNotifier voiceMessageEvent = ValueNotifier(null); + + final ValueNotifier> voiceMessageEvents = ValueNotifier([]); + + final ValueNotifier currentAudioStatus = + ValueNotifier(AudioPlayerStatus.notDownloaded); + + StreamSubscription? _audioPlayerStateSubscription; + + /// Sets up the audio player with auto-dispose listener when playback + /// completes. + /// + /// This method should be called after setting up a new audio source. + /// It automatically cleans up the audio player and resets state when + /// playback finishes. + void setupAudioPlayerAutoDispose() { + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = + audioPlayer?.playerStateStream.listen((state) async { + if (state.processingState == ProcessingState.completed) { + Logs().d( + 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', + ); + + if (voiceMessageEvents.value.isEmpty) { + // If no messages in queue, clean up everything + await cleanupAudioPlayer(); + return; + } + + // Remove the completed message from the list + final updatedVoiceMessageEvent = voiceMessageEvents.value + .where((e) => e.eventId != voiceMessageEvent.value?.eventId) + .toList(); + + Logs().d( + 'setupAudioPlayerAutoDispose: Remaining audio message - $updatedVoiceMessageEvent', + ); + + voiceMessageEvents.value = updatedVoiceMessageEvent; + + // Check if this was the last message + if (voiceMessageEvents.value.isEmpty) { + Logs().d( + 'setupAudioPlayerAutoDispose: Last audio finished, cleaning up all state', + ); + // Clear all audio player state since this was the last message + await cleanupAudioPlayer(); + return; + } + + // There are more messages to play + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + + final nextAudioMessage = voiceMessageEvents.value.first; + await autoPlayAudio( + currentEvent: nextAudioMessage, + ); + } + }); + } + + /// Automatically plays an audio message. + /// + /// Downloads, decrypts, and plays the audio file for the given event. + /// Handles platform-specific audio format conversions (e.g., OGG to CAF on iOS). + Future autoPlayAudio({ + required Event currentEvent, + }) async { + voiceMessageEvent.value = currentEvent; + File? file; + MatrixFile? matrixFile; + + currentAudioStatus.value = AudioPlayerStatus.downloading; + try { + matrixFile = await currentEvent.downloadAndDecryptAttachment(); + + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + final mxcUrl = currentEvent.attachmentOrThumbnailMxcUrl(); + if (mxcUrl == null) { + throw Exception('Event has no attachment URL'); + } + final fileName = Uri.encodeComponent( + mxcUrl.pathSegments.last, + ); + file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); + + if (matrixFile.bytes?.isEmpty == true) { + throw Exception('Downloaded file has no content'); + } + + await file.writeAsBytes(matrixFile.bytes ?? []); + + if (Platform.isIOS && + matrixFile.mimeType.toLowerCase() == 'audio/ogg') { + final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); + if (oggAudioFileIniOS != null) { + file = oggAudioFileIniOS; + } + } + } + + currentAudioStatus.value = AudioPlayerStatus.downloaded; + } catch (e, s) { + Logs().e('Could not download audio file', e, s); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toLocalizedString(context)), + ), + ); + } + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + return; + } + if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; + + // Initialize audio player before use + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + + audioPlayer = AudioPlayer(); + voiceMessageEvent.value = currentEvent; + + if (file != null) { + await audioPlayer?.setFilePath(file.path); + } else { + await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); + } + + // Set up auto-dispose listener managed globally in MatrixState + setupAudioPlayerAutoDispose(); + + audioPlayer?.play().onError((e, s) { + Logs().e('Could not play audio file', e, s); + // Reset state on playback error + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e?.toLocalizedString(context) ?? + L10n.of(context)!.couldNotPlayAudioFile, + ), + ), + ); + }); + } + + /// Converts OGG audio files to CAF format for iOS compatibility. + /// + /// Returns the converted file if successful, null otherwise. + Future handleOggAudioFileIniOS(File file) async { + try { + Logs().v('Convert ogg audio file for iOS...'); + final convertedFile = File('${file.path}.caf'); + if (await convertedFile.exists() == false) { + OpusCaf().convertOpusToCaf(file.path, convertedFile.path); + // Verify conversion succeeded + if (await convertedFile.exists() == false) { + Logs().w( + 'AudioPlayerMixin::handleOggAudioFileIniOS: OGG to CAF conversion failed - converted file does not exist', + ); + return null; + } + } + return convertedFile; + } catch (e, s) { + Logs().e('Could not convert ogg audio file for iOS', e, s); + return null; + } + } + + /// Cancels the audio player auto-dispose subscription. + void cancelAudioPlayerAutoDispose() { + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = null; + } + + /// Cleans up the audio player and clears playing lists. + /// + /// Should be called when logging out or switching accounts to ensure + /// audio playback is properly stopped and state is reset. + Future cleanupAudioPlayer() async { + try { + await audioPlayer?.pause(); + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + audioPlayer = null; + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = null; + voiceMessageEvents.value = []; + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + Logs().d('AudioPlayerMixin::cleanupAudioPlayer: Audio player cleaned up'); + } catch (e) { + Logs().e('AudioPlayerMixin::cleanupAudioPlayer: Error - $e'); + } + } + + /// Disposes audio player resources. + /// + /// Should be called in the dispose method of the State class using this mixin. + void disposeAudioPlayer() { + _audioPlayerStateSubscription?.cancel(); + audioPlayer?.dispose(); + voiceMessageEvents.dispose(); + voiceMessageEvent.dispose(); + currentAudioStatus.dispose(); + } +} From ca05e5d027aecdaccc45f45fda0a22c800b69047 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Thu, 15 Jan 2026 16:54:54 +0700 Subject: [PATCH 12/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- .../mixins/event_filter_mixin.dart | 16 +- lib/widgets/mixins/audio_player_mixin.dart | 4 +- .../presentation/mixins/audio_mixin_test.dart | 27 +- .../mixins/event_filter_mixin_test.dart | 395 ++++++++++++++++++ 4 files changed, 419 insertions(+), 23 deletions(-) create mode 100644 test/presentation/mixins/event_filter_mixin_test.dart diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart index f65a12798a..73d2c09eea 100644 --- a/lib/presentation/mixins/event_filter_mixin.dart +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -306,23 +306,20 @@ mixin EventFilterMixin { backwardEvents = decrypted[1]; } - final backwardEventsReversed = backwardEvents.reversed.toList(); - final forwardEventsReversed = forwardEvents.reversed.toList(); - initialFilteredEvents = [ ...(messageTypes != null ? filterEventsByTypes( - backwardEventsReversed, + backwardEvents.reversed.toList(), messageTypes, ) - : filterMediaEvents(backwardEventsReversed)), + : filterMediaEvents(backwardEvents.reversed.toList())), ...initialFilteredEvents, ...(messageTypes != null ? filterEventsByTypes( - forwardEventsReversed, + forwardEvents.reversed.toList(), messageTypes, ) - : filterMediaEvents(forwardEventsReversed)), + : filterMediaEvents(forwardEvents.reversed.toList())), ]; } @@ -359,7 +356,8 @@ mixin EventFilterMixin { List audioEvents, Event clickedEvent, ) { - final clickedIndex = audioEvents.indexWhere( + final reversedEvents = audioEvents.reversed.toList(); + final clickedIndex = reversedEvents.indexWhere( (event) => event.eventId == clickedEvent.eventId, ); @@ -368,7 +366,7 @@ mixin EventFilterMixin { } // Return from clicked event onwards for sequential playback - return audioEvents.sublist(clickedIndex); + return reversedEvents.sublist(clickedIndex); } /// Loads and processes initial audio events with automatic expansion. diff --git a/lib/widgets/mixins/audio_player_mixin.dart b/lib/widgets/mixins/audio_player_mixin.dart index 074c998fd3..4f9dee7082 100644 --- a/lib/widgets/mixins/audio_player_mixin.dart +++ b/lib/widgets/mixins/audio_player_mixin.dart @@ -108,11 +108,11 @@ mixin AudioPlayerMixin on State { ); file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); - if (matrixFile.bytes?.isEmpty == true) { + if (matrixFile.bytes.isEmpty == true) { throw Exception('Downloaded file has no content'); } - await file.writeAsBytes(matrixFile.bytes ?? []); + await file.writeAsBytes(matrixFile.bytes); if (Platform.isIOS && matrixFile.mimeType.toLowerCase() == 'audio/ogg') { diff --git a/test/presentation/mixins/audio_mixin_test.dart b/test/presentation/mixins/audio_mixin_test.dart index de1b42742a..3c52195d09 100644 --- a/test/presentation/mixins/audio_mixin_test.dart +++ b/test/presentation/mixins/audio_mixin_test.dart @@ -281,19 +281,21 @@ void main() { expect(result[1], lessThan(400)); }); - test('should repeat waveform for upsampling', () { + test( + 'should clamp waveCount to eventWaveForm.length when waveCount exceeds it', + () { // Arrange - const waveform = [100, 200]; // 2 points + const waveform = [100, 200, 300, 400, 500]; // 5 elements // Act final result = audioMixin.calculateWaveForm( eventWaveForm: waveform, - waveCount: 6, + waveCount: 10, // Request 10 but only 5 available ); // Assert - expect(result!.length, 6); - expect(result, [100, 200, 100, 200, 100, 200]); + expect(result!.length, 5); // Should be clamped to waveform length + expect(result, waveform); }); test('should clamp values correctly', () { @@ -314,7 +316,7 @@ void main() { expect(result[3], 500); // 500 should remain 500 }); - test('should handle single point waveform with upsampling', () { + test('should handle single point waveform', () { // Arrange const waveform = [300]; @@ -325,22 +327,23 @@ void main() { ); // Assert - expect(result, [300, 300, 300]); + expect(result, [300]); // Can't exceed source length }); - test('should handle large upsampling', () { + test('should downsample correctly', () { // Arrange - const waveform = [100, 200, 300]; + const waveform = [100, 200, 300, 400, 500, 600, 700, 800, 900]; // Act final result = audioMixin.calculateWaveForm( eventWaveForm: waveform, - waveCount: 9, + waveCount: 3, ); // Assert - expect(result!.length, 9); - expect(result, [100, 200, 300, 100, 200, 300, 100, 200, 300]); + expect(result!.length, 3); + expect(result[0], 100); // First value + expect(result[2], 900); // Last value }); }); diff --git a/test/presentation/mixins/event_filter_mixin_test.dart b/test/presentation/mixins/event_filter_mixin_test.dart new file mode 100644 index 0000000000..45ef39e8d6 --- /dev/null +++ b/test/presentation/mixins/event_filter_mixin_test.dart @@ -0,0 +1,395 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluffychat/presentation/mixins/event_filter_mixin.dart'; +import 'package:matrix/matrix.dart'; + +import '../../fake_client.dart'; + +class MockEventFilterMixin with EventFilterMixin {} + +void main() { + late MockEventFilterMixin eventFilterMixin; + late Client client; + late Room room; + + setUpAll(() async { + client = await getClient(); + room = Room( + client: client, + id: '!room:example.com', + membership: Membership.join, + ); + }); + + setUp(() { + eventFilterMixin = MockEventFilterMixin(); + }); + + group('getAudioEventsUpToClicked', () { + test( + 'should return events from clicked position onwards for sequential playback', + () { + // Arrange - Create mock events in REVERSE chronological order (newest first) + // This simulates how chat displays events: newest at top, oldest at bottom + final event5 = Event( + content: {'body': 'audio5.ogg'}, + type: EventTypes.Message, + eventId: 'event5', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 1)), + room: room, + ); + + final event4 = Event( + content: {'body': 'audio4.ogg'}, + type: EventTypes.Message, + eventId: 'event4', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 2)), + room: room, + ); + + final event3 = Event( + content: {'body': 'audio3.ogg'}, + type: EventTypes.Message, + eventId: 'event3', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 3)), + room: room, + ); + + final event2 = Event( + content: {'body': 'audio2.ogg'}, + type: EventTypes.Message, + eventId: 'event2', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 4)), + room: room, + ); + + final event1 = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 5)), + room: room, + ); + + // Chat order: [event5, event4, event3, event2, event1] (newest first) + final audioEvents = [event5, event4, event3, event2, event1]; + + // Act - User clicks on event3 (middle event in chat) + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + event3, + ); + + expect(result.length, 3); + expect(result[0].eventId, 'event3'); // Oldest (plays first) + expect(result[1].eventId, 'event4'); // Middle + expect(result[2].eventId, 'event5'); // Clicked event (plays last) + }, + ); + + test('should return single event when clicked on oldest event', () { + final event1 = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 3)), + room: room, + ); + + final event2 = Event( + content: {'body': 'audio2.ogg'}, + type: EventTypes.Message, + eventId: 'event2', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 2)), + room: room, + ); + + final event3 = Event( + content: {'body': 'audio3.ogg'}, + type: EventTypes.Message, + eventId: 'event3', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 1)), + room: room, + ); + + final audioEvents = [event3, event2, event1]; + + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + event1, + ); + + expect(result.length, 3); + expect(result[0].eventId, 'event1'); + expect(result[1].eventId, 'event2'); + expect(result[2].eventId, 'event3'); + }); + + test('should return single event when clicked on newest event', () { + // Arrange - Chronological order: oldest first + final event1 = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 3)), + room: room, + ); + + final event2 = Event( + content: {'body': 'audio2.ogg'}, + type: EventTypes.Message, + eventId: 'event2', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 2)), + room: room, + ); + + final event3 = Event( + content: {'body': 'audio3.ogg'}, + type: EventTypes.Message, + eventId: 'event3', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 1)), + room: room, + ); + + final audioEvents = [event3, event2, event1]; + + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + event3, + ); + + expect(result.length, 1); + expect(result[0].eventId, 'event3'); // Newest (only this plays) + }); + + test('should return empty list when clicked event is not found', () { + // Arrange + final event1 = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 3)), + room: room, + ); + + final event2 = Event( + content: {'body': 'audio2.ogg'}, + type: EventTypes.Message, + eventId: 'event2', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 2)), + room: room, + ); + + final clickedEvent = Event( + content: {'body': 'audio_not_in_list.ogg'}, + type: EventTypes.Message, + eventId: 'event_not_found', + senderId: '@user:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 1)), + room: room, + ); + + final audioEvents = [event1, event2]; + + // Act - User clicks on event that's not in the list + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + clickedEvent, + ); + + // Assert - Should return empty list + expect(result, []); + }); + + test('should handle single event list', () { + // Arrange + final singleEvent = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: DateTime.now(), + room: room, + ); + + final audioEvents = [singleEvent]; + + // Act + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + singleEvent, + ); + + // Assert + expect(result.length, 1); + expect(result[0].eventId, 'event1'); + }); + + test('should handle empty event list', () { + // Arrange + final clickedEvent = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: DateTime.now(), + room: room, + ); + + final audioEvents = []; + + // Act + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + clickedEvent, + ); + + // Assert + expect(result, []); + }); + + test('should maintain chronological order for sequential playback', () { + final now = DateTime.now(); + final event1 = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@user:example.com', + originServerTs: now.subtract(const Duration(minutes: 10)), + room: room, + ); + + final event2 = Event( + content: {'body': 'audio2.ogg'}, + type: EventTypes.Message, + eventId: 'event2', + senderId: '@user:example.com', + originServerTs: now.subtract(const Duration(minutes: 8)), + room: room, + ); + + final event3 = Event( + content: {'body': 'audio3.ogg'}, + type: EventTypes.Message, + eventId: 'event3', + senderId: '@user:example.com', + originServerTs: now.subtract(const Duration(minutes: 6)), + room: room, + ); + + final event4 = Event( + content: {'body': 'audio4.ogg'}, + type: EventTypes.Message, + eventId: 'event4', + senderId: '@user:example.com', + originServerTs: now.subtract(const Duration(minutes: 4)), + room: room, + ); + + final audioEvents = [event4, event3, event2, event1]; + + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + event2, + ); + + expect(result.length, 3); + expect(result[0].eventId, 'event2'); // Clicked event + expect(result[1].eventId, 'event3'); + expect(result[2].eventId, 'event4'); // Newest + + expect( + result[0].originServerTs.isBefore(result[1].originServerTs), + true, + ); + expect( + result[1].originServerTs.isBefore(result[2].originServerTs), + true, + ); + }); + + test('should work correctly with events from different senders', () { + // Arrange - Chronological order: oldest first + final event1 = Event( + content: {'body': 'audio1.ogg'}, + type: EventTypes.Message, + eventId: 'event1', + senderId: '@alice:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 5)), + room: room, + ); + + final event2 = Event( + content: {'body': 'audio2.ogg'}, + type: EventTypes.Message, + eventId: 'event2', + senderId: '@bob:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 4)), + room: room, + ); + + final event3 = Event( + content: {'body': 'audio3.ogg'}, + type: EventTypes.Message, + eventId: 'event3', + senderId: '@alice:example.com', + originServerTs: DateTime.now().subtract(const Duration(minutes: 3)), + room: room, + ); + + final audioEvents = [event3, event2, event1]; + + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + event2, + ); + + expect(result.length, 2); + expect(result[0].eventId, 'event2'); + expect(result[0].senderId, '@bob:example.com'); + expect(result[1].eventId, 'event3'); + expect(result[1].senderId, '@alice:example.com'); + }); + + test('should handle large list of events efficiently', () { + // Arrange - Create 100 events in CHRONOLOGICAL ORDER (oldest first) + final now = DateTime.now(); + final audioEvents = List.generate( + 100, + (index) => Event( + content: {'body': 'audio$index.ogg'}, + type: EventTypes.Message, + eventId: 'event$index', + senderId: '@user:example.com', + // Event 0 (oldest) has largest time offset + originServerTs: now.subtract(Duration(minutes: 100 - index)), + room: room, + ), + ); + + // Click on event at index 49 (which is event49) + final clickedEvent = audioEvents[49]; + + // Act + final result = eventFilterMixin.getAudioEventsUpToClicked( + audioEvents, + clickedEvent, + ); + expect(result.length, 50); + expect(result.first.eventId, 'event49'); + expect(result.last.eventId, 'event0'); + }); + }); +} From 3efdf7eaf94cd7d37ccab42005c1f2e001f68b14 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Fri, 16 Jan 2026 15:29:20 +0700 Subject: [PATCH 13/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/chat.dart | 1 + lib/pages/chat/chat_audio_player_widget.dart | 6 +- .../audio_message/audio_player_widget.dart | 6 +- lib/presentation/mixins/audio_mixin.dart | 238 +++++++++++++++++- lib/widgets/matrix.dart | 4 +- 5 files changed, 243 insertions(+), 12 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index b04d3fba29..b7c4285824 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -3120,6 +3120,7 @@ class ChatController extends State ); } + @override void disposeAudioPlayer() { if (PlatformInfos.isMobile) { return; diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index 216682bb5e..4d4e08dce8 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -8,7 +8,7 @@ import 'package:fluffychat/resource/image_paths.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/string_extension.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/mixins/audio_player_mixin.dart'; +import 'package:fluffychat/presentation/mixins/audio_mixin.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -39,7 +39,7 @@ class ChatAudioPlayerWidget extends StatefulWidget { } class _ChatAudioPlayerWidgetState extends State - with AudioPlayerMixin { + with AudioMixin { @override Widget build(BuildContext context) { // Return empty if matrix is not available @@ -247,7 +247,7 @@ class _ChatAudioPlayerWidgetState extends State } // Set up auto-dispose listener managed globally in MatrixState - widget.matrix?.setupAudioPlayerAutoDispose(); + widget.matrix?.setupAudioPlayerAutoDispose(context: context); widget.matrix?.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index e89eef4121..12da83c29b 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -15,7 +15,6 @@ import 'package:fluffychat/widgets/file_widget/circular_loading_download_widget. import 'package:fluffychat/widgets/file_widget/file_tile_widget.dart'; import 'package:fluffychat/widgets/file_widget/message_file_tile_style.dart'; import 'package:fluffychat/widgets/matrix.dart'; -import 'package:fluffychat/widgets/mixins/audio_player_mixin.dart'; import 'package:fluffychat/widgets/twake_components/twake_icon_button.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -50,8 +49,7 @@ class AudioPlayerState extends State with AudioMixin, AutomaticKeepAliveClientMixin, - EventFilterMixin, - AudioPlayerMixin { + EventFilterMixin { final List _calculatedWaveform = []; final ValueNotifier _durationNotifier = @@ -125,7 +123,7 @@ class AudioPlayerState extends State matrix.voiceMessageEvents.value = audioPending.events; - matrix.autoPlayAudio(currentEvent: widget.event); + matrix.autoPlayAudio(currentEvent: widget.event, context: context); } @override diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 12e318e98b..934af5375a 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -1,16 +1,23 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; +import 'package:fluffychat/generated/l10n/app_localizations.dart'; +import 'package:fluffychat/pages/chat/events/audio_message/audio_play_extension.dart'; +import 'package:fluffychat/pages/chat/events/audio_message/audio_player_widget.dart'; +import 'package:fluffychat/utils/dialog/twake_dialog.dart'; +import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_file_extension.dart'; import 'package:fluffychat/utils/platform_infos.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:just_audio/just_audio.dart'; import 'package:matrix/matrix.dart'; +import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; -import 'package:flutter/material.dart'; -import 'package:fluffychat/utils/dialog/twake_dialog.dart'; -import 'package:fluffychat/generated/l10n/app_localizations.dart'; import 'package:universal_html/html.dart' as html; enum AudioRecordState { @@ -25,6 +32,7 @@ mixin AudioMixin { static const waveCount = 40; static const maxRecordDurationInSeconds = 1800; // 30 minutes + // Audio recording properties final ValueNotifier recordDurationWebNotifier = ValueNotifier(0); Timer? _timerWeb; @@ -40,9 +48,22 @@ mixin AudioMixin { final ValueNotifier audioRecordStateNotifier = ValueNotifier(AudioRecordState.initial); + // Audio player properties + AudioPlayer? audioPlayer; + + final ValueNotifier voiceMessageEvent = ValueNotifier(null); + + final ValueNotifier> voiceMessageEvents = ValueNotifier([]); + + final ValueNotifier currentAudioStatus = + ValueNotifier(AudioPlayerStatus.notDownloaded); + + StreamSubscription? _audioPlayerStateSubscription; + void disposeAudioMixin() { audioRecordStateNotifier.dispose(); _disposeAudioRecorderWeb(); + disposeAudioPlayer(); } void startRecording() { @@ -462,4 +483,215 @@ mixin AudioMixin { isArrangeActionButtonsVertical: true, ); } + + // Audio player methods + + /// Sets up the audio player with auto-dispose listener when playback + /// completes. + /// + /// This method should be called after setting up a new audio source. + /// It automatically cleans up the audio player and resets state when + /// playback finishes. + void setupAudioPlayerAutoDispose({ + required BuildContext context, + }) { + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = + audioPlayer?.playerStateStream.listen((state) async { + if (state.processingState == ProcessingState.completed) { + Logs().d( + 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', + ); + + if (voiceMessageEvents.value.isEmpty) { + // If no messages in queue, clean up everything + await cleanupAudioPlayer(); + return; + } + + // Remove the completed message from the list + final updatedVoiceMessageEvent = voiceMessageEvents.value + .where((e) => e.eventId != voiceMessageEvent.value?.eventId) + .toList(); + + Logs().d( + 'setupAudioPlayerAutoDispose: Remaining audio message - $updatedVoiceMessageEvent', + ); + + voiceMessageEvents.value = updatedVoiceMessageEvent; + + // Check if this was the last message + if (voiceMessageEvents.value.isEmpty) { + Logs().d( + 'setupAudioPlayerAutoDispose: Last audio finished, cleaning up all state', + ); + // Clear all audio player state since this was the last message + await cleanupAudioPlayer(); + return; + } + + // There are more messages to play + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + + final nextAudioMessage = voiceMessageEvents.value.first; + await autoPlayAudio( + currentEvent: nextAudioMessage, + context: context, + ); + } + }); + } + + /// Automatically plays an audio message. + /// + /// Downloads, decrypts, and plays the audio file for the given event. + /// Handles platform-specific audio format conversions (e.g., OGG to CAF on iOS). + Future autoPlayAudio({ + required BuildContext context, + required Event currentEvent, + }) async { + voiceMessageEvent.value = currentEvent; + File? file; + MatrixFile? matrixFile; + + currentAudioStatus.value = AudioPlayerStatus.downloading; + try { + matrixFile = await currentEvent.downloadAndDecryptAttachment(); + + if (!kIsWeb) { + final tempDir = await getTemporaryDirectory(); + final mxcUrl = currentEvent.attachmentOrThumbnailMxcUrl(); + if (mxcUrl == null) { + throw Exception('Event has no attachment URL'); + } + final fileName = Uri.encodeComponent( + mxcUrl.pathSegments.last, + ); + file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); + + if (matrixFile.bytes.isEmpty == true) { + throw Exception('Downloaded file has no content'); + } + + await file.writeAsBytes(matrixFile.bytes); + + if (Platform.isIOS && + matrixFile.mimeType.toLowerCase() == 'audio/ogg') { + final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); + if (oggAudioFileIniOS != null) { + file = oggAudioFileIniOS; + } + } + } + + currentAudioStatus.value = AudioPlayerStatus.downloaded; + } catch (e, s) { + Logs().e('Could not download audio file', e, s); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.toLocalizedString(context)), + ), + ); + } + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + return; + } + if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; + + // Initialize audio player before use + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + + audioPlayer = AudioPlayer(); + voiceMessageEvent.value = currentEvent; + + if (file != null) { + await audioPlayer?.setFilePath(file.path); + } else { + await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); + } + + // Set up auto-dispose listener managed globally in MatrixState + setupAudioPlayerAutoDispose(context: context); + + audioPlayer?.play().onError((e, s) { + Logs().e('Could not play audio file', e, s); + // Reset state on playback error + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + e?.toLocalizedString(context) ?? + L10n.of(context)!.couldNotPlayAudioFile, + ), + ), + ); + }); + } + + /// Converts OGG audio files to CAF format for iOS compatibility. + /// + /// Returns the converted file if successful, null otherwise. + Future handleOggAudioFileIniOS(File file) async { + try { + Logs().v('Convert ogg audio file for iOS...'); + final convertedFile = File('${file.path}.caf'); + if (await convertedFile.exists() == false) { + OpusCaf().convertOpusToCaf(file.path, convertedFile.path); + // Verify conversion succeeded + if (await convertedFile.exists() == false) { + Logs().w( + 'AudioMixin::handleOggAudioFileIniOS: OGG to CAF conversion failed - converted file does not exist', + ); + return null; + } + } + return convertedFile; + } catch (e, s) { + Logs().e('Could not convert ogg audio file for iOS', e, s); + return null; + } + } + + /// Cancels the audio player auto-dispose subscription. + void cancelAudioPlayerAutoDispose() { + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = null; + } + + /// Cleans up the audio player and clears playing lists. + /// + /// Should be called when logging out or switching accounts to ensure + /// audio playback is properly stopped and state is reset. + Future cleanupAudioPlayer() async { + try { + await audioPlayer?.pause(); + await audioPlayer?.stop(); + await audioPlayer?.dispose(); + audioPlayer = null; + _audioPlayerStateSubscription?.cancel(); + _audioPlayerStateSubscription = null; + voiceMessageEvents.value = []; + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + Logs().d('AudioMixin::cleanupAudioPlayer: Audio player cleaned up'); + } catch (e) { + Logs().e('AudioMixin::cleanupAudioPlayer: Error - $e'); + } + } + + /// Disposes audio player resources. + /// + /// Should be called in the dispose method of the State class using this mixin. + void disposeAudioPlayer() { + _audioPlayerStateSubscription?.cancel(); + audioPlayer?.dispose(); + voiceMessageEvents.dispose(); + voiceMessageEvent.dispose(); + currentAudioStatus.dispose(); + } } diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 5566055bb8..a16f4e93ca 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -14,7 +14,7 @@ import 'package:fluffychat/event/twake_event_types.dart'; import 'package:fluffychat/presentation/mixins/init_config_mixin.dart'; import 'package:fluffychat/presentation/model/client_login_state_event.dart'; import 'package:fluffychat/widgets/layouts/agruments/logout_body_args.dart'; -import 'package:fluffychat/widgets/mixins/audio_player_mixin.dart'; +import 'package:fluffychat/presentation/mixins/audio_mixin.dart'; import 'package:flutter_emoji_mart/flutter_emoji_mart.dart'; import 'package:linagora_design_flutter/cozy_config_manager/cozy_config_manager.dart'; import 'package:universal_html/html.dart' as html hide File; @@ -94,7 +94,7 @@ class MatrixState extends State WidgetsBindingObserver, ReceiveSharingIntentMixin, InitConfigMixin, - AudioPlayerMixin { + AudioMixin { final _contactsManager = getIt.get(); int _activeClient = -1; From 58e4ce7e0dd3a55a418ea512aff7ab5c1e76c72a Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Fri, 16 Jan 2026 17:08:05 +0700 Subject: [PATCH 14/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/chat.dart | 13 +------------ lib/pages/chat/chat_audio_player_widget.dart | 8 +++++++- .../events/audio_message/audio_player_widget.dart | 7 +++---- lib/presentation/mixins/audio_mixin.dart | 5 ++++- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index b7c4285824..3391a7ceda 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -3120,17 +3120,6 @@ class ChatController extends State ); } - @override - void disposeAudioPlayer() { - if (PlatformInfos.isMobile) { - return; - } - disposeAudioMixin(); - matrix?.audioPlayer?.stop(); - matrix?.audioPlayer?.clearAudioSources(); - matrix?.voiceMessageEvent.value = null; - } - void initAudioPlayer() { if (matrix?.audioPlayer?.playing == true) { if (!PlatformInfos.isMobile) { @@ -3272,7 +3261,7 @@ class ChatController extends State showScrollDownButtonNotifier.dispose(); editEventNotifier.dispose(); focusHover.dispose(); - disposeAudioPlayer(); + disposeAudioMixin(); super.dispose(); } diff --git a/lib/pages/chat/chat_audio_player_widget.dart b/lib/pages/chat/chat_audio_player_widget.dart index 4d4e08dce8..cedc5aef77 100644 --- a/lib/pages/chat/chat_audio_player_widget.dart +++ b/lib/pages/chat/chat_audio_player_widget.dart @@ -201,7 +201,11 @@ class _ChatAudioPlayerWidgetState extends State if (Platform.isIOS && matrixFile?.mimeType.toLowerCase() == 'audio/ogg') { - file = await handleOggAudioFileIniOS(file); + final converted = await handleOggAudioFileIniOS(file); + if (converted == null) { + throw Exception('OGG to CAF conversion failed'); + } + file = converted; } } @@ -251,6 +255,8 @@ class _ChatAudioPlayerWidgetState extends State widget.matrix?.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); + widget.matrix?.voiceMessageEvent.value = null; + widget.matrix?.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index 12da83c29b..96eba0580d 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -46,10 +46,7 @@ class AudioPlayerWidget extends StatefulWidget { enum AudioPlayerStatus { notDownloaded, downloading, downloaded } class AudioPlayerState extends State - with - AudioMixin, - AutomaticKeepAliveClientMixin, - EventFilterMixin { + with AudioMixin, AutomaticKeepAliveClientMixin, EventFilterMixin { final List _calculatedWaveform = []; final ValueNotifier _durationNotifier = @@ -102,6 +99,8 @@ class AudioPlayerState extends State } else { matrix.audioPlayer?.play().onError((e, s) { Logs().e('Could not play audio file', e, s); + matrix.voiceMessageEvent.value = null; + matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 934af5375a..7fb13e5151 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -422,7 +422,9 @@ mixin AudioMixin { final int effectiveWaveCount = min(waveCount, eventWaveForm.length); if (effectiveWaveCount == 1) { - return [eventWaveForm[eventWaveForm.length ~/ 2]]; + final single = eventWaveForm[eventWaveForm.length ~/ 2]; + final clamped = single == 0 ? 1 : (single > 1024 ? 1024 : single); + return [clamped]; } // Use interpolation-based sampling @@ -688,6 +690,7 @@ mixin AudioMixin { /// /// Should be called in the dispose method of the State class using this mixin. void disposeAudioPlayer() { + cleanupAudioPlayer(); _audioPlayerStateSubscription?.cancel(); audioPlayer?.dispose(); voiceMessageEvents.dispose(); From bbd89a896f47bcd9551d0a88f806ac0c52412fb8 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 19 Jan 2026 13:49:20 +0700 Subject: [PATCH 15/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/presentation/mixins/audio_mixin.dart | 111 ++++++++++++++--------- 1 file changed, 67 insertions(+), 44 deletions(-) diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 7fb13e5151..683354dc59 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -486,14 +486,22 @@ mixin AudioMixin { ); } - // Audio player methods + // Memory cache for audio data + final Map _audioMemoryCache = {}; + + Future _getAudioCacheFile(Event event) async { + final tempDir = await getTemporaryDirectory(); + final mxcUrl = event.attachmentOrThumbnailMxcUrl(); + if (mxcUrl == null) { + throw Exception('Event has no attachment URL'); + } + final fileName = Uri.encodeComponent(mxcUrl.pathSegments.last); + final attachmentName = event.content['body'] as String? ?? 'audio.ogg'; + return File('${tempDir.path}/${fileName}_$attachmentName'); + } /// Sets up the audio player with auto-dispose listener when playback /// completes. - /// - /// This method should be called after setting up a new audio source. - /// It automatically cleans up the audio player and resets state when - /// playback finishes. void setupAudioPlayerAutoDispose({ required BuildContext context, }) { @@ -505,8 +513,21 @@ mixin AudioMixin { 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', ); + // Delete file for the finished event + final finishedEvent = voiceMessageEvent.value; + if (finishedEvent != null && !kIsWeb) { + try { + final file = await _getAudioCacheFile(finishedEvent); + if (await file.exists()) { + await file.delete(); + Logs().d('Deleted temporary audio file: ${file.path}'); + } + } catch (e) { + Logs().w('Failed to delete temporary audio file', e); + } + } + if (voiceMessageEvents.value.isEmpty) { - // If no messages in queue, clean up everything await cleanupAudioPlayer(); return; } @@ -516,27 +537,19 @@ mixin AudioMixin { .where((e) => e.eventId != voiceMessageEvent.value?.eventId) .toList(); - Logs().d( - 'setupAudioPlayerAutoDispose: Remaining audio message - $updatedVoiceMessageEvent', - ); - voiceMessageEvents.value = updatedVoiceMessageEvent; - // Check if this was the last message if (voiceMessageEvents.value.isEmpty) { - Logs().d( - 'setupAudioPlayerAutoDispose: Last audio finished, cleaning up all state', - ); - // Clear all audio player state since this was the last message await cleanupAudioPlayer(); return; } - // There are more messages to play + // Play next voiceMessageEvent.value = null; currentAudioStatus.value = AudioPlayerStatus.notDownloaded; final nextAudioMessage = voiceMessageEvents.value.first; + if (!context.mounted) return; await autoPlayAudio( currentEvent: nextAudioMessage, context: context, @@ -545,10 +558,6 @@ mixin AudioMixin { }); } - /// Automatically plays an audio message. - /// - /// Downloads, decrypts, and plays the audio file for the given event. - /// Handles platform-specific audio format conversions (e.g., OGG to CAF on iOS). Future autoPlayAudio({ required BuildContext context, required Event currentEvent, @@ -559,32 +568,46 @@ mixin AudioMixin { currentAudioStatus.value = AudioPlayerStatus.downloading; try { - matrixFile = await currentEvent.downloadAndDecryptAttachment(); - if (!kIsWeb) { - final tempDir = await getTemporaryDirectory(); - final mxcUrl = currentEvent.attachmentOrThumbnailMxcUrl(); - if (mxcUrl == null) { - throw Exception('Event has no attachment URL'); + file = await _getAudioCacheFile(currentEvent); + + // 1. Check Memory Cache + if (_audioMemoryCache.containsKey(currentEvent.eventId)) { + Logs().d('AudioMixin: Hit memory cache for ${currentEvent.eventId}'); + // Write to file mostly for the player to read it (just_audio often prefers files) + // Or we could use StreamAudioSource, but writing to file allows iOS conversion checks. + // Since we delete file after playback, we recreate it from memory if needed. + await file.writeAsBytes(_audioMemoryCache[currentEvent.eventId]!); } - final fileName = Uri.encodeComponent( - mxcUrl.pathSegments.last, - ); - file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); - - if (matrixFile.bytes.isEmpty == true) { - throw Exception('Downloaded file has no content'); + // 2. Check File Cache + else if (await file.exists() && await file.length() > 0) { + Logs().d('AudioMixin: Hit disk cache for ${file.path}'); + // Populate memory cache + _audioMemoryCache[currentEvent.eventId] = await file.readAsBytes(); + } + // 3. Download + else { + Logs().d('AudioMixin: Downloading ${currentEvent.eventId}'); + matrixFile = await currentEvent.downloadAndDecryptAttachment(); + if (matrixFile.bytes.isEmpty) { + throw Exception('Downloaded file has no content'); + } + await file.writeAsBytes(matrixFile.bytes); + _audioMemoryCache[currentEvent.eventId] = matrixFile.bytes; } - await file.writeAsBytes(matrixFile.bytes); + final contentInfo = currentEvent.content['info']; + final mimeType = matrixFile?.mimeType ?? + (contentInfo is Map ? contentInfo['mimetype'] as String? : null); - if (Platform.isIOS && - matrixFile.mimeType.toLowerCase() == 'audio/ogg') { + if (Platform.isIOS && mimeType?.toLowerCase() == 'audio/ogg') { final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); if (oggAudioFileIniOS != null) { file = oggAudioFileIniOS; } } + } else { + matrixFile = await currentEvent.downloadAndDecryptAttachment(); } currentAudioStatus.value = AudioPlayerStatus.downloaded; @@ -602,7 +625,7 @@ mixin AudioMixin { } if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; - // Initialize audio player before use + // Initialize audio player await audioPlayer?.stop(); await audioPlayer?.dispose(); @@ -611,28 +634,28 @@ mixin AudioMixin { if (file != null) { await audioPlayer?.setFilePath(file.path); - } else { + } else if (matrixFile != null) { await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); } - // Set up auto-dispose listener managed globally in MatrixState + // Set up auto-dispose listener setupAudioPlayerAutoDispose(context: context); - audioPlayer?.play().onError((e, s) { + try { + await audioPlayer?.play(); + } catch (e, s) { Logs().e('Could not play audio file', e, s); - // Reset state on playback error voiceMessageEvent.value = null; currentAudioStatus.value = AudioPlayerStatus.notDownloaded; if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - e?.toLocalizedString(context) ?? - L10n.of(context)!.couldNotPlayAudioFile, + e.toLocalizedString(context), ), ), ); - }); + } } /// Converts OGG audio files to CAF format for iOS compatibility. From 8d24e672b99de57bbb8cfe5073b56c2531c1de8d Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 19 Jan 2026 14:12:22 +0700 Subject: [PATCH 16/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/presentation/mixins/audio_mixin.dart | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 683354dc59..be6745319d 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -496,8 +496,10 @@ mixin AudioMixin { throw Exception('Event has no attachment URL'); } final fileName = Uri.encodeComponent(mxcUrl.pathSegments.last); - final attachmentName = event.content['body'] as String? ?? 'audio.ogg'; - return File('${tempDir.path}/${fileName}_$attachmentName'); + final rawAttachmentName = event.content['body'] as String? ?? 'audio.ogg'; + final safeAttachmentName = + rawAttachmentName.replaceAll(RegExp(r'[\\\/]+'), '_'); + return File('${tempDir.path}/${fileName}_$safeAttachmentName'); } /// Sets up the audio player with auto-dispose listener when playback @@ -608,6 +610,9 @@ mixin AudioMixin { } } else { matrixFile = await currentEvent.downloadAndDecryptAttachment(); + if (matrixFile.bytes.isEmpty) { + throw Exception('Downloaded file has no content'); + } } currentAudioStatus.value = AudioPlayerStatus.downloaded; @@ -692,7 +697,7 @@ mixin AudioMixin { /// /// Should be called when logging out or switching accounts to ensure /// audio playback is properly stopped and state is reset. - Future cleanupAudioPlayer() async { + Future cleanupAudioPlayer({bool resetState = true}) async { try { await audioPlayer?.pause(); await audioPlayer?.stop(); @@ -700,9 +705,12 @@ mixin AudioMixin { audioPlayer = null; _audioPlayerStateSubscription?.cancel(); _audioPlayerStateSubscription = null; - voiceMessageEvents.value = []; - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + if (resetState) { + voiceMessageEvents.value = []; + voiceMessageEvent.value = null; + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + } + _audioMemoryCache.clear(); Logs().d('AudioMixin::cleanupAudioPlayer: Audio player cleaned up'); } catch (e) { Logs().e('AudioMixin::cleanupAudioPlayer: Error - $e'); @@ -713,9 +721,7 @@ mixin AudioMixin { /// /// Should be called in the dispose method of the State class using this mixin. void disposeAudioPlayer() { - cleanupAudioPlayer(); - _audioPlayerStateSubscription?.cancel(); - audioPlayer?.dispose(); + unawaited(cleanupAudioPlayer(resetState: false)); voiceMessageEvents.dispose(); voiceMessageEvent.dispose(); currentAudioStatus.dispose(); From a6b07d67e3e4ae31ac8eb60aeb12784e6972610d Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 19 Jan 2026 16:48:13 +0700 Subject: [PATCH 17/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/presentation/mixins/audio_mixin.dart | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index be6745319d..9924fc13f8 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -243,7 +243,7 @@ mixin AudioMixin { final completer = Completer(); - reader.onLoad.listen((_) { + final loadListener = reader.onLoad.listen((_) { final result = reader.result; if (result is Uint8List) { completer.complete(result); @@ -254,16 +254,21 @@ mixin AudioMixin { } }); - reader.onError.listen((event) { + final errorListener = reader.onError.listen((event) { completer.completeError('FileReader failed: ${reader.error}'); }); reader.readAsArrayBuffer(blob); - yield await completer.future.timeout( - const Duration(seconds: 30), - onTimeout: () => throw TimeoutException('Chunk reading timed out'), - ); + try { + yield await completer.future.timeout( + const Duration(seconds: 30), + onTimeout: () => throw TimeoutException('Chunk reading timed out'), + ); + } finally { + await loadListener.cancel(); + await errorListener.cancel(); + } offset = end; } From 2338f7fc76624152c25bc54cbc2a32c70d347be4 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 19 Jan 2026 16:55:44 +0700 Subject: [PATCH 18/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/presentation/mixins/audio_mixin.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 9924fc13f8..d097e7926f 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -620,6 +620,7 @@ mixin AudioMixin { } } + if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; currentAudioStatus.value = AudioPlayerStatus.downloaded; } catch (e, s) { Logs().e('Could not download audio file', e, s); @@ -633,7 +634,6 @@ mixin AudioMixin { currentAudioStatus.value = AudioPlayerStatus.notDownloaded; return; } - if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; // Initialize audio player await audioPlayer?.stop(); From a18d44b34a74b4d49f5b49d7366b9698dd98949a Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Thu, 22 Jan 2026 09:36:18 +0700 Subject: [PATCH 19/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/events/audio_message/audio_player_widget.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index 96eba0580d..b52412b09f 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -130,6 +130,7 @@ class AudioPlayerState extends State super.initState(); matrix = Matrix.of(context); WidgetsBinding.instance.addPostFrameCallback((_) { + matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; final durationInt = widget.event.content .tryGetMap('org.matrix.msc1767.audio') ?.tryGet('duration'); From 5a389125f99592140789d1180dd6430bcf6a9d02 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Thu, 22 Jan 2026 09:59:05 +0700 Subject: [PATCH 20/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/pages/chat/events/audio_message/audio_player_widget.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index b52412b09f..3d7f94a194 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -122,7 +122,7 @@ class AudioPlayerState extends State matrix.voiceMessageEvents.value = audioPending.events; - matrix.autoPlayAudio(currentEvent: widget.event, context: context); + await matrix.autoPlayAudio(currentEvent: widget.event, context: context); } @override @@ -171,9 +171,10 @@ class AudioPlayerState extends State @override void dispose() { if (!PlatformInfos.isMobile) { + final currentEventId = widget.event.eventId; WidgetsBinding.instance.addPostFrameCallback((_) async { // Only dispose if this event is currently playing - if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { + if (matrix.voiceMessageEvent.value?.eventId == currentEventId) { // Stop and dispose audio player asynchronously to avoid blocking // dispose final playerToDispose = matrix.audioPlayer; From cb5a40fe3944d32e7c1cef296d00aabe3fe746db Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Thu, 22 Jan 2026 10:09:00 +0700 Subject: [PATCH 21/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- .../audio_message/audio_player_widget.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index 3d7f94a194..e7e786c92a 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -130,7 +130,11 @@ class AudioPlayerState extends State super.initState(); matrix = Matrix.of(context); WidgetsBinding.instance.addPostFrameCallback((_) { - matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + final isCurrentEvent = + matrix.voiceMessageEvent.value?.eventId == widget.event.eventId; + if (matrix.voiceMessageEvent.value == null || isCurrentEvent) { + matrix.currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + } final durationInt = widget.event.content .tryGetMap('org.matrix.msc1767.audio') ?.tryGet('duration'); @@ -143,9 +147,9 @@ class AudioPlayerState extends State ?.tryGetList('waveform'), waveCount: calculateWaveCountAuto( minWaves: AudioPlayerStyle.minWaveCount, - maxWaves: AudioPlayerStyle.maxWaveCount( - context, - ), + maxWaves: _calculatedWaveform.isEmpty + ? AudioPlayerStyle.maxWaveCount(context) + : _calculatedWaveform.length, durationInSeconds: _durationNotifier.value.inSeconds, ), ) ?? @@ -159,7 +163,9 @@ class AudioPlayerState extends State if (_calculatedWaveform.isEmpty) { _calculatedWaveform.addAll(waveFromHeight); - matrix.currentAudioStatus.value = AudioPlayerStatus.downloaded; + if (matrix.voiceMessageEvent.value == null || isCurrentEvent) { + matrix.currentAudioStatus.value = AudioPlayerStatus.downloaded; + } } if (matrix.voiceMessageEvent.value?.eventId == widget.event.eventId) { From b7a6e5bb46d0079e4dbbd8c628beab71f73fc43a Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 9 Feb 2026 14:34:17 +0700 Subject: [PATCH 22/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/widgets/mixins/audio_player_mixin.dart | 235 --------------------- 1 file changed, 235 deletions(-) delete mode 100644 lib/widgets/mixins/audio_player_mixin.dart diff --git a/lib/widgets/mixins/audio_player_mixin.dart b/lib/widgets/mixins/audio_player_mixin.dart deleted file mode 100644 index 4f9dee7082..0000000000 --- a/lib/widgets/mixins/audio_player_mixin.dart +++ /dev/null @@ -1,235 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:fluffychat/generated/l10n/app_localizations.dart'; -import 'package:fluffychat/pages/chat/events/audio_message/audio_play_extension.dart'; -import 'package:fluffychat/pages/chat/events/audio_message/audio_player_widget.dart'; -import 'package:fluffychat/utils/localized_exception_extension.dart'; -import 'package:just_audio/just_audio.dart'; -import 'package:matrix/matrix.dart'; -import 'package:opus_caf_converter_dart/opus_caf_converter_dart.dart'; -import 'package:path_provider/path_provider.dart'; - -/// Mixin that provides audio player functionality for voice messages. -/// -/// This mixin manages audio playback state, auto-play queue, and cleanup. -/// It should be used with State classes that need audio playback capabilities. -mixin AudioPlayerMixin on State { - AudioPlayer? audioPlayer; - - final ValueNotifier voiceMessageEvent = ValueNotifier(null); - - final ValueNotifier> voiceMessageEvents = ValueNotifier([]); - - final ValueNotifier currentAudioStatus = - ValueNotifier(AudioPlayerStatus.notDownloaded); - - StreamSubscription? _audioPlayerStateSubscription; - - /// Sets up the audio player with auto-dispose listener when playback - /// completes. - /// - /// This method should be called after setting up a new audio source. - /// It automatically cleans up the audio player and resets state when - /// playback finishes. - void setupAudioPlayerAutoDispose() { - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = - audioPlayer?.playerStateStream.listen((state) async { - if (state.processingState == ProcessingState.completed) { - Logs().d( - 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', - ); - - if (voiceMessageEvents.value.isEmpty) { - // If no messages in queue, clean up everything - await cleanupAudioPlayer(); - return; - } - - // Remove the completed message from the list - final updatedVoiceMessageEvent = voiceMessageEvents.value - .where((e) => e.eventId != voiceMessageEvent.value?.eventId) - .toList(); - - Logs().d( - 'setupAudioPlayerAutoDispose: Remaining audio message - $updatedVoiceMessageEvent', - ); - - voiceMessageEvents.value = updatedVoiceMessageEvent; - - // Check if this was the last message - if (voiceMessageEvents.value.isEmpty) { - Logs().d( - 'setupAudioPlayerAutoDispose: Last audio finished, cleaning up all state', - ); - // Clear all audio player state since this was the last message - await cleanupAudioPlayer(); - return; - } - - // There are more messages to play - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - - final nextAudioMessage = voiceMessageEvents.value.first; - await autoPlayAudio( - currentEvent: nextAudioMessage, - ); - } - }); - } - - /// Automatically plays an audio message. - /// - /// Downloads, decrypts, and plays the audio file for the given event. - /// Handles platform-specific audio format conversions (e.g., OGG to CAF on iOS). - Future autoPlayAudio({ - required Event currentEvent, - }) async { - voiceMessageEvent.value = currentEvent; - File? file; - MatrixFile? matrixFile; - - currentAudioStatus.value = AudioPlayerStatus.downloading; - try { - matrixFile = await currentEvent.downloadAndDecryptAttachment(); - - if (!kIsWeb) { - final tempDir = await getTemporaryDirectory(); - final mxcUrl = currentEvent.attachmentOrThumbnailMxcUrl(); - if (mxcUrl == null) { - throw Exception('Event has no attachment URL'); - } - final fileName = Uri.encodeComponent( - mxcUrl.pathSegments.last, - ); - file = File('${tempDir.path}/${fileName}_${matrixFile.name}'); - - if (matrixFile.bytes.isEmpty == true) { - throw Exception('Downloaded file has no content'); - } - - await file.writeAsBytes(matrixFile.bytes); - - if (Platform.isIOS && - matrixFile.mimeType.toLowerCase() == 'audio/ogg') { - final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); - if (oggAudioFileIniOS != null) { - file = oggAudioFileIniOS; - } - } - } - - currentAudioStatus.value = AudioPlayerStatus.downloaded; - } catch (e, s) { - Logs().e('Could not download audio file', e, s); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(e.toLocalizedString(context)), - ), - ); - } - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - return; - } - if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; - - // Initialize audio player before use - await audioPlayer?.stop(); - await audioPlayer?.dispose(); - - audioPlayer = AudioPlayer(); - voiceMessageEvent.value = currentEvent; - - if (file != null) { - await audioPlayer?.setFilePath(file.path); - } else { - await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); - } - - // Set up auto-dispose listener managed globally in MatrixState - setupAudioPlayerAutoDispose(); - - audioPlayer?.play().onError((e, s) { - Logs().e('Could not play audio file', e, s); - // Reset state on playback error - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - if (!context.mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - e?.toLocalizedString(context) ?? - L10n.of(context)!.couldNotPlayAudioFile, - ), - ), - ); - }); - } - - /// Converts OGG audio files to CAF format for iOS compatibility. - /// - /// Returns the converted file if successful, null otherwise. - Future handleOggAudioFileIniOS(File file) async { - try { - Logs().v('Convert ogg audio file for iOS...'); - final convertedFile = File('${file.path}.caf'); - if (await convertedFile.exists() == false) { - OpusCaf().convertOpusToCaf(file.path, convertedFile.path); - // Verify conversion succeeded - if (await convertedFile.exists() == false) { - Logs().w( - 'AudioPlayerMixin::handleOggAudioFileIniOS: OGG to CAF conversion failed - converted file does not exist', - ); - return null; - } - } - return convertedFile; - } catch (e, s) { - Logs().e('Could not convert ogg audio file for iOS', e, s); - return null; - } - } - - /// Cancels the audio player auto-dispose subscription. - void cancelAudioPlayerAutoDispose() { - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = null; - } - - /// Cleans up the audio player and clears playing lists. - /// - /// Should be called when logging out or switching accounts to ensure - /// audio playback is properly stopped and state is reset. - Future cleanupAudioPlayer() async { - try { - await audioPlayer?.pause(); - await audioPlayer?.stop(); - await audioPlayer?.dispose(); - audioPlayer = null; - _audioPlayerStateSubscription?.cancel(); - _audioPlayerStateSubscription = null; - voiceMessageEvents.value = []; - voiceMessageEvent.value = null; - currentAudioStatus.value = AudioPlayerStatus.notDownloaded; - Logs().d('AudioPlayerMixin::cleanupAudioPlayer: Audio player cleaned up'); - } catch (e) { - Logs().e('AudioPlayerMixin::cleanupAudioPlayer: Error - $e'); - } - } - - /// Disposes audio player resources. - /// - /// Should be called in the dispose method of the State class using this mixin. - void disposeAudioPlayer() { - _audioPlayerStateSubscription?.cancel(); - audioPlayer?.dispose(); - voiceMessageEvents.dispose(); - voiceMessageEvent.dispose(); - currentAudioStatus.dispose(); - } -} From 2b6e3d5841462d141f4c3f6dd141ba33ca7bde93 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Mon, 9 Feb 2026 17:29:56 +0700 Subject: [PATCH 23/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- .../audio_message/audio_player_style.dart | 17 +- .../audio_message/audio_player_widget.dart | 4 +- lib/presentation/mixins/audio_mixin.dart | 244 ++++++++++++++---- .../mixins/event_filter_mixin.dart | 12 +- .../presentation/mixins/audio_mixin_test.dart | 231 ++++++++++++++++- 5 files changed, 441 insertions(+), 67 deletions(-) diff --git a/lib/pages/chat/events/audio_message/audio_player_style.dart b/lib/pages/chat/events/audio_message/audio_player_style.dart index b3da71509f..f23e4f3521 100644 --- a/lib/pages/chat/events/audio_message/audio_player_style.dart +++ b/lib/pages/chat/events/audio_message/audio_player_style.dart @@ -7,11 +7,22 @@ class AudioPlayerStyle { static const maxWaveWidth = 4.0; - static const maxBodyContentWidth = 88.0; + // UI element widths that take up space in the audio player + static const playButtonWidth = 40.0; + static const spacingAfterButton = 4.0; + static const paddingLeft = 32.0; + static const paddingRight = 32.0; static int maxWaveCount(BuildContext context) { - return (MessageStyle.messageBubbleWidth(context) - maxBodyContentWidth) ~/ - maxWaveWidth; + // Calculate available width for waveform: + // messageBubbleWidth - padding - playButton - spacing - safetyMargin + final availableWidth = MessageStyle.messageBubbleWidth(context) - + paddingLeft - + paddingRight - + playButtonWidth - + spacingAfterButton; + + return availableWidth ~/ maxWaveWidth; } static const minWaveHeight = 4.0; diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index e7e786c92a..1edd9e67e2 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -141,6 +141,7 @@ class AudioPlayerState extends State if (durationInt != null) { _durationNotifier.value = Duration(milliseconds: durationInt); } + final maxBubbleWaves = AudioPlayerStyle.maxWaveCount(context); final waveForm = calculateWaveForm( eventWaveForm: widget.event.content .tryGetMap('org.matrix.msc1767.audio') @@ -148,10 +149,11 @@ class AudioPlayerState extends State waveCount: calculateWaveCountAuto( minWaves: AudioPlayerStyle.minWaveCount, maxWaves: _calculatedWaveform.isEmpty - ? AudioPlayerStyle.maxWaveCount(context) + ? maxBubbleWaves : _calculatedWaveform.length, durationInSeconds: _durationNotifier.value.inSeconds, ), + maxBubbleWaveCount: maxBubbleWaves, ) ?? []; diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index d097e7926f..618760eedd 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -416,27 +416,32 @@ mixin AudioMixin { List? calculateWaveForm({ required List? eventWaveForm, required int waveCount, + int? maxBubbleWaveCount, }) { // Handle edge cases if (eventWaveForm == null || eventWaveForm.isEmpty || waveCount <= 0) { return null; } - // Ensure the result will not exceed waveCount - // Use the minimum of waveCount and eventWaveForm.length - final int effectiveWaveCount = min(waveCount, eventWaveForm.length); + // If maxBubbleWaveCount is provided and waveCount exceeds it, cap it + int adjustedWaveCount = waveCount; + if (maxBubbleWaveCount != null && waveCount > maxBubbleWaveCount) { + adjustedWaveCount = maxBubbleWaveCount; + } - if (effectiveWaveCount == 1) { + // Use adjustedWaveCount as the target number of output waves + // Interpolation will generate the requested number from input data + if (adjustedWaveCount == 1) { final single = eventWaveForm[eventWaveForm.length ~/ 2]; final clamped = single == 0 ? 1 : (single > 1024 ? 1024 : single); return [clamped]; } - // Use interpolation-based sampling + // Use interpolation-based sampling to generate exactly adjustedWaveCount values final List result = []; - final double step = (eventWaveForm.length - 1) / (effectiveWaveCount - 1); + final double step = (eventWaveForm.length - 1) / (adjustedWaveCount - 1); - for (int i = 0; i < effectiveWaveCount; i++) { + for (int i = 0; i < adjustedWaveCount; i++) { final double exactIndex = i * step; final int lowerIndex = exactIndex.floor(); final int upperIndex = @@ -491,8 +496,99 @@ mixin AudioMixin { ); } - // Memory cache for audio data + // Memory cache for audio data with LRU eviction + static const _maxCachedAudioItems = 20; + static const _maxCacheMemoryBytes = 50 * 1024 * 1024; // 50MB + final Map _audioMemoryCache = {}; + final List _audioCacheAccessOrder = []; + int _currentCacheMemoryBytes = 0; + + /// Checks if audio is cached in memory. + bool isAudioCached(String eventId) { + return _audioMemoryCache.containsKey(eventId); + } + + /// Gets cached audio data with LRU tracking. + @visibleForTesting + Uint8List? getFromAudioCache(String eventId) { + final data = _audioMemoryCache[eventId]; + if (data != null) { + // Validate cached data isn't corrupted + const minValidAudioSize = 1024; + if (data.length < minValidAudioSize) { + Logs().w( + 'AudioMixin: Removing corrupted cache entry for $eventId ' + '(${data.length} bytes)', + ); + _removeFromAudioCache(eventId); + return null; + } + + _audioCacheAccessOrder.remove(eventId); + _audioCacheAccessOrder.add(eventId); + Logs().v('AudioMixin: LRU cache hit for $eventId'); + } + return data; + } + + /// Removes a specific item from the audio cache. + void _removeFromAudioCache(String eventId) { + final data = _audioMemoryCache.remove(eventId); + if (data != null) { + _currentCacheMemoryBytes -= data.length; + _audioCacheAccessOrder.remove(eventId); + Logs().v('AudioMixin: Removed $eventId from cache'); + } + } + + /// Stores audio data in cache with LRU eviction. + @visibleForTesting + void putInAudioCache(String eventId, Uint8List data) { + if (_audioMemoryCache.containsKey(eventId)) { + _currentCacheMemoryBytes -= _audioMemoryCache[eventId]!.length; + _audioCacheAccessOrder.remove(eventId); + } + + while ((_audioMemoryCache.length >= _maxCachedAudioItems || + _currentCacheMemoryBytes + data.length > _maxCacheMemoryBytes) && + _audioCacheAccessOrder.isNotEmpty) { + _evictOldestAudioFromCache(); + } + + _audioMemoryCache[eventId] = data; + _audioCacheAccessOrder.add(eventId); + _currentCacheMemoryBytes += data.length; + + Logs().d( + 'AudioMixin: Cached $eventId (${data.length} bytes). ' + 'Cache: ${_audioMemoryCache.length}/$_maxCachedAudioItems items, ' + '${_formatCacheBytes(_currentCacheMemoryBytes)}/' + '${_formatCacheBytes(_maxCacheMemoryBytes)}', + ); + } + + /// Evicts the least recently used audio from cache. + void _evictOldestAudioFromCache() { + if (_audioCacheAccessOrder.isEmpty) return; + final oldestKey = _audioCacheAccessOrder.removeAt(0); + final data = _audioMemoryCache.remove(oldestKey); + if (data != null) { + _currentCacheMemoryBytes -= data.length; + Logs().d( + 'AudioMixin: Evicted $oldestKey (${data.length} bytes) - LRU', + ); + } + } + + /// Formats bytes into human-readable string. + String _formatCacheBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } Future _getAudioCacheFile(Event event) async { final tempDir = await getTemporaryDirectory(); @@ -520,19 +616,7 @@ mixin AudioMixin { 'setupAudioPlayerAutoDispose: Current audio message - ${voiceMessageEvents.value}', ); - // Delete file for the finished event - final finishedEvent = voiceMessageEvent.value; - if (finishedEvent != null && !kIsWeb) { - try { - final file = await _getAudioCacheFile(finishedEvent); - if (await file.exists()) { - await file.delete(); - Logs().d('Deleted temporary audio file: ${file.path}'); - } - } catch (e) { - Logs().w('Failed to delete temporary audio file', e); - } - } + // Note: We keep disk cache files for future plays if (voiceMessageEvents.value.isEmpty) { await cleanupAudioPlayer(); @@ -570,54 +654,102 @@ mixin AudioMixin { required Event currentEvent, }) async { voiceMessageEvent.value = currentEvent; - File? file; MatrixFile? matrixFile; + Uint8List? audioBytes; + String? mimeType; currentAudioStatus.value = AudioPlayerStatus.downloading; try { + // Get mime type from event content + final contentInfo = currentEvent.content['info']; + mimeType = contentInfo is Map ? contentInfo['mimetype'] as String? : null; + if (!kIsWeb) { - file = await _getAudioCacheFile(currentEvent); - - // 1. Check Memory Cache - if (_audioMemoryCache.containsKey(currentEvent.eventId)) { - Logs().d('AudioMixin: Hit memory cache for ${currentEvent.eventId}'); - // Write to file mostly for the player to read it (just_audio often prefers files) - // Or we could use StreamAudioSource, but writing to file allows iOS conversion checks. - // Since we delete file after playback, we recreate it from memory if needed. - await file.writeAsBytes(_audioMemoryCache[currentEvent.eventId]!); - } - // 2. Check File Cache - else if (await file.exists() && await file.length() > 0) { - Logs().d('AudioMixin: Hit disk cache for ${file.path}'); - // Populate memory cache - _audioMemoryCache[currentEvent.eventId] = await file.readAsBytes(); - } - // 3. Download - else { - Logs().d('AudioMixin: Downloading ${currentEvent.eventId}'); - matrixFile = await currentEvent.downloadAndDecryptAttachment(); - if (matrixFile.bytes.isEmpty) { - throw Exception('Downloaded file has no content'); + // 1. Check Memory Cache (LRU) + final cachedData = getFromAudioCache(currentEvent.eventId); + if (cachedData != null) { + Logs().d( + 'AudioMixin: Hit memory cache for ${currentEvent.eventId} ' + '(${cachedData.length} bytes)', + ); + audioBytes = cachedData; + } else { + // 2. Check File Cache + final file = await _getAudioCacheFile(currentEvent); + if (await file.exists() && await file.length() > 0) { + final diskFileSize = await file.length(); + Logs().d( + 'AudioMixin: Hit disk cache for ${file.path} ($diskFileSize bytes)', + ); + + // Validate disk cache file size + if (diskFileSize < 1024) { + Logs().w( + 'AudioMixin: Disk cache file too small ($diskFileSize bytes), ' + 'deleting and re-downloading', + ); + await file.delete(); + } else { + audioBytes = await file.readAsBytes(); + putInAudioCache(currentEvent.eventId, audioBytes); + } } - await file.writeAsBytes(matrixFile.bytes); - _audioMemoryCache[currentEvent.eventId] = matrixFile.bytes; - } - final contentInfo = currentEvent.content['info']; - final mimeType = matrixFile?.mimeType ?? - (contentInfo is Map ? contentInfo['mimetype'] as String? : null); + // 3. Download (if not cached or cache was invalid) + if (audioBytes == null) { + Logs().d('AudioMixin: Downloading ${currentEvent.eventId}'); + matrixFile = await currentEvent.downloadAndDecryptAttachment(); + + Logs().d( + 'AudioMixin: Downloaded ${matrixFile.bytes.length} bytes, ' + 'MIME: ${matrixFile.mimeType}', + ); + + if (matrixFile.bytes.isEmpty) { + throw Exception('Downloaded file has no content'); + } + audioBytes = matrixFile.bytes; + mimeType = matrixFile.mimeType; + + // Cache the downloaded bytes + await file.writeAsBytes(audioBytes); + putInAudioCache(currentEvent.eventId, audioBytes); + } + } + + // iOS OGG to CAF conversion (if needed) if (Platform.isIOS && mimeType?.toLowerCase() == 'audio/ogg') { - final oggAudioFileIniOS = await handleOggAudioFileIniOS(file); + Logs().d('AudioMixin: Converting OGG to CAF for iOS...'); + // Write to temp file for conversion + final tempFile = await _getAudioCacheFile(currentEvent); + await tempFile.writeAsBytes(audioBytes); + final oggAudioFileIniOS = await handleOggAudioFileIniOS(tempFile); if (oggAudioFileIniOS != null) { - file = oggAudioFileIniOS; + audioBytes = await oggAudioFileIniOS.readAsBytes(); + mimeType = 'audio/x-caf'; + Logs().d('AudioMixin: Conversion successful'); + } else { + Logs().w('AudioMixin: OGG to CAF conversion failed'); } } + + // Create MatrixFile from cached bytes + matrixFile = MatrixFile( + bytes: audioBytes, + name: currentEvent.content['body'] as String? ?? 'audio.ogg', + mimeType: mimeType ?? 'application/octet-stream', + ); } else { + // Web: always download matrixFile = await currentEvent.downloadAndDecryptAttachment(); if (matrixFile.bytes.isEmpty) { throw Exception('Downloaded file has no content'); } + Logs().d( + 'AudioMixin: Downloaded ${matrixFile.bytes.length} bytes, ' + 'MIME: ${matrixFile.mimeType}', + ); } if (voiceMessageEvent.value?.eventId != currentEvent.eventId) return; @@ -642,11 +774,7 @@ mixin AudioMixin { audioPlayer = AudioPlayer(); voiceMessageEvent.value = currentEvent; - if (file != null) { - await audioPlayer?.setFilePath(file.path); - } else if (matrixFile != null) { - await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); - } + await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); // Set up auto-dispose listener setupAudioPlayerAutoDispose(context: context); @@ -716,6 +844,8 @@ mixin AudioMixin { currentAudioStatus.value = AudioPlayerStatus.notDownloaded; } _audioMemoryCache.clear(); + _audioCacheAccessOrder.clear(); + _currentCacheMemoryBytes = 0; Logs().d('AudioMixin::cleanupAudioPlayer: Audio player cleaned up'); } catch (e) { Logs().e('AudioMixin::cleanupAudioPlayer: Error - $e'); diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart index 73d2c09eea..022332a0ed 100644 --- a/lib/presentation/mixins/event_filter_mixin.dart +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -365,8 +365,18 @@ mixin EventFilterMixin { return []; } + // Get clicked event's timestamp + final clickedTimestamp = clickedEvent.originServerTs.millisecondsSinceEpoch; + // Return from clicked event onwards for sequential playback - return reversedEvents.sublist(clickedIndex); + final eventsFromClicked = reversedEvents.sublist(clickedIndex); + + // Filter by timestamp: only keep clicked event + events with newer timestamps + final filteredByTimestamp = eventsFromClicked.where((event) { + return event.originServerTs.millisecondsSinceEpoch >= clickedTimestamp; + }).toList(); + + return filteredByTimestamp; } /// Loads and processes initial audio events with automatic expansion. diff --git a/test/presentation/mixins/audio_mixin_test.dart b/test/presentation/mixins/audio_mixin_test.dart index 3c52195d09..d51b4ac8d0 100644 --- a/test/presentation/mixins/audio_mixin_test.dart +++ b/test/presentation/mixins/audio_mixin_test.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + import 'package:flutter_test/flutter_test.dart'; import 'package:fluffychat/presentation/mixins/audio_mixin.dart'; @@ -282,7 +284,7 @@ void main() { }); test( - 'should clamp waveCount to eventWaveForm.length when waveCount exceeds it', + 'should interpolate to generate waveCount values when waveCount exceeds input length', () { // Arrange const waveform = [100, 200, 300, 400, 500]; // 5 elements @@ -290,12 +292,16 @@ void main() { // Act final result = audioMixin.calculateWaveForm( eventWaveForm: waveform, - waveCount: 10, // Request 10 but only 5 available + waveCount: 10, // Request 10 from 5 input values ); // Assert - expect(result!.length, 5); // Should be clamped to waveform length - expect(result, waveform); + expect(result!.length, 10); // Should interpolate to 10 values + // First and last should match input + expect(result.first, 100); + expect(result.last, 500); + // Middle values should be interpolated + expect(result[5], closeTo(300, 50)); // ~middle value }); test('should clamp values correctly', () { @@ -327,7 +333,8 @@ void main() { ); // Assert - expect(result, [300]); // Can't exceed source length + // With 1 input value, all output values will be the same + expect(result, [300, 300, 300]); }); test('should downsample correctly', () { @@ -345,6 +352,74 @@ void main() { expect(result[0], 100); // First value expect(result[2], 900); // Last value }); + + test('should cap waveCount when maxBubbleWaveCount is exceeded', () { + // Arrange + const waveform = [100, 200, 300, 400, 500, 600, 700, 800]; + const requestedWaveCount = 10; + const maxBubbleWaveCount = 5; + + // Act + final result = audioMixin.calculateWaveForm( + eventWaveForm: waveform, + waveCount: requestedWaveCount, + maxBubbleWaveCount: maxBubbleWaveCount, + ); + + // Assert + expect(result!.length, maxBubbleWaveCount); + }); + + test('should not cap waveCount when below maxBubbleWaveCount', () { + // Arrange + const waveform = [100, 200, 300, 400, 500, 600, 700, 800]; + const requestedWaveCount = 3; + const maxBubbleWaveCount = 5; + + // Act + final result = audioMixin.calculateWaveForm( + eventWaveForm: waveform, + waveCount: requestedWaveCount, + maxBubbleWaveCount: maxBubbleWaveCount, + ); + + // Assert + expect(result!.length, requestedWaveCount); + }); + + test('should use exact maxBubbleWaveCount when requested equals max', () { + // Arrange + const waveform = [100, 200, 300, 400, 500, 600, 700, 800]; + const requestedWaveCount = 5; + const maxBubbleWaveCount = 5; + + // Act + final result = audioMixin.calculateWaveForm( + eventWaveForm: waveform, + waveCount: requestedWaveCount, + maxBubbleWaveCount: maxBubbleWaveCount, + ); + + // Assert + expect(result!.length, 5); + }); + + test('should ignore maxBubbleWaveCount when null', () { + // Arrange + const waveform = [100, 200, 300, 400, 500]; + const requestedWaveCount = 10; + + // Act + final result = audioMixin.calculateWaveForm( + eventWaveForm: waveform, + waveCount: requestedWaveCount, + maxBubbleWaveCount: null, + ); + + // Assert + // Should interpolate to requested count when no max is set + expect(result!.length, requestedWaveCount); + }); }); group('calculateWaveHeight', () { @@ -480,4 +555,150 @@ void main() { expect(result[1], 37.5); // 75 (max value) maps to maxHeight }); }); + + group('Audio LRU Cache', () { + test('should cache and retrieve audio data', () { + // Arrange + // Create data larger than 1KB minimum + final testData = Uint8List.fromList(List.generate(2048, (i) => i % 256)); + const eventId = 'test_event_1'; + + // Act + audioMixin.putInAudioCache(eventId, testData); + final result = audioMixin.getFromAudioCache(eventId); + + // Assert + expect(result, testData); + }); + + test('should return null for non-existent cache key', () { + // Act + final result = audioMixin.getFromAudioCache('non_existent'); + + // Assert + expect(result, null); + }); + + test('should update access order when retrieving cached data', () { + // Arrange + final data1 = Uint8List.fromList(List.generate(2048, (i) => 1)); + final data2 = Uint8List.fromList(List.generate(2048, (i) => 2)); + final data3 = Uint8List.fromList(List.generate(2048, (i) => 3)); + + // Act + audioMixin.putInAudioCache('event1', data1); + audioMixin.putInAudioCache('event2', data2); + audioMixin.putInAudioCache('event3', data3); + + // Access event1 to move it to the end + audioMixin.getFromAudioCache('event1'); + + // Assert - event1 should still be accessible + expect(audioMixin.getFromAudioCache('event1'), data1); + }); + + test('should evict oldest item when max items limit is reached', () { + // Arrange - Create valid test data (>1KB) + final smallData = Uint8List.fromList(List.generate(2048, (i) => i % 256)); + + // Act - Add 21 items (max is 20) + for (var i = 0; i < 21; i++) { + audioMixin.putInAudioCache('event_$i', smallData); + } + + // Assert - First item should be evicted + expect(audioMixin.getFromAudioCache('event_0'), null); + // Last item should still exist + expect(audioMixin.getFromAudioCache('event_20'), smallData); + }); + + test('should evict items when memory limit would be exceeded', () { + // Arrange - Create large data (10MB each) + final largeData = Uint8List(10 * 1024 * 1024); + + // Act - Add 6 items (6 * 10MB = 60MB, exceeds 50MB limit) + for (var i = 0; i < 6; i++) { + audioMixin.putInAudioCache('large_event_$i', largeData); + } + + // Assert - Early items should be evicted to stay under memory limit + expect(audioMixin.getFromAudioCache('large_event_0'), null); + // More recent items should exist + expect(audioMixin.getFromAudioCache('large_event_5'), isNotNull); + }); + + test('should preserve most recently accessed items during eviction', () { + // Arrange + final data = Uint8List.fromList(List.generate(2048, (i) => i % 256)); + + // Act - Add enough items to fill cache + for (var i = 1; i <= 19; i++) { + audioMixin.putInAudioCache('event_$i', data); + } + + // Access event_1 to make it most recent + audioMixin.getFromAudioCache('event_1'); + + // Add 2 more items to trigger eviction (total would be 21, max is 20) + audioMixin.putInAudioCache('event_20', data); + audioMixin.putInAudioCache('event_21', data); + + // Assert - event_1 should be preserved as it was accessed recently + // event_2 should be evicted as it was least recent + // event_3 should still be in cache (only one eviction needed) + expect(audioMixin.getFromAudioCache('event_2'), null); + expect(audioMixin.getFromAudioCache('event_3'), isNotNull); + expect(audioMixin.getFromAudioCache('event_1'), isNotNull); + expect(audioMixin.getFromAudioCache('event_20'), isNotNull); + expect(audioMixin.getFromAudioCache('event_21'), isNotNull); + }); + + test('should update existing cache entry without duplication', () { + // Arrange + final data1 = Uint8List.fromList(List.generate(2048, (i) => 1)); + final data2 = Uint8List.fromList(List.generate(3072, (i) => 2)); + const eventId = 'same_event'; + + // Act + audioMixin.putInAudioCache(eventId, data1); + audioMixin.putInAudioCache(eventId, data2); // Update with new data + + // Assert - Should have the updated data + final result = audioMixin.getFromAudioCache(eventId); + expect(result, data2); + expect(result!.length, 3072); + }); + + test('should reject cache entries that are too small', () { + // Arrange + final tooSmallData = Uint8List.fromList([1, 2, 3, 4, 5]); // Only 5 bytes + const eventId = 'small_event'; + + // Act + audioMixin.putInAudioCache(eventId, tooSmallData); + final result = audioMixin.getFromAudioCache(eventId); + + // Assert - Should not be cached (putInAudioCache rejects it) + expect(result, null); + }); + + test('should clear all cache data on cleanup', () { + // Arrange + final data = Uint8List.fromList(List.generate(2048, (i) => i % 256)); + audioMixin.putInAudioCache('event_1', data); + audioMixin.putInAudioCache('event_2', data); + audioMixin.putInAudioCache('event_3', data); + + // Act + audioMixin.cleanupAudioPlayer(); + + // Give async cleanup time to complete + return Future.delayed(const Duration(milliseconds: 100), () { + // Assert - All cache should be cleared + expect(audioMixin.getFromAudioCache('event_1'), null); + expect(audioMixin.getFromAudioCache('event_2'), null); + expect(audioMixin.getFromAudioCache('event_3'), null); + }); + }); + }); } From d4dd228ee89e0ff882f5d0bc87b5d0ed70973f2e Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Tue, 10 Feb 2026 11:46:50 +0700 Subject: [PATCH 24/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- .../audio_message/audio_player_style.dart | 6 ++-- lib/presentation/mixins/audio_mixin.dart | 31 +++++++------------ .../presentation/mixins/audio_mixin_test.dart | 19 +++++------- .../mixins/event_filter_mixin_test.dart | 6 ++-- 4 files changed, 26 insertions(+), 36 deletions(-) diff --git a/lib/pages/chat/events/audio_message/audio_player_style.dart b/lib/pages/chat/events/audio_message/audio_player_style.dart index f23e4f3521..841cc5c3ec 100644 --- a/lib/pages/chat/events/audio_message/audio_player_style.dart +++ b/lib/pages/chat/events/audio_message/audio_player_style.dart @@ -15,14 +15,16 @@ class AudioPlayerStyle { static int maxWaveCount(BuildContext context) { // Calculate available width for waveform: - // messageBubbleWidth - padding - playButton - spacing - safetyMargin + // messageBubbleWidth - padding - playButton - spacing final availableWidth = MessageStyle.messageBubbleWidth(context) - paddingLeft - paddingRight - playButtonWidth - spacingAfterButton; - return availableWidth ~/ maxWaveWidth; + return (availableWidth ~/ maxWaveWidth) + .clamp(minWaveCount, double.infinity) + .toInt(); } static const minWaveHeight = 4.0; diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 618760eedd..476107260e 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -514,14 +514,14 @@ mixin AudioMixin { Uint8List? getFromAudioCache(String eventId) { final data = _audioMemoryCache[eventId]; if (data != null) { - // Validate cached data isn't corrupted - const minValidAudioSize = 1024; - if (data.length < minValidAudioSize) { + // Validate size (reject entries < 1KB, same as disk cache validation) + if (data.length < 1024) { Logs().w( - 'AudioMixin: Removing corrupted cache entry for $eventId ' - '(${data.length} bytes)', + 'AudioMixin: Memory cache entry too small (${data.length} bytes), removing', ); - _removeFromAudioCache(eventId); + _audioMemoryCache.remove(eventId); + _audioCacheAccessOrder.remove(eventId); + _currentCacheMemoryBytes -= data.length; return null; } @@ -532,16 +532,6 @@ mixin AudioMixin { return data; } - /// Removes a specific item from the audio cache. - void _removeFromAudioCache(String eventId) { - final data = _audioMemoryCache.remove(eventId); - if (data != null) { - _currentCacheMemoryBytes -= data.length; - _audioCacheAccessOrder.remove(eventId); - Logs().v('AudioMixin: Removed $eventId from cache'); - } - } - /// Stores audio data in cache with LRU eviction. @visibleForTesting void putInAudioCache(String eventId, Uint8List data) { @@ -728,6 +718,7 @@ mixin AudioMixin { if (oggAudioFileIniOS != null) { audioBytes = await oggAudioFileIniOS.readAsBytes(); mimeType = 'audio/x-caf'; + putInAudioCache(currentEvent.eventId, audioBytes); Logs().d('AudioMixin: Conversion successful'); } else { Logs().w('AudioMixin: OGG to CAF conversion failed'); @@ -774,12 +765,12 @@ mixin AudioMixin { audioPlayer = AudioPlayer(); voiceMessageEvent.value = currentEvent; - await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); + try { + await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); - // Set up auto-dispose listener - setupAudioPlayerAutoDispose(context: context); + // Set up auto-dispose listener + setupAudioPlayerAutoDispose(context: context); - try { await audioPlayer?.play(); } catch (e, s) { Logs().e('Could not play audio file', e, s); diff --git a/test/presentation/mixins/audio_mixin_test.dart b/test/presentation/mixins/audio_mixin_test.dart index d51b4ac8d0..29089fa354 100644 --- a/test/presentation/mixins/audio_mixin_test.dart +++ b/test/presentation/mixins/audio_mixin_test.dart @@ -678,11 +678,11 @@ void main() { audioMixin.putInAudioCache(eventId, tooSmallData); final result = audioMixin.getFromAudioCache(eventId); - // Assert - Should not be cached (putInAudioCache rejects it) + // Assert - Should not be cached (getFromAudioCache rejects it) expect(result, null); }); - test('should clear all cache data on cleanup', () { + test('should clear all cache data on cleanup', () async { // Arrange final data = Uint8List.fromList(List.generate(2048, (i) => i % 256)); audioMixin.putInAudioCache('event_1', data); @@ -690,15 +690,12 @@ void main() { audioMixin.putInAudioCache('event_3', data); // Act - audioMixin.cleanupAudioPlayer(); - - // Give async cleanup time to complete - return Future.delayed(const Duration(milliseconds: 100), () { - // Assert - All cache should be cleared - expect(audioMixin.getFromAudioCache('event_1'), null); - expect(audioMixin.getFromAudioCache('event_2'), null); - expect(audioMixin.getFromAudioCache('event_3'), null); - }); + await audioMixin.cleanupAudioPlayer(); + + // Assert - All cache should be cleared + expect(audioMixin.getFromAudioCache('event_1'), null); + expect(audioMixin.getFromAudioCache('event_2'), null); + expect(audioMixin.getFromAudioCache('event_3'), null); }); }); } diff --git a/test/presentation/mixins/event_filter_mixin_test.dart b/test/presentation/mixins/event_filter_mixin_test.dart index 45ef39e8d6..d4e6b00080 100644 --- a/test/presentation/mixins/event_filter_mixin_test.dart +++ b/test/presentation/mixins/event_filter_mixin_test.dart @@ -364,7 +364,7 @@ void main() { }); test('should handle large list of events efficiently', () { - // Arrange - Create 100 events in CHRONOLOGICAL ORDER (oldest first) + // Arrange - Create 100 events in REVERSE CHRONOLOGICAL ORDER (newest first, like UI) final now = DateTime.now(); final audioEvents = List.generate( 100, @@ -373,8 +373,8 @@ void main() { type: EventTypes.Message, eventId: 'event$index', senderId: '@user:example.com', - // Event 0 (oldest) has largest time offset - originServerTs: now.subtract(Duration(minutes: 100 - index)), + // Event 0 (newest) has smallest time offset + originServerTs: now.subtract(Duration(minutes: index + 1)), room: room, ), ); From f67fcd5fe6f7a44e2b38727056b56eba054c65a6 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Tue, 10 Feb 2026 11:59:32 +0700 Subject: [PATCH 25/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- lib/presentation/mixins/audio_mixin.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/presentation/mixins/audio_mixin.dart b/lib/presentation/mixins/audio_mixin.dart index 476107260e..35b9734e96 100644 --- a/lib/presentation/mixins/audio_mixin.dart +++ b/lib/presentation/mixins/audio_mixin.dart @@ -768,6 +768,12 @@ mixin AudioMixin { try { await audioPlayer?.setAudioSource(MatrixFileAudioSource(matrixFile)); + // Reset status to notDownloaded immediately after setting audio source + // so the UI shows the normal play/pause button instead of loading + if (voiceMessageEvent.value?.eventId == currentEvent.eventId) { + currentAudioStatus.value = AudioPlayerStatus.notDownloaded; + } + // Set up auto-dispose listener setupAudioPlayerAutoDispose(context: context); From ff145e60911b67cbb1868ff2f67898f260abf6b5 Mon Sep 17 00:00:00 2001 From: HuyNguyen Date: Wed, 11 Feb 2026 15:57:58 +0700 Subject: [PATCH 26/26] fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! fixup! TW-2796: Implement autoplay for voice messages and enhance audio event handling --- .../audio_message/audio_player_widget.dart | 7 ++----- lib/presentation/mixins/event_filter_mixin.dart | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/pages/chat/events/audio_message/audio_player_widget.dart b/lib/pages/chat/events/audio_message/audio_player_widget.dart index 1edd9e67e2..ea739a92d1 100644 --- a/lib/pages/chat/events/audio_message/audio_player_widget.dart +++ b/lib/pages/chat/events/audio_message/audio_player_widget.dart @@ -232,12 +232,9 @@ class AudioPlayerState extends State currentPosition = maxPosition; } + // Use the fixed wave count calculated during initialization final wavePosition = (currentPosition / maxPosition) * - calculateWaveCountAuto( - minWaves: AudioPlayerStyle.minWaveCount, - maxWaves: _calculatedWaveform.length, - durationInSeconds: _durationNotifier.value.inSeconds, - ); + _calculatedWaveform.length; return Padding( padding: const EdgeInsets.only( diff --git a/lib/presentation/mixins/event_filter_mixin.dart b/lib/presentation/mixins/event_filter_mixin.dart index 022332a0ed..8a5f3e413b 100644 --- a/lib/presentation/mixins/event_filter_mixin.dart +++ b/lib/presentation/mixins/event_filter_mixin.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:fluffychat/utils/matrix_sdk_extensions/event_extension.dart'; import 'package:matrix/matrix.dart'; @@ -97,6 +99,7 @@ mixin EventFilterMixin { required String roomId, required String eventId, int limit = 100, + String? filter, }) async { if (client == null) return null; @@ -105,6 +108,7 @@ mixin EventFilterMixin { roomId, eventId, limit: limit, + filter: filter, ); } catch (e) { Logs().e('getInitialEventContext: Error getting event context', e); @@ -163,6 +167,7 @@ mixin EventFilterMixin { String? backwardToken, String? forwardToken, int limit = 10, + String? filter, }) async { if (client == null) return (backward: null, forward: null); @@ -174,6 +179,7 @@ mixin EventFilterMixin { Direction.b, limit: limit, from: backwardToken, + filter: filter, ), if (forwardToken != null) client.getRoomEvents( @@ -181,6 +187,7 @@ mixin EventFilterMixin { Direction.f, limit: limit, from: forwardToken, + filter: filter, ), ]); @@ -235,6 +242,7 @@ mixin EventFilterMixin { int minEventsTarget = 7, int maxExpandIterations = 10, int expandLimit = 10, + String? filter, }) async { if (client == null) { return (events: [], forwardToken: null, backwardToken: null); @@ -248,6 +256,7 @@ mixin EventFilterMixin { roomId: room.id, eventId: eventId, limit: initialLimit, + filter: filter, ); if (initialEventContext == null) { @@ -282,6 +291,7 @@ mixin EventFilterMixin { forwardToken: forwardToken, backwardToken: backwardToken, limit: expandLimit, + filter: filter, ); forwardToken = expandResult.forward?.end; @@ -416,6 +426,12 @@ mixin EventFilterMixin { minEventsTarget: minEventsTarget, maxExpandIterations: maxExpandIterations, expandLimit: expandLimit, + filter: jsonEncode( + SearchFilter( + containsUrl: true, + types: [EventTypes.Message], + ), + ), ); // Find the clicked event and return only events up to it