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