feat: concurrent download service & download prorvider

This commit is contained in:
Kingkor Roy Tirtho 2023-08-06 10:34:56 +06:00
parent cf7b849cdd
commit f0f0abd782
17 changed files with 843 additions and 226 deletions

View File

@ -122,12 +122,12 @@ Do the following:
- Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu
```bash
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev
$ apt-get install mpv libmpv-dev libappindicator3-1 gir1.2-appindicator3-0.1 libappindicator3-dev libsecret-1-0 libjsoncpp25 libsecret-1-dev libjsoncpp-dev libnotify-bin libnotify-dev network-manager
```
- Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro
```bash
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify networkmanager
```
- Fedora
```bash

View File

@ -1,6 +1,4 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:background_downloader/background_downloader.dart';
// import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -10,6 +8,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class UserDownloads extends HookConsumerWidget {
@ -31,7 +30,7 @@ class UserDownloads extends HookConsumerWidget {
Expanded(
child: AutoSizeText(
context.l10n
.currently_downloading(downloadManager.totalDownloads),
.currently_downloading(downloadManager.$downloadCount),
maxLines: 1,
style: Theme.of(context).textTheme.headlineMedium,
),
@ -42,7 +41,7 @@ class UserDownloads extends HookConsumerWidget {
backgroundColor: Colors.red[50],
foregroundColor: Colors.red[400],
),
onPressed: downloadManager.totalDownloads == 0
onPressed: downloadManager.$downloadCount == 0
? null
: downloadManager.cancelAll,
child: Text(context.l10n.cancel_all),
@ -53,24 +52,16 @@ class UserDownloads extends HookConsumerWidget {
Expanded(
child: SafeArea(
child: ListView.builder(
itemCount: downloadManager.totalDownloads,
itemCount: downloadManager.$downloadCount,
itemBuilder: (context, index) {
final track = downloadManager.items.elementAt(index);
final track = downloadManager.$history.elementAt(index);
return HookBuilder(builder: (context) {
final task = useStream(
downloadManager.activeDownloadProgress.stream
.where((element) => element.task.taskId == track.id),
final taskStatus = useListenable(
useMemoized(
() => downloadManager.getStatusNotifier(track),
[track],
),
);
final failedTaskStream = useStream(
downloadManager.failedDownloads.stream
.where((element) => element.taskId == track.id),
);
final taskItSelf = useFuture(
FileDownloader().database.recordForId(track.id!),
);
final hasFailed = failedTaskStream.hasData ||
taskItSelf.data?.status == TaskStatus.failed;
return ListTile(
title: Text(track.name ?? ''),
@ -88,19 +79,83 @@ class UserDownloads extends HookConsumerWidget {
),
),
),
horizontalTitleGap: 10,
trailing: downloadManager.activeItem?.id == track.id &&
!hasFailed
? CircularProgressIndicator(
value: task.data?.progress ?? 0,
)
: hasFailed
? Icon(SpotubeIcons.error, color: Colors.red[400])
: IconButton(
trailing: taskStatus == null
? null
: switch (taskStatus.value) {
DownloadStatus.downloading =>
HookBuilder(builder: (context) {
final taskProgress = useListenable(useMemoized(
() => downloadManager
.getProgressNotifier(track),
[track],
));
return SizedBox(
width: 140,
child: Row(
children: [
CircularProgressIndicator(
value: taskProgress?.value ?? 0,
),
const SizedBox(width: 10),
IconButton(
icon: const Icon(SpotubeIcons.pause),
onPressed: () {
downloadManager.pause(track);
}),
const SizedBox(width: 10),
IconButton(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
downloadManager.cancel(track);
}),
],
),
);
}),
DownloadStatus.paused => Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(SpotubeIcons.play),
onPressed: () {
downloadManager.resume(track);
}),
const SizedBox(width: 10),
IconButton(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
downloadManager.cancel(track);
})
],
),
DownloadStatus.failed ||
DownloadStatus.canceled =>
SizedBox(
width: 100,
child: Row(
children: [
Icon(
SpotubeIcons.error,
color: Colors.red[400],
),
const SizedBox(width: 10),
IconButton(
icon: const Icon(SpotubeIcons.refresh),
onPressed: () {
downloadManager.retry(track);
},
),
],
),
),
DownloadStatus.completed =>
Icon(SpotubeIcons.done, color: Colors.green[400]),
DownloadStatus.queued => IconButton(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
downloadManager.removeFromQueue(track);
}),
},
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start,

View File

@ -13,6 +13,7 @@ import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -39,8 +40,14 @@ class PlayerActions extends HookConsumerWidget {
final isLocalTrack = playlist.activeTrack is LocalTrack;
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final isInQueue = downloader.activeItem != null &&
downloader.activeItem!.id == playlist.activeTrack?.id;
final isInQueue = useMemoized(() {
if (playlist.activeTrack == null) return false;
return downloader.isActive(playlist.activeTrack!);
}, [
playlist.activeTrack,
downloader,
]);
final localTracks = [] /* ref.watch(localTracksProvider).value */;
final auth = ref.watch(AuthenticationNotifier.provider);
final sleepTimer = ref.watch(SleepTimerNotifier.provider);
@ -139,7 +146,7 @@ class PlayerActions extends HookConsumerWidget {
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,
),
onPressed: playlist.activeTrack != null
? () => downloader.enqueue(playlist.activeTrack!)
? () => downloader.addToQueue(playlist.activeTrack!)
: null,
),
if (playlist.activeTrack != null && !isLocalTrack && auth != null)

View File

@ -52,7 +52,7 @@ class Sidebar extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final downloadCount = ref.watch(
downloadManagerProvider.select((s) => s.length),
downloadManagerProvider.select((s) => s.$downloadCount),
);
final layoutMode =

View File

@ -27,7 +27,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final downloadCount = ref.watch(
downloadManagerProvider.select((s) => s.length),
downloadManagerProvider.select((s) => s.$downloadCount),
);
final mediaQuery = MediaQuery.of(context);
final layoutMode =

View File

@ -14,6 +14,7 @@ import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart';
@ -99,6 +100,20 @@ class TrackOptions extends HookConsumerWidget {
playlistId ?? "",
);
final isInQueue = useMemoized(() {
if (playlist.activeTrack == null) return false;
return downloadManager.isActive(playlist.activeTrack!);
}, [
playlist.activeTrack,
downloadManager,
]);
final progressNotifier = useMemoized(() {
final spotubeTrack = downloadManager.mapToSpotubeTrack(track);
if (spotubeTrack == null) return null;
return downloadManager.getProgressNotifier(spotubeTrack);
});
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
@ -175,7 +190,7 @@ class TrackOptions extends HookConsumerWidget {
);
break;
case TrackOptionValue.download:
await downloadManager.enqueue(track);
await downloadManager.addToQueue(track);
break;
}
},
@ -268,9 +283,14 @@ class TrackOptions extends HookConsumerWidget {
),
PopSheetEntry(
value: TrackOptionValue.download,
enabled: downloadManager.activeItem?.id != track.id!,
leading: downloadManager.activeItem?.id == track.id!
? const CircularProgressIndicator()
enabled: isInQueue,
leading: isInQueue
? HookBuilder(builder: (context) {
final progress = useListenable(progressNotifier!);
return CircularProgressIndicator(
value: progress.value,
);
})
: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track),
),

View File

@ -196,7 +196,7 @@ class TracksTableView extends HookConsumerWidget {
);
if (confirmed != true) return;
await downloader
.enqueueAll(selectedTracks.toList());
.batchAddToQueue(selectedTracks.toList());
if (context.mounted) {
selected.value = [];
showCheck.value = false;

View File

@ -16,7 +16,7 @@ class LibraryPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final downloadingCount =
ref.watch(downloadManagerProvider.select((s) => s.length));
ref.watch(downloadManagerProvider.select((s) => s.$downloadCount));
return DefaultTabController(
length: 5,

View File

@ -33,7 +33,7 @@ class RootApp extends HookConsumerWidget {
final index = useState(0);
final isMounted = useIsMounted();
final showingDialogCompleter = useRef(Completer()..complete());
final downloader = ref.watch(downloadManagerProvider.notifier);
final downloader = ref.watch(downloadManagerProvider);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {

View File

@ -23,7 +23,6 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/piped_instances_provider.dart';
@ -36,8 +35,6 @@ class SettingsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final UserPreferences preferences = ref.watch(userPreferencesProvider);
final auth = ref.watch(AuthenticationNotifier.provider);
final isDownloading =
ref.watch(downloadManagerProvider.select((s) => s.isNotEmpty));
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
@ -449,21 +446,15 @@ class SettingsPage extends HookConsumerWidget {
SectionCardWithHeading(
heading: context.l10n.downloads,
children: [
Tooltip(
message: isDownloading
? context.l10n.wait_for_download_to_finish
: "",
child: ListTile(
ListTile(
leading: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_location),
subtitle: Text(preferences.downloadLocation),
trailing: FilledButton(
onPressed:
isDownloading ? null : pickDownloadLocation,
onPressed: pickDownloadLocation,
child: const Icon(SpotubeIcons.folder),
),
onTap: isDownloading ? null : pickDownloadLocation,
),
onTap: pickDownloadLocation,
),
SwitchListTile(
secondary: const Icon(SpotubeIcons.lyrics),

View File

@ -1,83 +1,62 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'package:catcher/catcher.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/services/youtube/youtube.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
final Ref ref;
class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref})
: $history = <SpotubeTrack>{},
backHistory = <Track>{},
dl = DownloadManager() {
dl.statusStream.listen((event) async {
final (:request, :status) = event;
final StreamController<TaskProgressUpdate> activeDownloadProgress;
final StreamController<Task> failedDownloads;
Track? _activeItem;
FutureOr<bool> Function(Track)? onFileExists;
DownloadManagerProvider(this.ref)
: activeDownloadProgress = StreamController.broadcast(),
failedDownloads = StreamController.broadcast(),
super([]) {
if (kIsWeb) return;
FileDownloader().registerCallbacks(
group: FileDownloader.defaultGroup,
taskNotificationTapCallback: (task, notificationType) {
router.go("/library");
},
taskStatusCallback: (update) async {
if (update.status == TaskStatus.running) {
_activeItem =
state.firstWhereOrNull((track) => track.id == update.task.taskId);
state = state.toList();
}
if (update.status == TaskStatus.failed ||
update.status == TaskStatus.notFound) {
failedDownloads.add(update.task);
}
if (update.status == TaskStatus.complete) {
final track =
state.firstWhere((element) => element.id == update.task.taskId);
// resetting the replace downloaded file state on queue completion
if (state.last == track) {
ref.read(replaceDownloadedFileState.notifier).state = null;
}
state = state
.where((element) => element.id != update.task.taskId)
.toList();
final imageUri = TypeConversionUtils.image_X_UrlString(
track.album?.images ?? [],
placeholder: ImagePlaceholder.online,
final track = $history.firstWhereOrNull(
(element) => element.ytUri == request.url,
);
final response = await get(Uri.parse(imageUri));
if (track == null) return;
final tempFile = File(await update.task.filePath());
final savePath = getTrackFileUrl(track);
final oldFile = File("$savePath.old");
final file = tempFile.copySync(_getPathForTrack(track));
if ((status == DownloadStatus.failed ||
status == DownloadStatus.canceled) &&
await oldFile.exists()) {
await oldFile.rename(savePath);
}
if (status != DownloadStatus.completed) return;
await tempFile.delete();
var file = File(request.path);
await MetadataGod.writeMetadata(
file: file.path,
metadata: Metadata(
file.copySync(savePath);
file.deleteSync();
file = File(savePath);
if (await oldFile.exists()) {
await oldFile.delete();
}
final imageBytes = await downloadImage(
TypeConversionUtils.image_X_UrlString(track.album?.images,
placeholder: ImagePlaceholder.albumArt, index: 1),
);
final metadata = Metadata(
title: track.name,
artist: track.artists?.map((a) => a.name).join(", "),
album: track.album?.name,
@ -90,107 +69,181 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
durationMs: track.durationMs?.toDouble(),
fileSize: file.lengthSync(),
trackTotal: track.album?.tracks?.length,
picture: response.headers['content-type'] != null
picture: imageBytes != null
? Picture(
data: response.bodyBytes,
mimeType: response.headers['content-type']!,
data: imageBytes,
// Spotify images are always JPEGs
mimeType: 'image/jpeg',
)
: null,
),
);
}
},
taskProgressCallback: (update) {
activeDownloadProgress.add(update);
},
);
FileDownloader().trackTasks(markDownloadedComplete: true);
}
UserPreferences get preferences => ref.read(userPreferencesProvider);
YoutubeEndpoints get youtube => ref.read(youtubeProvider);
int get totalDownloads => state.length;
List<Track> get items => state;
Track? get activeItem => _activeItem;
String _getPathForTrack(Track track) => join(
preferences.downloadLocation,
"${track.name} - ${track.artists?.map((a) => a.name).join(", ")}.m4a",
);
Future<Task> _ensureSpotubeTrack(Track track) async {
if (state.any((element) => element.id == track.id)) {
final task = await FileDownloader().taskForId(track.id!);
if (task != null) {
return task;
}
// this makes sure we already have the fetched track
track = state.firstWhere((element) => element.id == track.id);
state.removeWhere((element) => element.id == track.id);
}
final spotubeTrack = track is SpotubeTrack
? track
: await SpotubeTrack.fetchFromTrack(
track,
youtube,
await MetadataGod.writeMetadata(
file: file.path,
metadata: metadata,
);
state = [...state, spotubeTrack];
final task = DownloadTask(
url: spotubeTrack.ytUri,
baseDirectory: BaseDirectory.applicationSupport,
taskId: spotubeTrack.id!,
updates: Updates.statusAndProgress,
);
return task;
});
}
Future<Task?> enqueue(Track track) async {
final replaceFileGlobal = ref.read(replaceDownloadedFileState);
final file = File(_getPathForTrack(track));
if (file.existsSync() &&
(replaceFileGlobal ?? await onFileExists?.call(track)) != true) {
if (state.isEmpty) {
ref.read(replaceDownloadedFileState.notifier).state = null;
Future<bool> Function(Track track) onFileExists = (Track track) async => true;
final Ref<DownloadManagerProvider> ref;
YoutubeEndpoints get yt => ref.read(youtubeProvider);
String get downloadDirectory =>
ref.read(userPreferencesProvider.select((s) => s.downloadLocation));
int get $downloadCount => dl
.getAllDownloads()
.where(
(download) =>
download.status.value == DownloadStatus.downloading ||
download.status.value == DownloadStatus.paused ||
download.status.value == DownloadStatus.queued,
)
.length;
final Set<SpotubeTrack> $history;
// these are the tracks which metadata hasn't been fetched yet
final Set<Track> backHistory;
final DownloadManager dl;
/// Spotify Images are always JPEGs
Future<Uint8List?> downloadImage(
String imageUrl,
) async {
try {
final fileStream = DefaultCacheManager().getImageFile(imageUrl);
final bytes = List<int>.empty(growable: true);
await for (final data in fileStream) {
if (data is FileInfo) {
bytes.addAll(data.file.readAsBytesSync());
break;
}
}
return Uint8List.fromList(bytes);
} catch (e, stackTrace) {
Catcher.reportCheckedError(e, stackTrace);
return null;
}
final task = await _ensureSpotubeTrack(track);
await FileDownloader().enqueue(task);
return task;
}
Future<List<Task>> enqueueAll(List<Track> tracks) async {
final tasks = await Future.wait(tracks.mapIndexed((i, e) {
if (i != 0) {
/// One second delay between each download to avoid
/// clogging the Piped server with too many requests
return Future.delayed(const Duration(seconds: 1), () => enqueue(e));
}
return enqueue(e);
}));
if (tasks.isEmpty) {
ref.read(replaceDownloadedFileState.notifier).state = null;
String getTrackFileUrl(Track track) {
final name =
"${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.m4a";
return join(downloadDirectory, name);
}
return tasks.whereType<Task>().toList();
bool isActive(Track track) {
if (backHistory.contains(track)) return true;
final spotubeTrack = mapToSpotubeTrack(track);
if (spotubeTrack == null) return false;
return dl
.getAllDownloads()
.where(
(download) =>
download.status.value == DownloadStatus.downloading ||
download.status.value == DownloadStatus.paused ||
download.status.value == DownloadStatus.queued,
)
.map((e) => e.request.url)
.contains(spotubeTrack.ytUri);
}
Future<void> cancel(Track track) async {
await FileDownloader().cancelTaskWithId(track.id!);
state = state.where((element) => element.id != track.id).toList();
/// For singular downloads
Future<void> addToQueue(Track track) async {
final savePath = getTrackFileUrl(track);
final oldFile = File(savePath);
if (await oldFile.exists() && !await onFileExists(track)) {
return;
}
Future<void> cancelAll() async {
(await FileDownloader().reset());
state = [];
if (await oldFile.exists()) {
await oldFile.rename("$savePath.old");
}
if (track is SpotubeTrack) {
final downloadTask = await dl.addDownload(track.ytUri, savePath);
if (downloadTask != null) {
$history.add(track);
}
} else {
backHistory.add(track);
final spotubeTrack =
await SpotubeTrack.fetchFromTrack(track, yt).then((d) {
backHistory.remove(track);
return d;
});
final downloadTask = await dl.addDownload(spotubeTrack.ytUri, savePath);
if (downloadTask != null) {
$history.add(spotubeTrack);
}
}
final downloadManagerProvider =
StateNotifierProvider<DownloadManagerProvider, List<Track>>(
DownloadManagerProvider.new,
notifyListeners();
}
Future<void> batchAddToQueue(List<Track> tracks) async {
backHistory.addAll(
tracks.where((element) => element is! SpotubeTrack),
);
for (final track in tracks) {
await addToQueue(track);
await Future.delayed(const Duration(seconds: 2));
}
}
Future<void> removeFromQueue(SpotubeTrack track) async {
await dl.removeDownload(track.ytUri);
$history.remove(track);
}
Future<void> pause(SpotubeTrack track) {
return dl.pauseDownload(track.ytUri);
}
Future<void> resume(SpotubeTrack track) {
return dl.resumeDownload(track.ytUri);
}
Future<void> retry(SpotubeTrack track) {
return addToQueue(track);
}
void cancel(SpotubeTrack track) {
dl.cancelDownload(track.ytUri);
}
void cancelAll() {
for (final download in dl.getAllDownloads()) {
dl.cancelDownload(download.request.url);
}
}
SpotubeTrack? mapToSpotubeTrack(Track track) {
if (track is SpotubeTrack) {
return track;
} else {
return $history.firstWhereOrNull((element) => element.id == track.id);
}
}
ValueNotifier<DownloadStatus>? getStatusNotifier(SpotubeTrack track) {
return dl.getDownload(track.ytUri)?.status;
}
ValueNotifier<double>? getProgressNotifier(SpotubeTrack track) {
return dl.getDownload(track.ytUri)?.progress;
}
}
final downloadManagerProvider = ChangeNotifierProvider<DownloadManagerProvider>(
(ref) => DownloadManagerProvider(ref: ref),
);

View File

@ -0,0 +1,413 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:spotube/services/download_manager/download_request.dart';
import 'package:spotube/services/download_manager/download_status.dart';
import 'package:spotube/services/download_manager/download_task.dart';
export './download_request.dart';
export './download_status.dart';
export './download_task.dart';
typedef DownloadStatusEvent = ({
DownloadStatus status,
DownloadRequest request
});
class DownloadManager {
final Map<String, DownloadTask> _cache = <String, DownloadTask>{};
final Queue<DownloadRequest> _queue = Queue();
var dio = Dio();
static const partialExtension = ".partial";
static const tempExtension = ".temp";
// var tasks = StreamController<DownloadTask>();
final _statusStreamController =
StreamController<DownloadStatusEvent>.broadcast();
Stream<DownloadStatusEvent> get statusStream =>
_statusStreamController.stream;
int maxConcurrentTasks = 2;
int runningTasks = 0;
static final DownloadManager _dm = DownloadManager._internal();
DownloadManager._internal();
factory DownloadManager({int? maxConcurrentTasks}) {
if (maxConcurrentTasks != null) {
_dm.maxConcurrentTasks = maxConcurrentTasks;
}
return _dm;
}
void Function(int, int) createCallback(url, int partialFileLength) =>
(int received, int total) {
getDownload(url)?.progress.value =
(received + partialFileLength) / (total + partialFileLength);
if (total == -1) {}
};
Future<void> download(String url, String savePath, cancelToken,
{forceDownload = false}) async {
late String partialFilePath;
late File partialFile;
try {
var task = getDownload(url);
if (task == null || task.status.value == DownloadStatus.canceled) {
return;
}
setStatus(task, DownloadStatus.downloading);
debugPrint(url);
var file = File(savePath.toString());
partialFilePath = savePath + partialExtension;
partialFile = File(partialFilePath);
var fileExist = await file.exists();
var partialFileExist = await partialFile.exists();
if (fileExist) {
debugPrint("File Exists");
setStatus(task, DownloadStatus.completed);
} else if (partialFileExist) {
debugPrint("Partial File Exists");
var partialFileLength = await partialFile.length();
var response = await dio.download(
url,
partialFilePath + tempExtension,
onReceiveProgress: createCallback(url, partialFileLength),
options: Options(
headers: {
HttpHeaders.rangeHeader: 'bytes=$partialFileLength-',
HttpHeaders.connectionHeader: "close",
},
),
cancelToken: cancelToken,
deleteOnError: true,
);
if (response.statusCode == HttpStatus.partialContent) {
var ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend);
var _f = File(partialFilePath + tempExtension);
await ioSink.addStream(_f.openRead());
await _f.delete();
await ioSink.close();
await partialFile.rename(savePath);
setStatus(task, DownloadStatus.completed);
}
} else {
var response = await dio.download(
url,
partialFilePath,
onReceiveProgress: createCallback(url, 0),
options: Options(headers: {
HttpHeaders.connectionHeader: "close",
}),
cancelToken: cancelToken,
deleteOnError: false,
);
if (response.statusCode == HttpStatus.ok) {
await partialFile.rename(savePath);
setStatus(task, DownloadStatus.completed);
}
}
} catch (e) {
var task = getDownload(url)!;
if (task.status.value != DownloadStatus.canceled &&
task.status.value != DownloadStatus.paused) {
setStatus(task, DownloadStatus.failed);
runningTasks--;
if (_queue.isNotEmpty) {
_startExecution();
}
rethrow;
} else if (task.status.value == DownloadStatus.paused) {
final ioSink = partialFile.openWrite(mode: FileMode.writeOnlyAppend);
final f = File(partialFilePath + tempExtension);
if (await f.exists()) {
await ioSink.addStream(f.openRead());
}
await ioSink.close();
}
}
runningTasks--;
if (_queue.isNotEmpty) {
_startExecution();
}
}
void disposeNotifiers(DownloadTask task) {
// task.status.dispose();
// task.progress.dispose();
}
void setStatus(DownloadTask? task, DownloadStatus status) {
if (task != null) {
task.status.value = status;
// tasks.add(task);
if (status.isCompleted) {
disposeNotifiers(task);
}
_statusStreamController.add((status: status, request: task.request));
}
}
Future<DownloadTask?> addDownload(String url, String savedDir) async {
if (url.isNotEmpty) {
if (savedDir.isEmpty) {
savedDir = ".";
}
var isDirectory = await Directory(savedDir).exists();
var downloadFilename = isDirectory
? savedDir + Platform.pathSeparator + getFileNameFromUrl(url)
: savedDir;
return _addDownloadRequest(DownloadRequest(url, downloadFilename));
}
return null;
}
Future<DownloadTask> _addDownloadRequest(
DownloadRequest downloadRequest,
) async {
if (_cache[downloadRequest.url] != null) {
if (!_cache[downloadRequest.url]!.status.value.isCompleted &&
_cache[downloadRequest.url]!.request == downloadRequest) {
// Do nothing
return _cache[downloadRequest.url]!;
} else {
_queue.remove(_cache[downloadRequest.url]);
}
}
_queue.add(DownloadRequest(downloadRequest.url, downloadRequest.path));
var task = DownloadTask(_queue.last);
_cache[downloadRequest.url] = task;
_startExecution();
return task;
}
Future<void> pauseDownload(String url) async {
debugPrint("Pause Download");
var task = getDownload(url)!;
setStatus(task, DownloadStatus.paused);
task.request.cancelToken.cancel();
_queue.remove(task.request);
}
Future<void> cancelDownload(String url) async {
debugPrint("Cancel Download");
var task = getDownload(url)!;
setStatus(task, DownloadStatus.canceled);
_queue.remove(task.request);
task.request.cancelToken.cancel();
}
Future<void> resumeDownload(String url) async {
debugPrint("Resume Download");
var task = getDownload(url)!;
setStatus(task, DownloadStatus.downloading);
task.request.cancelToken = CancelToken();
_queue.add(task.request);
_startExecution();
}
Future<void> removeDownload(String url) async {
cancelDownload(url);
_cache.remove(url);
}
// Do not immediately call getDownload After addDownload, rather use the returned DownloadTask from addDownload
DownloadTask? getDownload(String url) {
return _cache[url];
}
Future<DownloadStatus> whenDownloadComplete(String url,
{Duration timeout = const Duration(hours: 2)}) async {
DownloadTask? task = getDownload(url);
if (task != null) {
return task.whenDownloadComplete(timeout: timeout);
} else {
return Future.error("Not found");
}
}
List<DownloadTask> getAllDownloads() {
return _cache.values.toList();
}
// Batch Download Mechanism
Future<void> addBatchDownloads(List<String> urls, String savedDir) async {
urls.forEach((url) {
addDownload(url, savedDir);
});
}
List<DownloadTask?> getBatchDownloads(List<String> urls) {
return urls.map((e) => _cache[e]).toList();
}
Future<void> pauseBatchDownloads(List<String> urls) async {
urls.forEach((element) {
pauseDownload(element);
});
}
Future<void> cancelBatchDownloads(List<String> urls) async {
urls.forEach((element) {
cancelDownload(element);
});
}
Future<void> resumeBatchDownloads(List<String> urls) async {
urls.forEach((element) {
resumeDownload(element);
});
}
ValueNotifier<double> getBatchDownloadProgress(List<String> urls) {
ValueNotifier<double> progress = ValueNotifier(0);
var total = urls.length;
if (total == 0) {
return progress;
}
if (total == 1) {
return getDownload(urls.first)?.progress ?? progress;
}
var progressMap = Map<String, double>();
urls.forEach((url) {
DownloadTask? task = getDownload(url);
if (task != null) {
progressMap[url] = 0.0;
if (task.status.value.isCompleted) {
progressMap[url] = 1.0;
progress.value = progressMap.values.sum / total;
}
var progressListener;
progressListener = () {
progressMap[url] = task.progress.value;
progress.value = progressMap.values.sum / total;
};
task.progress.addListener(progressListener);
var listener;
listener = () {
if (task.status.value.isCompleted) {
progressMap[url] = 1.0;
progress.value = progressMap.values.sum / total;
task.status.removeListener(listener);
task.progress.removeListener(progressListener);
}
};
task.status.addListener(listener);
} else {
total--;
}
});
return progress;
}
Future<List<DownloadTask?>?> whenBatchDownloadsComplete(List<String> urls,
{Duration timeout = const Duration(hours: 2)}) async {
var completer = Completer<List<DownloadTask?>?>();
var completed = 0;
var total = urls.length;
urls.forEach((url) {
DownloadTask? task = getDownload(url);
if (task != null) {
if (task.status.value.isCompleted) {
completed++;
if (completed == total) {
completer.complete(getBatchDownloads(urls));
}
}
var listener;
listener = () {
if (task.status.value.isCompleted) {
completed++;
if (completed == total) {
completer.complete(getBatchDownloads(urls));
task.status.removeListener(listener);
}
}
};
task.status.addListener(listener);
} else {
total--;
if (total == 0) {
completer.complete(null);
}
}
});
return completer.future.timeout(timeout);
}
void _startExecution() async {
if (runningTasks == maxConcurrentTasks || _queue.isEmpty) {
return;
}
while (_queue.isNotEmpty && runningTasks < maxConcurrentTasks) {
runningTasks++;
debugPrint('Concurrent workers: $runningTasks');
var currentRequest = _queue.removeFirst();
await download(
currentRequest.url,
currentRequest.path,
currentRequest.cancelToken,
);
await Future.delayed(const Duration(milliseconds: 500), null);
}
}
/// This function is used for get file name with extension from url
String getFileNameFromUrl(String url) {
return url.split('/').last;
}
}

View File

@ -0,0 +1,24 @@
import 'package:dio/dio.dart';
class DownloadRequest {
final String url;
final String path;
var cancelToken = CancelToken();
var forceDownload = false;
DownloadRequest(
this.url,
this.path,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DownloadRequest &&
runtimeType == other.runtimeType &&
url == other.url &&
path == other.path;
@override
int get hashCode => url.hashCode ^ path.hashCode;
}

View File

@ -0,0 +1,26 @@
enum DownloadStatus {
queued,
downloading,
completed,
failed,
paused,
canceled;
bool get isCompleted {
switch (this) {
case DownloadStatus.queued:
return false;
case DownloadStatus.downloading:
return false;
case DownloadStatus.paused:
return false;
case DownloadStatus.completed:
return true;
case DownloadStatus.failed:
return true;
case DownloadStatus.canceled:
return true;
}
}
}

View File

@ -0,0 +1,36 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:spotube/services/download_manager/download_request.dart';
import 'package:spotube/services/download_manager/download_status.dart';
class DownloadTask {
final DownloadRequest request;
ValueNotifier<DownloadStatus> status = ValueNotifier(DownloadStatus.queued);
ValueNotifier<double> progress = ValueNotifier(0);
DownloadTask(
this.request,
);
Future<DownloadStatus> whenDownloadComplete(
{Duration timeout = const Duration(hours: 2)}) async {
var completer = Completer<DownloadStatus>();
if (status.value.isCompleted) {
completer.complete(status.value);
}
var listener;
listener = () {
if (status.value.isCompleted) {
completer.complete(status.value);
status.removeListener(listener);
}
};
status.addListener(listener);
return completer.future.timeout(timeout);
}
}

View File

@ -145,14 +145,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
background_downloader:
dependency: "direct main"
description:
name: background_downloader
sha256: "5e38a1d5d88a5cfea35c44cb376b89427688070518471ee52f6b04d07d85668e"
url: "https://pub.dev"
source: hosted
version: "7.4.0"
boolean_selector:
dependency: transitive
description:
@ -459,13 +451,13 @@ packages:
source: hosted
version: "1.1.0"
dio:
dependency: transitive
dependency: "direct main"
description:
name: dio
sha256: "347d56c26d63519552ef9a569f2a593dda99a81fdbdff13c584b7197cfe05059"
sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197
url: "https://pub.dev"
source: hosted
version: "5.1.2"
version: "5.3.2"
disable_battery_optimization:
dependency: "direct main"
description:

View File

@ -95,13 +95,13 @@ dependencies:
piped_client: ^0.1.0
device_preview: ^1.1.0
dbus: ^0.7.8
background_downloader: ^7.4.0
duration: ^3.0.12
disable_battery_optimization: ^1.1.0+1
youtube_explode_dart: ^1.12.4
flutter_displaymode: ^0.6.0
google_fonts: ^4.0.4
supabase: ^1.9.9
dio: ^5.3.2
dev_dependencies:
build_runner: ^2.3.2