diff --git a/lib/consts.dart b/lib/consts.dart index 5c205f86..f1390a3c 100644 --- a/lib/consts.dart +++ b/lib/consts.dart @@ -319,7 +319,7 @@ const Map>> kSubTypeSvg: kCodeRawBodyViewOptions, }, kTypeAudio: { - kSubTypeDefaultViewOptions: kNoBodyViewOptions, + kSubTypeDefaultViewOptions: kPreviewBodyViewOptions, }, kTypeVideo: { kSubTypeDefaultViewOptions: kNoBodyViewOptions, @@ -432,6 +432,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..8cb9c76f 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, error, 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..30b751dd --- /dev/null +++ b/lib/widgets/uint8_audio_player.dart @@ -0,0 +1,240 @@ +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, {required this.contentType}); + + final List bytes; + final String contentType; + + @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: contentType, + ); + } +} + +class Uint8AudioPlayer extends StatefulWidget { + /// Creates a widget for playing audio obtained from a [Uint8List]. + const Uint8AudioPlayer({ + super.key, + required this.bytes, + required this.type, + required 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, + contentType: '${widget.type}/${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 + 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())), + ), + + // 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 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()); + } + } + }, + ); + } + + 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 Flexible( + 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 cd71b3c4..a6f8f859 100644 --- a/lib/widgets/widgets.dart +++ b/lib/widgets/widgets.dart @@ -17,3 +17,4 @@ export 'response_widgets.dart'; export 'snackbars.dart'; export 'markdown.dart'; export 'view_codepane.dart'; +export 'uint8_audio_player.dart'; diff --git a/pubspec.lock b/pubspec.lock index 01ea5140..cf967534 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: @@ -153,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: @@ -360,6 +376,46 @@ 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_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: + 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" + 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: @@ -432,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: @@ -584,6 +648,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 fe3520c3..d944e010 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,6 +34,9 @@ dependencies: path: ^1.8.2 flutter_markdown: ^0.6.14 markdown: ^7.1.0 + just_audio: ^0.9.34 + just_audio_mpv: ^0.1.6 + just_audio_windows: ^0.2.0 dev_dependencies: flutter_test: diff --git a/test/widgets/previewer_test.dart b/test/widgets/previewer_test.dart index 4f8137b3..6ace405b 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 { @@ -120,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); + }); }