feat: blazingly™ fast download manager (#619)

* feat: concurrent download service & download prorvider

* feat: implement chunked downloader

* fix: no audio-tags in Linux and duration not showing up for local tracks

* feat: show matching tracks in queue as well

* feat: always uses piped api for download to avoid IP block

* fix: invalid downloadCount
This commit is contained in:
Kingkor Roy Tirtho 2023-08-07 16:49:11 +06:00 committed by GitHub
parent ae5edd17ef
commit 38dc4beb44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 1149 additions and 308 deletions

View File

@ -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

View File

@ -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 ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start,
),
);
});
return DownloadItem(track: history[index]);
},
),
),

View File

@ -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<DownloadStatus?>(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 ?? <Artist>[],
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);
}),
},
);
}
}

View File

@ -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)

View File

@ -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));

View File

@ -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));

View File

@ -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),
),

View File

@ -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;

View File

@ -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": "তথ্য অনুসন্ধান করা হচ্ছে"
}

View File

@ -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..."
}

View File

@ -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..."
}

View File

@ -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..."
}

View File

@ -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..."
}

View File

@ -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": "जानकारी प्राप्त करना"
}

View File

@ -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": "情報を取得中..."
}

View File

@ -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": "正在查询信息..."
}

View File

@ -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,

View File

