diff --git a/lib/components/dialogs/replace_downloaded_dialog.dart b/lib/components/dialogs/replace_downloaded_dialog.dart index 6634a039..5b5b194e 100644 --- a/lib/components/dialogs/replace_downloaded_dialog.dart +++ b/lib/components/dialogs/replace_downloaded_dialog.dart @@ -12,13 +12,12 @@ class ReplaceDownloadedDialog extends ConsumerWidget { @override Widget build(BuildContext context, ref) { - final groupValue = ref.watch(replaceDownloadedFileState); final replaceAll = ref.watch(replaceDownloadedFileState); return AlertDialog( title: Text(context.l10n.track_exists(track.name)), content: RadioGroup( - value: groupValue, + value: replaceAll, onChanged: (value) { ref.read(replaceDownloadedFileState.notifier).state = value; }, diff --git a/lib/components/track_presentation/presentation_actions.dart b/lib/components/track_presentation/presentation_actions.dart index 54aa3428..61202a48 100644 --- a/lib/components/track_presentation/presentation_actions.dart +++ b/lib/components/track_presentation/presentation_actions.dart @@ -89,7 +89,7 @@ class TrackPresentationActionsSection extends HookConsumerWidget { ) ?? false; if (confirmed != true) return; - downloader.batchAddToQueue(fullTrackObjects); + downloader.addAllToQueue(fullTrackObjects); notifier.deselectAllTracks(); if (!context.mounted) return; showToastForAction(context, action, fullTrackObjects.length); diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 61b47968..7d14493e 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -1,5 +1,3 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -44,7 +42,7 @@ class TrackOptions extends HookConsumerWidget { :isActiveTrack, :isAuthenticated, :isLiked, - :progressNotifier + :downloadTask ) = ref.watch(trackOptionsStateProvider(track)); final isLocalTrack = track is SpotubeLocalTrackObject; @@ -211,12 +209,19 @@ class TrackOptions extends HookConsumerWidget { }, enabled: !isInDownloadQueue, leading: isInDownloadQueue - ? HookBuilder(builder: (context) { - final progress = useListenable(progressNotifier); - return CircularProgressIndicator( - value: progress?.value, - ); - }) + ? StreamBuilder( + stream: downloadTask?.downloadedBytesStream, + builder: (context, snapshot) { + final progress = downloadTask?.totalSizeBytes == null || + downloadTask?.totalSizeBytes == 0 + ? 0 + : (snapshot.data ?? 0) / + downloadTask!.totalSizeBytes!; + return CircularProgressIndicator( + value: progress.toDouble(), + ); + }, + ) : const Icon(SpotubeIcons.download), title: Text(context.l10n.download_track), ), diff --git a/lib/extensions/dio.dart b/lib/extensions/dio.dart new file mode 100644 index 00000000..81bb1e70 --- /dev/null +++ b/lib/extensions/dio.dart @@ -0,0 +1,168 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; + +extension ChunkDownloaderDioExtension on Dio { + Future chunkDownload( + String urlPath, + dynamic savePath, { + ProgressCallback? onReceiveProgress, + Map? queryParameters, + CancelToken? cancelToken, + bool deleteOnError = true, + FileAccessMode fileAccessMode = FileAccessMode.write, + String lengthHeader = Headers.contentLengthHeader, + Object? data, + Options? options, + int connections = 4, + }) async { + final targetFile = File(savePath.toString()); + final tempRootDir = await getTemporaryDirectory(); + final tempSaveDir = Directory( + join( + tempRootDir.path, + 'Spotube', + '.chunk_dl_${targetFile.uri.pathSegments.last}', + ), + ); + if (await tempSaveDir.exists()) await tempSaveDir.delete(recursive: true); + await tempSaveDir.create(recursive: true); + + try { + int? totalLength; + bool supportsRange = false; + + Response? headResp; + try { + headResp = await head( + urlPath, + queryParameters: queryParameters, + options: Options( + headers: {'Range': 'bytes=0-0'}, + followRedirects: true, + ), + ); + } catch (_) { + // Some servers reject HEAD -> ignore + } + + final lengthStr = headResp?.headers[lengthHeader]?.first; + if (lengthStr != null) { + final parsed = int.tryParse(lengthStr); + if (parsed != null && parsed > 1) { + totalLength = parsed; + } + } + + supportsRange = headResp?.statusCode == 206 || + headResp?.headers.value(HttpHeaders.acceptRangesHeader) == 'bytes'; + + if (totalLength == null || totalLength <= 1) { + final resp = await get( + urlPath, + options: Options( + responseType: ResponseType.stream, + ), + queryParameters: queryParameters, + cancelToken: cancelToken, + ); + + final len = int.tryParse(resp.headers[lengthHeader]?.first ?? ''); + if (len == null || len <= 1) { + // can’t safely chunk — fallback + return download( + urlPath, + savePath, + onReceiveProgress: onReceiveProgress, + queryParameters: queryParameters, + cancelToken: cancelToken, + deleteOnError: deleteOnError, + options: options, + data: data, + ); + } + + totalLength = len; + supportsRange = + resp.headers.value(HttpHeaders.acceptRangesHeader)?.toLowerCase() == + 'bytes'; + } + + if (!supportsRange || connections <= 1) { + return download( + urlPath, + savePath, + onReceiveProgress: onReceiveProgress, + queryParameters: queryParameters, + cancelToken: cancelToken, + deleteOnError: deleteOnError, + options: options, + data: data, + ); + } + + final chunkSize = (totalLength / connections).ceil(); + int downloaded = 0; + + final partFiles = List.generate( + connections, + (i) => File(join(tempSaveDir.path, 'part_$i')), + ); + + final futures = List.generate(connections, (i) async { + final start = i * chunkSize; + final end = (i + 1) * chunkSize - 1; + if (start >= totalLength!) return; + + final resp = await get( + urlPath, + options: Options( + responseType: ResponseType.stream, + headers: {'Range': 'bytes=$start-$end'}, + ), + queryParameters: queryParameters, + cancelToken: cancelToken, + ); + + final file = partFiles[i]; + if (await file.exists()) await file.delete(); + await file.create(recursive: true); + final sink = file.openWrite(); + + await for (final chunk in resp.data!.stream) { + sink.add(chunk); + downloaded += chunk.length; + onReceiveProgress?.call(downloaded, totalLength); + } + + await sink.close(); + }); + + await Future.wait(futures); + + final targetSink = targetFile.openWrite(); + for (final f in partFiles) { + await targetSink.addStream(f.openRead()); + } + await targetSink.close(); + + await tempSaveDir.delete(recursive: true); + + return Response( + requestOptions: RequestOptions(path: urlPath), + data: targetFile, + statusCode: 200, + statusMessage: 'Chunked download completed ($connections connections)', + ); + } catch (e) { + if (deleteOnError) { + if (await targetFile.exists()) await targetFile.delete(); + if (await tempSaveDir.exists()) { + await tempSaveDir.delete(recursive: true); + } + } + rethrow; + } + } +} diff --git a/lib/modules/library/user_downloads/download_item.dart b/lib/modules/library/user_downloads/download_item.dart index 2dcfc28f..b1cd9f62 100644 --- a/lib/modules/library/user_downloads/download_item.dart +++ b/lib/modules/library/user_downloads/download_item.dart @@ -7,44 +7,19 @@ import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/components/links/artist_link.dart'; import 'package:spotube/components/ui/button_tile.dart'; -import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/services/download_manager/download_status.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; class DownloadItem extends HookConsumerWidget { - final SpotubeFullTrackObject track; + final DownloadTask task; const DownloadItem({ super.key, - required this.track, + required this.task, }); @override Widget build(BuildContext context, ref) { - final downloadManager = ref.watch(downloadManagerProvider); - - final taskStatus = useState(null); - - useEffect(() { - if (track is! SourcedTrack) return null; - final notifier = downloadManager.getStatusNotifier(track); - - taskStatus.value = notifier?.value; - - void listener() { - taskStatus.value = notifier?.value; - } - - notifier?.addListener(listener); - - return () { - notifier?.removeListener(listener); - }; - }, [track]); - - final isQueryingSourceInfo = - taskStatus.value == null || track is! SourcedTrack; + final downloadManager = ref.watch(downloadManagerProvider.notifier); return ButtonTile( style: ButtonVariance.ghost, @@ -55,90 +30,72 @@ class DownloadItem extends HookConsumerWidget { child: UniversalImage( height: 40, width: 40, - path: track.album.images.asUrlString( + path: task.track.album.images.asUrlString( placeholder: ImagePlaceholder.albumArt, ), ), ), ), - title: Text(track.name), + title: Text(task.track.name), subtitle: ArtistLink( - artists: track.artists, + artists: task.track.artists, mainAxisAlignment: WrapAlignment.start, onOverflowArtistClick: () { - context.navigateTo(TrackRoute(trackId: track.id)); + context.navigateTo(TrackRoute(trackId: task.track.id)); }, ), - trailing: isQueryingSourceInfo - ? Text(context.l10n.querying_info).small() - : switch (taskStatus.value!) { - DownloadStatus.downloading => HookBuilder(builder: (context) { - final taskProgress = useListenable(useMemoized( - () => downloadManager.getProgressNotifier(track), - [track], - )); + trailing: switch (task.status) { + DownloadStatus.downloading => HookBuilder(builder: (context) { + return StreamBuilder( + stream: task.downloadedBytesStream, + builder: (context, asyncSnapshot) { + final progress = + task.totalSizeBytes == null || task.totalSizeBytes == 0 + ? 0 + : (asyncSnapshot.data ?? 0) / task.totalSizeBytes!; + return Row( children: [ CircularProgressIndicator( - value: taskProgress?.value ?? 0, + value: progress.toDouble(), ), const SizedBox(width: 10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.pause), - onPressed: () { - downloadManager.pause(track); - }), const SizedBox(width: 10), IconButton.ghost( icon: const Icon(SpotubeIcons.close), onPressed: () { - downloadManager.cancel(track); + downloadManager.cancel(task.track); }), ], ); - }), - DownloadStatus.paused => Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton.ghost( - icon: const Icon(SpotubeIcons.play), - onPressed: () { - downloadManager.resume(track); - }), - const SizedBox(width: 10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - downloadManager.cancel(track); - }) - ], + }); + }), + DownloadStatus.failed || DownloadStatus.canceled => SizedBox( + width: 100, + child: Row( + children: [ + Icon( + SpotubeIcons.error, + color: Colors.red[400], ), - DownloadStatus.failed || DownloadStatus.canceled => SizedBox( - width: 100, - child: Row( - children: [ - Icon( - SpotubeIcons.error, - color: Colors.red[400], - ), - const SizedBox(width: 10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.refresh), - onPressed: () { - downloadManager.retry(track); - }, - ), - ], - ), - ), - DownloadStatus.completed => - Icon(SpotubeIcons.done, color: Colors.green[400]), - DownloadStatus.queued => IconButton.ghost( - icon: const Icon(SpotubeIcons.close), + const SizedBox(width: 10), + IconButton.ghost( + icon: const Icon(SpotubeIcons.refresh), onPressed: () { - downloadManager.removeFromQueue(track); - }), - }, + downloadManager.retry(task.track); + }, + ), + ], + ), + ), + DownloadStatus.completed => + Icon(SpotubeIcons.done, color: Colors.green[400]), + DownloadStatus.queued => IconButton.ghost( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(task.track); + }), + }, ); } } diff --git a/lib/modules/player/player_actions.dart b/lib/modules/player/player_actions.dart index df1e2a2d..9f8639ec 100644 --- a/lib/modules/player/player_actions.dart +++ b/lib/modules/player/player_actions.dart @@ -43,8 +43,12 @@ class PlayerActions extends HookConsumerWidget { final downloader = ref.watch(downloadManagerProvider.notifier); final isInQueue = useMemoized(() { if (playlist.activeTrack is! SpotubeFullTrackObject) return false; - return downloader - .isActive(playlist.activeTrack! as SpotubeFullTrackObject); + final downloadTask = + downloader.getTaskByTrackId(playlist.activeTrack!.id); + return const [ + DownloadStatus.queued, + DownloadStatus.downloading, + ].contains(downloadTask?.status); }, [ playlist.activeTrack, downloader, diff --git a/lib/modules/root/sidebar/sidebar_footer.dart b/lib/modules/root/sidebar/sidebar_footer.dart index 4c46c13b..0f8ac9d8 100644 --- a/lib/modules/root/sidebar/sidebar_footer.dart +++ b/lib/modules/root/sidebar/sidebar_footer.dart @@ -24,7 +24,12 @@ class SidebarFooter extends HookConsumerWidget implements NavigationBarItem { final theme = Theme.of(context); final router = AutoRouter.of(context, watch: true); final mediaQuery = MediaQuery.of(context); - final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; + final downloadCount = ref + .watch(downloadManagerProvider) + .where((e) => + e.status == DownloadStatus.downloading || + e.status == DownloadStatus.queued) + .length; final userSnapshot = ref.watch(metadataPluginUserProvider); final data = userSnapshot.asData?.value; diff --git a/lib/modules/root/spotube_navigation_bar.dart b/lib/modules/root/spotube_navigation_bar.dart index 15417fa6..47ea3ca3 100644 --- a/lib/modules/root/spotube_navigation_bar.dart +++ b/lib/modules/root/spotube_navigation_bar.dart @@ -25,7 +25,12 @@ class SpotubeNavigationBar extends HookConsumerWidget { Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); - final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; + final downloadCount = ref + .watch(downloadManagerProvider) + .where((e) => + e.status == DownloadStatus.downloading || + e.status == DownloadStatus.queued) + .length; final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); diff --git a/lib/modules/root/use_downloader_dialogs.dart b/lib/modules/root/use_downloader_dialogs.dart deleted file mode 100644 index e2f91043..00000000 --- a/lib/modules/root/use_downloader_dialogs.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:async'; - -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; -import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; - -void useDownloaderDialogs(WidgetRef ref) { - final context = useContext(); - final showingDialogCompleter = useRef(Completer()..complete()); - final downloader = ref.watch(downloadManagerProvider); - - useEffect(() { - downloader.onFileExists = (track) async { - if (!context.mounted) return false; - - if (!showingDialogCompleter.value.isCompleted) { - await showingDialogCompleter.value.future; - } - - final replaceAll = ref.read(replaceDownloadedFileState); - - if (replaceAll != null) return replaceAll; - - showingDialogCompleter.value = Completer(); - - if (context.mounted) { - final result = await showDialog( - context: context, - builder: (context) => ReplaceDownloadedDialog( - track: track, - ), - ) ?? - false; - - showingDialogCompleter.value.complete(); - return result; - } - - // it'll never reach here as root_app is always mounted - return false; - }; - return null; - }, [downloader]); -} diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index 172d9af3..de438451 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -17,7 +17,12 @@ class LibraryPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; + final downloadingCount = ref + .watch(downloadManagerProvider) + .where((e) => + e.status == DownloadStatus.downloading || + e.status == DownloadStatus.queued) + .length; final router = context.watchRouter; final sidebarLibraryTileList = useMemoized( () => [ diff --git a/lib/pages/library/user_downloads.dart b/lib/pages/library/user_downloads.dart index 73dc692f..f6a130bb 100644 --- a/lib/pages/library/user_downloads.dart +++ b/lib/pages/library/user_downloads.dart @@ -14,9 +14,8 @@ class UserDownloadsPage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final downloadManager = ref.watch(downloadManagerProvider); - - final history = downloadManager.$history; + final downloadQueue = ref.watch(downloadManagerProvider); + final downloadManagerNotifier = ref.watch(downloadManagerProvider.notifier); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -28,16 +27,15 @@ class UserDownloadsPage extends HookConsumerWidget { children: [ Expanded( child: AutoSizeText( - context.l10n - .currently_downloading(downloadManager.$downloadCount), + context.l10n.currently_downloading(downloadQueue.length), maxLines: 1, ).semiBold(), ), const SizedBox(width: 10), Button.destructive( - onPressed: downloadManager.$downloadCount == 0 + onPressed: downloadQueue.isEmpty ? null - : downloadManager.cancelAll, + : downloadManagerNotifier.clearAll, child: Text(context.l10n.cancel_all), ), ], @@ -46,9 +44,12 @@ class UserDownloadsPage extends HookConsumerWidget { Expanded( child: SafeArea( child: ListView.builder( - itemCount: history.length, + itemCount: downloadQueue.length, + padding: const EdgeInsets.only(bottom: 200), itemBuilder: (context, index) { - return DownloadItem(track: history.elementAt(index).query); + return DownloadItem( + task: downloadQueue.elementAt(index), + ); }, ), ), diff --git a/lib/pages/library/user_local_tracks/local_folder.dart b/lib/pages/library/user_local_tracks/local_folder.dart index 58a7a023..55a148f6 100644 --- a/lib/pages/library/user_local_tracks/local_folder.dart +++ b/lib/pages/library/user_local_tracks/local_folder.dart @@ -14,6 +14,7 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/button/back_button.dart'; +import 'package:spotube/components/track_presentation/presentation_actions.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; @@ -68,6 +69,37 @@ class LocalLibraryPage extends HookConsumerWidget { } } + Future shufflePlayLocalTracks( + WidgetRef ref, + List tracks, + ) async { + final playlist = ref.read(audioPlayerProvider); + final playback = ref.read(audioPlayerProvider.notifier); + final isPlaylistPlaying = playlist.containsTracks(tracks); + final shuffledTracks = tracks.shuffled(); + if (isPlaylistPlaying) return; + + await playback.load( + shuffledTracks, + initialIndex: 0, + autoPlay: true, + ); + } + + Future addToQueueLocalTracks( + BuildContext context, + WidgetRef ref, + List tracks, + ) async { + final playlist = ref.read(audioPlayerProvider); + final playback = ref.read(audioPlayerProvider.notifier); + final isPlaylistPlaying = playlist.containsTracks(tracks); + if (isPlaylistPlaying) return; + await playback.addTracks(tracks); + if (!context.mounted) return; + showToastForAction(context, "add-to-queue", tracks.length); + } + @override Widget build(BuildContext context, ref) { final scale = context.theme.scaling; @@ -75,8 +107,12 @@ class LocalLibraryPage extends HookConsumerWidget { final sortBy = useState(SortBy.none); final playlist = ref.watch(audioPlayerProvider); final trackSnapshot = ref.watch(localTracksProvider); - final isPlaylistPlaying = playlist.containsTracks( - trackSnapshot.asData?.value.values.flattened.toList() ?? []); + final isPlaylistPlaying = useMemoized( + () => playlist.containsTracks( + trackSnapshot.asData?.value[location] ?? [], + ), + [playlist, trackSnapshot, location], + ); final searchController = useShadcnTextEditingController(); useValueListenable(searchController); @@ -222,26 +258,79 @@ class LocalLibraryPage extends HookConsumerWidget { child: Row( children: [ const Gap(5), - Button.primary( - onPressed: trackSnapshot.asData?.value != null - ? () async { - if (trackSnapshot.asData?.value.isNotEmpty == - true) { - if (!isPlaylistPlaying) { - await playLocalTracks( - ref, - trackSnapshot.asData!.value[location] ?? [], - ); + Tooltip( + tooltip: + TooltipContainer(child: Text(context.l10n.play)).call, + child: IconButton.primary( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await playLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? + [], + ); + } } } - } - : null, - leading: Icon( - isPlaylistPlaying - ? SpotubeIcons.stop - : SpotubeIcons.play, + : null, + icon: Icon( + isPlaylistPlaying + ? SpotubeIcons.stop + : SpotubeIcons.play, + ), + ), + ), + const Gap(5), + Tooltip( + tooltip: + TooltipContainer(child: Text(context.l10n.shuffle)) + .call, + child: IconButton.outline( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await shufflePlayLocalTracks( + ref, + trackSnapshot.asData!.value[location] ?? + [], + ); + } + } + } + : null, + enabled: !isPlaylistPlaying, + icon: const Icon(SpotubeIcons.shuffle), + ), + ), + const Gap(5), + Tooltip( + tooltip: TooltipContainer( + child: Text(context.l10n.add_to_queue)) + .call, + child: IconButton.outline( + onPressed: trackSnapshot.asData?.value != null + ? () async { + if (trackSnapshot.asData?.value.isNotEmpty == + true) { + if (!isPlaylistPlaying) { + await addToQueueLocalTracks( + context, + ref, + trackSnapshot.asData!.value[location] ?? + [], + ); + } + } + } + : null, + enabled: !isPlaylistPlaying, + icon: const Icon(SpotubeIcons.queueAdd), ), - child: Text(context.l10n.play), ), const Spacer(), if (constraints.smAndDown) diff --git a/lib/pages/library/user_local_tracks/user_local_tracks.dart b/lib/pages/library/user_local_tracks/user_local_tracks.dart index 43fa3cc9..5f7502e6 100644 --- a/lib/pages/library/user_local_tracks/user_local_tracks.dart +++ b/lib/pages/library/user_local_tracks/user_local_tracks.dart @@ -13,7 +13,6 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/local_tracks/local_tracks_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/utils/platform.dart'; -// ignore: depend_on_referenced_packages enum SortBy { none, diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 4cd02881..44b8416f 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -9,7 +9,6 @@ import 'package:spotube/modules/root/bottom_player.dart'; import 'package:spotube/modules/root/sidebar/sidebar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; -import 'package:spotube/modules/root/use_downloader_dialogs.dart'; import 'package:spotube/modules/root/use_global_subscriptions.dart'; import 'package:spotube/provider/glance/glance.dart'; @@ -25,7 +24,6 @@ class RootAppPage extends HookConsumerWidget { ref.listen(glanceProvider, (_, __) {}); useGlobalSubscriptions(ref); - useDownloaderDialogs(ref); useEndlessPlayback(ref); useCheckYtDlpInstalled(ref); diff --git a/lib/provider/audio_player/state.dart b/lib/provider/audio_player/state.dart index bb0527bf..7bd47f76 100644 --- a/lib/provider/audio_player/state.dart +++ b/lib/provider/audio_player/state.dart @@ -51,7 +51,11 @@ class AudioPlayerState with _$AudioPlayerState { } bool containsTrack(SpotubeTrackObject track) { - return tracks.any((t) => t.id == track.id); + return tracks.any( + (t) => t is SpotubeLocalTrackObject && track is SpotubeLocalTrackObject + ? t.path == track.path + : t.id == track.id, + ); } bool containsTracks(List tracks) { diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index d64da32e..0ca99ec1 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,268 +1,285 @@ import 'dart:async'; import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:dio/dio.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:metadata_god/metadata_god.dart'; +import 'package:path/path.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' hide join; +import 'package:spotube/collections/routes.dart'; +import 'package:spotube/components/dialogs/replace_downloaded_dialog.dart'; +import 'package:spotube/extensions/dio.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart'; import 'package:spotube/provider/server/sourced_track_provider.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:collection/collection.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:metadata_god/metadata_god.dart'; -import 'package:path/path.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; -import 'package:spotube/services/download_manager/download_manager.dart'; -import 'package:spotube/services/sourced_track/sourced_track.dart'; -import 'package:spotube/utils/primitive_utils.dart'; +import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/utils/service_utils.dart'; -class DownloadManagerProvider extends ChangeNotifier { - DownloadManagerProvider({required this.ref}) - : $history = {}, - dl = DownloadManager() { - dl.statusStream.listen((event) async { - try { - final (:request, :status) = event; +enum DownloadStatus { + queued, + downloading, + completed, + failed, + canceled, +} - final sourcedTrack = $history.firstWhereOrNull( - (element) => - element.getUrlOfQuality( - downloadContainer, - downloadQualityIndex, - ) == - request.url, - ); +class DownloadTask { + final SpotubeFullTrackObject track; + final DownloadStatus status; + final CancelToken cancelToken; + final int? totalSizeBytes; + final StreamController _downloadedBytesStreamController; - if (sourcedTrack == null) return; + Stream get downloadedBytesStream => + _downloadedBytesStreamController.stream; - final savePath = getTrackFileUrl(sourcedTrack); - // related to onFileExists - final oldFile = File("$savePath.old"); + DownloadTask({ + required this.track, + required this.status, + required this.cancelToken, + this.totalSizeBytes, + StreamController? downloadedBytesStreamController, + }) : _downloadedBytesStreamController = + downloadedBytesStreamController ?? StreamController.broadcast(); - // if download failed and old file exists, rename it back - if ((status == DownloadStatus.failed || - status == DownloadStatus.canceled) && - await oldFile.exists()) { - await oldFile.rename(savePath); - } - - if (status != DownloadStatus.completed || - //? WebA audiotagging is not supported yet - //? Although in future by converting weba to opus & then tagging it - //? is possible using vorbis comments - downloadContainer.getFileExtension() == "weba") { - return; - } - - final file = File(request.path); - - if (await oldFile.exists()) { - await oldFile.delete(); - } - - final imageBytes = await ServiceUtils.downloadImage( - (sourcedTrack.query.album.images).asUrlString( - placeholder: ImagePlaceholder.albumArt, - index: 1, - ), - ); - - final metadata = sourcedTrack.query.toMetadata( - fileLength: await file.length(), - imageBytes: imageBytes, - ); - - await MetadataGod.writeMetadata( - file: file.path, - metadata: metadata, - ); - } catch (e, stack) { - AppLogger.reportError(e, stack); - } - }); - } - - Future Function(SpotubeFullTrackObject track) onFileExists = - (SpotubeFullTrackObject track) async => true; - - final Ref ref; - - String get downloadDirectory => - ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); - SpotubeAudioSourceContainerPreset get downloadContainer => ref.read( - audioSourcePresetsProvider - .select((s) => s.presets[s.selectedDownloadingContainerIndex]), - ); - - int get downloadQualityIndex => ref.read(audioSourcePresetsProvider - .select((s) => s.selectedDownloadingQualityIndex)); - - int get $downloadCount => dl - .getAllDownloads() - .where( - (download) => - download.status.value == DownloadStatus.downloading || - download.status.value == DownloadStatus.paused || - download.status.value == DownloadStatus.queued, - ) - .length; - - final Set $history; - // these are the tracks which metadata hasn't been fetched yet - final DownloadManager dl; - - String getTrackFileUrl(SourcedTrack track) { - final name = - "${track.query.name} - ${track.query.artists.map((e) => e.name).join(", ")}.${downloadContainer.getFileExtension()}"; - return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name)); - } - - bool isActive(SpotubeFullTrackObject track) { - if ($history.any((e) => e.query.id == track.id)) return true; - - final sourcedTrack = $history.firstWhereOrNull( - (element) => element.query.id == track.id, + DownloadTask copyWith({ + SpotubeFullTrackObject? track, + DownloadStatus? status, + CancelToken? cancelToken, + int? totalSizeBytes, + StreamController? downloadedBytesStreamController, + }) { + return DownloadTask( + track: track ?? this.track, + status: status ?? this.status, + cancelToken: cancelToken ?? this.cancelToken, + totalSizeBytes: totalSizeBytes ?? this.totalSizeBytes, + downloadedBytesStreamController: + downloadedBytesStreamController ?? _downloadedBytesStreamController, ); - - if (sourcedTrack == null) return false; - - return dl - .getAllDownloads() - .where( - (download) => - download.status.value == DownloadStatus.downloading || - download.status.value == DownloadStatus.paused || - download.status.value == DownloadStatus.queued, - ) - .map((e) => e.request.url) - .contains(sourcedTrack.getUrlOfQuality( - downloadContainer, - downloadQualityIndex, - )!); - } - - /// For singular downloads - Future addToQueue(SpotubeFullTrackObject track) async { - final sourcedTrack = await ref.read(sourcedTrackProvider(track).future); - - final savePath = getTrackFileUrl(sourcedTrack); - - final oldFile = File(savePath); - if (await oldFile.exists() && !await onFileExists(track)) { - return; - } - - if (await oldFile.exists()) { - await oldFile.rename("$savePath.old"); - } - - final downloadTask = await dl.addDownload( - sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!, - savePath, - ); - if (downloadTask != null) { - $history.add(sourcedTrack); - } - notifyListeners(); - } - - Future batchAddToQueue(List tracks) async { - notifyListeners(); - for (final track in tracks) { - try { - if (track == tracks.first) { - await addToQueue(track); - } else { - await Future.delayed( - const Duration(seconds: 1), - () => addToQueue(track), - ); - } - } catch (e) { - AppLogger.reportError(e, StackTrace.current); - continue; - } - } - } - - Future removeFromQueue(SpotubeFullTrackObject track) async { - final sourcedTrack = await mapToSourcedTrack(track); - await dl.removeDownload( - sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!); - $history.remove(sourcedTrack); - } - - Future pause(SpotubeFullTrackObject track) async { - final sourcedTrack = await mapToSourcedTrack(track); - return dl.pauseDownload( - sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!); - } - - Future resume(SpotubeFullTrackObject track) async { - final sourcedTrack = await mapToSourcedTrack(track); - return dl.resumeDownload( - sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!); - } - - Future retry(SpotubeFullTrackObject track) { - return addToQueue(track); - } - - void cancel(SpotubeFullTrackObject track) async { - final sourcedTrack = await mapToSourcedTrack(track); - return dl.cancelDownload( - sourcedTrack.getUrlOfQuality(downloadContainer, downloadQualityIndex)!); - } - - void cancelAll() { - for (final download in dl.getAllDownloads()) { - if (download.status.value == DownloadStatus.completed) continue; - dl.cancelDownload(download.request.url); - } - } - - Future mapToSourcedTrack(SpotubeFullTrackObject track) async { - final historicTrack = - $history.firstWhereOrNull((element) => element.query.id == track.id); - - if (historicTrack != null) { - return historicTrack; - } - - final sourcedTrack = await ref.read(sourcedTrackProvider(track).future); - - return sourcedTrack; - } - - ValueNotifier? getStatusNotifier( - SpotubeFullTrackObject track, - ) { - final sourcedTrack = $history.firstWhereOrNull( - (element) => element.query.id == track.id, - ); - if (sourcedTrack == null) { - return null; - } - return dl - .getDownload(sourcedTrack.getUrlOfQuality( - downloadContainer, downloadQualityIndex)!) - ?.status; - } - - ValueNotifier? getProgressNotifier(SpotubeFullTrackObject track) { - final sourcedTrack = $history.firstWhereOrNull( - (element) => element.query.id == track.id, - ); - if (sourcedTrack == null) { - return null; - } - return dl - .getDownload(sourcedTrack.getUrlOfQuality( - downloadContainer, downloadQualityIndex)!) - ?.progress; } } -final downloadManagerProvider = ChangeNotifierProvider( - (ref) => DownloadManagerProvider(ref: ref), +class DownloadManagerNotifier extends Notifier> { + final Dio dio; + DownloadManagerNotifier() + : dio = Dio(), + super(); + + @override + build() { + ref.onDispose(() { + for (final task in state) { + if (task.status == DownloadStatus.downloading) { + task.cancelToken.cancel(); + } + task._downloadedBytesStreamController.close(); + } + }); + + return []; + } + + DownloadTask? getTaskByTrackId(String trackId) { + return state.firstWhereOrNull((element) => element.track.id == trackId); + } + + void addToQueue(SpotubeFullTrackObject track) { + if (state.any((element) => element.track.id == track.id)) return; + state = [ + ...state, + DownloadTask( + track: track, + status: DownloadStatus.queued, + cancelToken: CancelToken(), + ), + ]; + + ref.read(sourcedTrackProvider(track)); + + _startDownloading(); // No await should be invoked to avoid stuck UI + } + + void addAllToQueue(List tracks) { + state = [ + ...state, + ...tracks.map((e) => DownloadTask( + track: e, + status: DownloadStatus.queued, + cancelToken: CancelToken(), + )), + ]; + + ref.read(sourcedTrackProvider(tracks.first)); + _startDownloading(); // No await should be invoked to avoid stuck UI + } + + void retry(SpotubeFullTrackObject track) { + if (state.firstWhereOrNull((e) => e.track.id == track.id)?.status + case DownloadStatus.canceled || DownloadStatus.failed) { + _setStatus(track, DownloadStatus.queued); + _startDownloading(); // No await should be invoked to avoid stuck UI + } + } + + void cancel(SpotubeFullTrackObject track) { + if (state.firstWhereOrNull((e) => e.track.id == track.id)?.status == + DownloadStatus.failed) { + return; + } + _setStatus(track, DownloadStatus.canceled); + } + + void clearAll() { + for (final task in state) { + if (task.status == DownloadStatus.downloading) { + task.cancelToken.cancel(); + } + } + state = []; + } + + void _setStatus(SpotubeFullTrackObject track, DownloadStatus status) { + state = state.map((e) { + if (e.track.id == track.id) { + if ((status == DownloadStatus.canceled) && e.cancelToken.isCancelled) { + e.cancelToken.cancel(); + } + + return e.copyWith(status: status); + } + return e; + }).toList(); + } + + bool _isShowingDialog = false; + + Future _shouldReplaceFileOnExist(DownloadTask task) async { + if (rootNavigatorKey.currentContext == null || _isShowingDialog) { + return false; + } + final replaceAll = ref.read(replaceDownloadedFileState); + if (replaceAll != null) return replaceAll; + _isShowingDialog = true; + try { + return await showDialog( + context: rootNavigatorKey.currentContext!, + builder: (context) => ReplaceDownloadedDialog( + track: task.track, + ), + ) ?? + false; + } finally { + _isShowingDialog = false; + } + } + + Future _downloadTrack(DownloadTask task) async { + try { + _setStatus(task.track, DownloadStatus.downloading); + final track = await ref.read(sourcedTrackProvider(task.track).future); + if (task.cancelToken.isCancelled) { + _setStatus(task.track, DownloadStatus.canceled); + } + final presets = ref.read(audioSourcePresetsProvider); + final container = + presets.presets[presets.selectedDownloadingContainerIndex]; + final downloadLocation = ref.read( + userPreferencesProvider.select((value) => value.downloadLocation)); + + final url = track.getUrlOfQuality( + container, + presets.selectedDownloadingQualityIndex, + ); + + if (url == null) { + throw Exception("No download URL found for selected codec"); + } + + final savePath = join( + downloadLocation, + ServiceUtils.sanitizeFilename( + "${track.query.name} - ${track.query.artists.map((e) => e.name).join(", ")}.${container.getFileExtension()}", + ), + ); + + final savePathFile = File(savePath); + if (await savePathFile.exists()) { + // dio automatically replaces the file if it exists so no deletion required + if (!await _shouldReplaceFileOnExist(task)) { + _setStatus(track.query, DownloadStatus.completed); + return; + } + } + + final response = await dio.chunkDownload( + url, + savePath, + cancelToken: task.cancelToken, + onReceiveProgress: (count, total) { + if (task.totalSizeBytes == null) { + state = state.map((e) { + if (e.track.id == track.query.id) { + return e.copyWith(totalSizeBytes: total); + } + return e; + }).toList(); + } + task._downloadedBytesStreamController.add(count); + }, + deleteOnError: true, + fileAccessMode: FileAccessMode.write, + ); + if (response.statusCode != null && response.statusCode! < 400) { + _setStatus(track.query, DownloadStatus.completed); + } else { + _setStatus(track.query, DownloadStatus.failed); + return; + } + + if (container.getFileExtension() == "weba") return; + + final imageBytes = await ServiceUtils.downloadImage( + (task.track.album.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + index: 1, + ), + ); + await MetadataGod.writeMetadata( + file: savePath, + metadata: task.track.toMetadata( + fileLength: await savePathFile.length(), + imageBytes: imageBytes, + ), + ); + } catch (e, stack) { + if (e is! DioException || e.type != DioExceptionType.cancel) { + _setStatus(task.track, DownloadStatus.failed); + AppLogger.reportError(e, stack); + } + } + } + + Future _startDownloading() async { + for (final task in state) { + if (task.status == DownloadStatus.downloading) return; + + if (task.status == DownloadStatus.queued) { + try { + await _downloadTrack(task); + } finally { + // After completion, check for more queued tasks + // Ignore errors of the prior task to allow next task to complete + await _startDownloading(); + } + } + } + } +} + +final downloadManagerProvider = + NotifierProvider>( + DownloadManagerNotifier.new, ); diff --git a/lib/provider/track_options/track_options_provider.dart b/lib/provider/track_options/track_options_provider.dart index e6b05201..d31aba73 100644 --- a/lib/provider/track_options/track_options_provider.dart +++ b/lib/provider/track_options/track_options_provider.dart @@ -49,7 +49,7 @@ class TrackOptionsActions { ref.read(metadataPluginSavedTracksProvider.notifier); MetadataPluginSavedPlaylistsNotifier get favoritePlaylistsNotifier => ref.read(metadataPluginSavedPlaylistsProvider.notifier); - DownloadManagerProvider get downloadManager => + DownloadManagerNotifier get downloadManager => ref.read(downloadManagerProvider.notifier); BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier); @@ -263,7 +263,7 @@ typedef TrackOptionFlags = ({ bool isActiveTrack, bool isAuthenticated, bool isLiked, - ValueNotifier? progressNotifier, + DownloadTask? downloadTask, }); final trackOptionActionsProvider = @@ -283,15 +283,16 @@ final trackOptionsStateProvider = final isBlacklisted = blacklist.contains(track); final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id)); + final downloadTask = playlist.activeTrack?.id == null + ? null + : downloadManager.getTaskByTrackId(playlist.activeTrack!.id); final isInDownloadQueue = playlist.activeTrack == null || playlist.activeTrack! is SpotubeLocalTrackObject ? false - : downloadManager - .isActive(playlist.activeTrack! as SpotubeFullTrackObject); - - final progressNotifier = track is SpotubeLocalTrackObject - ? null - : downloadManager.getProgressNotifier(track as SpotubeFullTrackObject); + : const [ + DownloadStatus.queued, + DownloadStatus.downloading, + ].contains(downloadTask?.status); return ( isInQueue: playlist.containsTrack(track), @@ -300,6 +301,6 @@ final trackOptionsStateProvider = isActiveTrack: playlist.activeTrack?.id == track.id, isAuthenticated: authenticated.asData?.value ?? false, isLiked: isSavedTrack.asData?.value ?? false, - progressNotifier: progressNotifier, + downloadTask: downloadTask, ); }); diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart deleted file mode 100644 index 80a3e78f..00000000 --- a/lib/services/download_manager/chunked_download.dart +++ /dev/null @@ -1,145 +0,0 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:dio/dio.dart'; - -/// Downloading by spiting as file in chunks -extension ChunkDownload on Dio { - Future chunkedDownload( - url, { - Map? queryParameters, - required String savePath, - ProgressCallback? onReceiveProgress, - CancelToken? cancelToken, - bool deleteOnError = true, - int chunkSize = 102400, // 100KB - int maxConcurrentChunk = 3, - String tempExtension = ".temp", - }) async { - int total = 0; - var progress = []; - - ProgressCallback createCallback(int chunkIndex) { - return (int received, _) { - progress[chunkIndex] = received; - if (onReceiveProgress != null && total != 0) { - onReceiveProgress(progress.reduce((a, b) => a + b), total); - } - }; - } - - // this is the last response - // status & headers will the last chunk's status & headers - final completer = Completer(); - - Future downloadChunk( - String url, { - required int start, - required int end, - required int chunkIndex, - }) async { - progress.add(0); - --end; - final res = await download( - url, - savePath + tempExtension + chunkIndex.toString(), - onReceiveProgress: createCallback(chunkIndex), - cancelToken: cancelToken, - queryParameters: queryParameters, - deleteOnError: deleteOnError, - options: Options( - responseType: ResponseType.bytes, - headers: {"range": "bytes=$start-$end"}, - ), - ); - - return res; - } - - Future mergeTempFiles(int chunk) async { - File headFile = File("$savePath${tempExtension}0"); - var raf = await headFile.open(mode: FileMode.writeOnlyAppend); - for (int i = 1; i < chunk; ++i) { - File chunkFile = File(savePath + tempExtension + i.toString()); - raf = await raf.writeFrom(await chunkFile.readAsBytes()); - await chunkFile.delete(); - } - await raf.close(); - - headFile = await headFile.rename(savePath); - } - - final firstResponse = await downloadChunk( - url, - start: 0, - end: chunkSize, - chunkIndex: 0, - ); - - final responses = [firstResponse]; - - if (firstResponse.statusCode == HttpStatus.partialContent) { - total = int.parse( - firstResponse.headers - .value(HttpHeaders.contentRangeHeader) - ?.split("/") - .lastOrNull ?? - '0', - ); - - final reserved = total - - int.parse( - firstResponse.headers.value(HttpHeaders.contentLengthHeader) ?? - // since its a partial content, the content length will be the chunk size - chunkSize.toString(), - ); - - int chunk = (reserved / chunkSize).ceil() + 1; - - if (chunk > 1) { - int currentChunkSize = chunkSize; - if (chunk > maxConcurrentChunk + 1) { - chunk = maxConcurrentChunk + 1; - currentChunkSize = (reserved / maxConcurrentChunk).ceil(); - } - - responses.addAll( - await Future.wait( - List.generate(maxConcurrentChunk, (i) { - int start = chunkSize + i * currentChunkSize; - return downloadChunk( - url, - start: start, - end: start + currentChunkSize, - chunkIndex: i + 1, - ); - }), - ), - ); - } - - await mergeTempFiles(chunk).then((_) { - final response = responses.last; - final isPartialStatus = - response.statusCode == HttpStatus.partialContent; - - completer.complete( - Response( - data: response.data, - headers: response.headers, - requestOptions: response.requestOptions, - statusCode: isPartialStatus ? HttpStatus.ok : response.statusCode, - statusMessage: isPartialStatus ? 'Ok' : response.statusMessage, - extra: response.extra, - isRedirect: response.isRedirect, - redirects: response.redirects, - ), - ); - }).catchError((e) { - completer.completeError(e); - }); - } - - return completer.future; - } -} diff --git a/lib/services/download_manager/download_manager.dart b/lib/services/download_manager/download_manager.dart deleted file mode 100644 index d2072bd7..00000000 --- a/lib/services/download_manager/download_manager.dart +++ /dev/null @@ -1,416 +0,0 @@ -import 'dart:async'; -import 'dart:collection'; -import 'dart:io'; -import 'package:collection/collection.dart'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as path; -import 'package:path_provider/path_provider.dart'; - -import 'package:spotube/services/download_manager/chunked_download.dart'; -import 'package:spotube/services/download_manager/download_request.dart'; -import 'package:spotube/services/download_manager/download_status.dart'; -import 'package:spotube/services/download_manager/download_task.dart'; -import 'package:spotube/services/logger/logger.dart'; -import 'package:spotube/utils/primitive_utils.dart'; - -export './download_request.dart'; -export './download_status.dart'; -export './download_task.dart'; - -typedef DownloadStatusEvent = ({ - DownloadStatus status, - DownloadRequest request -}); - -class DownloadManager { - final Map _cache = {}; - final Queue _queue = Queue(); - var dio = Dio(); - static const partialExtension = ".partial"; - static const tempExtension = ".temp"; - - // var tasks = StreamController(); - - final _statusStreamController = - StreamController.broadcast(); - Stream get statusStream => - _statusStreamController.stream; - - int maxConcurrentTasks = 2; - int runningTasks = 0; - - static final DownloadManager _dm = DownloadManager._internal(); - - DownloadManager._internal(); - - factory DownloadManager({int? maxConcurrentTasks}) { - if (maxConcurrentTasks != null) { - _dm.maxConcurrentTasks = maxConcurrentTasks; - } - return _dm; - } - - void Function(int, int) createCallback(url, int partialFileLength) => - (int received, int total) { - getDownload(url)?.progress.value = - (received + partialFileLength) / (total + partialFileLength); - - if (total == -1) {} - }; - - Future download( - String url, - String savePath, - CancelToken cancelToken, { - forceDownload = false, - }) async { - late String partialFilePath; - late File partialFile; - try { - final task = getDownload(url); - - if (task == null || task.status.value == DownloadStatus.canceled) { - return; - } - setStatus(task, DownloadStatus.downloading); - - final file = File(savePath.toString()); - - await Directory(path.dirname(savePath)).create(recursive: true); - - final tmpDirPath = await Directory( - path.join( - (await getTemporaryDirectory()).path, - "spotube-downloads", - ), - ).create(recursive: true); - - partialFilePath = path.join( - tmpDirPath.path, - path.basename(savePath) + partialExtension, - ); - partialFile = File(partialFilePath); - - final fileExist = await file.exists(); - final partialFileExist = await partialFile.exists(); - - if (fileExist) { - setStatus(task, DownloadStatus.completed); - } else if (partialFileExist) { - final partialFileLength = await partialFile.length(); - - final response = await dio.download( - url, - partialFilePath + tempExtension, - onReceiveProgress: createCallback(url, partialFileLength), - options: Options( - headers: { - HttpHeaders.rangeHeader: 'bytes=$partialFileLength-', - HttpHeaders.connectionHeader: "close", - }, - ), - cancelToken: cancelToken, - deleteOnError: true, - ); - - if (response.statusCode == HttpStatus.partialContent) { - final ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend); - final partialChunkFile = File(partialFilePath + tempExtension); - await ioSink.addStream(partialChunkFile.openRead()); - await partialChunkFile.delete(); - await ioSink.close(); - - await partialFile.copy(savePath); - await partialFile.delete(); - - setStatus(task, DownloadStatus.completed); - } - } else { - final response = await dio.chunkedDownload( - url, - savePath: partialFilePath, - onReceiveProgress: createCallback(url, 0), - cancelToken: cancelToken, - deleteOnError: true, - ); - - if (response.statusCode == HttpStatus.ok) { - await partialFile.copy(savePath); - await partialFile.delete(); - setStatus(task, DownloadStatus.completed); - } - } - } catch (e, stackTrace) { - AppLogger.reportError(e, stackTrace); - - var task = getDownload(url)!; - if (task.status.value != DownloadStatus.canceled && - task.status.value != DownloadStatus.paused) { - setStatus(task, DownloadStatus.failed); - runningTasks--; - - if (_queue.isNotEmpty) { - _startExecution(); - } - rethrow; - } else if (task.status.value == DownloadStatus.paused) { - final ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend); - final f = File(partialFilePath + tempExtension); - if (await f.exists()) { - await ioSink.addStream(f.openRead()); - } - await ioSink.close(); - } - } - - runningTasks--; - - if (_queue.isNotEmpty) { - _startExecution(); - } - } - - void disposeNotifiers(DownloadTask task) { - // task.status.dispose(); - // task.progress.dispose(); - } - - void setStatus(DownloadTask? task, DownloadStatus status) { - if (task != null) { - task.status.value = status; - - // tasks.add(task); - if (status.isCompleted) { - disposeNotifiers(task); - } - - _statusStreamController.add((status: status, request: task.request)); - } - } - - Future addDownload(String url, String savedPath) async { - if (url.isEmpty) throw Exception("Invalid Url. Url is empty: $url"); - return _addDownloadRequest(DownloadRequest(url, savedPath)); - } - - Future _addDownloadRequest( - DownloadRequest downloadRequest, - ) async { - if (_cache[downloadRequest.url] != null) { - if (!_cache[downloadRequest.url]!.status.value.isCompleted && - _cache[downloadRequest.url]!.request == downloadRequest) { - // Do nothing - return _cache[downloadRequest.url]!; - } else { - _queue.remove(_cache[downloadRequest.url]?.request); - } - } - - _queue.add(DownloadRequest(downloadRequest.url, downloadRequest.path)); - - final task = DownloadTask(_queue.last); - - _cache[downloadRequest.url] = task; - - _startExecution(); - - return task; - } - - Future pauseDownload(String url) async { - var task = getDownload(url)!; - setStatus(task, DownloadStatus.paused); - task.request.cancelToken.cancel(); - - _queue.remove(task.request); - } - - Future cancelDownload(String url) async { - var task = getDownload(url)!; - setStatus(task, DownloadStatus.canceled); - _queue.remove(task.request); - task.request.cancelToken.cancel(); - } - - Future resumeDownload(String url) async { - var task = getDownload(url)!; - setStatus(task, DownloadStatus.downloading); - task.request.cancelToken = CancelToken(); - _queue.add(task.request); - - _startExecution(); - } - - Future removeDownload(String url) async { - cancelDownload(url); - _cache.remove(url); - } - - // Do not immediately call getDownload After addDownload, rather use the returned DownloadTask from addDownload - DownloadTask? getDownload(String url) { - return _cache[url]; - } - - Future whenDownloadComplete(String url, - {Duration timeout = const Duration(hours: 2)}) async { - DownloadTask? task = getDownload(url); - - if (task != null) { - return task.whenDownloadComplete(timeout: timeout); - } else { - return Future.error("Not found"); - } - } - - List getAllDownloads() { - return _cache.values.toList(); - } - - // Batch Download Mechanism - Future addBatchDownloads(List urls, String savePath) async { - for (final url in urls) { - addDownload(url, savePath); - } - } - - List getBatchDownloads(List urls) { - return urls.map((e) => _cache[e]).toList(); - } - - Future pauseBatchDownloads(List urls) async { - for (var element in urls) { - pauseDownload(element); - } - } - - Future cancelBatchDownloads(List urls) async { - for (var element in urls) { - cancelDownload(element); - } - } - - Future resumeBatchDownloads(List urls) async { - for (var element in urls) { - resumeDownload(element); - } - } - - ValueNotifier getBatchDownloadProgress(List urls) { - ValueNotifier progress = ValueNotifier(0); - var total = urls.length; - - if (total == 0) { - return progress; - } - - if (total == 1) { - return getDownload(urls.first)?.progress ?? progress; - } - - var progressMap = {}; - - for (var url in urls) { - DownloadTask? task = getDownload(url); - - if (task != null) { - progressMap[url] = 0.0; - - if (task.status.value.isCompleted) { - progressMap[url] = 1.0; - progress.value = progressMap.values.sum / total; - } - - void progressListener() { - progressMap[url] = task.progress.value; - progress.value = progressMap.values.sum / total; - } - - task.progress.addListener(progressListener); - - void listener() { - if (task.status.value.isCompleted) { - progressMap[url] = 1.0; - progress.value = progressMap.values.sum / total; - task.status.removeListener(listener); - task.progress.removeListener(progressListener); - } - } - - task.status.addListener(listener); - } else { - total--; - } - } - - return progress; - } - - Future?> whenBatchDownloadsComplete(List urls, - {Duration timeout = const Duration(hours: 2)}) async { - var completer = Completer?>(); - - var completed = 0; - var total = urls.length; - - for (final url in urls) { - DownloadTask? task = getDownload(url); - - if (task != null) { - if (task.status.value.isCompleted) { - completed++; - - if (completed == total) { - completer.complete(getBatchDownloads(urls)); - } - } - - void listener() { - if (task.status.value.isCompleted) { - completed++; - - if (completed == total) { - completer.complete(getBatchDownloads(urls)); - task.status.removeListener(listener); - } - } - } - - task.status.addListener(listener); - } else { - total--; - - if (total == 0) { - completer.complete(null); - } - } - } - - return completer.future.timeout(timeout); - } - - void _startExecution() async { - if (runningTasks == maxConcurrentTasks || _queue.isEmpty) { - return; - } - - while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) { - runningTasks++; - var currentRequest = _queue.removeFirst(); - - await download( - currentRequest.url, - currentRequest.path, - currentRequest.cancelToken, - ); - - await Future.delayed(const Duration(milliseconds: 500), null); - } - } - - /// This function is used for get file name with extension from url - String getFileNameFromUrl(String url) { - return PrimitiveUtils.toSafeFileName(url.split('/').last); - } -} diff --git a/lib/services/download_manager/download_request.dart b/lib/services/download_manager/download_request.dart deleted file mode 100644 index 80c4af37..00000000 --- a/lib/services/download_manager/download_request.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:dio/dio.dart'; - -class DownloadRequest { - final String url; - final String path; - var cancelToken = CancelToken(); - var forceDownload = false; - - DownloadRequest( - this.url, - this.path, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is DownloadRequest && - runtimeType == other.runtimeType && - url == other.url && - path == other.path; - - @override - int get hashCode => url.hashCode ^ path.hashCode; -} diff --git a/lib/services/download_manager/download_status.dart b/lib/services/download_manager/download_status.dart deleted file mode 100644 index b97080fa..00000000 --- a/lib/services/download_manager/download_status.dart +++ /dev/null @@ -1,26 +0,0 @@ -enum DownloadStatus { - queued, - downloading, - completed, - failed, - paused, - canceled; - - bool get isCompleted { - switch (this) { - case DownloadStatus.queued: - return false; - case DownloadStatus.downloading: - return false; - case DownloadStatus.paused: - return false; - case DownloadStatus.completed: - return true; - case DownloadStatus.failed: - return true; - - case DownloadStatus.canceled: - return true; - } - } -} diff --git a/lib/services/download_manager/download_task.dart b/lib/services/download_manager/download_task.dart deleted file mode 100644 index d79cf95b..00000000 --- a/lib/services/download_manager/download_task.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/foundation.dart'; -import 'package:spotube/services/download_manager/download_request.dart'; -import 'package:spotube/services/download_manager/download_status.dart'; - -class DownloadTask { - final DownloadRequest request; - ValueNotifier status = ValueNotifier(DownloadStatus.queued); - ValueNotifier progress = ValueNotifier(0); - - DownloadTask( - this.request, - ); - - Future whenDownloadComplete( - {Duration timeout = const Duration(hours: 2)}) async { - var completer = Completer(); - - if (status.value.isCompleted) { - completer.complete(status.value); - } - - void listener() { - if (status.value.isCompleted) { - completer.complete(status.value); - status.removeListener(listener); - } - } - - status.addListener(listener); - - return completer.future.timeout(timeout); - } -}