From 08f913e9761d0f5c447af9dfb6eedb44b675498c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Tue, 9 Aug 2022 09:10:51 +0600 Subject: [PATCH] feat: add download queue for desktop & initial playlist download support --- lib/components/Shared/TrackTile.dart | 12 ++++ lib/components/Shared/TracksTableView.dart | 84 +++++++++++++++++++--- lib/provider/Downloader.dart | 75 +++++++++++++++++++ pubspec.lock | 14 ++++ pubspec.yaml | 2 + 5 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 lib/provider/Downloader.dart diff --git a/lib/components/Shared/TrackTile.dart b/lib/components/Shared/TrackTile.dart index 114b16442..d098e2f31 100644 --- a/lib/components/Shared/TrackTile.dart +++ b/lib/components/Shared/TrackTile.dart @@ -30,6 +30,10 @@ class TrackTile extends HookConsumerWidget { final bool isActive; + final bool isChecked; + final bool showCheck; + final void Function(bool?)? onCheckChange; + TrackTile( this.playback, { required this.track, @@ -40,6 +44,9 @@ class TrackTile extends HookConsumerWidget { this.thumbnailUrl, this.onTrackPlayButtonPressed, this.showAlbum = true, + this.isChecked = false, + this.showCheck = false, + this.onCheckChange, Key? key, }) : super(key: key); @@ -182,6 +189,11 @@ class TrackTile extends HookConsumerWidget { type: MaterialType.transparency, child: Row( children: [ + if (showCheck) + Checkbox( + value: isChecked, + onChanged: (s) => onCheckChange?.call(s), + ), SizedBox( height: 20, width: 25, diff --git a/lib/components/Shared/TracksTableView.dart b/lib/components/Shared/TracksTableView.dart index 406ee4fa9..81f2310a1 100644 --- a/lib/components/Shared/TracksTableView.dart +++ b/lib/components/Shared/TracksTableView.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/Shared/TrackTile.dart'; import 'package:spotube/hooks/useBreakpoints.dart'; +import 'package:spotube/provider/Downloader.dart'; import 'package:spotube/provider/Playback.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; @@ -26,16 +28,31 @@ class TracksTableView extends HookConsumerWidget { @override Widget build(context, ref) { Playback playback = ref.watch(playbackProvider); + final downloader = ref.watch(downloaderProvider); TextStyle tableHeadStyle = const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); final breakpoint = useBreakpoints(); + final selected = useState>([]); + final showCheck = useState(false); + return SliverList( delegate: SliverChildListDelegate([ if (heading != null) heading!, Row( children: [ + Checkbox( + value: selected.value.length == tracks.length, + onChanged: (checked) { + if (!showCheck.value) showCheck.value = true; + if (checked == true) { + selected.value = tracks.map((s) => s.id!).toList(); + } else { + selected.value = []; + } + }, + ), Padding( padding: const EdgeInsets.all(8.0), child: Text( @@ -75,8 +92,36 @@ class TracksTableView extends HookConsumerWidget { Text("Time", style: tableHeadStyle), const SizedBox(width: 10), ], - SizedBox( - width: breakpoint.isLessThan(Breakpoints.lg) ? 40 : 110, + PopupMenuButton( + itemBuilder: (context) { + return [ + PopupMenuItem( + child: Row( + children: const [ + Icon(Icons.file_download_outlined), + Text("Download"), + ], + ), + onTap: () async { + final spotubeTracks = await Future.wait( + tracks + .where( + (track) => selected.value.contains(track.id), + ) + .map((track) { + return Future.delayed(const Duration(seconds: 2), + () => playback.toSpotubeTrack(track)); + }), + ); + + for (var spotubeTrack in spotubeTracks) { + downloader.addToQueue(spotubeTrack); + } + }, + value: "download", + ), + ]; + }, ), ], ), @@ -87,15 +132,32 @@ class TracksTableView extends HookConsumerWidget { ); String duration = "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; - return TrackTile( - playback, - playlistId: playlistId, - track: track, - duration: duration, - thumbnailUrl: thumbnailUrl, - userPlaylist: userPlaylist, - isActive: playback.track?.id == track.value.id, - onTrackPlayButtonPressed: onTrackPlayButtonPressed, + return GestureDetector( + onDoubleTap: () { + showCheck.value = true; + selected.value = [...selected.value, track.value.id!]; + }, + child: TrackTile( + playback, + playlistId: playlistId, + track: track, + duration: duration, + thumbnailUrl: thumbnailUrl, + userPlaylist: userPlaylist, + isActive: playback.track?.id == track.value.id, + onTrackPlayButtonPressed: onTrackPlayButtonPressed, + isChecked: selected.value.contains(track.value.id), + showCheck: showCheck.value, + onCheckChange: (checked) { + if (checked == true) { + selected.value = [...selected.value, track.value.id!]; + } else { + selected.value = selected.value + .where((id) => id != track.value.id) + .toList(); + } + }, + ), ); }).toList() ]), diff --git a/lib/provider/Downloader.dart b/lib/provider/Downloader.dart new file mode 100644 index 000000000..96a783dfe --- /dev/null +++ b/lib/provider/Downloader.dart @@ -0,0 +1,75 @@ +import 'dart:io'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:queue/queue.dart'; +import 'package:path/path.dart' as path; +import 'package:spotube/models/SpotubeTrack.dart'; +import 'package:spotube/provider/UserPreferences.dart'; +import 'package:spotube/provider/YouTube.dart'; +import 'package:youtube_explode_dart/youtube_explode_dart.dart'; + +Queue _queueInstance = Queue(delay: const Duration(seconds: 1)); + +class Downloader with ChangeNotifier { + Queue _queue; + YoutubeExplode yt; + String downloadPath; + Downloader( + this._queue, { + required this.downloadPath, + required this.yt, + }); + + int currentlyRunning = 0; + + void addToQueue(SpotubeTrack track) { + currentlyRunning++; + notifyListeners(); + _queue.add(() async { + try { + final file = + File(path.join(downloadPath, '${track.ytTrack.title}.mp3')); + // TODO find a way to let the UI know there's already provided file is available + if (file.existsSync()) return; + file.createSync(recursive: true); + StreamManifest manifest = + await yt.videos.streamsClient.getManifest(track.ytTrack.url); + final audioStream = yt.videos.streamsClient + .get( + manifest.audioOnly + .where((audio) => audio.codec.mimeType == "audio/mp4") + .withHighestBitrate(), + ) + .asBroadcastStream(); + + IOSink outputFileStream = file.openWrite(); + await audioStream.pipe(outputFileStream); + await outputFileStream.flush(); + } finally { + currentlyRunning--; + notifyListeners(); + } + }); + } + + cancel() { + _queue.cancel(); + _queueInstance = Queue(); + _queue = _queueInstance; + } +} + +final downloaderProvider = ChangeNotifierProvider( + (ref) { + return Downloader( + _queueInstance, + yt: ref.watch(youtubeProvider), + downloadPath: ref.watch( + userPreferencesProvider.select( + (s) => s.downloadLocation, + ), + ), + ); + }, +); diff --git a/pubspec.lock b/pubspec.lock index c692d9869..7576f2fc8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -538,6 +538,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "0.0.9" + flutter_downloader: + dependency: "direct main" + description: + name: flutter_downloader + url: "https://pub.dartlang.org" + source: hosted + version: "1.8.1" flutter_hooks: dependency: "direct main" description: @@ -1017,6 +1024,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.2.0" + queue: + dependency: "direct main" + description: + name: queue + url: "https://pub.dartlang.org" + source: hosted + version: "3.1.0+1" riverpod: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 284b39bfe..ff4857070 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -67,6 +67,8 @@ dependencies: audio_session: ^0.1.9 file_picker: ^4.6.1 popover: ^0.2.6+3 + queue: ^3.1.0+1 + flutter_downloader: ^1.8.1 dev_dependencies: flutter_test: