From b578b302b71a910141b9cce2902cf72e654c1052 Mon Sep 17 00:00:00 2001 From: Dushant Date: Thu, 1 Jun 2023 20:52:14 +0530 Subject: [PATCH 1/4] Feat: Audio Player --- lib/consts.dart | 4 + lib/utils/convert_utils.dart | 8 + lib/widgets/previewer.dart | 10 +- lib/widgets/uint8_audio_player.dart | 230 ++++++++++++++++++++++++++++ lib/widgets/widgets.dart | 1 + pubspec.lock | 40 +++++ pubspec.yaml | 1 + 7 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/uint8_audio_player.dart diff --git a/lib/consts.dart b/lib/consts.dart index c3c0a12c..88b22725 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -78,6 +78,7 @@ const kTabHeight = 45.0; const kHeaderHeight = 32.0; const kSegmentHeight = 24.0; const kTextButtonMinWidth = 36.0; +const kSliderWidth = 160.0; const kRandMax = 100000; @@ -424,6 +425,9 @@ const kUnexpectedRaiseIssue = const kImageError = "There seems to be an issue rendering this image. Please raise an issue in API Dash GitHub repo so that we can resolve it."; +const kAudioError = + "There seems to be an issue playing this audio. Please raise an issue in API Dash GitHub repo so that we can resolve it."; + const kRaiseIssue = "\nPlease raise an issue in API Dash GitHub repo so that we can resolve it."; diff --git a/lib/utils/convert_utils.dart b/lib/utils/convert_utils.dart index 9b8a0751..e626c53f 100644 --- a/lib/utils/convert_utils.dart +++ b/lib/utils/convert_utils.dart @@ -24,6 +24,14 @@ String humanizeDuration(Duration? duration) { } } +String audioPosition(Duration? duration) { + if (duration == null) return ""; + var min = duration.inMinutes; + var secs = duration.inSeconds.remainder(60); + var secondsPadding = secs < 10 ? "0" : ""; + return "$min:$secondsPadding$secs"; +} + String capitalizeFirstLetter(String? text) { if (text == null || text == "") { return ""; diff --git a/lib/widgets/previewer.dart b/lib/widgets/previewer.dart index 28b3c2ea..e990d3fd 100644 --- a/lib/widgets/previewer.dart +++ b/lib/widgets/previewer.dart @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'error_message.dart'; import 'package:apidash/consts.dart'; +import 'uint8_audio_player.dart'; class Previewer extends StatefulWidget { const Previewer({ @@ -36,7 +37,14 @@ class _PreviewerState extends State { // TODO: PDF Viewer } if (widget.type == kTypeAudio) { - // TODO: Audio Player + return Uint8AudioPlayer( + bytes: widget.bytes, + type: widget.type, + subtype: widget.subtype, + errorBuilder: (context, _, stacktrace) { + return const ErrorMessage(message: kAudioError); + }, + ); } if (widget.type == kTypeVideo) { // TODO: Video Player diff --git a/lib/widgets/uint8_audio_player.dart b/lib/widgets/uint8_audio_player.dart new file mode 100644 index 00000000..65878bcb --- /dev/null +++ b/lib/widgets/uint8_audio_player.dart @@ -0,0 +1,230 @@ +import 'dart:typed_data'; +import 'package:apidash/consts.dart'; +import 'package:apidash/utils/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; + +typedef AudioErrorWidgetBuilder = Widget Function( + BuildContext context, + Object error, + StackTrace? stackTrace, +); + +// Uint8List AudioSource for just_audio +class Uint8AudioSource extends StreamAudioSource { + Uint8AudioSource(this.bytes, {this.type = 'audio', this.subtype = 'mpeg'}); + + final List bytes; + final String? type; + final String? subtype; + + @override + Future request([int? start, int? end]) async { + start ??= 0; + end ??= bytes.length; + return StreamAudioResponse( + sourceLength: bytes.length, + contentLength: end - start, + offset: start, + stream: Stream.value(bytes.sublist(start, end)), + contentType: '$type/$subtype', + ); + } +} + +class Uint8AudioPlayer extends StatefulWidget { + /// Creates a widget for playing audio obtained from a [Uint8List]. + const Uint8AudioPlayer({ + super.key, + required this.bytes, + this.type, + this.subtype, + required this.errorBuilder, + }); + + final Uint8List bytes; + final String? type; + final String? subtype; + final AudioErrorWidgetBuilder errorBuilder; + + @override + State createState() => _Uint8AudioPlayerState(); +} + +class _Uint8AudioPlayerState extends State { + final player = AudioPlayer(); + + @override + void initState() { + player.setAudioSource(Uint8AudioSource(widget.bytes, + type: widget.type, subtype: widget.subtype)); + super.initState(); + } + + @override + void dispose() { + player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: player.playerStateStream, + builder: (context, snapshot) { + if (snapshot.hasError) { + return widget.errorBuilder( + context, snapshot.error!, snapshot.stackTrace); + } else { + final playerState = snapshot.data; + final processingState = playerState?.processingState; + if (processingState == ProcessingState.ready || + processingState == ProcessingState.completed || + processingState == ProcessingState.buffering) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Audio Player + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Duration Position Builder (time elapsed / total duration) + _buildDuration( + player.positionStream, + maxDuration: player.duration, + ), + + // Slider to view & change Duration Position + _buildPositionBar( + player.positionStream, + maxDuration: player.duration, + onChanged: (value) => + player.seek(Duration(seconds: value.toInt())), + ), + + // Total Duration + Text( + audioPosition(player.duration), + style: TextStyle(fontFamily: kCodeStyle.fontFamily), + ), + ], + ), + + // Audio Player Controls + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Play/Pause Button + _buildPlayButton( + player.playingStream, + play: player.play, + pause: player.pause, + restart: () => player.seek(Duration.zero), + completed: processingState == ProcessingState.completed, + ), + + // Mute/UnMute button + _buildVolumeButton( + player.volumeStream, + mute: () => player.setVolume(0), + unmute: () => player.setVolume(1), + ), + ], + ), + ], + ); + } else { + return const Center(child: CircularProgressIndicator()); + } + } + }, + ); + } + + StreamBuilder _buildPlayButton( + Stream stream, { + VoidCallback? play, + VoidCallback? pause, + VoidCallback? restart, + required bool completed, + }) { + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + final playing = snapshot.data; + if (playing != true) { + return IconButton( + icon: const Icon(Icons.play_arrow), + onPressed: play, + ); + } else if (completed) { + return IconButton( + icon: const Icon(Icons.replay), + onPressed: restart, + ); + } else { + return IconButton( + icon: const Icon(Icons.pause), + onPressed: pause, + ); + } + }, + ); + } + + StreamBuilder _buildDuration( + Stream stream, { + Duration? maxDuration, + }) { + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + final position = snapshot.data; + return Text( + audioPosition(position), + style: TextStyle(fontFamily: kCodeStyle.fontFamily), + ); + }, + ); + } + + StreamBuilder _buildPositionBar( + Stream stream, { + Duration? maxDuration, + ValueChanged? onChanged, + }) { + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + return SizedBox( + width: kSliderWidth, + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackShape: const RectangularSliderTrackShape(), + thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0), + overlayShape: const RoundSliderOverlayShape(overlayRadius: 16.0), + ), + child: Slider( + value: snapshot.data?.inSeconds.toDouble() ?? 0, + max: maxDuration?.inSeconds.toDouble() ?? 0, + onChanged: onChanged, + ), + ), + ); + }, + ); + } + + StreamBuilder _buildVolumeButton(Stream stream, + {VoidCallback? mute, VoidCallback? unmute}) { + return StreamBuilder( + stream: stream, + builder: (context, snapshot) { + return snapshot.data == 0 + ? IconButton(icon: const Icon(Icons.volume_off), onPressed: unmute) + : IconButton(icon: const Icon(Icons.volume_up), onPressed: mute); + }, + ); + } +} diff --git a/lib/widgets/widgets.dart b/lib/widgets/widgets.dart index 75426ad1..b259e7bf 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -16,3 +16,4 @@ export 'request_widgets.dart'; export 'response_widgets.dart'; export 'snackbars.dart'; export 'markdown.dart'; +export 'uint8_audio_player.dart'; diff --git a/pubspec.lock b/pubspec.lock index a1de621b..1f5df9a9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.10.0" + audio_session: + dependency: transitive + description: + name: audio_session + sha256: "655343841a723646f74932215c5785ef7156b76d2de4b99bcd3205476f08dc61" + url: "https://pub.dev" + source: hosted + version: "0.1.15" axis_layout: dependency: transitive description: @@ -360,6 +368,30 @@ packages: url: "https://pub.dev" source: hosted version: "6.6.1" + just_audio: + dependency: "direct main" + description: + name: just_audio + sha256: "890cd0fc41a1a4530c171e375a2a3fb6a09d84e9d508c5195f40bcff54330327" + url: "https://pub.dev" + source: hosted + version: "0.9.34" + just_audio_platform_interface: + dependency: transitive + description: + name: just_audio_platform_interface + sha256: d8409da198bbc59426cd45d4c92fca522a2ec269b576ce29459d6d6fcaeb44df + url: "https://pub.dev" + source: hosted + version: "4.2.1" + just_audio_web: + dependency: transitive + description: + name: just_audio_web + sha256: ff62f733f437b25a0ff590f0e295fa5441dcb465f1edbdb33b3dea264705bc13 + url: "https://pub.dev" + source: hosted + version: "0.4.8" lints: dependency: transitive description: @@ -584,6 +616,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.4" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "0c7c0cedd93788d996e33041ffecda924cc54389199cde4e6a34b440f50044cb" + url: "https://pub.dev" + source: hosted + version: "0.27.7" screen_retriever: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index ac71735d..a8f25443 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: path: ^1.8.2 flutter_markdown: ^0.6.14 markdown: ^7.1.0 + just_audio: ^0.9.34 dev_dependencies: flutter_test: From 8a700abe3042439bf4580ea1164c1f050d02f29b Mon Sep 17 00:00:00 2001 From: Dushant Date: Fri, 2 Jun 2023 17:11:23 +0530 Subject: [PATCH 2/4] fix test --- lib/consts.dart | 2 +- lib/widgets/uint8_audio_player.dart | 2 +- test/widgets/previewer_test.dart | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/lib/consts.dart b/lib/consts.dart index 88b22725..47891ebe 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -312,7 +312,7 @@ const Map>> kSubTypeSvg: kCodeRawBodyViewOptions, }, kTypeAudio: { - kSubTypeDefaultViewOptions: kNoBodyViewOptions, + kSubTypeDefaultViewOptions: kPreviewBodyViewOptions, }, kTypeVideo: { kSubTypeDefaultViewOptions: kNoBodyViewOptions, diff --git a/lib/widgets/uint8_audio_player.dart b/lib/widgets/uint8_audio_player.dart index 65878bcb..f9f5fcf4 100644 --- a/lib/widgets/uint8_audio_player.dart +++ b/lib/widgets/uint8_audio_player.dart @@ -88,7 +88,7 @@ class _Uint8AudioPlayerState extends State { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - // Duration Position Builder (time elapsed / total duration) + // Duration Position Builder (time elapsed) _buildDuration( player.positionStream, maxDuration: player.duration, diff --git a/test/widgets/previewer_test.dart b/test/widgets/previewer_test.dart index 4f8137b3..0e83d02d 100644 --- a/test/widgets/previewer_test.dart +++ b/test/widgets/previewer_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:apidash/widgets/previewer.dart'; +import 'package:apidash/widgets/widgets.dart'; import 'package:apidash/consts.dart'; import 'package:flutter/foundation.dart'; import '../test_consts.dart'; @@ -33,9 +33,7 @@ void main() { ), ); - expect( - find.text("${kMimeTypeRaiseIssueStart}audio/mpeg$kMimeTypeRaiseIssue"), - findsOneWidget); + expect(find.byType(Uint8AudioPlayer), findsOneWidget); }); testWidgets('Testing when type/subtype is video/H264', (tester) async { From fad1bf14ce5bfb1bcee5b1fc44e7d6acedfba07d Mon Sep 17 00:00:00 2001 From: Dushant Date: Sat, 3 Jun 2023 11:09:41 +0530 Subject: [PATCH 3/4] Add test and made fixes --- lib/consts.dart | 1 - lib/widgets/previewer.dart | 6 +-- lib/widgets/uint8_audio_player.dart | 76 ++++++++++++++++------------- pubspec.yaml | 2 + test/widgets/previewer_test.dart | 17 +++++++ 5 files changed, 65 insertions(+), 37 deletions(-) diff --git a/lib/consts.dart b/lib/consts.dart index 47891ebe..f6b812a9 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -78,7 +78,6 @@ const kTabHeight = 45.0; const kHeaderHeight = 32.0; const kSegmentHeight = 24.0; const kTextButtonMinWidth = 36.0; -const kSliderWidth = 160.0; const kRandMax = 100000; diff --git a/lib/widgets/previewer.dart b/lib/widgets/previewer.dart index e990d3fd..8cb9c76f 100644 --- a/lib/widgets/previewer.dart +++ b/lib/widgets/previewer.dart @@ -39,9 +39,9 @@ class _PreviewerState extends State { if (widget.type == kTypeAudio) { return Uint8AudioPlayer( bytes: widget.bytes, - type: widget.type, - subtype: widget.subtype, - errorBuilder: (context, _, stacktrace) { + type: widget.type!, + subtype: widget.subtype!, + errorBuilder: (context, error, stacktrace) { return const ErrorMessage(message: kAudioError); }, ); diff --git a/lib/widgets/uint8_audio_player.dart b/lib/widgets/uint8_audio_player.dart index f9f5fcf4..30b751dd 100644 --- a/lib/widgets/uint8_audio_player.dart +++ b/lib/widgets/uint8_audio_player.dart @@ -12,11 +12,10 @@ typedef AudioErrorWidgetBuilder = Widget Function( // Uint8List AudioSource for just_audio class Uint8AudioSource extends StreamAudioSource { - Uint8AudioSource(this.bytes, {this.type = 'audio', this.subtype = 'mpeg'}); + Uint8AudioSource(this.bytes, {required this.contentType}); final List bytes; - final String? type; - final String? subtype; + final String contentType; @override Future request([int? start, int? end]) async { @@ -27,7 +26,7 @@ class Uint8AudioSource extends StreamAudioSource { contentLength: end - start, offset: start, stream: Stream.value(bytes.sublist(start, end)), - contentType: '$type/$subtype', + contentType: contentType, ); } } @@ -37,14 +36,14 @@ class Uint8AudioPlayer extends StatefulWidget { const Uint8AudioPlayer({ super.key, required this.bytes, - this.type, - this.subtype, + required this.type, + required this.subtype, required this.errorBuilder, }); final Uint8List bytes; - final String? type; - final String? subtype; + final String type; + final String subtype; final AudioErrorWidgetBuilder errorBuilder; @override @@ -56,8 +55,10 @@ class _Uint8AudioPlayerState extends State { @override void initState() { - player.setAudioSource(Uint8AudioSource(widget.bytes, - type: widget.type, subtype: widget.subtype)); + player.setAudioSource(Uint8AudioSource( + widget.bytes, + contentType: '${widget.type}/${widget.subtype}', + )); super.initState(); } @@ -85,29 +86,32 @@ class _Uint8AudioPlayerState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ // Audio Player - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Duration Position Builder (time elapsed) - _buildDuration( - player.positionStream, - maxDuration: player.duration, - ), + Padding( + padding: kPh20v10, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Duration Position Builder (time elapsed) + _buildDuration( + player.positionStream, + maxDuration: player.duration, + ), - // Slider to view & change Duration Position - _buildPositionBar( - player.positionStream, - maxDuration: player.duration, - onChanged: (value) => - player.seek(Duration(seconds: value.toInt())), - ), + // Slider to view & change Duration Position + _buildPositionBar( + player.positionStream, + maxDuration: player.duration, + onChanged: (value) => + player.seek(Duration(seconds: value.toInt())), + ), - // Total Duration - Text( - audioPosition(player.duration), - style: TextStyle(fontFamily: kCodeStyle.fontFamily), - ), - ], + // Total Duration + Text( + audioPosition(player.duration), + style: TextStyle(fontFamily: kCodeStyle.fontFamily), + ), + ], + ), ), // Audio Player Controls @@ -134,6 +138,13 @@ class _Uint8AudioPlayerState extends State { ), ], ); + } else if (processingState == ProcessingState.idle) { + // Error in Loading AudioSource + return widget.errorBuilder( + context, + ErrorDescription('${player.audioSource} Loading Error'), + snapshot.stackTrace, + ); } else { return const Center(child: CircularProgressIndicator()); } @@ -197,8 +208,7 @@ class _Uint8AudioPlayerState extends State { return StreamBuilder( stream: stream, builder: (context, snapshot) { - return SizedBox( - width: kSliderWidth, + return Flexible( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackShape: const RectangularSliderTrackShape(), diff --git a/pubspec.yaml b/pubspec.yaml index a8f25443..efbab17d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,8 @@ dependencies: flutter_markdown: ^0.6.14 markdown: ^7.1.0 just_audio: ^0.9.34 + # just_audio_mpv: ^0.1.6 # Uncomment for linux support of just_audio package + # just_audio_windows: ^0.2.0 # Uncomment for windows support of just_audio package dev_dependencies: flutter_test: diff --git a/test/widgets/previewer_test.dart b/test/widgets/previewer_test.dart index 0e83d02d..6ace405b 100644 --- a/test/widgets/previewer_test.dart +++ b/test/widgets/previewer_test.dart @@ -118,4 +118,21 @@ void main() { await tester.pumpAndSettle(); expect(find.text(kImageError), findsOneWidget); }); + + testWidgets('Testing when type/subtype is audio/mpeg corrupted', + (tester) async { + Uint8List bytesAudioCorrupt = + Uint8List.fromList(List.generate(100, (index) => index)); + await tester.pumpWidget( + MaterialApp( + title: 'Previewer', + home: Scaffold( + body: Previewer( + type: 'audio', subtype: 'mpeg', bytes: bytesAudioCorrupt), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text(kAudioError), findsOneWidget); + }); } From 517ba37475a95a8ce788be68555e351f0dff2bad Mon Sep 17 00:00:00 2001 From: Dushant Date: Sat, 3 Jun 2023 23:11:19 +0530 Subject: [PATCH 4/4] Add just_audio dependencies for windows & linux --- pubspec.lock | 32 ++++++++++++++++++++++++++++++++ pubspec.yaml | 4 ++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 1f5df9a9..b8908a29 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -161,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + eventify: + dependency: transitive + description: + name: eventify + sha256: b829429f08586cc2001c628e7499e3e3c2493a1d895fd73b00ecb23351aa5a66 + url: "https://pub.dev" + source: hosted + version: "1.0.1" fake_async: dependency: transitive description: @@ -376,6 +384,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.34" + just_audio_mpv: + dependency: "direct main" + description: + name: just_audio_mpv + sha256: "98ac36712f3fe4fb0cf545f29c250fbd55e52c8445a4b0a4ee0bc9322f192797" + url: "https://pub.dev" + source: hosted + version: "0.1.6" just_audio_platform_interface: dependency: transitive description: @@ -392,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.4.8" + just_audio_windows: + dependency: "direct main" + description: + name: just_audio_windows + sha256: "7b8801f3987e98a2002cd23b5600b2daf162248ff1413266fb44c84448c1c0d3" + url: "https://pub.dev" + source: hosted + version: "0.2.0" lints: dependency: transitive description: @@ -464,6 +488,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + mpv_dart: + dependency: transitive + description: + name: mpv_dart + sha256: a33bd9a68439b496b7a5f36fecd3aa3cf6cbf0176ae15b9b60b12ae96e58f5a4 + url: "https://pub.dev" + source: hosted + version: "0.0.1" multi_split_view: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index efbab17d..67f0c400 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,8 +34,8 @@ dependencies: flutter_markdown: ^0.6.14 markdown: ^7.1.0 just_audio: ^0.9.34 - # just_audio_mpv: ^0.1.6 # Uncomment for linux support of just_audio package - # just_audio_windows: ^0.2.0 # Uncomment for windows support of just_audio package + just_audio_mpv: ^0.1.6 + just_audio_windows: ^0.2.0 dev_dependencies: flutter_test: