diff --git a/lib/apis/message_api.dart b/lib/apis/message_api.dart index c27ae7d..0ab86a2 100644 --- a/lib/apis/message_api.dart +++ b/lib/apis/message_api.dart @@ -6,6 +6,7 @@ import 'package:contacts_plus_plus/models/message.dart'; class MessageApi { static Future> getUserMessages(ApiClient client, {String userId = "", DateTime? fromTime, int maxItems = 50, bool unreadOnly = false}) async { + final response = await client.get("/users/${client.userId}/messages" "?maxItems=$maxItems" "${fromTime == null ? "" : "&fromTime${fromTime.toLocal().toIso8601String()}"}" diff --git a/lib/auxiliary.dart b/lib/auxiliary.dart index 74be2f4..12e4036 100644 --- a/lib/auxiliary.dart +++ b/lib/auxiliary.dart @@ -70,4 +70,17 @@ extension StripHTLM on String { final document = htmlparser.parse(this); return htmlparser.parse(document.body?.text).documentElement?.text ?? ""; } +} + +extension Format on Duration { + String format() { + final hh = (inHours).toString().padLeft(2, '0'); + final mm = (inMinutes % 60).toString().padLeft(2, '0'); + final ss = (inSeconds % 60).toString().padLeft(2, '0'); + if (inHours == 0) { + return "$mm:$ss"; + } else { + return "$hh:$mm:$ss"; + } + } } \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index 57d930f..80d0d8d 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -151,4 +151,18 @@ class MessageCache { _messages.sort(); _messages.unique((element) => element.id); } +} + +class AudioClipContent { + final String id; + final String assetUri; + + AudioClipContent({required this.id, required this.assetUri}); + + factory AudioClipContent.fromMap(Map map) { + return AudioClipContent( + id: map["id"], + assetUri: map["assetUri"], + ); + } } \ No newline at end of file diff --git a/lib/widgets/audio_clip_player.dart b/lib/widgets/audio_clip_player.dart new file mode 100644 index 0000000..99b85a0 --- /dev/null +++ b/lib/widgets/audio_clip_player.dart @@ -0,0 +1,132 @@ +import 'dart:convert'; + +import 'package:contacts_plus_plus/api_client.dart'; +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/message.dart'; +import 'package:contacts_plus_plus/widgets/messages.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:just_audio/just_audio.dart'; + +class MessageAudioPlayer extends StatefulWidget { + const MessageAudioPlayer({required this.message, super.key}); + + final Message message; + + @override + State createState() => _MessageAudioPlayerState(); +} + +class _MessageAudioPlayerState extends State { + final AudioPlayer _audioPlayer = AudioPlayer(); + final DateFormat _dateFormat = DateFormat.Hm(); + double _sliderValue = 0; + + @override + void initState() { + super.initState(); + _audioPlayer.setAudioSource(AudioSource.uri(Uri.parse( + Aux.neosDbToHttp(AudioClipContent.fromMap(jsonDecode(widget.message.content)).assetUri) + ))).whenComplete(() => _audioPlayer.setLoopMode(LoopMode.off)); + } + + @override + Widget build(BuildContext context) { + return IntrinsicWidth( + child: StreamBuilder( + stream: _audioPlayer.playerStateStream, + builder: (context, snapshot) { + if (snapshot.hasData) { + final playerState = snapshot.data as PlayerState; + return Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + switch (playerState.processingState) { + case ProcessingState.idle: + case ProcessingState.loading: + case ProcessingState.buffering: + break; + case ProcessingState.ready: + _audioPlayer.play(); + break; + case ProcessingState.completed: + _audioPlayer.seek(Duration.zero); + _audioPlayer.play(); + break; + }}, + icon: playerState.processingState == ProcessingState.loading + ? const Center(child: CircularProgressIndicator(),) + : Icon((_audioPlayer.duration! - _audioPlayer.position).inMilliseconds < 10 ? Icons.replay + : (playerState.playing ? Icons.pause : Icons.play_arrow)), + ), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + _sliderValue = (_audioPlayer.position.inMilliseconds / + (_audioPlayer.duration?.inMilliseconds ?? 0)).clamp(0, 1); + return StatefulBuilder( + builder: (context, setState) { + return Slider( + value: _sliderValue, + min: 0.0, + max: 1.0, + onChanged: (value) async { + _audioPlayer.pause(); + setState(() { + _sliderValue = value; + }); + _audioPlayer.seek(Duration( + milliseconds: (value * (_audioPlayer.duration?.inMilliseconds ?? 0)).round(), + )); + }, + ); + } + ); + } + ) + ], + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const SizedBox(width: 12), + StreamBuilder( + stream: _audioPlayer.positionStream, + builder: (context, snapshot) { + return Text("${snapshot.data?.format() ?? "??"}/${_audioPlayer.duration?.format() ?? "??"}"); + } + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + _dateFormat.format(widget.message.sendTime), + style: Theme + .of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.white54), + ), + ), + const SizedBox(width: 4,), + if (widget.message.senderId == ClientHolder.of(context).client.userId) Padding( + padding: const EdgeInsets.only(right: 12.0), + child: MessageStateIndicator(messageState: widget.message.state), + ), + ], + ) + ], + ); + } else { + return const Center(child: CircularProgressIndicator(),); + } + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart index 35e3161..97b1179 100644 --- a/lib/widgets/messages.dart +++ b/lib/widgets/messages.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:developer'; import 'package:cached_network_image/cached_network_image.dart'; @@ -6,6 +7,7 @@ import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/friend.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/models/session.dart'; +import 'package:contacts_plus_plus/widgets/audio_clip_player.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; @@ -334,55 +336,92 @@ class MyMessageBubble extends StatelessWidget { @override Widget build(BuildContext context) { var content = message.content; - if (message.type == MessageType.sessionInvite) { - content = ""; - } else if (message.type == MessageType.sound) { - content = ""; - } else if (message.type == MessageType.object) { - content = ""; - } - return Row( - mainAxisAlignment: MainAxisAlignment.end, - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Card( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - color: Theme.of(context).colorScheme.primaryContainer, - margin: const EdgeInsets.only(left: 32, bottom: 16, right: 12), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - content, - softWrap: true, - maxLines: null, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 6,), - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, + switch (message.type) { + case MessageType.sessionInvite: + content = "[Session Invite]"; + continue rawText; + case MessageType.object: + content = "[Asset]"; + continue rawText; + case MessageType.unknown: + rawText: + case MessageType.text: + return Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Theme + .of(context) + .colorScheme + .primaryContainer, + margin: const EdgeInsets.only(left: 32, bottom: 16, right: 12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - _dateFormat.format(message.sendTime), - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), + content, + softWrap: true, + maxLines: null, + style: Theme + .of(context) + .textTheme + .bodyLarge, + ), + const SizedBox(height: 6,), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: Text( + _dateFormat.format(message.sendTime), + style: Theme + .of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.white54), + ), + ), + MessageStateIndicator(messageState: message.state), + ], ), - const SizedBox(width: 4,), - MessageStateIndicator(messageState: message.state), ], ), - ], + ), ), ), - ), - ), - ], - ); + ], + ); + case MessageType.sound: + return Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Theme + .of(context) + .colorScheme + .primaryContainer, + margin: const EdgeInsets.only(left: 32, bottom: 16, right: 12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: MessageAudioPlayer(message: message,), + ), + ), + ], + ); + } } } @@ -403,12 +442,65 @@ class OtherMessageBubble extends StatelessWidget { } else if (message.type == MessageType.object) { content = "[Asset]"; } - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Flexible( - child: Card( + switch (message.type) { + case MessageType.sessionInvite: + content = "[Session Invite]"; + continue rawText; + case MessageType.object: + content = "[Asset]"; + continue rawText; + case MessageType.unknown: + rawText: + case MessageType.text: + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + color: Theme + .of(context) + .colorScheme + .secondaryContainer, + margin: const EdgeInsets.only(right: 32, bottom: 16, left: 12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + content, + softWrap: true, + maxLines: null, + style: Theme + .of(context) + .textTheme + .bodyLarge, + ), + const SizedBox(height: 6,), + Text( + _dateFormat.format(message.sendTime), + style: Theme + .of(context) + .textTheme + .labelMedium + ?.copyWith(color: Colors.white54), + ), + ], + ), + ), + ), + ), + ], + ); + case MessageType.sound: + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), @@ -419,27 +511,12 @@ class OtherMessageBubble extends StatelessWidget { margin: const EdgeInsets.only(right: 32, bottom: 16, left: 12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - content, - softWrap: true, - maxLines: null, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 6,), - Text( - _dateFormat.format(message.sendTime), - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), - ), - ], - ), + child: MessageAudioPlayer(message: message,), ), ), - ), - ], - ); + ], + ); + } } } diff --git a/pubspec.lock b/pubspec.lock index a1f318f..26606d8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.10.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: e4acc4e9eaa32436dfc5d7aed7f0a370f2d7bb27ee27de30d6c4f220c2a05c73 + url: "https://pub.dev" + source: hosted + version: "0.1.13" boolean_selector: dependency: transitive description: @@ -240,6 +248,30 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.5" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: "7e6d31508dacd01a066e3889caf6282e5f1eb60707c230203b21a83af5c55586" + url: "https://pub.dev" + source: hosted + version: "0.9.32" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: eff112d5138bea3ba544b6338b1e0537a32b5e1425e4d0dc38f732771cda7c84 + url: "https://pub.dev" + source: hosted + version: "4.2.0" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: "89d8db6f19f3821bb6bf908c4bfb846079afb2ab575b783d781a6bf119e3abaf" + url: "https://pub.dev" + source: hosted + version: "0.4.7" lints: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index be030fd..5375501 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -45,6 +45,7 @@ dependencies: cached_network_image: ^3.2.3 web_socket_channel: ^2.4.0 html: ^0.15.2 + just_audio: ^0.9.32 dev_dependencies: flutter_test: