diff --git a/CONTRIBUTION.md b/CONTRIBUTION.md index c9526640..b4b52084 100644 --- a/CONTRIBUTION.md +++ b/CONTRIBUTION.md @@ -122,12 +122,12 @@ Do the following: - Install Development dependencies in linux - Debian (>=12/Bookworm)/Ubuntu ```bash - $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev + $ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev network-manager ``` - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04) - Arch/Manjaro ```bash - yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify + yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify networkmanager ``` - Fedora ```bash diff --git a/lib/components/library/user_downloads.dart b/lib/components/library/user_downloads.dart index eedd7840..c8ceee66 100644 --- a/lib/components/library/user_downloads.dart +++ b/lib/components/library/user_downloads.dart @@ -1,24 +1,22 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:background_downloader/background_downloader.dart'; -// import 'package:background_downloader/background_downloader.dart'; 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/collections/spotube_icons.dart'; -import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/library/user_downloads/download_item.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; -import 'package:spotube/utils/type_conversion_utils.dart'; class UserDownloads extends HookConsumerWidget { const UserDownloads({Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { - ref.watch(downloadManagerProvider); - final downloadManager = ref.watch(downloadManagerProvider.notifier); + final downloadManager = ref.watch(downloadManagerProvider); + + final history = [ + ...downloadManager.$history, + ...downloadManager.$backHistory, + ]; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -31,7 +29,7 @@ class UserDownloads extends HookConsumerWidget { Expanded( child: AutoSizeText( context.l10n - .currently_downloading(downloadManager.totalDownloads), + .currently_downloading(downloadManager.$downloadCount), maxLines: 1, style: Theme.of(context).textTheme.headlineMedium, ), @@ -42,7 +40,7 @@ class UserDownloads extends HookConsumerWidget { backgroundColor: Colors.red[50], foregroundColor: Colors.red[400], ), - onPressed: downloadManager.totalDownloads == 0 + onPressed: downloadManager.$downloadCount == 0 ? null : downloadManager.cancelAll, child: Text(context.l10n.cancel_all), @@ -53,60 +51,9 @@ class UserDownloads extends HookConsumerWidget { Expanded( child: SafeArea( child: ListView.builder( - itemCount: downloadManager.totalDownloads, + itemCount: history.length, itemBuilder: (context, index) { - final track = downloadManager.items.elementAt(index); - return HookBuilder(builder: (context) { - final task = useStream( - downloadManager.activeDownloadProgress.stream - .where((element) => element.task.taskId == track.id), - ); - final failedTaskStream = useStream( - downloadManager.failedDownloads.stream - .where((element) => element.taskId == track.id), - ); - final taskItSelf = useFuture( - FileDownloader().database.recordForId(track.id!), - ); - - final hasFailed = failedTaskStream.hasData || - taskItSelf.data?.status == TaskStatus.failed; - - return ListTile( - title: Text(track.name ?? ''), - leading: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5), - child: ClipRRect( - borderRadius: BorderRadius.circular(10), - child: UniversalImage( - height: 40, - width: 40, - path: TypeConversionUtils.image_X_UrlString( - track.album?.images, - placeholder: ImagePlaceholder.albumArt, - ), - ), - ), - ), - horizontalTitleGap: 10, - trailing: downloadManager.activeItem?.id == track.id && - !hasFailed - ? CircularProgressIndicator( - value: task.data?.progress ?? 0, - ) - : hasFailed - ? Icon(SpotubeIcons.error, color: Colors.red[400]) - : IconButton( - icon: const Icon(SpotubeIcons.close), - onPressed: () { - downloadManager.cancel(track); - }), - subtitle: TypeConversionUtils.artists_X_ClickableArtists( - track.artists ?? [], - mainAxisAlignment: WrapAlignment.start, - ), - ); - }); + return DownloadItem(track: history[index]); }, ), ), diff --git a/lib/components/library/user_downloads/download_item.dart b/lib/components/library/user_downloads/download_item.dart new file mode 100644 index 00000000..ae8a2513 --- /dev/null +++ b/lib/components/library/user_downloads/download_item.dart @@ -0,0 +1,145 @@ +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/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/spotube_track.dart'; +import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/services/download_manager/download_status.dart'; +import 'package:spotube/utils/type_conversion_utils.dart'; + +class DownloadItem extends HookConsumerWidget { + final Track track; + const DownloadItem({ + Key? key, + required this.track, + }) : super(key: key); + + @override + Widget build(BuildContext context, ref) { + final downloadManager = ref.watch(downloadManagerProvider); + + final taskStatus = useState(null); + + useEffect(() { + if (track is! SpotubeTrack) return null; + final notifier = downloadManager.getStatusNotifier(track as SpotubeTrack); + + taskStatus.value = notifier?.value; + listener() { + taskStatus.value = notifier?.value; + } + + downloadManager + .getStatusNotifier(track as SpotubeTrack) + ?.addListener(listener); + + return () { + downloadManager + .getStatusNotifier(track as SpotubeTrack) + ?.removeListener(listener); + }; + }, [track]); + + return ListTile( + leading: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + height: 40, + width: 40, + path: TypeConversionUtils.image_X_UrlString( + track.album?.images, + placeholder: ImagePlaceholder.albumArt, + ), + ), + ), + ), + title: Text(track.name ?? ''), + subtitle: TypeConversionUtils.artists_X_ClickableArtists( + track.artists ?? [], + mainAxisAlignment: WrapAlignment.start, + ), + trailing: taskStatus.value == null || track is! SpotubeTrack + ? Text( + context.l10n.querying_info, + style: Theme.of(context).textTheme.labelMedium, + ) + : switch (taskStatus.value!) { + DownloadStatus.downloading => HookBuilder(builder: (context) { + final taskProgress = useListenable(useMemoized( + () => downloadManager + .getProgressNotifier(track as SpotubeTrack), + [track], + )); + return SizedBox( + width: 140, + child: Row( + children: [ + CircularProgressIndicator( + value: taskProgress?.value ?? 0, + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.pause), + onPressed: () { + downloadManager.pause(track as SpotubeTrack); + }), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(track as SpotubeTrack); + }), + ], + ), + ); + }), + DownloadStatus.paused => Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(SpotubeIcons.play), + onPressed: () { + downloadManager.resume(track as SpotubeTrack); + }), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.cancel(track as SpotubeTrack); + }) + ], + ), + DownloadStatus.failed || DownloadStatus.canceled => SizedBox( + width: 100, + child: Row( + children: [ + Icon( + SpotubeIcons.error, + color: Colors.red[400], + ), + const SizedBox(width: 10), + IconButton( + icon: const Icon(SpotubeIcons.refresh), + onPressed: () { + downloadManager.retry(track as SpotubeTrack); + }, + ), + ], + ), + ), + DownloadStatus.completed => + Icon(SpotubeIcons.done, color: Colors.green[400]), + DownloadStatus.queued => IconButton( + icon: const Icon(SpotubeIcons.close), + onPressed: () { + downloadManager.removeFromQueue(track as SpotubeTrack); + }), + }, + ); + } +} diff --git a/lib/components/player/player_actions.dart b/lib/components/player/player_actions.dart index 78d90ff1..78fb53b7 100644 --- a/lib/components/player/player_actions.dart +++ b/lib/components/player/player_actions.dart @@ -13,6 +13,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/local_track.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; @@ -39,8 +40,14 @@ class PlayerActions extends HookConsumerWidget { final isLocalTrack = playlist.activeTrack is LocalTrack; ref.watch(downloadManagerProvider); final downloader = ref.watch(downloadManagerProvider.notifier); - final isInQueue = downloader.activeItem != null && - downloader.activeItem!.id == playlist.activeTrack?.id; + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloader.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloader, + ]); + final localTracks = [] /* ref.watch(localTracksProvider).value */; final auth = ref.watch(AuthenticationNotifier.provider); final sleepTimer = ref.watch(SleepTimerNotifier.provider); @@ -139,7 +146,7 @@ class PlayerActions extends HookConsumerWidget { isDownloaded ? SpotubeIcons.done : SpotubeIcons.download, ), onPressed: playlist.activeTrack != null - ? () => downloader.enqueue(playlist.activeTrack!) + ? () => downloader.addToQueue(playlist.activeTrack!) : null, ), if (playlist.activeTrack != null && !isLocalTrack && auth != null) diff --git a/lib/components/root/sidebar.dart b/lib/components/root/sidebar.dart index 64525823..dcbc2c39 100644 --- a/lib/components/root/sidebar.dart +++ b/lib/components/root/sidebar.dart @@ -51,9 +51,7 @@ class Sidebar extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final mediaQuery = MediaQuery.of(context); - final downloadCount = ref.watch( - downloadManagerProvider.select((s) => s.length), - ); + final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); diff --git a/lib/components/root/spotube_navigation_bar.dart b/lib/components/root/spotube_navigation_bar.dart index 111e7dd5..ee8e3319 100644 --- a/lib/components/root/spotube_navigation_bar.dart +++ b/lib/components/root/spotube_navigation_bar.dart @@ -26,9 +26,7 @@ class SpotubeNavigationBar extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); - final downloadCount = ref.watch( - downloadManagerProvider.select((s) => s.length), - ); + final downloadCount = ref.watch(downloadManagerProvider).$downloadCount; final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); diff --git a/lib/components/shared/track_table/track_options.dart b/lib/components/shared/track_table/track_options.dart index 13c1e5d5..a1bc3fef 100644 --- a/lib/components/shared/track_table/track_options.dart +++ b/lib/components/shared/track_table/track_options.dart @@ -14,6 +14,7 @@ import 'package:spotube/components/shared/heart_button.dart'; import 'package:spotube/components/shared/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/local_track.dart'; +import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart'; @@ -99,6 +100,20 @@ class TrackOptions extends HookConsumerWidget { playlistId ?? "", ); + final isInQueue = useMemoized(() { + if (playlist.activeTrack == null) return false; + return downloadManager.isActive(playlist.activeTrack!); + }, [ + playlist.activeTrack, + downloadManager, + ]); + + final progressNotifier = useMemoized(() { + final spotubeTrack = downloadManager.mapToSpotubeTrack(track); + if (spotubeTrack == null) return null; + return downloadManager.getProgressNotifier(spotubeTrack); + }); + return ListTileTheme( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -175,7 +190,7 @@ class TrackOptions extends HookConsumerWidget { ); break; case TrackOptionValue.download: - await downloadManager.enqueue(track); + await downloadManager.addToQueue(track); break; } }, @@ -268,9 +283,14 @@ class TrackOptions extends HookConsumerWidget { ), PopSheetEntry( value: TrackOptionValue.download, - enabled: downloadManager.activeItem?.id != track.id!, - leading: downloadManager.activeItem?.id == track.id! - ? const CircularProgressIndicator() + enabled: !isInQueue, + leading: isInQueue + ? HookBuilder(builder: (context) { + final progress = useListenable(progressNotifier!); + return CircularProgressIndicator( + value: progress.value, + ); + }) : const Icon(SpotubeIcons.download), title: Text(context.l10n.download_track), ), diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 6172f501..d412dd36 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -196,7 +196,7 @@ class TracksTableView extends HookConsumerWidget { ); if (confirmed != true) return; await downloader - .enqueueAll(selectedTracks.toList()); + .batchAddToQueue(selectedTracks.toList()); if (context.mounted) { selected.value = []; showCheck.value = false; diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index a69b40b4..98e165aa 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -253,5 +253,6 @@ "youtube_api_type": "API প্রকার", "ok": "ঠিক আছে", "failed_to_encrypt": "এনক্রিপ্ট করা ব্যর্থ হয়েছে", - "encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে" + "encryption_failed_warning": "Spotube আপনার তথ্যগুলি নিরাপদভাবে স্টোর করতে এনক্রিপশন ব্যবহার করে। কিন্তু এটি ব্যর্থ হয়েছে। তাই এটি অনিরাপদ স্টোরে ফলফল হবে\nযদি আপনি Linux ব্যবহার করেন, তবে দয়া করে নিশ্চিত হউন যে আপনার কোনও সিক্রেট-সার্ভিস gnome-keyring, kde-wallet, keepassxc ইত্যাদি ইনস্টল করা আছে", + "querying_info": "তথ্য অনুসন্ধান করা হচ্ছে" } \ No newline at end of file diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 32ff1459..d84333ec 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -253,5 +253,6 @@ "youtube_api_type": "API-Typ", "ok": "OK", "failed_to_encrypt": "Verschlüsselung fehlgeschlagen", - "encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben" + "encryption_failed_warning": "Spotube verwendet Verschlüsselung, um Ihre Daten sicher zu speichern. Dies ist jedoch fehlgeschlagen. Daher wird es auf unsichere Speicherung zurückgreifen\nWenn Sie Linux verwenden, stellen Sie bitte sicher, dass Sie Secret-Services wie gnome-keyring, kde-wallet und keepassxc installiert haben", + "querying_info": "Abfrageinformationen..." } \ No newline at end of file diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5bc6df2b..15d9b4fe 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -253,5 +253,6 @@ "youtube_api_type": "API Type", "ok": "Ok", "failed_to_encrypt": "Failed to encrypt", - "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed" + "encryption_failed_warning": "Spotube uses encryption to securely store your data. But failed to do so. So it'll fallback to insecure storage\nIf you're using linux, please make sure you've any secret-service (gnome-keyring, kde-wallet, keepassxc etc) installed", + "querying_info": "Querying info..." } \ No newline at end of file diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index f2f7ae77..101ba1b8 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -253,5 +253,6 @@ "youtube_api_type": "Tipo de API de YouTube", "ok": "OK", "failed_to_encrypt": "Error al cifrar", - "encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc" + "encryption_failed_warning": "Spotube utiliza el cifrado para almacenar sus datos de forma segura. Pero ha fallado. Por lo tanto, volverá a un almacenamiento no seguro\nSi está utilizando Linux, asegúrese de tener instalados servicios secretos como gnome-keyring, kde-wallet y keepassxc", + "querying_info": "Consultando información..." } \ No newline at end of file diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 17aba9b4..302e4740 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -253,5 +253,6 @@ "youtube_api_type": "Type d'API", "ok": "OK", "failed_to_encrypt": "Échec de la cryptage", - "encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc" + "encryption_failed_warning": "Spotube utilise le cryptage pour stocker vos données en toute sécurité. Mais cela a échoué. Il basculera donc vers un stockage non sécurisé\nSi vous utilisez Linux, assurez-vous d'avoir installé des services secrets tels que gnome-keyring, kde-wallet et keepassxc", + "querying_info": "Interrogation des info..." } \ No newline at end of file diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index a3ff9a2a..e60286f4 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -253,5 +253,6 @@ "youtube_api_type": "API प्रकार", "ok": "ठीक है", "failed_to_encrypt": "एन्क्रिप्ट करने में विफल रहा", - "encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है" + "encryption_failed_warning": "Spotube आपके डेटा को सुरक्षित रूप से स्टोर करने के लिए एन्क्रिप्शन का उपयोग करता है। लेकिन इसमें विफल रहा। इसलिए, यह असुरक्षित स्टोरेज पर फॉलबैक करेगा\nयदि आप Linux का उपयोग कर रहे हैं, तो कृपया सुनिश्चित करें कि आपके पास gnome-keyring, kde-wallet, keepassxc आदि जैसी कोई सीक्रेट-सर्विस इंस्टॉल की गई है", + "querying_info": "जानकारी प्राप्त करना" } \ No newline at end of file diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 3eb39ef1..9efd59ba 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -253,5 +253,6 @@ "youtube_api_type": "APIの種類", "ok": "分かりました", "failed_to_encrypt": "暗号化に失敗しました", - "encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください" + "encryption_failed_warning": "Spotubeはデータを安全に保存するために暗号化を使用しています。しかし、失敗しました。したがって、安全でないストレージにフォールバックします\nLinuxを使用している場合は、gnome-keyring、kde-wallet、keepassxcなどのシークレットサービスがインストールされていることを確認してください", + "querying_info": "情報を取得中..." } \ No newline at end of file diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 889c6d21..9810e4f2 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -253,5 +253,6 @@ "youtube_api_type": "API 类型", "ok": "确定", "failed_to_encrypt": "加密失败", - "encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此,它将回退到不安全的存储\n如果您使用Linux,请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务" + "encryption_failed_warning": "Spotube使用加密来安全地存储您的数据。但是失败了。因此,它将回退到不安全的存储\n如果您使用Linux,请确保已安装gnome-keyring、kde-wallet和keepassxc等秘密服务", + "querying_info": "正在查询信息..." } \ No newline at end of file diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index 15434f19..030851aa 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -15,8 +15,7 @@ class LibraryPage extends HookConsumerWidget { const LibraryPage({Key? key}) : super(key: key); @override Widget build(BuildContext context, ref) { - final downloadingCount = - ref.watch(downloadManagerProvider.select((s) => s.length)); + final downloadingCount = ref.watch(downloadManagerProvider).$downloadCount; return DefaultTabController( length: 5, diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index a9ef43da..e144e3c6 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -33,7 +33,7 @@ class RootApp extends HookConsumerWidget { final index = useState(0); final isMounted = useIsMounted(); final showingDialogCompleter = useRef(Completer()..complete()); - final downloader = ref.watch(downloadManagerProvider.notifier); + final downloader = ref.watch(downloadManagerProvider); useEffect(() { WidgetsBinding.instance.addPostFrameCallback((_) async { diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 10e5139b..033d2946 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -23,7 +23,6 @@ import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/matched_track.dart'; -import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/authentication_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart'; @@ -36,8 +35,6 @@ class SettingsPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final UserPreferences preferences = ref.watch(userPreferencesProvider); final auth = ref.watch(AuthenticationNotifier.provider); - final isDownloading = - ref.watch(downloadManagerProvider.select((s) => s.isNotEmpty)); final theme = Theme.of(context); final mediaQuery = MediaQuery.of(context); @@ -449,21 +446,15 @@ class SettingsPage extends HookConsumerWidget { SectionCardWithHeading( heading: context.l10n.downloads, children: [ - Tooltip( - message: isDownloading - ? context.l10n.wait_for_download_to_finish - : "", - child: ListTile( - leading: const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_location), - subtitle: Text(preferences.downloadLocation), - trailing: FilledButton( - onPressed: - isDownloading ? null : pickDownloadLocation, - child: const Icon(SpotubeIcons.folder), - ), - onTap: isDownloading ? null : pickDownloadLocation, + ListTile( + leading: const Icon(SpotubeIcons.download), + title: Text(context.l10n.download_location), + subtitle: Text(preferences.downloadLocation), + trailing: FilledButton( + onPressed: pickDownloadLocation, + child: const Icon(SpotubeIcons.folder), ), + onTap: pickDownloadLocation, ), SwitchListTile( secondary: const Icon(SpotubeIcons.lyrics), diff --git a/lib/provider/download_manager_provider.dart b/lib/provider/download_manager_provider.dart index 0de027d4..db443082 100644 --- a/lib/provider/download_manager_provider.dart +++ b/lib/provider/download_manager_provider.dart @@ -1,196 +1,258 @@ import 'dart:async'; import 'dart:io'; -import 'package:background_downloader/background_downloader.dart'; +import 'package:catcher/catcher.dart'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:http/http.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:metadata_god/metadata_god.dart'; import 'package:path/path.dart'; import 'package:spotify/spotify.dart'; -import 'package:spotube/collections/routes.dart'; -import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/models/spotube_track.dart'; - import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/youtube_provider.dart'; +import 'package:spotube/services/download_manager/download_manager.dart'; import 'package:spotube/services/youtube/youtube.dart'; import 'package:spotube/utils/type_conversion_utils.dart'; -class DownloadManagerProvider extends StateNotifier> { - final Ref ref; +class DownloadManagerProvider extends ChangeNotifier { + DownloadManagerProvider({required this.ref}) + : $history = {}, + $backHistory = {}, + dl = DownloadManager() { + dl.statusStream.listen((event) async { + final (:request, :status) = event; - final StreamController activeDownloadProgress; - final StreamController failedDownloads; - Track? _activeItem; + final track = $history.firstWhereOrNull( + (element) => element.ytUri == request.url, + ); + if (track == null) return; - FutureOr Function(Track)? onFileExists; + final savePath = getTrackFileUrl(track); + // related to onFileExists + final oldFile = File("$savePath.old"); - DownloadManagerProvider(this.ref) - : activeDownloadProgress = StreamController.broadcast(), - failedDownloads = StreamController.broadcast(), - super([]) { - if (kIsWeb) return; + // 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) return; - FileDownloader().registerCallbacks( - group: FileDownloader.defaultGroup, - taskNotificationTapCallback: (task, notificationType) { - router.go("/library"); - }, - taskStatusCallback: (update) async { - if (update.status == TaskStatus.running) { - _activeItem = - state.firstWhereOrNull((track) => track.id == update.task.taskId); - state = state.toList(); - } + final file = File(request.path); - if (update.status == TaskStatus.failed || - update.status == TaskStatus.notFound) { - failedDownloads.add(update.task); - } + if (await oldFile.exists()) { + await oldFile.delete(); + } - if (update.status == TaskStatus.complete) { - final track = - state.firstWhere((element) => element.id == update.task.taskId); - - // resetting the replace downloaded file state on queue completion - if (state.last == track) { - ref.read(replaceDownloadedFileState.notifier).state = null; - } - - state = state - .where((element) => element.id != update.task.taskId) - .toList(); - - final imageUri = TypeConversionUtils.image_X_UrlString( - track.album?.images ?? [], - placeholder: ImagePlaceholder.online, - ); - final response = await get(Uri.parse(imageUri)); - - final tempFile = File(await update.task.filePath()); - - final file = tempFile.copySync(_getPathForTrack(track)); - - await tempFile.delete(); - - await MetadataGod.writeMetadata( - file: file.path, - metadata: Metadata( - title: track.name, - artist: track.artists?.map((a) => a.name).join(", "), - album: track.album?.name, - albumArtist: track.artists?.map((a) => a.name).join(", "), - year: track.album?.releaseDate != null - ? int.tryParse(track.album!.releaseDate!) - : null, - trackNumber: track.trackNumber, - discNumber: track.discNumber, - durationMs: track.durationMs?.toDouble(), - fileSize: file.lengthSync(), - trackTotal: track.album?.tracks?.length, - picture: response.headers['content-type'] != null - ? Picture( - data: response.bodyBytes, - mimeType: response.headers['content-type']!, - ) - : null, - ), - ); - } - }, - taskProgressCallback: (update) { - activeDownloadProgress.add(update); - }, - ); - FileDownloader().trackTasks(markDownloadedComplete: true); - } - - UserPreferences get preferences => ref.read(userPreferencesProvider); - YoutubeEndpoints get youtube => ref.read(youtubeProvider); - - int get totalDownloads => state.length; - List get items => state; - Track? get activeItem => _activeItem; - - String _getPathForTrack(Track track) => join( - preferences.downloadLocation, - "${track.name} - ${track.artists?.map((a) => a.name).join(", ")}.m4a", + final imageBytes = await downloadImage( + TypeConversionUtils.image_X_UrlString(track.album?.images, + placeholder: ImagePlaceholder.albumArt, index: 1), ); - Future _ensureSpotubeTrack(Track track) async { - if (state.any((element) => element.id == track.id)) { - final task = await FileDownloader().taskForId(track.id!); - if (task != null) { - return task; - } - // this makes sure we already have the fetched track - track = state.firstWhere((element) => element.id == track.id); - state.removeWhere((element) => element.id == track.id); - } - final spotubeTrack = track is SpotubeTrack - ? track - : await SpotubeTrack.fetchFromTrack( - track, - youtube, - ); - state = [...state, spotubeTrack]; - final task = DownloadTask( - url: spotubeTrack.ytUri, - baseDirectory: BaseDirectory.applicationSupport, - taskId: spotubeTrack.id!, - updates: Updates.statusAndProgress, - ); - return task; + final metadata = Metadata( + title: track.name, + artist: track.artists?.map((a) => a.name).join(", "), + album: track.album?.name, + albumArtist: track.artists?.map((a) => a.name).join(", "), + year: track.album?.releaseDate != null + ? int.tryParse(track.album!.releaseDate!) ?? 1969 + : 1969, + trackNumber: track.trackNumber, + discNumber: track.discNumber, + durationMs: track.durationMs?.toDouble() ?? 0.0, + fileSize: await file.length(), + trackTotal: track.album?.tracks?.length ?? 0, + picture: imageBytes != null + ? Picture( + data: imageBytes, + // Spotify images are always JPEGs + mimeType: 'image/jpeg', + ) + : null, + ); + + await MetadataGod.writeMetadata( + file: file.path, + metadata: metadata, + ); + }); } - Future enqueue(Track track) async { - final replaceFileGlobal = ref.read(replaceDownloadedFileState); - final file = File(_getPathForTrack(track)); - if (file.existsSync() && - (replaceFileGlobal ?? await onFileExists?.call(track)) != true) { - if (state.isEmpty) { - ref.read(replaceDownloadedFileState.notifier).state = null; + Future Function(Track track) onFileExists = (Track track) async => true; + + final Ref ref; + + YoutubeEndpoints get yt => ref.read(downloadYoutubeProvider); + String get downloadDirectory => + ref.read(userPreferencesProvider.select((s) => s.downloadLocation)); + + 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 Set $backHistory; + final DownloadManager dl; + + /// Spotify Images are always JPEGs + Future downloadImage( + String imageUrl, + ) async { + try { + final fileStream = DefaultCacheManager().getImageFile(imageUrl); + + final bytes = List.empty(growable: true); + + await for (final data in fileStream) { + if (data is FileInfo) { + bytes.addAll(data.file.readAsBytesSync()); + break; + } } + + return Uint8List.fromList(bytes); + } catch (e, stackTrace) { + Catcher.reportCheckedError(e, stackTrace); return null; } - - final task = await _ensureSpotubeTrack(track); - - await FileDownloader().enqueue(task); - return task; } - Future> enqueueAll(List tracks) async { - final tasks = await Future.wait(tracks.mapIndexed((i, e) { - if (i != 0) { - /// One second delay between each download to avoid - /// clogging the Piped server with too many requests - return Future.delayed(const Duration(seconds: 1), () => enqueue(e)); - } - return enqueue(e); - })); + String getTrackFileUrl(Track track) { + final name = + "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? [])}.m4a"; + return join(downloadDirectory, name); + } - if (tasks.isEmpty) { - ref.read(replaceDownloadedFileState.notifier).state = null; + bool isActive(Track track) { + if ($backHistory.contains(track)) return true; + + final spotubeTrack = mapToSpotubeTrack(track); + + if (spotubeTrack == 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(spotubeTrack.ytUri); + } + + /// For singular downloads + Future addToQueue(Track track) async { + final savePath = getTrackFileUrl(track); + + final oldFile = File(savePath); + if (await oldFile.exists() && !await onFileExists(track)) { + return; } - return tasks.whereType().toList(); + if (await oldFile.exists()) { + await oldFile.rename("$savePath.old"); + } + + if (track is SpotubeTrack) { + final downloadTask = await dl.addDownload(track.ytUri, savePath); + if (downloadTask != null) { + $history.add(track); + } + } else { + $backHistory.add(track); + final spotubeTrack = + await SpotubeTrack.fetchFromTrack(track, yt).then((d) { + $backHistory.remove(track); + return d; + }); + final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath); + if (downloadTask != null) { + $history.add(spotubeTrack); + } + } + + notifyListeners(); } - Future cancel(Track track) async { - await FileDownloader().cancelTaskWithId(track.id!); - state = state.where((element) => element.id != track.id).toList(); + Future batchAddToQueue(List tracks) async { + $backHistory.addAll( + tracks.where((element) => element is! SpotubeTrack), + ); + 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) { + Catcher.reportCheckedError(e, StackTrace.current); + continue; + } + } } - Future cancelAll() async { - (await FileDownloader().reset()); - state = []; + Future removeFromQueue(SpotubeTrack track) async { + await dl.removeDownload(track.ytUri); + $history.remove(track); + } + + Future pause(SpotubeTrack track) { + return dl.pauseDownload(track.ytUri); + } + + Future resume(SpotubeTrack track) { + return dl.resumeDownload(track.ytUri); + } + + Future retry(SpotubeTrack track) { + return addToQueue(track); + } + + void cancel(SpotubeTrack track) { + dl.cancelDownload(track.ytUri); + } + + void cancelAll() { + for (final download in dl.getAllDownloads()) { + if (download.status.value == DownloadStatus.completed) continue; + dl.cancelDownload(download.request.url); + } + } + + SpotubeTrack? mapToSpotubeTrack(Track track) { + if (track is SpotubeTrack) { + return track; + } else { + return $history.firstWhereOrNull((element) => element.id == track.id); + } + } + + ValueNotifier? getStatusNotifier(SpotubeTrack track) { + return dl.getDownload(track.ytUri)?.status; + } + + ValueNotifier? getProgressNotifier(SpotubeTrack track) { + return dl.getDownload(track.ytUri)?.progress; } } -final downloadManagerProvider = - StateNotifierProvider>( - DownloadManagerProvider.new, +final downloadManagerProvider = ChangeNotifierProvider( + (ref) => DownloadManagerProvider(ref: ref), ); diff --git a/lib/provider/user_preferences_provider.dart b/lib/provider/user_preferences_provider.dart index 3a46b8f4..2a4efae2 100644 --- a/lib/provider/user_preferences_provider.dart +++ b/lib/provider/user_preferences_provider.dart @@ -281,6 +281,45 @@ class UserPreferences extends PersistedChangeNotifier { "youtubeApiType": youtubeApiType.name, }; } + + UserPreferences copyWith({ + ThemeMode? themeMode, + SpotubeColor? accentColorScheme, + bool? albumColorSync, + bool? checkUpdate, + AudioQuality? audioQuality, + String? downloadLocation, + LayoutMode? layoutMode, + CloseBehavior? closeBehavior, + bool? showSystemTrayIcon, + Locale? locale, + String? pipedInstance, + SearchMode? searchMode, + bool? skipNonMusic, + YoutubeApiType? youtubeApiType, + String? recommendationMarket, + bool? saveTrackLyrics, + }) { + return UserPreferences( + ref, + themeMode: themeMode ?? this.themeMode, + accentColorScheme: accentColorScheme ?? this.accentColorScheme, + albumColorSync: albumColorSync ?? this.albumColorSync, + checkUpdate: checkUpdate ?? this.checkUpdate, + audioQuality: audioQuality ?? this.audioQuality, + downloadLocation: downloadLocation ?? this.downloadLocation, + layoutMode: layoutMode ?? this.layoutMode, + closeBehavior: closeBehavior ?? this.closeBehavior, + showSystemTrayIcon: showSystemTrayIcon ?? this.showSystemTrayIcon, + locale: locale ?? this.locale, + pipedInstance: pipedInstance ?? this.pipedInstance, + searchMode: searchMode ?? this.searchMode, + skipNonMusic: skipNonMusic ?? this.skipNonMusic, + youtubeApiType: youtubeApiType ?? this.youtubeApiType, + recommendationMarket: recommendationMarket ?? this.recommendationMarket, + saveTrackLyrics: saveTrackLyrics ?? this.saveTrackLyrics, + ); + } } final userPreferencesProvider = ChangeNotifierProvider( diff --git a/lib/provider/youtube_provider.dart b/lib/provider/youtube_provider.dart index 0e7b7d0e..20b5ba2b 100644 --- a/lib/provider/youtube_provider.dart +++ b/lib/provider/youtube_provider.dart @@ -6,3 +6,13 @@ final youtubeProvider = Provider((ref) { final preferences = ref.watch(userPreferencesProvider); return YoutubeEndpoints(preferences); }); + +// this provider overrides the API provider to use piped.video for downloading +final downloadYoutubeProvider = Provider((ref) { + final preferences = ref.watch(userPreferencesProvider); + return YoutubeEndpoints( + preferences.copyWith( + youtubeApiType: YoutubeApiType.piped, + ), + ); +}); diff --git a/lib/services/download_manager/chunked_download.dart b/lib/services/download_manager/chunked_download.dart new file mode 100644 index 00000000..672acfb3 --- /dev/null +++ b/lib/services/download_manager/chunked_download.dart @@ -0,0 +1,150 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.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(); + + debugPrint("Downloaded file path: ${headFile.path}"); + + headFile = await headFile.rename(savePath); + + debugPrint("Renamed file path: ${headFile.path}"); + } + + 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 new file mode 100644 index 00000000..cd496e0a --- /dev/null +++ b/lib/services/download_manager/download_manager.dart @@ -0,0 +1,407 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:io'; +import 'package:catcher/core/catcher.dart'; +import 'package:collection/collection.dart'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.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'; + +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); + + debugPrint("[DownloadManager] $url"); + final file = File(savePath.toString()); + partialFilePath = savePath + partialExtension; + partialFile = File(partialFilePath); + + final fileExist = await file.exists(); + final partialFileExist = await partialFile.exists(); + + if (fileExist) { + debugPrint("[DownloadManager] File Exists"); + setStatus(task, DownloadStatus.completed); + } else if (partialFileExist) { + debugPrint("[DownloadManager] Partial File Exists"); + + 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.rename(savePath); + + 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.rename(savePath); + setStatus(task, DownloadStatus.completed); + } + } + } catch (e, stackTrace) { + Catcher.reportCheckedError(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]); + } + } + + _queue.add(DownloadRequest(downloadRequest.url, downloadRequest.path)); + + final task = DownloadTask(_queue.last); + + _cache[downloadRequest.url] = task; + + _startExecution(); + + return task; + } + + Future pauseDownload(String url) async { + debugPrint("[DownloadManager] Pause Download"); + var task = getDownload(url)!; + setStatus(task, DownloadStatus.paused); + task.request.cancelToken.cancel(); + + _queue.remove(task.request); + } + + Future cancelDownload(String url) async { + debugPrint("[DownloadManager] Cancel Download"); + var task = getDownload(url)!; + setStatus(task, DownloadStatus.canceled); + _queue.remove(task.request); + task.request.cancelToken.cancel(); + } + + Future resumeDownload(String url) async { + debugPrint("[DownloadManager] Resume Download"); + 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 { + urls.forEach((element) { + pauseDownload(element); + }); + } + + Future cancelBatchDownloads(List urls) async { + urls.forEach((element) { + cancelDownload(element); + }); + } + + Future resumeBatchDownloads(List urls) async { + urls.forEach((element) { + 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 = Map(); + + urls.forEach((url) { + 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; + } + + var progressListener; + progressListener = () { + progressMap[url] = task.progress.value; + progress.value = progressMap.values.sum / total; + }; + + task.progress.addListener(progressListener); + + var listener; + 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)); + } + } + + var listener; + 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++; + debugPrint('Concurrent workers: $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 url.split('/').last; + } +} diff --git a/lib/services/download_manager/download_request.dart b/lib/services/download_manager/download_request.dart new file mode 100644 index 00000000..80c4af37 --- /dev/null +++ b/lib/services/download_manager/download_request.dart @@ -0,0 +1,24 @@ +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 new file mode 100644 index 00000000..b97080fa --- /dev/null +++ b/lib/services/download_manager/download_status.dart @@ -0,0 +1,26 @@ +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 new file mode 100644 index 00000000..5d57a655 --- /dev/null +++ b/lib/services/download_manager/download_task.dart @@ -0,0 +1,36 @@ +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); + } + + var listener; + listener = () { + if (status.value.isCompleted) { + completer.complete(status.value); + status.removeListener(listener); + } + }; + + status.addListener(listener); + + return completer.future.timeout(timeout); + } +} diff --git a/lib/services/youtube/youtube.dart b/lib/services/youtube/youtube.dart index 9ae6a224..1922faa2 100644 --- a/lib/services/youtube/youtube.dart +++ b/lib/services/youtube/youtube.dart @@ -162,15 +162,13 @@ class YoutubeEndpoints { } String _pipedStreamResponseToStreamUrl(PipedStreamResponse stream) { - final streamFormat = DesktopTools.platform.isLinux - ? PipedAudioStreamFormat.webm - : PipedAudioStreamFormat.m4a; - return switch (preferences.audioQuality) { - AudioQuality.high => - stream.highestBitrateAudioStreamOfFormat(streamFormat)!.url, - AudioQuality.low => - stream.lowestBitrateAudioStreamOfFormat(streamFormat)!.url, + AudioQuality.high => stream + .highestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! + .url, + AudioQuality.low => stream + .lowestBitrateAudioStreamOfFormat(PipedAudioStreamFormat.m4a)! + .url, }; } @@ -180,15 +178,7 @@ class YoutubeEndpoints { () => youtube!.videos.streams.getManifest(id), ); final audioOnlyManifests = res.audioOnly.where((info) { - final isMp4a = info.codec.mimeType == "audio/mp4"; - if (DesktopTools.platform.isLinux) { - return !isMp4a; - } else if (DesktopTools.platform.isMacOS || - DesktopTools.platform.isIOS) { - return isMp4a; - } else { - return true; - } + return info.codec.mimeType == "audio/mp4"; }); return switch (preferences.audioQuality) { diff --git a/lib/utils/type_conversion_utils.dart b/lib/utils/type_conversion_utils.dart index 16a930c9..5694d3fe 100644 --- a/lib/utils/type_conversion_utils.dart +++ b/lib/utils/type_conversion_utils.dart @@ -167,7 +167,7 @@ abstract class TypeConversionUtils { track.name = metadata?.title ?? basenameWithoutExtension(file.path); track.type = "track"; track.uri = file.path; - track.durationMs = metadata?.durationMs?.toInt(); + track.durationMs = (metadata?.durationMs?.toInt() ?? 0) * 1000; return track; } diff --git a/pubspec.lock b/pubspec.lock index a83e4dad..778e23f6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -145,14 +145,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - background_downloader: - dependency: "direct main" - description: - name: background_downloader - sha256: "5e38a1d5d88a5cfea35c44cb376b89427688070518471ee52f6b04d07d85668e" - url: "https://pub.dev" - source: hosted - version: "7.4.0" boolean_selector: dependency: transitive description: @@ -338,14 +330,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - colorize: - dependency: transitive - description: - name: colorize - sha256: "584746cd6ba1cba0633b6720f494fe6f9601c4170f0666c1579d2aa2a61071ba" - url: "https://pub.dev" - source: hosted - version: "3.0.0" connectivity_plus: dependency: transitive description: @@ -459,13 +443,13 @@ packages: source: hosted version: "1.1.0" dio: - dependency: transitive + dependency: "direct main" description: name: dio - sha256: "347d56c26d63519552ef9a569f2a593dda99a81fdbdff13c584b7197cfe05059" + sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197 url: "https://pub.dev" source: hosted - version: "5.1.2" + version: "5.3.2" disable_battery_optimization: dependency: "direct main" description: @@ -518,10 +502,10 @@ packages: dependency: transitive description: name: ffi - sha256: a38574032c5f1dd06c4aee541789906c12ccaab8ba01446e800d9c5b79c4a978 + sha256: ed5337a5660c506388a9f012be0288fb38b49020ce2b45fe1f8b8323fe429f99 url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" file: dependency: transitive description: @@ -742,10 +726,10 @@ packages: dependency: transitive description: name: flutter_rust_bridge - sha256: "34f5becca2df35955b2ec5e875349028ea609a826de7aade4de80534cf876b27" + sha256: ff90d5ddd0cda6d94ed048cc9c4a4d993d1a4bb11605d60a1282fc1bbf173c77 url: "https://pub.dev" source: hosted - version: "1.72.1" + version: "1.80.1" flutter_secure_storage: dependency: "direct main" description: @@ -1474,10 +1458,10 @@ packages: dependency: transitive description: name: puppeteer - sha256: "1304d8044054a8c89df648ef12352cf840ed2d34bbfbfa03d91537c72ae0f2c7" + sha256: "59e723cc5b69537159a7c34efd645dc08a6a1ac4647d7d7823606802c0f93cdb" url: "https://pub.dev" source: hosted - version: "2.22.0" + version: "3.2.0" quiver: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 20909d63..9a77cb32 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -95,13 +95,13 @@ dependencies: piped_client: ^0.1.0 device_preview: ^1.1.0 dbus: ^0.7.8 - background_downloader: ^7.4.0 duration: ^3.0.12 disable_battery_optimization: ^1.1.0+1 youtube_explode_dart: ^1.12.4 flutter_displaymode: ^0.6.0 google_fonts: ^4.0.4 supabase: ^1.9.9 + dio: ^5.3.2 dev_dependencies: build_runner: ^2.3.2