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
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final groupValue = ref.watch(replaceDownloadedFileState);
|
|
||||||
final replaceAll = ref.watch(replaceDownloadedFileState);
|
final replaceAll = ref.watch(replaceDownloadedFileState);
|
||||||
|
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: Text(context.l10n.track_exists(track.name)),
|
title: Text(context.l10n.track_exists(track.name)),
|
||||||
content: RadioGroup(
|
content: RadioGroup(
|
||||||
value: groupValue,
|
value: replaceAll,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
ref.read(replaceDownloadedFileState.notifier).state = value;
|
ref.read(replaceDownloadedFileState.notifier).state = value;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -89,7 +89,7 @@ class TrackPresentationActionsSection extends HookConsumerWidget {
|
|||||||
) ??
|
) ??
|
||||||
false;
|
false;
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
downloader.batchAddToQueue(fullTrackObjects);
|
downloader.addAllToQueue(fullTrackObjects);
|
||||||
notifier.deselectAllTracks();
|
notifier.deselectAllTracks();
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
showToastForAction(context, action, fullTrackObjects.length);
|
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:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||||
|
|
||||||
@ -44,7 +42,7 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
:isActiveTrack,
|
:isActiveTrack,
|
||||||
:isAuthenticated,
|
:isAuthenticated,
|
||||||
:isLiked,
|
:isLiked,
|
||||||
:progressNotifier
|
:downloadTask
|
||||||
) = ref.watch(trackOptionsStateProvider(track));
|
) = ref.watch(trackOptionsStateProvider(track));
|
||||||
final isLocalTrack = track is SpotubeLocalTrackObject;
|
final isLocalTrack = track is SpotubeLocalTrackObject;
|
||||||
|
|
||||||
@ -211,12 +209,19 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
enabled: !isInDownloadQueue,
|
enabled: !isInDownloadQueue,
|
||||||
leading: isInDownloadQueue
|
leading: isInDownloadQueue
|
||||||
? HookBuilder(builder: (context) {
|
? StreamBuilder(
|
||||||
final progress = useListenable(progressNotifier);
|
stream: downloadTask?.downloadedBytesStream,
|
||||||
return CircularProgressIndicator(
|
builder: (context, snapshot) {
|
||||||
value: progress?.value,
|
final progress = downloadTask?.totalSizeBytes == null ||
|
||||||
);
|
downloadTask?.totalSizeBytes == 0
|
||||||
})
|
? 0
|
||||||
|
: (snapshot.data ?? 0) /
|
||||||
|
downloadTask!.totalSizeBytes!;
|
||||||
|
return CircularProgressIndicator(
|
||||||
|
value: progress.toDouble(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
: const Icon(SpotubeIcons.download),
|
: const Icon(SpotubeIcons.download),
|
||||||
title: Text(context.l10n.download_track),
|
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/image/universal_image.dart';
|
||||||
import 'package:spotube/components/links/artist_link.dart';
|
import 'package:spotube/components/links/artist_link.dart';
|
||||||
import 'package:spotube/components/ui/button_tile.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/models/metadata/metadata.dart';
|
||||||
import 'package:spotube/provider/download_manager_provider.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 {
|
class DownloadItem extends HookConsumerWidget {
|
||||||
final SpotubeFullTrackObject track;
|
final DownloadTask task;
|
||||||
const DownloadItem({
|
const DownloadItem({
|
||||||
super.key,
|
super.key,
|
||||||
required this.track,
|
required this.task,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final downloadManager = ref.watch(downloadManagerProvider);
|
final downloadManager = ref.watch(downloadManagerProvider.notifier);
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return ButtonTile(
|
return ButtonTile(
|
||||||
style: ButtonVariance.ghost,
|
style: ButtonVariance.ghost,
|
||||||
@ -55,90 +30,72 @@ class DownloadItem extends HookConsumerWidget {
|
|||||||
child: UniversalImage(
|
child: UniversalImage(
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
path: track.album.images.asUrlString(
|
path: task.track.album.images.asUrlString(
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: Text(track.name),
|
title: Text(task.track.name),
|
||||||
subtitle: ArtistLink(
|
subtitle: ArtistLink(
|
||||||
artists: track.artists,
|
artists: task.track.artists,
|
||||||
mainAxisAlignment: WrapAlignment.start,
|
mainAxisAlignment: WrapAlignment.start,
|
||||||
onOverflowArtistClick: () {
|
onOverflowArtistClick: () {
|
||||||
context.navigateTo(TrackRoute(trackId: track.id));
|
context.navigateTo(TrackRoute(trackId: task.track.id));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
trailing: isQueryingSourceInfo
|
trailing: switch (task.status) {
|
||||||
? Text(context.l10n.querying_info).small()
|
DownloadStatus.downloading => HookBuilder(builder: (context) {
|
||||||
: switch (taskStatus.value!) {
|
return StreamBuilder(
|
||||||
DownloadStatus.downloading => HookBuilder(builder: (context) {
|
stream: task.downloadedBytesStream,
|
||||||
final taskProgress = useListenable(useMemoized(
|
builder: (context, asyncSnapshot) {
|
||||||
() => downloadManager.getProgressNotifier(track),
|
final progress =
|
||||||
[track],
|
task.totalSizeBytes == null || task.totalSizeBytes == 0
|
||||||
));
|
? 0
|
||||||
|
: (asyncSnapshot.data ?? 0) / task.totalSizeBytes!;
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(
|
CircularProgressIndicator(
|
||||||
value: taskProgress?.value ?? 0,
|
value: progress.toDouble(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
IconButton.ghost(
|
|
||||||
icon: const Icon(SpotubeIcons.pause),
|
|
||||||
onPressed: () {
|
|
||||||
downloadManager.pause(track);
|
|
||||||
}),
|
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
IconButton.ghost(
|
IconButton.ghost(
|
||||||
icon: const Icon(SpotubeIcons.close),
|
icon: const Icon(SpotubeIcons.close),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
downloadManager.cancel(track);
|
downloadManager.cancel(task.track);
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
});
|
||||||
DownloadStatus.paused => Row(
|
}),
|
||||||
mainAxisSize: MainAxisSize.min,
|
DownloadStatus.failed || DownloadStatus.canceled => SizedBox(
|
||||||
children: [
|
width: 100,
|
||||||
IconButton.ghost(
|
child: Row(
|
||||||
icon: const Icon(SpotubeIcons.play),
|
children: [
|
||||||
onPressed: () {
|
Icon(
|
||||||
downloadManager.resume(track);
|
SpotubeIcons.error,
|
||||||
}),
|
color: Colors.red[400],
|
||||||
const SizedBox(width: 10),
|
|
||||||
IconButton.ghost(
|
|
||||||
icon: const Icon(SpotubeIcons.close),
|
|
||||||
onPressed: () {
|
|
||||||
downloadManager.cancel(track);
|
|
||||||
})
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
DownloadStatus.failed || DownloadStatus.canceled => SizedBox(
|
const SizedBox(width: 10),
|
||||||
width: 100,
|
IconButton.ghost(
|
||||||
child: Row(
|
icon: const Icon(SpotubeIcons.refresh),
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
SpotubeIcons.error,
|
|
||||||
color: Colors.red[400],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
IconButton.ghost(
|
|
||||||
icon: const Icon(SpotubeIcons.refresh),
|
|
||||||
onPressed: () {
|
|
||||||
downloadManager.retry(track);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
DownloadStatus.completed =>
|
|
||||||
Icon(SpotubeIcons.done, color: Colors.green[400]),
|
|
||||||
DownloadStatus.queued => IconButton.ghost(
|
|
||||||
icon: const Icon(SpotubeIcons.close),
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
downloadManager.removeFromQueue(track);
|
downloadManager.retry(task.track);
|
||||||
}),
|
},
|
||||||
},
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DownloadStatus.completed =>
|
||||||
|
Icon(SpotubeIcons.done, color: Colors.green[400]),
|
||||||
|
DownloadStatus.queued => IconButton.ghost(
|
||||||
|
icon: const Icon(SpotubeIcons.close),
|
||||||
|
onPressed: () {
|
||||||
|
downloadManager.cancel(task.track);
|
||||||
|
}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,8 +43,12 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
final downloader = ref.watch(downloadManagerProvider.notifier);
|
final downloader = ref.watch(downloadManagerProvider.notifier);
|
||||||
final isInQueue = useMemoized(() {
|
final isInQueue = useMemoized(() {
|
||||||
if (playlist.activeTrack is! SpotubeFullTrackObject) return false;
|
if (playlist.activeTrack is! SpotubeFullTrackObject) return false;
|
||||||
return downloader
|
final downloadTask =
|
||||||
.isActive(playlist.activeTrack! as SpotubeFullTrackObject);
|
downloader.getTaskByTrackId(playlist.activeTrack!.id);
|
||||||
|
return const [
|
||||||
|
DownloadStatus.queued,
|
||||||
|
DownloadStatus.downloading,
|
||||||
|
].contains(downloadTask?.status);
|
||||||
}, [
|
}, [
|
||||||
playlist.activeTrack,
|
playlist.activeTrack,
|
||||||
downloader,
|
downloader,
|
||||||
|
|||||||
@ -24,7 +24,12 @@ class SidebarFooter extends HookConsumerWidget implements NavigationBarItem {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final router = AutoRouter.of(context, watch: true);
|
final router = AutoRouter.of(context, watch: true);
|
||||||
final mediaQuery = MediaQuery.of(context);
|
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 userSnapshot = ref.watch(metadataPluginUserProvider);
|
||||||
final data = userSnapshot.asData?.value;
|
final data = userSnapshot.asData?.value;
|
||||||
|
|
||||||
|
|||||||
@ -25,7 +25,12 @@ class SpotubeNavigationBar extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final mediaQuery = MediaQuery.of(context);
|
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 =
|
final layoutMode =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.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
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
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 router = context.watchRouter;
|
||||||
final sidebarLibraryTileList = useMemoized(
|
final sidebarLibraryTileList = useMemoized(
|
||||||
() => [
|
() => [
|
||||||
|
|||||||
@ -14,9 +14,8 @@ class UserDownloadsPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final downloadManager = ref.watch(downloadManagerProvider);
|
final downloadQueue = ref.watch(downloadManagerProvider);
|
||||||
|
final downloadManagerNotifier = ref.watch(downloadManagerProvider.notifier);
|
||||||
final history = downloadManager.$history;
|
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -28,16 +27,15 @@ class UserDownloadsPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: AutoSizeText(
|
child: AutoSizeText(
|
||||||
context.l10n
|
context.l10n.currently_downloading(downloadQueue.length),
|
||||||
.currently_downloading(downloadManager.$downloadCount),
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
).semiBold(),
|
).semiBold(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Button.destructive(
|
Button.destructive(
|
||||||
onPressed: downloadManager.$downloadCount == 0
|
onPressed: downloadQueue.isEmpty
|
||||||
? null
|
? null
|
||||||
: downloadManager.cancelAll,
|
: downloadManagerNotifier.clearAll,
|
||||||
child: Text(context.l10n.cancel_all),
|
child: Text(context.l10n.cancel_all),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -46,9 +44,12 @@ class UserDownloadsPage extends HookConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: history.length,
|
itemCount: downloadQueue.length,
|
||||||
|
padding: const EdgeInsets.only(bottom: 200),
|
||||||
itemBuilder: (context, index) {
|
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/fake.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/button/back_button.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/constrains.dart';
|
||||||
import 'package:spotube/extensions/string.dart';
|
import 'package:spotube/extensions/string.dart';
|
||||||
import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.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
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final scale = context.theme.scaling;
|
final scale = context.theme.scaling;
|
||||||
@ -75,8 +107,12 @@ class LocalLibraryPage extends HookConsumerWidget {
|
|||||||
final sortBy = useState<SortBy>(SortBy.none);
|
final sortBy = useState<SortBy>(SortBy.none);
|
||||||
final playlist = ref.watch(audioPlayerProvider);
|
final playlist = ref.watch(audioPlayerProvider);
|
||||||
final trackSnapshot = ref.watch(localTracksProvider);
|
final trackSnapshot = ref.watch(localTracksProvider);
|
||||||
final isPlaylistPlaying = playlist.containsTracks(
|
final isPlaylistPlaying = useMemoized(
|
||||||
trackSnapshot.asData?.value.values.flattened.toList() ?? []);
|
() => playlist.containsTracks(
|
||||||
|
trackSnapshot.asData?.value[location] ?? [],
|
||||||
|
),
|
||||||
|
[playlist, trackSnapshot, location],
|
||||||
|
);
|
||||||
|
|
||||||
final searchController = useShadcnTextEditingController();
|
final searchController = useShadcnTextEditingController();
|
||||||
useValueListenable(searchController);
|
useValueListenable(searchController);
|
||||||
@ -222,26 +258,79 @@ class LocalLibraryPage extends HookConsumerWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Gap(5),
|
const Gap(5),
|
||||||
Button.primary(
|
Tooltip(
|
||||||
onPressed: trackSnapshot.asData?.value != null
|
tooltip:
|
||||||
? () async {
|
TooltipContainer(child: Text(context.l10n.play)).call,
|
||||||
if (trackSnapshot.asData?.value.isNotEmpty ==
|
child: IconButton.primary(
|
||||||
true) {
|
onPressed: trackSnapshot.asData?.value != null
|
||||||
if (!isPlaylistPlaying) {
|
? () async {
|
||||||
await playLocalTracks(
|
if (trackSnapshot.asData?.value.isNotEmpty ==
|
||||||
ref,
|
true) {
|
||||||
trackSnapshot.asData!.value[location] ?? [],
|
if (!isPlaylistPlaying) {
|
||||||
);
|
await playLocalTracks(
|
||||||
|
ref,
|
||||||
|
trackSnapshot.asData!.value[location] ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
: null,
|
||||||
: null,
|
icon: Icon(
|
||||||
leading: Icon(
|
isPlaylistPlaying
|
||||||
isPlaylistPlaying
|
? SpotubeIcons.stop
|
||||||
? SpotubeIcons.stop
|
: SpotubeIcons.play,
|
||||||
: SpotubeIcons.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),
|
||||||
),
|
),
|
||||||
child: Text(context.l10n.play),
|
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (constraints.smAndDown)
|
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/local_tracks/local_tracks_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
// ignore: depend_on_referenced_packages
|
|
||||||
|
|
||||||
enum SortBy {
|
enum SortBy {
|
||||||
none,
|
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/sidebar/sidebar.dart';
|
||||||
import 'package:spotube/modules/root/spotube_navigation_bar.dart';
|
import 'package:spotube/modules/root/spotube_navigation_bar.dart';
|
||||||
import 'package:spotube/hooks/configurators/use_endless_playback.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/modules/root/use_global_subscriptions.dart';
|
||||||
import 'package:spotube/provider/glance/glance.dart';
|
import 'package:spotube/provider/glance/glance.dart';
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ class RootAppPage extends HookConsumerWidget {
|
|||||||
ref.listen(glanceProvider, (_, __) {});
|
ref.listen(glanceProvider, (_, __) {});
|
||||||
|
|
||||||
useGlobalSubscriptions(ref);
|
useGlobalSubscriptions(ref);
|
||||||
useDownloaderDialogs(ref);
|
|
||||||
useEndlessPlayback(ref);
|
useEndlessPlayback(ref);
|
||||||
useCheckYtDlpInstalled(ref);
|
useCheckYtDlpInstalled(ref);
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,11 @@ class AudioPlayerState with _$AudioPlayerState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool containsTrack(SpotubeTrackObject track) {
|
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) {
|
bool containsTracks(List<SpotubeTrackObject> tracks) {
|
||||||
|
|||||||
@ -1,268 +1,285 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
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/models/metadata/metadata.dart';
|
||||||
import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
|
import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
|
||||||
import 'package:spotube/provider/server/sourced_track_provider.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/provider/user_preferences/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/download_manager/download_manager.dart';
|
import 'package:spotube/services/logger/logger.dart';
|
||||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class DownloadManagerProvider extends ChangeNotifier {
|
enum DownloadStatus {
|
||||||
DownloadManagerProvider({required this.ref})
|
queued,
|
||||||
: $history = <SourcedTrack>{},
|
downloading,
|
||||||
dl = DownloadManager() {
|
completed,
|
||||||
dl.statusStream.listen((event) async {
|
failed,
|
||||||
try {
|
canceled,
|
||||||
final (:request, :status) = event;
|
}
|
||||||
|
|
||||||
final sourcedTrack = $history.firstWhereOrNull(
|
class DownloadTask {
|
||||||
(element) =>
|
final SpotubeFullTrackObject track;
|
||||||
element.getUrlOfQuality(
|
final DownloadStatus status;
|
||||||
downloadContainer,
|
final CancelToken cancelToken;
|
||||||
downloadQualityIndex,
|
final int? totalSizeBytes;
|
||||||
) ==
|
final StreamController<int> _downloadedBytesStreamController;
|
||||||
request.url,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (sourcedTrack == null) return;
|
Stream<int> get downloadedBytesStream =>
|
||||||
|
_downloadedBytesStreamController.stream;
|
||||||
|
|
||||||
final savePath = getTrackFileUrl(sourcedTrack);
|
DownloadTask({
|
||||||
// related to onFileExists
|
required this.track,
|
||||||
final oldFile = File("$savePath.old");
|
required this.status,
|
||||||
|
required this.cancelToken,
|
||||||
|
this.totalSizeBytes,
|
||||||
|
StreamController<int>? downloadedBytesStreamController,
|
||||||
|
}) : _downloadedBytesStreamController =
|
||||||
|
downloadedBytesStreamController ?? StreamController.broadcast();
|
||||||
|
|
||||||
// if download failed and old file exists, rename it back
|
DownloadTask copyWith({
|
||||||
if ((status == DownloadStatus.failed ||
|
SpotubeFullTrackObject? track,
|
||||||
status == DownloadStatus.canceled) &&
|
DownloadStatus? status,
|
||||||
await oldFile.exists()) {
|
CancelToken? cancelToken,
|
||||||
await oldFile.rename(savePath);
|
int? totalSizeBytes,
|
||||||
}
|
StreamController<int>? downloadedBytesStreamController,
|
||||||
|
}) {
|
||||||
if (status != DownloadStatus.completed ||
|
return DownloadTask(
|
||||||
//? WebA audiotagging is not supported yet
|
track: track ?? this.track,
|
||||||
//? Although in future by converting weba to opus & then tagging it
|
status: status ?? this.status,
|
||||||
//? is possible using vorbis comments
|
cancelToken: cancelToken ?? this.cancelToken,
|
||||||
downloadContainer.getFileExtension() == "weba") {
|
totalSizeBytes: totalSizeBytes ?? this.totalSizeBytes,
|
||||||
return;
|
downloadedBytesStreamController:
|
||||||
}
|
downloadedBytesStreamController ?? _downloadedBytesStreamController,
|
||||||
|
|
||||||
final file = File(request.path);
|
|
||||||
|
|
||||||
if (await oldFile.exists()) {
|
|
||||||
await oldFile.delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
final imageBytes = await ServiceUtils.downloadImage(
|
|
||||||
(sourcedTrack.query.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,
|
|
||||||
);
|
|
||||||
} catch (e, stack) {
|
|
||||||
AppLogger.reportError(e, stack);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> Function(SpotubeFullTrackObject track) onFileExists =
|
|
||||||
(SpotubeFullTrackObject track) async => true;
|
|
||||||
|
|
||||||
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) {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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>(
|
class DownloadManagerNotifier extends Notifier<List<DownloadTask>> {
|
||||||
(ref) => DownloadManagerProvider(ref: ref),
|
final Dio dio;
|
||||||
|
DownloadManagerNotifier()
|
||||||
|
: dio = Dio(),
|
||||||
|
super();
|
||||||
|
|
||||||
|
@override
|
||||||
|
build() {
|
||||||
|
ref.onDispose(() {
|
||||||
|
for (final task in state) {
|
||||||
|
if (task.status == DownloadStatus.downloading) {
|
||||||
|
task.cancelToken.cancel();
|
||||||
|
}
|
||||||
|
task._downloadedBytesStreamController.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (container.getFileExtension() == "weba") return;
|
||||||
|
|
||||||
|
final imageBytes = await ServiceUtils.downloadImage(
|
||||||
|
(task.track.album.images).asUrlString(
|
||||||
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
|
index: 1,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await MetadataGod.writeMetadata(
|
||||||
|
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<void> _startDownloading() async {
|
||||||
|
for (final task in state) {
|
||||||
|
if (task.status == DownloadStatus.downloading) return;
|
||||||
|
|
||||||
|
if (task.status == DownloadStatus.queued) {
|
||||||
|
try {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final downloadManagerProvider =
|
||||||
|
NotifierProvider<DownloadManagerNotifier, List<DownloadTask>>(
|
||||||
|
DownloadManagerNotifier.new,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -49,7 +49,7 @@ class TrackOptionsActions {
|
|||||||
ref.read(metadataPluginSavedTracksProvider.notifier);
|
ref.read(metadataPluginSavedTracksProvider.notifier);
|
||||||
MetadataPluginSavedPlaylistsNotifier get favoritePlaylistsNotifier =>
|
MetadataPluginSavedPlaylistsNotifier get favoritePlaylistsNotifier =>
|
||||||
ref.read(metadataPluginSavedPlaylistsProvider.notifier);
|
ref.read(metadataPluginSavedPlaylistsProvider.notifier);
|
||||||
DownloadManagerProvider get downloadManager =>
|
DownloadManagerNotifier get downloadManager =>
|
||||||
ref.read(downloadManagerProvider.notifier);
|
ref.read(downloadManagerProvider.notifier);
|
||||||
BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier);
|
BlackListNotifier get blacklist => ref.read(blacklistProvider.notifier);
|
||||||
|
|
||||||
@ -263,7 +263,7 @@ typedef TrackOptionFlags = ({
|
|||||||
bool isActiveTrack,
|
bool isActiveTrack,
|
||||||
bool isAuthenticated,
|
bool isAuthenticated,
|
||||||
bool isLiked,
|
bool isLiked,
|
||||||
ValueNotifier<double>? progressNotifier,
|
DownloadTask? downloadTask,
|
||||||
});
|
});
|
||||||
|
|
||||||
final trackOptionActionsProvider =
|
final trackOptionActionsProvider =
|
||||||
@ -283,15 +283,16 @@ final trackOptionsStateProvider =
|
|||||||
final isBlacklisted = blacklist.contains(track);
|
final isBlacklisted = blacklist.contains(track);
|
||||||
final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id));
|
final isSavedTrack = ref.watch(metadataPluginIsSavedTrackProvider(track.id));
|
||||||
|
|
||||||
|
final downloadTask = playlist.activeTrack?.id == null
|
||||||
|
? null
|
||||||
|
: downloadManager.getTaskByTrackId(playlist.activeTrack!.id);
|
||||||
final isInDownloadQueue = playlist.activeTrack == null ||
|
final isInDownloadQueue = playlist.activeTrack == null ||
|
||||||
playlist.activeTrack! is SpotubeLocalTrackObject
|
playlist.activeTrack! is SpotubeLocalTrackObject
|
||||||
? false
|
? false
|
||||||
: downloadManager
|
: const [
|
||||||
.isActive(playlist.activeTrack! as SpotubeFullTrackObject);
|
DownloadStatus.queued,
|
||||||
|
DownloadStatus.downloading,
|
||||||
final progressNotifier = track is SpotubeLocalTrackObject
|
].contains(downloadTask?.status);
|
||||||
? null
|
|
||||||
: downloadManager.getProgressNotifier(track as SpotubeFullTrackObject);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
isInQueue: playlist.containsTrack(track),
|
isInQueue: playlist.containsTrack(track),
|
||||||
@ -300,6 +301,6 @@ final trackOptionsStateProvider =
|
|||||||
isActiveTrack: playlist.activeTrack?.id == track.id,
|
isActiveTrack: playlist.activeTrack?.id == track.id,
|
||||||
isAuthenticated: authenticated.asData?.value ?? false,
|
isAuthenticated: authenticated.asData?.value ?? false,
|
||||||
isLiked: isSavedTrack.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