@ -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 {

View File

@ -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(
ListTile(
leading: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_location),
subtitle: Text(preferences.downloadLocation),
trailing: FilledButton(
onPressed:
isDownloading ? null : pickDownloadLocation,
onPressed: pickDownloadLocation,
child: const Icon(SpotubeIcons.folder),
),
onTap: isDownloading ? null : pickDownloadLocation,
),
onTap: pickDownloadLocation,
),
SwitchListTile(
secondary: const Icon(SpotubeIcons.lyrics),

View File

@ -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<List<SpotubeTrack>> {
final Ref ref;
class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref})
: $history = <SpotubeTrack>{},
$backHistory = <Track>{},
dl = DownloadManager() {
dl.statusStream.listen((event) async {
final (:request, :status) = event;
final StreamController<TaskProgressUpdate> activeDownloadProgress;
final StreamController<Task> failedDownloads;
Track? _activeItem;
FutureOr<bool> Function(Track)? onFileExists;
DownloadManagerProvider(this.ref)
: activeDownloadProgress = StreamController.broadcast(),
failedDownloads = StreamController.broadcast(),
super([]) {
if (kIsWeb) 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();
}
if (update.status == TaskStatus.failed ||
update.status == TaskStatus.notFound) {
failedDownloads.add(update.task);
}
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 track = $history.firstWhereOrNull(
(element) => element.ytUri == request.url,
);
final response = await get(Uri.parse(imageUri));
if (track == null) return;
final tempFile = File(await update.task.filePath());
final savePath = getTrackFileUrl(track);
// related to onFileExists
final oldFile = File("$savePath.old");
final file = tempFile.copySync(_getPathForTrack(track));
// 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;
await tempFile.delete();
final file = File(request.path);
await MetadataGod.writeMetadata(
file: file.path,
metadata: Metadata(
if (await oldFile.exists()) {
await oldFile.delete();
}
final imageBytes = await downloadImage(
TypeConversionUtils.image_X_UrlString(track.album?.images,
placeholder: ImagePlaceholder.albumArt, index: 1),
);
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!)
: null,
? int.tryParse(track.album!.releaseDate!) ?? 1969
: 1969,
trackNumber: track.trackNumber,
discNumber: track.discNumber,
durationMs: track.durationMs?.toDouble(),
fileSize: file.lengthSync(),
trackTotal: track.album?.tracks?.length,
picture: response.headers['content-type'] != null
durationMs: track.durationMs?.toDouble() ?? 0.0,
fileSize: await file.length(),
trackTotal: track.album?.tracks?.length ?? 0,
picture: imageBytes != null
? Picture(
data: response.bodyBytes,
mimeType: response.headers['content-type']!,
data: imageBytes,
// Spotify images are always JPEGs
mimeType: 'image/jpeg',
)
: 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<Track> get items => state;
Track? get activeItem => _activeItem;
String _getPathForTrack(Track track) => join(
preferences.downloadLocation,
"${track.name} - ${track.artists?.map((a) => a.name).join(", ")}.m4a",
);
Future<Task> _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,
await MetadataGod.writeMetadata(
file: file.path,
metadata: metadata,
);
state = [...state, spotubeTrack];
final task = DownloadTask(
url: spotubeTrack.ytUri,
baseDirectory: BaseDirectory.applicationSupport,
taskId: spotubeTrack.id!,
updates: Updates.statusAndProgress,
);
return task;
});
}
Future<Task?> 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<bool> Function(Track track) onFileExists = (Track track) async => true;
final Ref<DownloadManagerProvider> 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<SpotubeTrack> $history;
// these are the tracks which metadata hasn't been fetched yet
final Set<Track> $backHistory;
final DownloadManager dl;
/// Spotify Images are always JPEGs
Future<Uint8List?> downloadImage(
String imageUrl,
) async {
try {
final fileStream = DefaultCacheManager().getImageFile(imageUrl);
final bytes = List<int>.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<List<Task>> enqueueAll(List<Track> 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);
}));
if (tasks.isEmpty) {
ref.read(replaceDownloadedFileState.notifier).state = null;
String getTrackFileUrl(Track track) {
final name =
"${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.m4a";
return join(downloadDirectory, name);
}
return tasks.whereType<Task>().toList();
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);
}
Future<void> cancel(Track track) async {
await FileDownloader().cancelTaskWithId(track.id!);
state = state.where((element) => element.id != track.id).toList();
/// For singular downloads
Future<void> addToQueue(Track track) async {
final savePath = getTrackFileUrl(track);
final oldFile = File(savePath);
if (await oldFile.exists() && !await onFileExists(track)) {
return;
}
Future<void> cancelAll() async {
(await FileDownloader().reset());
state = [];
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<void> batchAddToQueue(List<Track> 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<void> removeFromQueue(SpotubeTrack track) async {
await dl.removeDownload(track.ytUri);
$history.remove(track);
}
Future<void> pause(SpotubeTrack track) {
return dl.pauseDownload(track.ytUri);
}
Future<void> resume(SpotubeTrack track) {
return dl.resumeDownload(track.ytUri);
}
Future<void> 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<DownloadStatus>? getStatusNotifier(SpotubeTrack track) {
return dl.getDownload(track.ytUri)?.status;
}
ValueNotifier<double>? getProgressNotifier(SpotubeTrack track) {
return dl.getDownload(track.ytUri)?.progress;
}
}
final downloadManagerProvider =
StateNotifierProvider<DownloadManagerProvider, List<Track>>(
DownloadManagerProvider.new,
final downloadManagerProvider = ChangeNotifierProvider<DownloadManagerProvider>(
(ref) => DownloadManagerProvider(ref: ref),
);

View File

@ -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(

View File

@ -6,3 +6,13 @@ final youtubeProvider = Provider<YoutubeEndpoints>((ref) {
final preferences = ref.watch(userPreferencesProvider);
return YoutubeEndpoints(preferences);
});
// this provider overrides the API provider to use piped.video for downloading
final downloadYoutubeProvider = Provider<YoutubeEndpoints>((ref) {
final preferences = ref.watch(userPreferencesProvider);
return YoutubeEndpoints(
preferences.copyWith(
youtubeApiType: YoutubeApiType.piped,
),
);
});

View File

@ -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<Response> chunkedDownload(
url, {
Map<String, dynamic>? 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 = <int>[];
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<Response>();
Future<Response> 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<void> 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 = <Response>[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;
}
}

View File

@ -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<String, DownloadTask> _cache = <String, DownloadTask>{};
final Queue<DownloadRequest> _queue = Queue();
var dio = Dio();
static const partialExtension = ".partial";
static const tempExtension = ".temp";
// var tasks = StreamController<DownloadTask>();
final _statusStreamController =
StreamController<DownloadStatusEvent>.broadcast();
Stream<DownloadStatusEvent> 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<void> 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<DownloadTask?> addDownload(String url, String savedPath) async {
if (url.isEmpty) throw Exception("Invalid Url. Url is empty: $url");
return _addDownloadRequest(DownloadRequest(url, savedPath));
}
Future<DownloadTask> _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<void> 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<void> 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<void> 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<void> 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<DownloadStatus> 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<DownloadTask> getAllDownloads() {
return _cache.values.toList();
}
// Batch Download Mechanism
Future<void> addBatchDownloads(List<String> urls, String savePath) async {
for (final url in urls) {
addDownload(url, savePath);
}
}
List<DownloadTask?> getBatchDownloads(List<String> urls) {
return urls.map((e) => _cache[e]).toList();
}
Future<void> pauseBatchDownloads(List<String> urls) async {
urls.forEach((element) {
pauseDownload(element);
});
}
Future<void> cancelBatchDownloads(List<String> urls) async {
urls.forEach((element) {
cancelDownload(element);
});
}
Future<void> resumeBatchDownloads(List<String> urls) async {
urls.forEach((element) {
resumeDownload(element);
});
}
ValueNotifier<double> getBatchDownloadProgress(List<String> urls) {
ValueNotifier<double> progress = ValueNotifier(0);
var total = urls.length;
if (total == 0) {
return progress;
}
if (total == 1) {
return getDownload(urls.first)?.progress ?? progress;
}
var progressMap = Map<String, double>();
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<List<DownloadTask?>?> whenBatchDownloadsComplete(List<String> urls,
{Duration timeout = const Duration(hours: 2)}) async {
var completer = Completer<List<DownloadTask?>?>();
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;
}
}

View File

@ -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;
}

View File

@ -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;
}
}
}

View File

@ -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<DownloadStatus> status = ValueNotifier(DownloadStatus.queued);
ValueNotifier<double> progress = ValueNotifier(0);
DownloadTask(
this.request,
);
Future<DownloadStatus> whenDownloadComplete(
{Duration timeout = const Duration(hours: 2)}) async {
var completer = Completer<DownloadStatus>();
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);
}
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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:

View File

@ -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