diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index aadf776..be5acdf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,7 +5,8 @@ + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> json) => MessageData( @@ -34,19 +38,26 @@ class MessageData { writedByAdmin: json['writedByAdmin'], readed: json['readed'], createdAt: json['createdAt'], - waveform: json['waveForm'] != null + audioDuration: json['waveform'] == null + ? null + : jsonDecode(json['waveform'])['duration'] ?? 0, + waveform: json['waveform'] != null ? Waveform( - version: json['version'], - flags: json['flags'], - sampleRate: json['sampleRate'], - samplesPerPixel: json['samplesPerPixel'], - length: json['length'], - data: json['data'], + version: 1, + flags: 0, + sampleRate: 44100, + samplesPerPixel: 441, + length: jsonDecode(json['waveform'])['length'], + data: Int16List.fromList( + List.from( + jsonDecode(json['waveform'])['data'], + ), + ), ) : null, radar: json['radar'] == null ? null - : RadarAttachment.fromJson(json['radar'] as Map), + : RadarAttachment.fromJson(json['radar']), ); Map toJson() => { diff --git a/lib/pages/home/direct/direct_state.dart b/lib/pages/home/direct/direct_state.dart index bfe16f2..ec9e16d 100644 --- a/lib/pages/home/direct/direct_state.dart +++ b/lib/pages/home/direct/direct_state.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:io'; import 'package:didvan/models/enums.dart'; @@ -89,37 +90,57 @@ class DirectState extends CoreProvier { Future sendMessage() async { if ((text == null || text!.isEmpty) && recordedFile == null) return; + if (recordedFile != null) { + while (waveform == null) {} + } + messages.insert( + 0, + MessageData( + id: 0, + writedByAdmin: false, + readed: false, + createdAt: + DateTime.now().subtract(const Duration(minutes: 210)).toString(), + text: text, + audio: null, + audioFile: recordedFile, + radar: replyRadar, + waveform: waveform, + audioDuration: waveform != null ? waveform!.duration.inSeconds : null, + ), + ); + _addToDailyGrouped(); final body = {}; if (text != null) { body.addAll({'text': text}); - messages.insert( - 0, - MessageData( - id: 0, - writedByAdmin: false, - readed: false, - createdAt: DateTime.now().toString(), - text: text, - audio: null, - audioFile: recordedFile, - radar: replyRadar, - waveform: waveform, - ), - ); } - _addToDailyGrouped(); if (replyRadar != null) { body.addAll({'radarId': replyRadar!.id}); } + final uploadFile = recordedFile; text = null; recordedFile = null; notifyListeners(); final service = RequestService(RequestHelper.sendDirectMessage(typeId), body: body); - if (recordedFile == null) { - await service.post(); + if (uploadFile == null) { + service.post(); } else { - await service.multipart(recordedFile, 'POST'); + body.addAll({ + 'waveform': jsonEncode({ + 'data': waveform!.data, + 'length': waveform!.length, + 'duration': waveform!.duration, + }) + }); + service.multipart( + file: uploadFile, + method: 'POST', + fieldName: 'audio', + fileName: 'voice-message', + mediaExtension: 'm4a', + mediaFormat: 'audio', + ); } } } diff --git a/lib/pages/home/direct/widgets/message.dart b/lib/pages/home/direct/widgets/message.dart index 9c4171c..4d75af1 100644 --- a/lib/pages/home/direct/widgets/message.dart +++ b/lib/pages/home/direct/widgets/message.dart @@ -59,10 +59,14 @@ class Message extends StatelessWidget { child: Column( children: [ if (message.text != null) DidvanText(message.text!), - if (message.audio != null) - AudioVisualizer( - audioUrl: message.audio, - waveform: message.waveform, + if (message.audio != null || message.audioFile != null) + SizedBox( + height: 50, + child: AudioVisualizer( + audioUrl: message.audio, + waveform: message.waveform, + backgroundColor: Colors.transparent, + ), ), if (message.radar != null) const DidvanDivider(), if (message.radar != null) const SizedBox(height: 4), @@ -156,7 +160,7 @@ class _MessageContainer extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), decoration: BoxDecoration( borderRadius: DesignConfig.mediumBorderRadius.copyWith( bottomLeft: writedByAdmin ? Radius.zero : null, diff --git a/lib/pages/home/widgets/audio_visualizer.dart b/lib/pages/home/widgets/audio_visualizer.dart index f4c77dc..2c4b525 100644 --- a/lib/pages/home/widgets/audio_visualizer.dart +++ b/lib/pages/home/widgets/audio_visualizer.dart @@ -6,6 +6,7 @@ import 'package:didvan/config/theme_data.dart'; import 'package:didvan/constants/app_icons.dart'; import 'package:didvan/constants/assets.dart'; import 'package:didvan/pages/home/direct/direct_state.dart'; +import 'package:didvan/services/media/media.dart'; import 'package:didvan/services/storage/storage.dart'; import 'package:didvan/utils/date_time.dart'; import 'package:didvan/widgets/didvan/icon_button.dart'; @@ -13,7 +14,6 @@ import 'package:didvan/widgets/didvan/text.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:just_audio/just_audio.dart'; import 'package:just_waveform/just_waveform.dart'; import 'package:provider/provider.dart'; @@ -21,12 +21,16 @@ class AudioVisualizer extends StatefulWidget { final File? audioFile; final Waveform? waveform; final String? audioUrl; + final int? duration; + final Color? backgroundColor; const AudioVisualizer({ Key? key, this.audioFile, this.waveform, this.audioUrl, + this.duration, + this.backgroundColor, }) : super(key: key); @override @@ -34,8 +38,6 @@ class AudioVisualizer extends StatefulWidget { } class _AudioVisualizerState extends State { - final AudioPlayer _audioPlayer = AudioPlayer(); - Stream? waveDataStream; @override @@ -47,34 +49,43 @@ class _AudioVisualizerState extends State { zoom: const WaveformZoom.pixelsPerSecond(100), ); } - _setupAudioPlayer(); super.initState(); } + bool get _nowPlaying => + MediaService.lastAudioPath == widget.audioFile || + MediaService.lastAudioPath == widget.audioUrl; + @override Widget build(BuildContext context) { return Container( decoration: BoxDecoration( - color: DesignConfig.isDark - ? Theme.of(context).colorScheme.black - : Theme.of(context).colorScheme.background, + color: widget.backgroundColor ?? + (DesignConfig.isDark + ? Theme.of(context).colorScheme.black + : Theme.of(context).colorScheme.background), borderRadius: DesignConfig.mediumBorderRadius, ), child: Row( children: [ const SizedBox(width: 12), StreamBuilder( - stream: _audioPlayer.positionStream, + stream: + _nowPlaying ? MediaService.audioPlayer.positionStream : null, builder: (context, snapshot) { String text = ''; - if (_audioPlayer.duration == null) { + if (MediaService.audioPlayer.duration == null) { Future.delayed(Duration.zero, () { - setState(() {}); + if (mounted) { + setState(() {}); + } }); } if (snapshot.data == null || snapshot.data == Duration.zero) { text = DateTimeUtils.normalizeTimeDuration( - _audioPlayer.duration ?? Duration.zero); + MediaService.audioPlayer.duration ?? + widget.waveform?.duration ?? + Duration.zero); } else { text = DateTimeUtils.normalizeTimeDuration(snapshot.data!); } @@ -92,15 +103,12 @@ class _AudioVisualizerState extends State { if (kIsWeb) { return SvgPicture.asset(Assets.record); } - if (widget.audioFile != null) { return StreamBuilder( stream: waveDataStream, builder: (context, snapshot) { - if (snapshot.data == null) { - return const SizedBox(); - } - if (snapshot.data!.waveform == null) { + if (snapshot.data == null || + snapshot.data!.waveform == null) { return const SizedBox(); } final waveform = snapshot.data!.waveform!; @@ -109,19 +117,28 @@ class _AudioVisualizerState extends State { }, ); } + if (widget.waveform == null && waveDataStream == null) { + return SvgPicture.asset(Assets.record); + } return _waveWidget(widget.waveform!); }, ), ), StreamBuilder( - stream: _audioPlayer.playingStream, + stream: _nowPlaying ? MediaService.audioPlayer.playingStream : null, builder: (context, snapshot) { return DidvanIconButton( icon: snapshot.data == true ? DidvanIcons.pause_circle_solid : DidvanIcons.play_circle_solid, color: Theme.of(context).colorScheme.focusedBorder, - onPressed: _playAndPouse, + onPressed: () { + MediaService.handleAudioPlayback( + audioSource: widget.audioFile ?? widget.audioUrl, + isNetworkAudio: widget.audioFile == null, + ); + setState(() {}); + }, ); }, ), @@ -130,127 +147,96 @@ class _AudioVisualizerState extends State { ); } - Widget _waveWidget(Waveform waveform) => GestureDetector( - onHorizontalDragUpdate: _changePosition, - onTapDown: _changePosition, - child: SizedBox( - height: double.infinity, - width: double.infinity, - child: _AudioWaveformWidget( - waveform: waveform, - audioPlayer: _audioPlayer, - start: Duration.zero, - scale: 2, - strokeWidth: 3, - duration: waveform.duration, - waveColor: Theme.of(context).colorScheme.focusedBorder, + Widget _waveWidget(Waveform waveform) => IgnorePointer( + ignoring: !_nowPlaying, + child: GestureDetector( + onHorizontalDragUpdate: _changePosition, + onTapDown: _changePosition, + child: SizedBox( + height: double.infinity, + width: double.infinity, + child: _AudioWaveformWidget( + waveform: waveform, + start: Duration.zero, + scale: 2, + strokeWidth: 3, + nowPlaying: _nowPlaying, + duration: waveform.duration, + waveColor: Theme.of(context).colorScheme.focusedBorder, + ), ), ), ); void _changePosition(details) { + if (MediaService.audioPlayer.audioSource == null) return; double posper = details.localPosition.dx / (MediaQuery.of(context).size.width - 200); if (posper >= 1 || posper < 0) return; - final position = _audioPlayer.duration!.inMilliseconds; - _audioPlayer.seek( + final position = MediaService.audioPlayer.duration!.inMilliseconds; + MediaService.audioPlayer.seek( Duration(milliseconds: (posper * position).toInt()), ); - setState(() {}); - } - - Future _setupAudioPlayer() async { - if (kIsWeb || widget.audioFile == null) { - await _audioPlayer.setUrl( - kIsWeb - ? widget.audioFile!.uri.path.replaceAll('%3A', ':') - : widget.audioUrl!, - ); - } else { - await _audioPlayer.setFilePath(widget.audioFile!.path); - } - } - - Future _playAndPouse() async { - if (_audioPlayer.playing) { - _audioPlayer.pause(); - return; - } - await _audioPlayer.play(); - } - - @override - void dispose() { - _audioPlayer.dispose(); - super.dispose(); } } -class _AudioWaveformWidget extends StatefulWidget { +class _AudioWaveformWidget extends StatelessWidget { final Color waveColor; final double scale; final double strokeWidth; final double pixelsPerStep; final Waveform waveform; final Duration start; + final bool nowPlaying; final Duration duration; - final AudioPlayer audioPlayer; const _AudioWaveformWidget({ Key? key, required this.waveform, required this.start, required this.duration, - required this.audioPlayer, + required this.nowPlaying, this.waveColor = Colors.blue, this.scale = 1.0, this.strokeWidth = 5.0, this.pixelsPerStep = 8.0, }) : super(key: key); - @override - __AudioWaveformWidgetState createState() => __AudioWaveformWidgetState(); -} - -class __AudioWaveformWidgetState extends State<_AudioWaveformWidget> - with SingleTickerProviderStateMixin { - double progress = 0; - - @override - void initState() { - widget.audioPlayer.positionStream.listen((event) { - if (widget.audioPlayer.duration == null) return; - setState(() { - progress = event.inMilliseconds / - widget.audioPlayer.duration!.inMilliseconds * - 100; - if (progress >= 100) { - progress = 0; - widget.audioPlayer.stop(); - widget.audioPlayer.seek(Duration.zero); - } - }); - }); - super.initState(); - } - @override Widget build(BuildContext context) { return ClipRect( - child: CustomPaint( - painter: _AudioWaveformPainter( - waveColor: widget.waveColor, - waveform: widget.waveform, - start: widget.start, - duration: widget.duration, - scale: widget.scale, - strokeWidth: widget.strokeWidth, - pixelsPerStep: widget.pixelsPerStep, - progressPercentage: progress, - progressColor: Theme.of(context).colorScheme.focusedBorder, - color: Theme.of(context).colorScheme.border, - ), - ), + child: StreamBuilder( + stream: nowPlaying ? MediaService.audioPlayer.positionStream : null, + builder: (context, snapshot) { + double progress = 0; + if (snapshot.data == null || + MediaService.audioPlayer.duration == null) { + progress = 0; + } else { + progress = snapshot.data!.inMilliseconds / + MediaService.audioPlayer.duration!.inMilliseconds * + 100; + } + if (progress >= 100) { + progress = 0; + MediaService.audioPlayer.stop(); + MediaService.audioPlayer.seek(Duration.zero); + } + return CustomPaint( + painter: _AudioWaveformPainter( + waveColor: waveColor, + waveform: waveform, + start: start, + duration: duration, + scale: scale, + strokeWidth: strokeWidth, + pixelsPerStep: pixelsPerStep, + progressPercentage: progress, + progressColor: Theme.of(context).colorScheme.focusedBorder, + color: Theme.of(context).colorScheme.border, + ), + ); + }), ); } } diff --git a/lib/providers/user_provider.dart b/lib/providers/user_provider.dart index 9d47e85..77aa17d 100644 --- a/lib/providers/user_provider.dart +++ b/lib/providers/user_provider.dart @@ -37,7 +37,14 @@ class UserProvider extends CoreProvier { appState = AppState.isolatedBusy; final RequestService service = RequestService(RequestHelper.updateProfilePhoto); - await service.multipart(file, 'PUT'); + await service.multipart( + file: file, + method: 'PUT', + fileName: 'user-profile', + fieldName: 'photo', + mediaExtension: 'jpg', + mediaFormat: 'image', + ); if (service.isSuccess) { user = user.copyWith(photo: service.result['photo']); appState = AppState.idle; diff --git a/lib/services/media/media.dart b/lib/services/media/media.dart index de0dba3..a2c83da 100644 --- a/lib/services/media/media.dart +++ b/lib/services/media/media.dart @@ -1,6 +1,41 @@ +import 'package:didvan/services/network/request.dart'; +import 'package:didvan/services/network/request_helper.dart'; +import 'package:flutter/foundation.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:just_audio/just_audio.dart'; class MediaService { + static final AudioPlayer audioPlayer = AudioPlayer(); + static dynamic lastAudioPath; + + static Future handleAudioPlayback( + {required dynamic audioSource, required bool isNetworkAudio}) async { + if (lastAudioPath == audioSource) { + if (audioPlayer.playing) { + await audioPlayer.pause(); + } else { + await audioPlayer.play(); + } + } else { + lastAudioPath = audioSource; + if (isNetworkAudio) { + await audioPlayer.setUrl( + RequestHelper.baseUrl + + audioSource + + '?accessToken=${RequestService.token}', + ); + } else { + if (kIsWeb) { + await audioPlayer + .setUrl(audioSource!.uri.path.replaceAll('%3A', ':')); + } else { + await audioPlayer.setFilePath(audioSource.path); + } + } + await audioPlayer.play(); + } + } + static Future pickImage({required ImageSource source}) async { final imagePicker = ImagePicker(); final XFile? pickedFile = await imagePicker.pickImage(source: source); diff --git a/lib/services/network/request.dart b/lib/services/network/request.dart index 037fc71..1614cbc 100644 --- a/lib/services/network/request.dart +++ b/lib/services/network/request.dart @@ -99,7 +99,14 @@ class RequestService { } } - Future multipart(dynamic file, String method) async { + Future multipart({ + required dynamic file, + required String method, + required String fileName, + required String fieldName, + required String mediaFormat, + required String mediaExtension, + }) async { try { final request = http.MultipartRequest(method, Uri.parse(url)); _headers.update('Content-Type', (_) => 'multipart/form-data'); @@ -112,11 +119,11 @@ class RequestService { } request.files.add( http.MultipartFile( - 'photo', + fieldName, file.readAsBytes().asStream(), length, - filename: 'profile-photo', - contentType: parser.MediaType('image', 'jpg'), + filename: fileName + '.' + mediaExtension, + contentType: parser.MediaType(mediaFormat, mediaExtension), ), ); final streamedResponse = await request