From 95adae786cf28365bb1e908b76d2375c5d72e590 Mon Sep 17 00:00:00 2001 From: MarcZierle Date: Wed, 10 May 2023 16:30:06 +0200 Subject: [PATCH] add album and playlist download --- lib/providers/download_provider.dart | 5 + lib/ui/screens/album_details.dart | 1 + lib/ui/screens/playlist_details.dart | 1 + lib/ui/widgets/song_cache_icon.dart | 10 +- lib/ui/widgets/song_list_cache_icon.dart | 128 +++++++++++++++++++++++ lib/ui/widgets/widgets.dart | 1 + 6 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 lib/ui/widgets/song_list_cache_icon.dart diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index e84acfd0..9ad7fe61 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -24,6 +24,7 @@ class DownloadProvider with StreamSubscriber { final _downloadsCleared = StreamController.broadcast(); final _downloadRemoved = StreamController.broadcast(); final _songDownloaded = StreamController.broadcast(); + final _songDownloadStarted = StreamController.broadcast(); Stream get downloadsClearedStream => _downloadsCleared.stream; @@ -31,6 +32,8 @@ class DownloadProvider with StreamSubscriber { Stream get songDownloadedStream => _songDownloaded.stream; + Stream get downloadStartedStream => _songDownloadStarted.stream; + static const serializedSongContainer = 'Downloads'; static const downloadCacheKey = 'koel.downloaded.songs'; static final _songStorage = GetStorage(serializedSongContainer); @@ -79,6 +82,8 @@ class DownloadProvider with StreamSubscriber { get serializedSongKey => '${preferences.host}.${preferences.userEmail}.songs'; Future download({required Song song}) async { + _songDownloadStarted.add(song); + final file = await _downloadManager.downloadFile( song.sourceUrl, key: song.cacheKey, diff --git a/lib/ui/screens/album_details.dart b/lib/ui/screens/album_details.dart index 3f8e7c17..5c5bbd84 100644 --- a/lib/ui/screens/album_details.dart +++ b/lib/ui/screens/album_details.dart @@ -70,6 +70,7 @@ class _AlbumDetailsScreenState extends State { AppBar( headingText: album.name, actions: [ + SongListCacheIcon(songs: songs), SortButton( fields: ['track', 'title', 'created_at'], currentField: sortConfig.field, diff --git a/lib/ui/screens/playlist_details.dart b/lib/ui/screens/playlist_details.dart index 8c3099a7..4e6c2f54 100644 --- a/lib/ui/screens/playlist_details.dart +++ b/lib/ui/screens/playlist_details.dart @@ -76,6 +76,7 @@ class _PlaylistDetailsScreen extends State { headingText: playlist.name, coverImage: _cover, actions: [ + SongListCacheIcon(songs: songs), SortButton( fields: ['title', 'artist_name', 'created_at'], currentField: sortConfig.field, diff --git a/lib/ui/widgets/song_cache_icon.dart b/lib/ui/widgets/song_cache_icon.dart index 803ffbc7..168f99aa 100644 --- a/lib/ui/widgets/song_cache_icon.dart +++ b/lib/ui/widgets/song_cache_icon.dart @@ -34,7 +34,15 @@ class _SongCacheIconState extends State with StreamSubscriber { })); subscribe(downloadProvider.songDownloadedStream.listen((event) { - if (event.song == widget.song) setState(() => _downloaded = true); + if (event.song == widget.song) + setState(() { + _downloaded = true; + _downloading = false; + }); + })); + + subscribe(downloadProvider.downloadStartedStream.listen((song) { + if (song == widget.song) setState(() => _downloading = true); })); setState(() => _downloaded = downloadProvider.has(song: widget.song)); diff --git a/lib/ui/widgets/song_list_cache_icon.dart b/lib/ui/widgets/song_list_cache_icon.dart new file mode 100644 index 00000000..d1351616 --- /dev/null +++ b/lib/ui/widgets/song_list_cache_icon.dart @@ -0,0 +1,128 @@ +import 'package:app/constants/constants.dart'; +import 'package:app/mixins/stream_subscriber.dart'; +import 'package:app/models/models.dart'; +import 'package:app/providers/providers.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class SongListCacheIcon extends StatefulWidget { + final List songs; + + const SongListCacheIcon({Key? key, required this.songs}) : super(key: key); + + @override + _SongListCacheIconState createState() => _SongListCacheIconState(); +} + +class _SongListCacheIconState extends State + with StreamSubscriber { + late DownloadProvider downloadProvider; + var _downloading = false; + bool? _downloaded = false; + + static const downloadBatchSize = 3; + + @override + void initState() { + super.initState(); + downloadProvider = context.read(); + + subscribe(downloadProvider.downloadsClearedStream.listen((_) { + setState(() => _downloaded = false); + })); + + subscribe(downloadProvider.downloadRemovedStream.listen((song) { + if (widget.songs.contains(song)) setState(() => _downloaded = false); + })); + + subscribe(downloadProvider.songDownloadedStream.listen((event) { + if (widget.songs.contains(event.song)) _resolveDownloadStatus(); + })); + + _resolveDownloadStatus(); + } + + /// Since this widget is rendered inside NowPlayingScreen, change to current + /// song in the parent will not trigger initState() and as a result not + /// refresh the song's cache status. + /// For that, we hook into didUpdateWidget(). + /// See https://stackoverflow.com/questions/54759920/flutter-why-is-child-widgets-initstate-is-not-called-on-every-rebuild-of-pa. + @override + void didUpdateWidget(covariant SongListCacheIcon oldWidget) { + super.didUpdateWidget(oldWidget); + _resolveDownloadStatus(); + } + + void _resolveDownloadStatus() { + setState(() => _downloaded = + !widget.songs.any((song) => !downloadProvider.has(song: song))); + } + + @override + void dispose() { + unsubscribeAll(); + super.dispose(); + } + + Future _download() async { + setState(() => _downloading = true); + + int indexLastStarted = 0; + + /// Download songs in parallel. + /// Recursively iterates over the song list, downloading songs that haven't + /// been downloaded yet. + Future downloadNextSong() async { + if (indexLastStarted >= widget.songs.length) return; + + Song song; + do { + song = widget.songs[indexLastStarted++]; + } while (downloadProvider.has(song: song) && + indexLastStarted < widget.songs.length); + + await downloadProvider.download(song: song); + await downloadNextSong(); + } + + await Future.wait( + List.generate(downloadBatchSize, (_) => downloadNextSong())); + + setState(() { + _downloading = false; + _downloaded = true; + }); + } + + @override + Widget build(BuildContext context) { + if (_downloading) + return const Padding( + padding: EdgeInsets.only(right: 4.0), + child: CupertinoActivityIndicator(radius: 9, color: AppColors.white), + ); + + final downloaded = this._downloaded; + + if (downloaded == null) return const SizedBox.shrink(); + + if (downloaded) { + return const Padding( + padding: EdgeInsets.only(right: 4.0), + child: Icon( + CupertinoIcons.checkmark_alt_circle_fill, + size: 18, + color: Color(0xFFFAD763), + ), + ); + } + + return IconButton( + onPressed: _download, + constraints: const BoxConstraints(), + padding: const EdgeInsets.symmetric(horizontal: 0.0), + icon: const Icon(CupertinoIcons.cloud_download_fill, size: 16), + ); + } +} diff --git a/lib/ui/widgets/widgets.dart b/lib/ui/widgets/widgets.dart index 6c0f0446..2a6cfc26 100644 --- a/lib/ui/widgets/widgets.dart +++ b/lib/ui/widgets/widgets.dart @@ -29,3 +29,4 @@ export 'song_row.dart'; export 'song_thumbnail.dart'; export 'spinner.dart'; export 'typography.dart'; +export 'song_list_cache_icon.dart';