fix: download not working in different devices and slow

This commit is contained in:
Kingkor Roy Tirtho 2025-11-11 10:39:13 +06:00
parent 834445eda3
commit 4fae9013a7
22 changed files with 653 additions and 1088 deletions

View File

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

View File

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

View File

@ -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
View 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) {
// cant 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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(
() => [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
final sourcedTrack = $history.firstWhereOrNull(
(element) =>
element.getUrlOfQuality(
downloadContainer,
downloadQualityIndex,
) ==
request.url,
);
if (sourcedTrack == null) return;
final savePath = getTrackFileUrl(sourcedTrack);
// related to onFileExists
final oldFile = File("$savePath.old");
// if download failed and old file exists, rename it back
if ((status == DownloadStatus.failed ||
status == DownloadStatus.canceled) &&
await oldFile.exists()) {
await oldFile.rename(savePath);
enum DownloadStatus {
queued,
downloading,
completed,
failed,
canceled,
}
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") {
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,
);
}
}
class DownloadManagerNotifier extends Notifier<List<DownloadTask>> {
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;
}
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;
}
}
}
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),
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,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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