mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-08 16:27:31 +00:00
feat: concurrent download service & download prorvider
This commit is contained in:
parent
cf7b849cdd
commit
f0f0abd782
@ -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,6 +1,4 @@
|
||||
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';
|
||||
@ -10,6 +8,7 @@ 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/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/services/download_manager/download_manager.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class UserDownloads extends HookConsumerWidget {
|
||||
@ -31,7 +30,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 +41,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,24 +52,16 @@ class UserDownloads extends HookConsumerWidget {
|
||||
Expanded(
|
||||
child: SafeArea(
|
||||
child: ListView.builder(
|
||||
itemCount: downloadManager.totalDownloads,
|
||||
itemCount: downloadManager.$downloadCount,
|
||||
itemBuilder: (context, index) {
|
||||
final track = downloadManager.items.elementAt(index);
|
||||
final track = downloadManager.$history.elementAt(index);
|
||||
return HookBuilder(builder: (context) {
|
||||
final task = useStream(
|
||||
downloadManager.activeDownloadProgress.stream
|
||||
.where((element) => element.task.taskId == track.id),
|
||||
final taskStatus = useListenable(
|
||||
useMemoized(
|
||||
() => downloadManager.getStatusNotifier(track),
|
||||
[track],
|
||||
),
|
||||
);
|
||||
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 ?? ''),
|
||||
@ -88,19 +79,83 @@ class UserDownloads extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
horizontalTitleGap: 10,
|
||||
trailing: downloadManager.activeItem?.id == track.id &&
|
||||
!hasFailed
|
||||
? CircularProgressIndicator(
|
||||
value: task.data?.progress ?? 0,
|
||||
)
|
||||
: hasFailed
|
||||
? Icon(SpotubeIcons.error, color: Colors.red[400])
|
||||
: IconButton(
|
||||
trailing: taskStatus == null
|
||||
? null
|
||||
: switch (taskStatus.value) {
|
||||
DownloadStatus.downloading =>
|
||||
HookBuilder(builder: (context) {
|
||||
final taskProgress = useListenable(useMemoized(
|
||||
() => downloadManager
|
||||
.getProgressNotifier(track),
|
||||
[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);
|
||||
}),
|
||||
const SizedBox(width: 10),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.close),
|
||||
onPressed: () {
|
||||
downloadManager.cancel(track);
|
||||
}),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
DownloadStatus.paused => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.play),
|
||||
onPressed: () {
|
||||
downloadManager.resume(track);
|
||||
}),
|
||||
const SizedBox(width: 10),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.close),
|
||||
onPressed: () {
|
||||
downloadManager.cancel(track);
|
||||
})
|
||||
],
|
||||
),
|
||||
DownloadStatus.failed ||
|
||||
DownloadStatus.canceled =>
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
SpotubeIcons.error,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.refresh),
|
||||
onPressed: () {
|
||||
downloadManager.retry(track);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
DownloadStatus.completed =>
|
||||
Icon(SpotubeIcons.done, color: Colors.green[400]),
|
||||
DownloadStatus.queued => IconButton(
|
||||
icon: const Icon(SpotubeIcons.close),
|
||||
onPressed: () {
|
||||
downloadManager.removeFromQueue(track);
|
||||
}),
|
||||
},
|
||||
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.artists ?? <Artist>[],
|
||||
mainAxisAlignment: WrapAlignment.start,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -52,7 +52,7 @@ class Sidebar extends HookConsumerWidget {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final downloadCount = ref.watch(
|
||||
downloadManagerProvider.select((s) => s.length),
|
||||
downloadManagerProvider.select((s) => s.$downloadCount),
|
||||
);
|
||||
|
||||
final layoutMode =
|
||||
|
||||
@ -27,7 +27,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final downloadCount = ref.watch(
|
||||
downloadManagerProvider.select((s) => s.length),
|
||||
downloadManagerProvider.select((s) => s.$downloadCount),
|
||||
);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final 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;
|
||||
|
||||
@ -16,7 +16,7 @@ class LibraryPage extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final downloadingCount =
|
||||
ref.watch(downloadManagerProvider.select((s) => s.length));
|
||||
ref.watch(downloadManagerProvider.select((s) => s.$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(
|
||||
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),
|
||||
|
||||
@ -1,83 +1,62 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
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);
|
||||
final oldFile = File("$savePath.old");
|
||||
|
||||
final file = tempFile.copySync(_getPathForTrack(track));
|
||||
if ((status == DownloadStatus.failed ||
|
||||
status == DownloadStatus.canceled) &&
|
||||
await oldFile.exists()) {
|
||||
await oldFile.rename(savePath);
|
||||
}
|
||||
if (status != DownloadStatus.completed) return;
|
||||
|
||||
await tempFile.delete();
|
||||
var file = File(request.path);
|
||||
|
||||
await MetadataGod.writeMetadata(
|
||||
file: file.path,
|
||||
metadata: Metadata(
|
||||
file.copySync(savePath);
|
||||
file.deleteSync();
|
||||
|
||||
file = File(savePath);
|
||||
|
||||
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,
|
||||
@ -90,107 +69,181 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
durationMs: track.durationMs?.toDouble(),
|
||||
fileSize: file.lengthSync(),
|
||||
trackTotal: track.album?.tracks?.length,
|
||||
picture: response.headers['content-type'] != null
|
||||
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(youtubeProvider);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
final downloadManagerProvider =
|
||||
StateNotifierProvider<DownloadManagerProvider, List<Track>>(
|
||||
DownloadManagerProvider.new,
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> batchAddToQueue(List<Track> tracks) async {
|
||||
backHistory.addAll(
|
||||
tracks.where((element) => element is! SpotubeTrack),
|
||||
);
|
||||
for (final track in tracks) {
|
||||
await addToQueue(track);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
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 = ChangeNotifierProvider<DownloadManagerProvider>(
|
||||
(ref) => DownloadManagerProvider(ref: ref),
|
||||
);
|
||||
|
||||
413
lib/services/download_manager/download_manager.dart
Normal file
413
lib/services/download_manager/download_manager.dart
Normal file
@ -0,0 +1,413 @@
|
||||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package: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,
|
||||
{forceDownload = false}) async {
|
||||
late String partialFilePath;
|
||||
late File partialFile;
|
||||
try {
|
||||
var task = getDownload(url);
|
||||
|
||||
if (task == null || task.status.value == DownloadStatus.canceled) {
|
||||
return;
|
||||
}
|
||||
setStatus(task, DownloadStatus.downloading);
|
||||
|
||||
debugPrint(url);
|
||||
var file = File(savePath.toString());
|
||||
partialFilePath = savePath + partialExtension;
|
||||
partialFile = File(partialFilePath);
|
||||
|
||||
var fileExist = await file.exists();
|
||||
var partialFileExist = await partialFile.exists();
|
||||
|
||||
if (fileExist) {
|
||||
debugPrint("File Exists");
|
||||
setStatus(task, DownloadStatus.completed);
|
||||
} else if (partialFileExist) {
|
||||
debugPrint("Partial File Exists");
|
||||
|
||||
var partialFileLength = await partialFile.length();
|
||||
|
||||
var 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) {
|
||||
var ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend);
|
||||
var _f = File(partialFilePath + tempExtension);
|
||||
await ioSink.addStream(_f.openRead());
|
||||
await _f.delete();
|
||||
await ioSink.close();
|
||||
await partialFile.rename(savePath);
|
||||
|
||||
setStatus(task, DownloadStatus.completed);
|
||||
}
|
||||
} else {
|
||||
var response = await dio.download(
|
||||
url,
|
||||
partialFilePath,
|
||||
onReceiveProgress: createCallback(url, 0),
|
||||
options: Options(headers: {
|
||||
HttpHeaders.connectionHeader: "close",
|
||||
}),
|
||||
cancelToken: cancelToken,
|
||||
deleteOnError: false,
|
||||
);
|
||||
|
||||
if (response.statusCode == HttpStatus.ok) {
|
||||
await partialFile.rename(savePath);
|
||||
setStatus(task, DownloadStatus.completed);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
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 savedDir) async {
|
||||
if (url.isNotEmpty) {
|
||||
if (savedDir.isEmpty) {
|
||||
savedDir = ".";
|
||||
}
|
||||
|
||||
var isDirectory = await Directory(savedDir).exists();
|
||||
var downloadFilename = isDirectory
|
||||
? savedDir + Platform.pathSeparator + getFileNameFromUrl(url)
|
||||
: savedDir;
|
||||
|
||||
return _addDownloadRequest(DownloadRequest(url, downloadFilename));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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));
|
||||
var task = DownloadTask(_queue.last);
|
||||
|
||||
_cache[downloadRequest.url] = task;
|
||||
|
||||
_startExecution();
|
||||
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<void> pauseDownload(String url) async {
|
||||
debugPrint("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("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("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 savedDir) async {
|
||||
urls.forEach((url) {
|
||||
addDownload(url, savedDir);
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
urls.forEach((url) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
14
pubspec.lock
14
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:
|
||||
@ -459,13 +451,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:
|
||||
|
||||
@ -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