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 - Install Development dependencies in linux
- Debian (>=12/Bookworm)/Ubuntu - Debian (>=12/Bookworm)/Ubuntu
```bash ```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) - Use `libjsoncpp1` instead of `libjsoncpp25` (for Ubuntu < 22.04)
- Arch/Manjaro - Arch/Manjaro
```bash ```bash
yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify yay -S mpv libappindicator-gtk3 libsecret jsoncpp libnotify networkmanager
``` ```
- Fedora - Fedora
```bash ```bash

View File

@ -1,6 +1,4 @@
import 'package:auto_size_text/auto_size_text.dart'; 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.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'; import 'package:spotube/utils/type_conversion_utils.dart';
class UserDownloads extends HookConsumerWidget { class UserDownloads extends HookConsumerWidget {
@ -31,7 +30,7 @@ class UserDownloads extends HookConsumerWidget {
Expanded( Expanded(
child: AutoSizeText( child: AutoSizeText(
context.l10n context.l10n
.currently_downloading(downloadManager.totalDownloads), .currently_downloading(downloadManager.$downloadCount),
maxLines: 1, maxLines: 1,
style: Theme.of(context).textTheme.headlineMedium, style: Theme.of(context).textTheme.headlineMedium,
), ),
@ -42,7 +41,7 @@ class UserDownloads extends HookConsumerWidget {
backgroundColor: Colors.red[50], backgroundColor: Colors.red[50],
foregroundColor: Colors.red[400], foregroundColor: Colors.red[400],
), ),
onPressed: downloadManager.totalDownloads == 0 onPressed: downloadManager.$downloadCount == 0
? null ? null
: downloadManager.cancelAll, : downloadManager.cancelAll,
child: Text(context.l10n.cancel_all), child: Text(context.l10n.cancel_all),
@ -53,24 +52,16 @@ class UserDownloads extends HookConsumerWidget {
Expanded( Expanded(
child: SafeArea( child: SafeArea(
child: ListView.builder( child: ListView.builder(
itemCount: downloadManager.totalDownloads, itemCount: downloadManager.$downloadCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = downloadManager.items.elementAt(index); final track = downloadManager.$history.elementAt(index);
return HookBuilder(builder: (context) { return HookBuilder(builder: (context) {
final task = useStream( final taskStatus = useListenable(
downloadManager.activeDownloadProgress.stream useMemoized(
.where((element) => element.task.taskId == track.id), () => 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( return ListTile(
title: Text(track.name ?? ''), title: Text(track.name ?? ''),
@ -88,19 +79,83 @@ class UserDownloads extends HookConsumerWidget {
), ),
), ),
), ),
horizontalTitleGap: 10, trailing: taskStatus == null
trailing: downloadManager.activeItem?.id == track.id && ? null
!hasFailed : switch (taskStatus.value) {
? CircularProgressIndicator( DownloadStatus.downloading =>
value: task.data?.progress ?? 0, HookBuilder(builder: (context) {
) final taskProgress = useListenable(useMemoized(
: hasFailed () => downloadManager
? Icon(SpotubeIcons.error, color: Colors.red[400]) .getProgressNotifier(track),
: IconButton( [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), icon: const Icon(SpotubeIcons.close),
onPressed: () { onPressed: () {
downloadManager.cancel(track); downloadManager.removeFromQueue(track);
}), }),
},
subtitle: TypeConversionUtils.artists_X_ClickableArtists( subtitle: TypeConversionUtils.artists_X_ClickableArtists(
track.artists ?? <Artist>[], track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start, mainAxisAlignment: WrapAlignment.start,

View File

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

View File

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

View File

@ -27,7 +27,7 @@ class SpotubeNavigationBar extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final downloadCount = ref.watch( final downloadCount = ref.watch(
downloadManagerProvider.select((s) => s.length), downloadManagerProvider.select((s) => s.$downloadCount),
); );
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final layoutMode = 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/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.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/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
@ -99,6 +100,20 @@ class TrackOptions extends HookConsumerWidget {
playlistId ?? "", 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( return ListTileTheme(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
@ -175,7 +190,7 @@ class TrackOptions extends HookConsumerWidget {
); );
break; break;
case TrackOptionValue.download: case TrackOptionValue.download:
await downloadManager.enqueue(track); await downloadManager.addToQueue(track);
break; break;
} }
}, },
@ -268,9 +283,14 @@ class TrackOptions extends HookConsumerWidget {
), ),
PopSheetEntry( PopSheetEntry(
value: TrackOptionValue.download, value: TrackOptionValue.download,
enabled: downloadManager.activeItem?.id != track.id!, enabled: isInQueue,
leading: downloadManager.activeItem?.id == track.id! leading: isInQueue
? const CircularProgressIndicator() ? HookBuilder(builder: (context) {
final progress = useListenable(progressNotifier!);
return CircularProgressIndicator(
value: progress.value,
);
})
: const Icon(SpotubeIcons.download), : const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track), title: Text(context.l10n.download_track),
), ),

View File

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

View File

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

View File

@ -33,7 +33,7 @@ class RootApp extends HookConsumerWidget {
final index = useState(0); final index = useState(0);
final isMounted = useIsMounted(); final isMounted = useIsMounted();
final showingDialogCompleter = useRef(Completer()..complete()); final showingDialogCompleter = useRef(Completer()..complete());
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider);
useEffect(() { useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async { 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/extensions/context.dart';
import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/l10n/l10n.dart';
import 'package:spotube/models/matched_track.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/authentication_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/piped_instances_provider.dart'; import 'package:spotube/provider/piped_instances_provider.dart';
@ -36,8 +35,6 @@ class SettingsPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final UserPreferences preferences = ref.watch(userPreferencesProvider); final UserPreferences preferences = ref.watch(userPreferencesProvider);
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(AuthenticationNotifier.provider);
final isDownloading =
ref.watch(downloadManagerProvider.select((s) => s.isNotEmpty));
final theme = Theme.of(context); final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
@ -449,21 +446,15 @@ class SettingsPage extends HookConsumerWidget {
SectionCardWithHeading( SectionCardWithHeading(
heading: context.l10n.downloads, heading: context.l10n.downloads,
children: [ children: [
Tooltip( ListTile(
message: isDownloading leading: const Icon(SpotubeIcons.download),
? context.l10n.wait_for_download_to_finish title: Text(context.l10n.download_location),
: "", subtitle: Text(preferences.downloadLocation),
child: ListTile( trailing: FilledButton(
leading: const Icon(SpotubeIcons.download), onPressed: pickDownloadLocation,
title: Text(context.l10n.download_location), child: const Icon(SpotubeIcons.folder),
subtitle: Text(preferences.downloadLocation),
trailing: FilledButton(
onPressed:
isDownloading ? null : pickDownloadLocation,
child: const Icon(SpotubeIcons.folder),
),
onTap: isDownloading ? null : pickDownloadLocation,
), ),
onTap: pickDownloadLocation,
), ),
SwitchListTile( SwitchListTile(
secondary: const Icon(SpotubeIcons.lyrics), secondary: const Icon(SpotubeIcons.lyrics),

View File

@ -1,196 +1,249 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart'; import 'package:catcher/catcher.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:http/http.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:metadata_god/metadata_god.dart'; import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart'; import 'package:path/path.dart';
import 'package:spotify/spotify.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/models/spotube_track.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_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/services/youtube/youtube.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> { class DownloadManagerProvider extends ChangeNotifier {
final Ref ref; DownloadManagerProvider({required this.ref})
: $history = <SpotubeTrack>{},
backHistory = <Track>{},
dl = DownloadManager() {
dl.statusStream.listen((event) async {
final (:request, :status) = event;
final StreamController<TaskProgressUpdate> activeDownloadProgress; final track = $history.firstWhereOrNull(
final StreamController<Task> failedDownloads; (element) => element.ytUri == request.url,
Track? _activeItem; );
if (track == null) return;
FutureOr<bool> Function(Track)? onFileExists; final savePath = getTrackFileUrl(track);
final oldFile = File("$savePath.old");
DownloadManagerProvider(this.ref) if ((status == DownloadStatus.failed ||
: activeDownloadProgress = StreamController.broadcast(), status == DownloadStatus.canceled) &&
failedDownloads = StreamController.broadcast(), await oldFile.exists()) {
super([]) { await oldFile.rename(savePath);
if (kIsWeb) return; }
if (status != DownloadStatus.completed) return;
FileDownloader().registerCallbacks( var file = File(request.path);
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 || file.copySync(savePath);
update.status == TaskStatus.notFound) { file.deleteSync();
failedDownloads.add(update.task);
}
if (update.status == TaskStatus.complete) { file = File(savePath);
final track =
state.firstWhere((element) => element.id == update.task.taskId);
// resetting the replace downloaded file state on queue completion if (await oldFile.exists()) {
if (state.last == track) { await oldFile.delete();
ref.read(replaceDownloadedFileState.notifier).state = null; }
}
state = state final imageBytes = await downloadImage(
.where((element) => element.id != update.task.taskId) TypeConversionUtils.image_X_UrlString(track.album?.images,
.toList(); placeholder: ImagePlaceholder.albumArt, index: 1),
final imageUri = TypeConversionUtils.image_X_UrlString(
track.album?.images ?? [],
placeholder: ImagePlaceholder.online,
);
final response = await get(Uri.parse(imageUri));
final tempFile = File(await update.task.filePath());
final file = tempFile.copySync(_getPathForTrack(track));
await tempFile.delete();
await MetadataGod.writeMetadata(
file: file.path,
metadata: Metadata(
title: track.name,
artist: track.artists?.map((a) => a.name).join(", "),
album: track.album?.name,
albumArtist: track.artists?.map((a) => a.name).join(", "),
year: track.album?.releaseDate != null
? int.tryParse(track.album!.releaseDate!)
: null,
trackNumber: track.trackNumber,
discNumber: track.discNumber,
durationMs: track.durationMs?.toDouble(),
fileSize: file.lengthSync(),
trackTotal: track.album?.tracks?.length,
picture: response.headers['content-type'] != null
? Picture(
data: response.bodyBytes,
mimeType: response.headers['content-type']!,
)
: 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 { final metadata = Metadata(
if (state.any((element) => element.id == track.id)) { title: track.name,
final task = await FileDownloader().taskForId(track.id!); artist: track.artists?.map((a) => a.name).join(", "),
if (task != null) { album: track.album?.name,
return task; albumArtist: track.artists?.map((a) => a.name).join(", "),
} year: track.album?.releaseDate != null
// this makes sure we already have the fetched track ? int.tryParse(track.album!.releaseDate!)
track = state.firstWhere((element) => element.id == track.id); : null,
state.removeWhere((element) => element.id == track.id); trackNumber: track.trackNumber,
} discNumber: track.discNumber,
final spotubeTrack = track is SpotubeTrack durationMs: track.durationMs?.toDouble(),
? track fileSize: file.lengthSync(),
: await SpotubeTrack.fetchFromTrack( trackTotal: track.album?.tracks?.length,
track, picture: imageBytes != null
youtube, ? Picture(
); data: imageBytes,
state = [...state, spotubeTrack]; // Spotify images are always JPEGs
final task = DownloadTask( mimeType: 'image/jpeg',
url: spotubeTrack.ytUri, )
baseDirectory: BaseDirectory.applicationSupport, : null,
taskId: spotubeTrack.id!, );
updates: Updates.statusAndProgress,
); await MetadataGod.writeMetadata(
return task; file: file.path,
metadata: metadata,
);
});
} }
Future<Task?> enqueue(Track track) async { Future<bool> Function(Track track) onFileExists = (Track track) async => true;
final replaceFileGlobal = ref.read(replaceDownloadedFileState);
final file = File(_getPathForTrack(track)); final Ref<DownloadManagerProvider> ref;
if (file.existsSync() &&
(replaceFileGlobal ?? await onFileExists?.call(track)) != true) { YoutubeEndpoints get yt => ref.read(youtubeProvider);
if (state.isEmpty) { String get downloadDirectory =>
ref.read(replaceDownloadedFileState.notifier).state = null; 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; return null;
} }
final task = await _ensureSpotubeTrack(track);
await FileDownloader().enqueue(task);
return task;
} }
Future<List<Task>> enqueueAll(List<Track> tracks) async { String getTrackFileUrl(Track track) {
final tasks = await Future.wait(tracks.mapIndexed((i, e) { final name =
if (i != 0) { "${track.name} - ${TypeConversionUtils.artists_X_String(track.artists ?? <Artist>[])}.m4a";
/// One second delay between each download to avoid return join(downloadDirectory, name);
/// clogging the Piped server with too many requests }
return Future.delayed(const Duration(seconds: 1), () => enqueue(e));
}
return enqueue(e);
}));
if (tasks.isEmpty) { bool isActive(Track track) {
ref.read(replaceDownloadedFileState.notifier).state = null; 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);
}
/// 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;
} }
return tasks.whereType<Task>().toList(); 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);
}
}
notifyListeners();
} }
Future<void> cancel(Track track) async { Future<void> batchAddToQueue(List<Track> tracks) async {
await FileDownloader().cancelTaskWithId(track.id!); backHistory.addAll(
state = state.where((element) => element.id != track.id).toList(); tracks.where((element) => element is! SpotubeTrack),
);
for (final track in tracks) {
await addToQueue(track);
await Future.delayed(const Duration(seconds: 2));
}
} }
Future<void> cancelAll() async { Future<void> removeFromQueue(SpotubeTrack track) async {
(await FileDownloader().reset()); await dl.removeDownload(track.ytUri);
state = []; $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 = final downloadManagerProvider = ChangeNotifierProvider<DownloadManagerProvider>(
StateNotifierProvider<DownloadManagerProvider, List<Track>>( (ref) => DownloadManagerProvider(ref: ref),
DownloadManagerProvider.new,
); );

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" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" 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: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -459,13 +451,13 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
dio: dio:
dependency: transitive dependency: "direct main"
description: description:
name: dio name: dio
sha256: "347d56c26d63519552ef9a569f2a593dda99a81fdbdff13c584b7197cfe05059" sha256: ce75a1b40947fea0a0e16ce73337122a86762e38b982e1ccb909daa3b9bc4197
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.1.2" version: "5.3.2"
disable_battery_optimization: disable_battery_optimization:
dependency: "direct main" dependency: "direct main"
description: description:

View File

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