mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00
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:
parent
ae5edd17ef
commit
38dc4beb44
@ -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
|
||||
|
@ -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]);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
145
lib/components/library/user_downloads/download_item.dart
Normal file
145
lib/components/library/user_downloads/download_item.dart
Normal 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);
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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));
|
||||
|
@ -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));
|
||||
|
@ -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),
|
||||
),
|
||||
|
@ -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;
|
||||
|
@ -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": "তথ্য অনুসন্ধান করা হচ্ছে"
|
||||
}
|
@ -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..."
|
||||
}
|
@ -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..."
|
||||
}
|
@ -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..."
|
||||
}
|
@ -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..."
|
||||
}
|
@ -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": "जानकारी प्राप्त करना"
|
||||
}
|
@ -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": "情報を取得中..."
|
||||
}
|
@ -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": "正在查询信息..."
|
||||
}
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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),
|
||||
|
@ -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;
|
||||
final track = $history.firstWhereOrNull(
|
||||
(element) => element.ytUri == request.url,
|
||||
);
|
||||
if (track == null) return;
|
||||
|
||||
FutureOr<bool> 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<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",
|
||||
final imageBytes = await downloadImage(
|
||||
TypeConversionUtils.image_X_UrlString(track.album?.images,
|
||||
placeholder: ImagePlaceholder.albumArt, index: 1),
|
||||
);
|
||||
|
||||
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,
|
||||
);
|
||||
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<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);
|
||||
}));
|
||||
String getTrackFileUrl(Track track) {
|
||||
final name =
|
||||
"${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.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<void> addToQueue(Track track) async {
|
||||
final savePath = getTrackFileUrl(track);
|
||||
|
||||
final oldFile = File(savePath);
|
||||
if (await oldFile.exists() && !await onFileExists(track)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return tasks.whereType<Task>().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<void> cancel(Track track) async {
|
||||
await FileDownloader().cancelTaskWithId(track.id!);
|
||||
state = state.where((element) => element.id != track.id).toList();
|
||||
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> cancelAll() async {
|
||||
(await FileDownloader().reset());
|
||||
state = [];
|
||||
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),
|
||||
);
|
||||
|
@ -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(
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
150
lib/services/download_manager/chunked_download.dart
Normal file
150
lib/services/download_manager/chunked_download.dart
Normal 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;
|
||||
}
|
||||
}
|
407
lib/services/download_manager/download_manager.dart
Normal file
407
lib/services/download_manager/download_manager.dart
Normal 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;
|
||||
}
|
||||
}
|
24
lib/services/download_manager/download_request.dart
Normal file
24
lib/services/download_manager/download_request.dart
Normal 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;
|
||||
}
|
26
lib/services/download_manager/download_status.dart
Normal file
26
lib/services/download_manager/download_status.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
36
lib/services/download_manager/download_task.dart
Normal file
36
lib/services/download_manager/download_task.dart
Normal 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);
|
||||
}
|
||||
}
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
34
pubspec.lock
34
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:
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user