From 7cc341ac917c00a07d2db00e56b3cd11876cd132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Sat, 10 May 2025 13:49:01 +0200 Subject: [PATCH] feat: Background audio player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Christian Kußowski --- lib/pages/chat/events/audio_player.dart | 635 ++++++++++++++---------- lib/widgets/matrix.dart | 4 + 2 files changed, 364 insertions(+), 275 deletions(-) diff --git a/lib/pages/chat/events/audio_player.dart b/lib/pages/chat/events/audio_player.dart index 10d13362d..3d8aa0d11 100644 --- a/lib/pages/chat/events/audio_player.dart +++ b/lib/pages/chat/events/audio_player.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:async/async.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:just_audio/just_audio.dart'; import 'package:matrix/matrix.dart'; @@ -17,6 +18,7 @@ import 'package:fluffychat/utils/file_description.dart'; import 'package:fluffychat/utils/localized_exception_extension.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import '../../../utils/matrix_sdk_extensions/event_extension.dart'; +import '../../../widgets/matrix.dart'; class AudioPlayerWidget extends StatefulWidget { final Color color; @@ -24,8 +26,6 @@ class AudioPlayerWidget extends StatefulWidget { final double fontSize; final Event event; - static String? currentId; - static const int wavesCount = 40; const AudioPlayerWidget( @@ -43,48 +43,115 @@ class AudioPlayerWidget extends StatefulWidget { enum AudioPlayerStatus { notDownloaded, downloading, downloaded } class AudioPlayerState extends State { - AudioPlayerStatus status = AudioPlayerStatus.notDownloaded; - AudioPlayer? audioPlayer; - - StreamSubscription? onAudioPositionChanged; - StreamSubscription? onDurationChanged; - StreamSubscription? onPlayerStateChanged; - StreamSubscription? onPlayerError; + static const double buttonSize = 36; - String? statusText; - double currentPosition = 0; - double maxPosition = 1; + AudioPlayerStatus status = AudioPlayerStatus.notDownloaded; - MatrixFile? matrixFile; - File? audioFile; + late final MatrixState matrix; + late final List? _waveform; + late final String? _durationString; @override void dispose() { - if (audioPlayer?.playerState.playing == true) { - audioPlayer?.stop(); - } - onAudioPositionChanged?.cancel(); - onDurationChanged?.cancel(); - onPlayerStateChanged?.cancel(); - onPlayerError?.cancel(); - super.dispose(); + final audioPlayer = matrix.voiceMessageEventId.value != widget.event.eventId + ? null + : matrix.audioPlayer; + if (audioPlayer != null) { + if (audioPlayer.playing && !audioPlayer.isAtEndPosition) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(matrix.context).showMaterialBanner( + MaterialBanner( + padding: EdgeInsets.zero, + content: Row( + children: [ + StreamBuilder( + stream: audioPlayer.playerStateStream.asBroadcastStream(), + builder: (context, _) => IconButton( + onPressed: () { + if (audioPlayer.isAtEndPosition) { + audioPlayer.seek(Duration.zero); + } else if (audioPlayer.playing) { + audioPlayer.pause(); + } else { + audioPlayer.play(); + } + }, + icon: audioPlayer.playing && !audioPlayer.isAtEndPosition + ? const Icon(Icons.pause_outlined) + : const Icon(Icons.play_arrow_outlined), + ), + ), + Expanded( + child: StreamBuilder( + stream: audioPlayer.positionStream.asBroadcastStream(), + builder: (context, _) { + return Text( + '🎙️ ${audioPlayer.position.minuteSecondString} / ${audioPlayer.duration?.minuteSecondString} - ${widget.event.senderFromMemoryOrFallback.calcDisplayname()}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + ); + }, + ), + ), + ], + ), + actions: [ + IconButton( + onPressed: () { + audioPlayer.pause(); + audioPlayer.dispose(); + matrix.voiceMessageEventId.value = + matrix.audioPlayer = null; + + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(matrix.context) + .clearMaterialBanners(); + }); + }, + icon: const Icon(Icons.close_outlined), + ), + ], + ), + ); + }); + return; + } + audioPlayer.pause(); + audioPlayer.dispose(); + matrix.voiceMessageEventId.value = matrix.audioPlayer = null; + } } - void _startAction() { - if (status == AudioPlayerStatus.downloaded) { - _playAction(); - } else { - _downloadAction(); + void _onButtonTap() async { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(matrix.context).clearMaterialBanners(); + }); + final currentPlayer = + matrix.voiceMessageEventId.value != widget.event.eventId + ? null + : matrix.audioPlayer; + if (currentPlayer != null) { + if (currentPlayer.isAtEndPosition) { + currentPlayer.seek(Duration.zero); + } else if (currentPlayer.playing) { + currentPlayer.pause(); + } else { + currentPlayer.play(); + } + return; } - } - Future _downloadAction() async { - if (status != AudioPlayerStatus.notDownloaded) return; + matrix.voiceMessageEventId.value = widget.event.eventId; + matrix.audioPlayer + ?..stop() + ..dispose(); + File? file; + MatrixFile? matrixFile; + setState(() => status = AudioPlayerStatus.downloading); try { - final matrixFile = await widget.event.downloadAndDecryptAttachment(); - File? file; + matrixFile = await widget.event.downloadAndDecryptAttachment(); if (!kIsWeb) { final tempDir = await getTemporaryDirectory(); @@ -107,11 +174,8 @@ class AudioPlayerState extends State { } setState(() { - audioFile = file; - this.matrixFile = matrixFile; status = AudioPlayerStatus.downloaded; }); - _playAction(); } catch (e, s) { Logs().v('Could not download audio file', e, s); ScaffoldMessenger.of(context).showSnackBar( @@ -119,94 +183,27 @@ class AudioPlayerState extends State { content: Text(e.toLocalizedString(context)), ), ); + rethrow; } - } + if (!context.mounted) return; + if (matrix.voiceMessageEventId.value != widget.event.eventId) return; - void _playAction() async { - final audioPlayer = this.audioPlayer ??= AudioPlayer(); - if (AudioPlayerWidget.currentId != widget.event.eventId) { - if (AudioPlayerWidget.currentId != null) { - if (audioPlayer.playerState.playing) { - await audioPlayer.stop(); - setState(() {}); - } - } - AudioPlayerWidget.currentId = widget.event.eventId; - } - if (audioPlayer.playerState.playing) { - await audioPlayer.pause(); - return; - } else if (audioPlayer.position != Duration.zero) { - await audioPlayer.play(); - return; - } + final audioPlayer = matrix.audioPlayer = AudioPlayer(); - onAudioPositionChanged ??= audioPlayer.positionStream.listen((state) { - if (maxPosition <= 0) return; - setState(() { - statusText = - '${state.inMinutes.toString().padLeft(2, '0')}:${(state.inSeconds % 60).toString().padLeft(2, '0')}'; - currentPosition = state.inMilliseconds.toDouble(); - }); - if (state.inMilliseconds.toDouble() == maxPosition) { - audioPlayer.stop(); - audioPlayer.seek(null); - } - }); - onDurationChanged ??= audioPlayer.durationStream.listen((max) { - if (max == null || max == Duration.zero) return; - setState(() => maxPosition = max.inMilliseconds.toDouble()); - }); - onPlayerStateChanged ??= - audioPlayer.playingStream.listen((_) => setState(() {})); - final audioFile = this.audioFile; - if (audioFile != null) { - audioPlayer.setFilePath(audioFile.path); + if (file != null) { + audioPlayer.setFilePath(file.path); } else { - await audioPlayer.setAudioSource(MatrixFileAudioSource(matrixFile!)); + await audioPlayer.setAudioSource(MatrixFileAudioSource(matrixFile)); } + audioPlayer.play().onError( ErrorReporter(context, 'Unable to play audio message') .onErrorCallback, ); } - static const double buttonSize = 36; - - String? get _durationString { - final durationInt = widget.event.content - .tryGetMap('info') - ?.tryGet('duration'); - if (durationInt == null) return null; - final duration = Duration(milliseconds: durationInt); - return '${duration.inMinutes.toString().padLeft(2, '0')}:${(duration.inSeconds % 60).toString().padLeft(2, '0')}'; - } - - List? _getWaveform() { - final eventWaveForm = widget.event.content - .tryGetMap('org.matrix.msc1767.audio') - ?.tryGetList('waveform'); - if (eventWaveForm == null || eventWaveForm.isEmpty) { - return null; - } - while (eventWaveForm.length < AudioPlayerWidget.wavesCount) { - for (var i = 0; i < eventWaveForm.length; i = i + 2) { - eventWaveForm.insert(i, eventWaveForm[i]); - } - } - var i = 0; - final step = (eventWaveForm.length / AudioPlayerWidget.wavesCount).round(); - while (eventWaveForm.length > AudioPlayerWidget.wavesCount) { - eventWaveForm.removeAt(i); - i = (i + step) % AudioPlayerWidget.wavesCount; - } - return eventWaveForm.map((i) => i > 1024 ? 1024 : i).toList(); - } - - late final List? _waveform; - void _toggleSpeed() async { - final audioPlayer = this.audioPlayer; + final audioPlayer = matrix.audioPlayer; if (audioPlayer == null) return; switch (audioPlayer.speed) { case 1.0: @@ -229,10 +226,47 @@ class AudioPlayerState extends State { setState(() {}); } + List? _getWaveform() { + final eventWaveForm = widget.event.content + .tryGetMap('org.matrix.msc1767.audio') + ?.tryGetList('waveform'); + if (eventWaveForm == null || eventWaveForm.isEmpty) { + return null; + } + while (eventWaveForm.length < AudioPlayerWidget.wavesCount) { + for (var i = 0; i < eventWaveForm.length; i = i + 2) { + eventWaveForm.insert(i, eventWaveForm[i]); + } + } + var i = 0; + final step = (eventWaveForm.length / AudioPlayerWidget.wavesCount).round(); + while (eventWaveForm.length > AudioPlayerWidget.wavesCount) { + eventWaveForm.removeAt(i); + i = (i + step) % AudioPlayerWidget.wavesCount; + } + return eventWaveForm.map((i) => i > 1024 ? 1024 : i).toList(); + } + @override void initState() { super.initState(); + matrix = Matrix.of(context); _waveform = _getWaveform(); + + if (matrix.voiceMessageEventId.value == widget.event.eventId && + matrix.audioPlayer != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(matrix.context).clearMaterialBanners(); + }); + } + + final durationInt = widget.event.content + .tryGetMap('info') + ?.tryGet('duration'); + if (durationInt != null) { + final duration = Duration(milliseconds: durationInt); + _durationString = duration.minuteSecondString; + } } @override @@ -240,186 +274,224 @@ class AudioPlayerState extends State { final theme = Theme.of(context); final waveform = _waveform; - final statusText = this.statusText ??= _durationString ?? '00:00'; - final audioPlayer = this.audioPlayer; - - final fileDescription = widget.event.fileDescription; - - final wavePosition = - (currentPosition / maxPosition) * AudioPlayerWidget.wavesCount; - - return Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConstrainedBox( - constraints: - const BoxConstraints(maxWidth: FluffyThemes.columnWidth), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: buttonSize, - height: buttonSize, - child: status == AudioPlayerStatus.downloading - ? CircularProgressIndicator( - strokeWidth: 2, - color: widget.color, - ) - : InkWell( - borderRadius: BorderRadius.circular(64), - onLongPress: () => widget.event.saveFile(context), - onTap: _startAction, - child: Material( - color: widget.color.withAlpha(64), - borderRadius: BorderRadius.circular(64), - child: Icon( - audioPlayer?.playerState.playing == true - ? Icons.pause_outlined - : Icons.play_arrow_outlined, - color: widget.color, - ), - ), + return ValueListenableBuilder( + valueListenable: matrix.voiceMessageEventId, + builder: (context, eventId, _) { + final audioPlayer = + eventId != widget.event.eventId ? null : matrix.audioPlayer; + + final fileDescription = widget.event.fileDescription; + + return StreamBuilder( + stream: audioPlayer == null + ? null + : StreamGroup.merge([ + audioPlayer.positionStream.asBroadcastStream(), + audioPlayer.playerStateStream.asBroadcastStream(), + ]), + builder: (context, _) { + final maxPosition = + audioPlayer?.duration?.inMilliseconds.toDouble() ?? 1.0; + var currentPosition = + audioPlayer?.position.inMilliseconds.toDouble() ?? 0.0; + if (currentPosition > maxPosition) currentPosition = maxPosition; + + final wavePosition = + (currentPosition / maxPosition) * AudioPlayerWidget.wavesCount; + + final statusText = audioPlayer == null + ? _durationString ?? '00:00' + : audioPlayer.position.minuteSecondString; + return Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: buttonSize, + height: buttonSize, + child: status == AudioPlayerStatus.downloading + ? CircularProgressIndicator( + strokeWidth: 2, + color: widget.color, + ) + : InkWell( + borderRadius: BorderRadius.circular(64), + onLongPress: () => + widget.event.saveFile(context), + onTap: _onButtonTap, + child: Material( + color: widget.color.withAlpha(64), + borderRadius: BorderRadius.circular(64), + child: Icon( + audioPlayer?.playing == true && + audioPlayer?.isAtEndPosition == + false + ? Icons.pause_outlined + : Icons.play_arrow_outlined, + color: widget.color, + ), + ), + ), ), - ), - const SizedBox(width: 8), - Expanded( - child: Stack( - children: [ - if (waveform != null) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Row( + const SizedBox(width: 8), + Expanded( + child: Stack( children: [ - for (var i = 0; - i < AudioPlayerWidget.wavesCount; - i++) - Expanded( - child: Container( - height: 32, - alignment: Alignment.center, - child: Container( - margin: const EdgeInsets.symmetric( - horizontal: 1, - ), - decoration: BoxDecoration( - color: i < wavePosition - ? widget.color - : widget.color.withAlpha(128), - borderRadius: BorderRadius.circular(64), - ), - height: 32 * (waveform[i] / 1024), - ), + if (waveform != null) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, ), + child: Row( + children: [ + for (var i = 0; + i < AudioPlayerWidget.wavesCount; + i++) + Expanded( + child: Container( + height: 32, + alignment: Alignment.center, + child: Container( + margin: + const EdgeInsets.symmetric( + horizontal: 1, + ), + decoration: BoxDecoration( + color: i < wavePosition + ? widget.color + : widget.color + .withAlpha(128), + borderRadius: + BorderRadius.circular(64), + ), + height: 32 * (waveform[i] / 1024), + ), + ), + ), + ], + ), + ), + SizedBox( + height: 32, + child: Slider( + thumbColor: widget.event.senderId == + widget.event.room.client.userID + ? theme.colorScheme.onPrimary + : theme.colorScheme.primary, + activeColor: waveform == null + ? widget.color + : Colors.transparent, + inactiveColor: waveform == null + ? widget.color.withAlpha(128) + : Colors.transparent, + max: maxPosition, + value: currentPosition, + onChanged: (position) => audioPlayer == null + ? _onButtonTap() + : audioPlayer.seek( + Duration( + milliseconds: position.round(), + ), + ), ), + ), ], ), ), - SizedBox( - height: 32, - child: Slider( - thumbColor: widget.event.senderId == - widget.event.room.client.userID - ? theme.colorScheme.onPrimary - : theme.colorScheme.primary, - activeColor: waveform == null - ? widget.color - : Colors.transparent, - inactiveColor: waveform == null - ? widget.color.withAlpha(128) - : Colors.transparent, - max: maxPosition, - value: currentPosition, - onChanged: (position) => audioPlayer == null - ? _startAction() - : audioPlayer.seek( - Duration(milliseconds: position.round()), - ), - ), - ), - ], - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 36, - child: Text( - statusText, - style: TextStyle( - color: widget.color, - fontSize: 12, - ), - ), - ), - const SizedBox(width: 8), - AnimatedCrossFade( - firstChild: Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon( - Icons.mic_none_outlined, - color: widget.color, - ), - ), - secondChild: Material( - color: widget.color.withAlpha(64), - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - child: InkWell( - borderRadius: - BorderRadius.circular(AppConfig.borderRadius), - onTap: _toggleSpeed, - child: SizedBox( - width: 32, - height: 20, - child: Center( + const SizedBox(width: 8), + SizedBox( + width: 36, child: Text( - '${audioPlayer?.speed.toString()}x', + statusText, style: TextStyle( color: widget.color, - fontSize: 9, + fontSize: 12, ), ), ), - ), + const SizedBox(width: 8), + AnimatedCrossFade( + firstChild: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon( + Icons.mic_none_outlined, + color: widget.color, + ), + ), + secondChild: Material( + color: widget.color.withAlpha(64), + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + child: InkWell( + borderRadius: + BorderRadius.circular(AppConfig.borderRadius), + onTap: _toggleSpeed, + child: SizedBox( + width: 32, + height: 20, + child: Center( + child: Text( + '${audioPlayer?.speed.toString()}x', + style: TextStyle( + color: widget.color, + fontSize: 9, + ), + ), + ), + ), + ), + ), + alignment: Alignment.center, + crossFadeState: audioPlayer == null + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + duration: FluffyThemes.animationDuration, + ), + ], ), ), - alignment: Alignment.center, - crossFadeState: audioPlayer == null - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - duration: FluffyThemes.animationDuration, - ), - ], - ), - ), - if (fileDescription != null) ...[ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Linkify( - text: fileDescription, - textScaleFactor: MediaQuery.textScalerOf(context).scale(1), - style: TextStyle( - color: widget.color, - fontSize: widget.fontSize, - ), - options: const LinkifyOptions(humanize: false), - linkStyle: TextStyle( - color: widget.linkColor, - fontSize: widget.fontSize, - decoration: TextDecoration.underline, - decorationColor: widget.linkColor, - ), - onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), + if (fileDescription != null) ...[ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Linkify( + text: fileDescription, + textScaleFactor: + MediaQuery.textScalerOf(context).scale(1), + style: TextStyle( + color: widget.color, + fontSize: widget.fontSize, + ), + options: const LinkifyOptions(humanize: false), + linkStyle: TextStyle( + color: widget.linkColor, + fontSize: widget.fontSize, + decoration: TextDecoration.underline, + decorationColor: widget.linkColor, + ), + onOpen: (url) => + UrlLauncher(context, url.url).launchUrl(), + ), + ), + ], + ], ), - ), - ], - ], - ), + ); + }, + ); + }, ); } } @@ -443,3 +515,16 @@ class MatrixFileAudioSource extends StreamAudioSource { ); } } + +extension on AudioPlayer { + bool get isAtEndPosition { + final duration = this.duration; + if (duration == null) return true; + return position >= duration; + } +} + +extension on Duration { + String get minuteSecondString => + '${inMinutes.toString().padLeft(2, '0')}:${(inSeconds % 60).toString().padLeft(2, '0')}'; +} diff --git a/lib/widgets/matrix.dart b/lib/widgets/matrix.dart index 125ec0b49..4b8f42979 100644 --- a/lib/widgets/matrix.dart +++ b/lib/widgets/matrix.dart @@ -10,6 +10,7 @@ import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:http/http.dart' as http; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; +import 'package:just_audio/just_audio.dart'; import 'package:matrix/encryption.dart'; import 'package:matrix/matrix.dart'; import 'package:provider/provider.dart'; @@ -148,6 +149,9 @@ class MatrixState extends State with WidgetsBindingObserver { Client? _loginClientCandidate; + AudioPlayer? audioPlayer; + final ValueNotifier voiceMessageEventId = ValueNotifier(null); + Client getLoginClient() { if (widget.clients.isNotEmpty && !client.isLogged()) { return client;