From b2b92a388e1e40722e4091c91405f428335c763e Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:49:02 +0000 Subject: [PATCH] download a whole album, playlist or artist at once Third block of #65: bulk downloads and the list-level actions. - The hero's download button on albums and playlists is real now. It starts the songs the list is missing, cancels what is in flight, or deletes what is already downloaded, chosen from the list's own status. The artist has no hero button, so its bulk actions live in the context menu, which now carries the same status. - No new storage: a bulk job is the download rows for the list, and the status is counted from song_downloads joined to the list's songs, so cancel and delete stay well defined without anything to keep in sync. - downloadAll fetches artwork for the whole list under one shared budget, once per unique cover, and skips songs that are already downloaded so re-tapping Download resumes rather than restarting. --- .../com/subtracks/data/db/DownloadDao.kt | 47 ++++++++ .../com/subtracks/data/model/Download.kt | 10 ++ .../subtracks/data/repo/DownloadRepository.kt | 104 ++++++++++++++++-- .../main/kotlin/com/subtracks/di/AppModule.kt | 2 +- .../ui/components/HeroDetailScaffold.kt | 43 +++++++- .../ui/components/ItemContextMenu.kt | 36 +++++- .../subtracks/ui/library/AlbumDetailScreen.kt | 17 ++- .../ui/library/AlbumDetailViewModel.kt | 24 ++++ .../ui/library/ArtistDetailScreen.kt | 6 +- .../ui/library/ArtistDetailViewModel.kt | 26 +++++ .../ui/library/PlaylistDetailScreen.kt | 16 ++- .../ui/library/PlaylistDetailViewModel.kt | 24 ++++ .../data/repo/DownloadRepositoryTest.kt | 96 ++++++++++++++++ .../ui/components/HeroDownloadTest.kt | 94 ++++++++++++++++ 14 files changed, 527 insertions(+), 18 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/HeroDownloadTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index eddd187b..b43b0967 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -4,6 +4,7 @@ import androidx.room3.Dao import androidx.room3.Query import androidx.room3.Upsert import com.subtracks.data.model.DownloadArtwork +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.SongDownload import kotlinx.coroutines.flow.Flow @@ -15,6 +16,52 @@ interface DownloadDao { @Query("SELECT * FROM song_downloads") suspend fun all(): List + @Query("SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY albumId, disc, track, id") + suspend fun artistSongIds( + sourceId: Long, + artistId: String, + ): List + + @Query( + "SELECT COUNT(songs.id) AS total, " + + "COALESCE(SUM(CASE WHEN song_downloads.status = 'Completed' THEN 1 ELSE 0 END), 0) AS downloaded, " + + "COALESCE(SUM(CASE WHEN song_downloads.status IN ('Queued', 'Running') THEN 1 ELSE 0 END), 0) AS downloading " + + "FROM songs " + + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + + "WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId", + ) + fun albumStatus( + sourceId: Long, + albumId: String, + ): Flow + + @Query( + "SELECT COUNT(DISTINCT songs.id) AS total, " + + "COUNT(DISTINCT CASE WHEN song_downloads.status = 'Completed' THEN songs.id END) AS downloaded, " + + "COUNT(DISTINCT CASE WHEN song_downloads.status IN ('Queued', 'Running') THEN songs.id END) AS downloading " + + "FROM playlist_songs " + + "JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " + + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + + "WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId", + ) + fun playlistStatus( + sourceId: Long, + playlistId: String, + ): Flow + + @Query( + "SELECT COUNT(songs.id) AS total, " + + "COALESCE(SUM(CASE WHEN song_downloads.status = 'Completed' THEN 1 ELSE 0 END), 0) AS downloaded, " + + "COALESCE(SUM(CASE WHEN song_downloads.status IN ('Queued', 'Running') THEN 1 ELSE 0 END), 0) AS downloading " + + "FROM songs " + + "LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " + + "WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId", + ) + fun artistStatus( + sourceId: Long, + artistId: String, + ): Flow + @Query( "SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + "al.id AS albumRow, al.coverArt AS albumCoverArt, " + diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index c0152ecc..bc573181 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -40,6 +40,16 @@ data class SongDownload( get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) } } +enum class DownloadList { Album, Playlist, Artist } + +data class ListDownloadStatus( + val total: Long = 0, + val downloaded: Long = 0, + val downloading: Long = 0, +) { + val complete: Boolean get() = total > 0 && downloaded == total +} + data class DownloadArtwork( val sourceId: Long, val albumId: String?, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index 5c28c697..4434583b 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -8,7 +8,9 @@ import com.subtracks.data.download.DownloadEngine import com.subtracks.data.download.EngineDownload import com.subtracks.data.download.EngineRequest import com.subtracks.data.download.EngineStatus +import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.SongDownload import com.subtracks.data.model.coverArtKey import kotlinx.coroutines.CancellationException @@ -103,7 +105,7 @@ class DownloadRepository( if (!enqueueIfAbsent(sourceId, songId)) return@withContext // The media is already with the platform, so this only has to happen while we are still // in the foreground: artwork is fetched by us, and it must not hold up the download. - fetchArtworkSafely(sourceId, songId) + fetchArtworkSafely(sourceId, listOf(songId)) } private suspend fun enqueueIfAbsent( @@ -124,14 +126,66 @@ class DownloadRepository( songId: String, ) = withContext(dispatcher) { mutex.withLock { - val row = db.downloadDao().find(sourceId, songId) ?: return@withLock - row.engineId?.let(engine::cancel) - file(sourceId, songId).delete() - db.downloadDao().delete(sourceId, songId) + if (!removeRow(sourceId, songId)) return@withLock sweep(db.downloadDao().all()) } } + fun status( + sourceId: Long, + list: DownloadList, + refId: String, + ): Flow = + when (list) { + DownloadList.Album -> db.downloadDao().albumStatus(sourceId, refId) + DownloadList.Playlist -> db.downloadDao().playlistStatus(sourceId, refId) + DownloadList.Artist -> db.downloadDao().artistStatus(sourceId, refId) + } + + suspend fun downloadAll( + sourceId: Long, + list: DownloadList, + refId: String, + ) = withContext(dispatcher) { + if (sourceRepository.activeSourceIdOnce() != sourceId) { + showMessage(NOT_ACTIVE) + return@withContext + } + val songIds = songIds(sourceId, list, refId) + if (songIds.isEmpty()) return@withContext + if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) { + showMessage(NO_ADDRESS) + return@withContext + } + songIds.forEach { enqueueIfAbsent(sourceId, it) } + fetchArtworkSafely(sourceId, songIds) + } + + suspend fun cancelAll( + sourceId: Long, + list: DownloadList, + refId: String, + ) = withContext(dispatcher) { + mutex.withLock { + val removed = rowsFor(sourceId, list, refId).filter { it.status.isActive }.map { removeRow(sourceId, it.songId) } + if (removed.any { it }) sweep(db.downloadDao().all()) + } + } + + suspend fun deleteAll( + sourceId: Long, + list: DownloadList, + refId: String, + ) = withContext(dispatcher) { + mutex.withLock { + val removed = + rowsFor(sourceId, list, refId) + .filter { it.status == DownloadStatus.Completed } + .map { removeRow(sourceId, it.songId) } + if (removed.any { it }) sweep(db.downloadDao().all()) + } + } + suspend fun removeSource(sourceId: Long) = withContext(dispatcher) { mutex.withLock { @@ -155,7 +209,7 @@ class DownloadRepository( ensurePolling() resumed } - resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) } + resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) } } @OptIn(ExperimentalCoroutinesApi::class) @@ -284,12 +338,44 @@ class DownloadRepository( return resumed } - private suspend fun fetchArtworkSafely( + private suspend fun removeRow( sourceId: Long, songId: String, + ): Boolean { + val row = db.downloadDao().find(sourceId, songId) ?: return false + row.engineId?.let(engine::cancel) + file(sourceId, songId).delete() + db.downloadDao().delete(sourceId, songId) + return true + } + + private suspend fun rowsFor( + sourceId: Long, + list: DownloadList, + refId: String, + ): List { + val ids = songIds(sourceId, list, refId).toHashSet() + if (ids.isEmpty()) return emptyList() + return db.downloadDao().all().filter { it.sourceId == sourceId && it.songId in ids } + } + + private suspend fun songIds( + sourceId: Long, + list: DownloadList, + refId: String, + ): List = + when (list) { + DownloadList.Album -> db.queueDao().albumSongIds(sourceId, refId) + DownloadList.Playlist -> db.queueDao().playlistSongIds(sourceId, refId) + DownloadList.Artist -> db.downloadDao().artistSongIds(sourceId, refId) + } + + private suspend fun fetchArtworkSafely( + sourceId: Long, + songIds: List, ) { try { - withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) } + withTimeoutOrNull(ART_TIMEOUT_MS) { songIds.forEach { fetchArtwork(sourceId, it) } } } catch (e: CancellationException) { throw e } catch (_: Exception) { @@ -343,7 +429,7 @@ class DownloadRepository( } // Fetching artwork here would hold up the next tick, and with it the progress // the queue is mirrored from. - resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, row.songId) } } + resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, listOf(row.songId)) } } } } } diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 9e3959c3..26006d05 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -73,7 +73,7 @@ fun appModule( viewModel { SettingsViewModel(get(), get(), get()) } viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } - viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) } + viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { QueueViewModel(get(), get()) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt index c5a9a1ed..1427f31b 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/HeroDetailScaffold.kt @@ -27,10 +27,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Shuffle +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledIconButton import androidx.compose.material3.Icon @@ -61,6 +64,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.HeroGradient @@ -204,6 +208,15 @@ private fun HeroSubtitle( ) } +enum class BulkDownloadAction { Download, Cancel, Delete } + +fun ListDownloadStatus.action(): BulkDownloadAction = + when { + complete -> BulkDownloadAction.Delete + downloading > 0 -> BulkDownloadAction.Cancel + else -> BulkDownloadAction.Download + } + @Composable fun HeroHeader( art: CoverArtRef?, @@ -212,7 +225,8 @@ fun HeroHeader( hasSongs: Boolean, onPlay: () -> Unit, onShuffle: () -> Unit, - onDownload: () -> Unit, + downloadStatus: ListDownloadStatus, + onDownloadAction: (BulkDownloadAction) -> Unit, onMore: () -> Unit, topInset: Dp, controlsModifier: Modifier = Modifier, @@ -271,8 +285,31 @@ fun HeroHeader( modifier = controlsModifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - IconButton(onClick = onDownload, modifier = Modifier.padding(horizontal = 8.dp)) { - Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.offset(y = 1.5.dp)) + IconButton( + onClick = { onDownloadAction(downloadStatus.action()) }, + enabled = hasSongs, + modifier = Modifier.padding(horizontal = 8.dp), + ) { + when (downloadStatus.action()) { + BulkDownloadAction.Delete -> { + Icon(Icons.Rounded.Delete, contentDescription = "Delete download", modifier = Modifier.offset(y = 1.5.dp)) + } + + BulkDownloadAction.Cancel -> { + Box(contentAlignment = Alignment.Center) { + CircularProgressIndicator( + progress = { downloadStatus.downloaded.toFloat() / downloadStatus.total }, + modifier = Modifier.size(26.dp), + strokeWidth = 2.dp, + ) + Icon(Icons.Rounded.Close, contentDescription = "Cancel download", modifier = Modifier.size(16.dp)) + } + } + + BulkDownloadAction.Download -> { + Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.offset(y = 1.5.dp)) + } + } } Row( modifier = Modifier.weight(1f), diff --git a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt index 2b6d6d4b..5c1e13a8 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/ItemContextMenu.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.QueueKind import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType @@ -84,6 +85,7 @@ sealed interface MenuTarget { data class Artist( val artist: ArtistModel, override val coverArt: CoverArtRef? = null, + val download: ListDownloadStatus? = null, ) : MenuTarget { override val title: String get() = artist.name @@ -125,7 +127,10 @@ class ItemActions( val download: ((SongModel) -> Unit)? = null, val cancelDownload: ((SongModel) -> Unit)? = null, val deleteDownload: ((SongModel) -> Unit)? = null, - val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> }, + val downloadArtist: ((ArtistModel) -> Unit)? = null, + val cancelArtistDownload: ((ArtistModel) -> Unit)? = null, + val deleteArtistDownload: ((ArtistModel) -> Unit)? = null, + val setStar: suspend (StarType, String, Boolean) -> Result = { _, _, _ -> Result.success(Unit) }, val viewAlbum: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null, ) @@ -235,6 +240,7 @@ fun ItemContextMenu( } is MenuTarget.Artist -> { + BulkDownloadItem(target.download, target.artist, actions, ::dismiss) StarItem( current = target.artist.starred, onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, @@ -318,6 +324,34 @@ private fun DownloadItem( } } +@Composable +private fun BulkDownloadItem( + download: ListDownloadStatus?, + artist: ArtistModel, + actions: ItemActions, + dismiss: (() -> Unit) -> Unit, +) { + when ((download ?: ListDownloadStatus()).action()) { + BulkDownloadAction.Delete -> { + actions.deleteArtistDownload?.let { delete -> + MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { delete(artist) } } + } + } + + BulkDownloadAction.Cancel -> { + actions.cancelArtistDownload?.let { cancel -> + MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { cancel(artist) } } + } + } + + BulkDownloadAction.Download -> { + actions.downloadArtist?.let { start -> + MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(artist) } } + } + } + } +} + @Composable private fun StarItem( current: Long?, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt index 78a45178..028783ec 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailScreen.kt @@ -23,11 +23,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Disc +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.QueueKind import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.BulkDownloadAction import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader @@ -57,6 +59,7 @@ fun AlbumDetailRoute( val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) val downloads by viewModel.downloads.collectAsStateWithLifecycle() + val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } @@ -80,6 +83,14 @@ fun AlbumDetailRoute( coverArt = viewModel::coverArt, artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), downloads = downloads, + downloadStatus = downloadStatus, + onDownloadAction = { action -> + when (action) { + BulkDownloadAction.Download -> viewModel.downloadAll() + BulkDownloadAction.Cancel -> viewModel.cancelDownloads() + BulkDownloadAction.Delete -> viewModel.deleteDownloads() + } + }, onBack = onBack, onSongClick = viewModel::play, onSongLongClick = { contextMenuHost?.show(it, actions) }, @@ -108,7 +119,8 @@ fun AlbumDetailScreen( onSongLongClick: (MenuTarget) -> Unit = {}, onShuffle: () -> Unit = {}, onPlay: () -> Unit = { onSongClick(0) }, - onDownload: () -> Unit = {}, + downloadStatus: ListDownloadStatus = ListDownloadStatus(), + onDownloadAction: (BulkDownloadAction) -> Unit = {}, onMore: () -> Unit = {}, onArtistClick: (() -> Unit)? = null, playingSongId: String? = null, @@ -161,7 +173,8 @@ fun AlbumDetailScreen( hasSongs = songs.isNotEmpty(), onPlay = onPlay, onShuffle = onShuffle, - onDownload = onDownload, + downloadStatus = downloadStatus, + onDownloadAction = onDownloadAction, onMore = onMore, topInset = topInset, controlsModifier = controlsModifier, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt index a73150c5..2b5cc8fb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumDetailViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.Disc +import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.repo.DownloadRepository @@ -13,10 +15,12 @@ import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -37,6 +41,26 @@ class AlbumDetailViewModel( val downloads: StateFlow> = downloadRepository.states() + val downloadStatus: StateFlow = + sourceId + .flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) + + fun downloadAll() { + viewModelScope.launch { downloadRepository.downloadAll(sourceId.first(), DownloadList.Album, albumId) } + } + + fun cancelDownloads() { + viewModelScope.launch { downloadRepository.cancelAll(sourceId.first(), DownloadList.Album, albumId) } + } + + fun deleteDownloads() { + viewModelScope.launch { + downloadRepository.deleteAll(sourceId.first(), DownloadList.Album, albumId) + playbackController.refreshMediaItems() + } + } + fun coverArt( coverArt: String?, thumbnail: Boolean, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt index 888de9fb..80f6b4a5 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -138,6 +138,7 @@ fun ArtistDetailRoute( val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) val art by viewModel.art.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() + val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val actions = ItemActions( @@ -145,6 +146,9 @@ fun ArtistDetailRoute( shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + downloadArtist = { viewModel.downloadAll() }, + cancelArtistDownload = { viewModel.cancelDownloads() }, + deleteArtistDownload = { viewModel.deleteDownloads() }, setStar = setStar, viewAlbum = onViewAlbum, ) @@ -158,7 +162,7 @@ fun ArtistDetailRoute( onBack = onBack, onAlbumClick = onAlbumClick, onAlbumLongClick = { contextMenuHost?.show(it, actions) }, - onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art), actions) } }, + onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } }, starred = artist?.starred != null, onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } }, ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt index 65fdbb2b..ba0b575a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.ListDownloadStatus +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController @@ -12,11 +15,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -24,6 +29,7 @@ class ArtistDetailViewModel( private val libraryRepository: LibraryRepository, private val sourceRepository: SourceRepository, private val playbackController: PlaybackController, + private val downloadRepository: DownloadRepository, private val artistId: String, ) : ViewModel() { private val sourceId = libraryRepository.activeSourceId.filterNotNull() @@ -32,6 +38,26 @@ class ArtistDetailViewModel( val albums: Flow> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) } + val downloadStatus: StateFlow = + sourceId + .flatMapLatest { downloadRepository.status(it, DownloadList.Artist, artistId) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) + + fun downloadAll() { + viewModelScope.launch { downloadRepository.downloadAll(sourceId.first(), DownloadList.Artist, artistId) } + } + + fun cancelDownloads() { + viewModelScope.launch { downloadRepository.cancelAll(sourceId.first(), DownloadList.Artist, artistId) } + } + + fun deleteDownloads() { + viewModelScope.launch { + downloadRepository.deleteAll(sourceId.first(), DownloadList.Artist, artistId) + playbackController.refreshMediaItems() + } + } + private val _art = MutableStateFlow(null) val art: StateFlow = _art diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt index 08f3d24f..9a9175d2 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailScreen.kt @@ -12,12 +12,14 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.QueueKind import com.subtracks.data.model.SongDownload import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController +import com.subtracks.ui.components.BulkDownloadAction import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroHeader @@ -45,6 +47,7 @@ fun PlaylistDetailRoute( ) { val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val downloads by viewModel.downloads.collectAsStateWithLifecycle() + val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle() val context = playback.context val actions = @@ -66,6 +69,14 @@ fun PlaylistDetailRoute( coverArt = viewModel::coverArt, artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), downloads = downloads, + downloadStatus = downloadStatus, + onDownloadAction = { action -> + when (action) { + BulkDownloadAction.Download -> viewModel.downloadAll() + BulkDownloadAction.Cancel -> viewModel.cancelDownloads() + BulkDownloadAction.Delete -> viewModel.deleteDownloads() + } + }, onBack = onBack, onSongClick = viewModel::play, onSongLongClick = { contextMenuHost?.show(it, actions) }, @@ -83,6 +94,8 @@ fun PlaylistDetailScreen( coverArt: (String?, Boolean) -> CoverArtRef?, artwork: ArtworkColors?, downloads: Map = emptyMap(), + downloadStatus: ListDownloadStatus = ListDownloadStatus(), + onDownloadAction: (BulkDownloadAction) -> Unit = {}, onBack: () -> Unit, onSongClick: (Int) -> Unit, onSongLongClick: (MenuTarget) -> Unit = {}, @@ -106,7 +119,8 @@ fun PlaylistDetailScreen( hasSongs = songs.itemCount > 0, onPlay = onPlay, onShuffle = onShuffle, - onDownload = {}, + downloadStatus = downloadStatus, + onDownloadAction = onDownloadAction, onMore = onMore, topInset = topInset, controlsModifier = controlsModifier, diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt index 982cda93..0ffe09d9 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistDetailViewModel.kt @@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.DownloadList +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.Playlist import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.Song @@ -15,10 +17,12 @@ import com.subtracks.data.repo.SourceRepository import com.subtracks.playback.PlaybackController import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @OptIn(ExperimentalCoroutinesApi::class) @@ -41,6 +45,26 @@ class PlaylistDetailViewModel( val downloads: StateFlow> = downloadRepository.states() + val downloadStatus: StateFlow = + sourceId + .flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) + + fun downloadAll() { + viewModelScope.launch { downloadRepository.downloadAll(sourceId.first(), DownloadList.Playlist, playlistId) } + } + + fun cancelDownloads() { + viewModelScope.launch { downloadRepository.cancelAll(sourceId.first(), DownloadList.Playlist, playlistId) } + } + + fun deleteDownloads() { + viewModelScope.launch { + downloadRepository.deleteAll(sourceId.first(), DownloadList.Playlist, playlistId) + playbackController.refreshMediaItems() + } + } + fun download(song: Song) { viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) } } diff --git a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt index 2dc0922b..e3cc9277 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -12,13 +12,18 @@ import com.subtracks.data.download.ArtworkStore import com.subtracks.data.download.FakeDownloadEngine import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadStatus +import com.subtracks.data.model.ListDownloadStatus +import com.subtracks.data.model.Playlist +import com.subtracks.data.model.PlaylistSong import com.subtracks.data.model.Song import com.subtracks.data.model.SongDownload import com.subtracks.data.model.Source import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.coverArtKey import com.subtracks.data.prefs.fakeUserPreferences +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import okhttp3.OkHttpClient @@ -418,6 +423,97 @@ class DownloadRepositoryTest { assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false))) } + @Test + fun downloadingAListQueuesEverySongAndStoresItsArtworkOnce() = + runTest { + seedLibrary() + + runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") } + + assertEquals(2, engine.requests.size) + assertEquals(4, requestedArt.size) + assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, true))) + } + + @Test + fun downloadingAListSkipsTheSongsItAlreadyHas() = + runTest { + seedLibrary() + runBlocking { repository.download(1, "s1") } + val before = engine.requests.size + + runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") } + + assertEquals(before + 1, engine.requests.size) + } + + @Test + fun cancelAllStopsTheRunningSongsAndKeepsTheCompletedOnes() = + runTest { + seedLibrary() + writeFile(1, "s1", "audio") + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11)) + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22)) + + runBlocking { repository.cancelAll(1, DownloadList.Album, "al1") } + + assertNotNull(row(1, "s1")) + assertNull(row(1, "s2")) + assertTrue(engine.cancelled.contains(22L)) + } + + @Test + fun deleteAllRemovesTheCompletedSongsAndKeepsTheRunningOnes() = + runTest { + seedLibrary() + writeFile(1, "s1", "audio") + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11)) + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22)) + + runBlocking { repository.deleteAll(1, DownloadList.Album, "al1") } + + assertNull(row(1, "s1")) + assertFalse(file(1, "s1").exists()) + assertNotNull(row(1, "s2")) + } + + @Test + fun theListStatusCountsTheSongsAndTracksProgress() = + runTest { + seedLibrary() + writeFile(1, "s1", "audio") + db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11)) + + assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 0), statusOf(DownloadList.Album, "al1")) + assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 0), statusOf(DownloadList.Artist, "ar1")) + + db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Queued, engineId = 22)) + + assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 1), statusOf(DownloadList.Album, "al1")) + } + + @Test + fun thePlaylistStatusCountsASongThatAppearsTwiceOnce() = + runTest { + seedLibrary() + runBlocking { + db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, 2, 0))) + db.libraryDao().upsertPlaylistSongs( + listOf( + PlaylistSong(1, "pl1", "s1", 0), + PlaylistSong(1, "pl1", "s1", 1), + ), + ) + } + + assertEquals(ListDownloadStatus(total = 1, downloaded = 0, downloading = 0), statusOf(DownloadList.Playlist, "pl1")) + } + + private fun statusOf( + list: DownloadList, + refId: String, + ): ListDownloadStatus = runBlocking { repository.status(1, list, refId).first() } + private fun album(coverArt: String?) = Album( sourceId = 1, diff --git a/app/src/test/kotlin/com/subtracks/ui/components/HeroDownloadTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/HeroDownloadTest.kt new file mode 100644 index 00000000..3b39c688 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/HeroDownloadTest.kt @@ -0,0 +1,94 @@ +package com.subtracks.ui.components + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.subtracks.data.model.ListDownloadStatus +import com.subtracks.ui.theme.SubtracksTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.GraphicsMode + +@RunWith(AndroidJUnit4::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class HeroDownloadTest { + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun anUndownloadedListOffersDownload() { + assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3).action()) + assertEquals(BulkDownloadAction.Download, ListDownloadStatus().action()) + } + + @Test + fun aPartiallyDownloadedListStillOffersDownload() { + assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3, downloaded = 1).action()) + } + + @Test + fun aListWithDownloadsInFlightOffersCancel() { + assertEquals(BulkDownloadAction.Cancel, ListDownloadStatus(total = 3, downloaded = 1, downloading = 1).action()) + } + + @Test + fun aFullyDownloadedListOffersDelete() { + assertEquals(BulkDownloadAction.Delete, ListDownloadStatus(total = 3, downloaded = 3).action()) + } + + @Test + fun tappingTheButtonReportsTheActionItsStateOffers() { + val actions = mutableListOf() + render(ListDownloadStatus(total = 3, downloaded = 1, downloading = 1)) { actions += it } + + composeRule.onNodeWithContentDescription("Cancel download").performClick() + + assertEquals(listOf(BulkDownloadAction.Cancel), actions) + } + + @Test + fun aFullyDownloadedListTapsThroughToDelete() { + val actions = mutableListOf() + render(ListDownloadStatus(total = 3, downloaded = 3)) { actions += it } + + composeRule.onNodeWithContentDescription("Delete download").performClick() + + assertEquals(listOf(BulkDownloadAction.Delete), actions) + } + + @Test + fun theButtonIsDisabledWithoutSongs() { + render(ListDownloadStatus(), hasSongs = false) {} + + composeRule.onNodeWithContentDescription("Download").assertIsNotEnabled() + } + + private fun render( + status: ListDownloadStatus, + hasSongs: Boolean = true, + onAction: (BulkDownloadAction) -> Unit, + ) { + composeRule.setContent { + SubtracksTheme { + HeroHeader( + art = null, + name = "Album", + subtitle = "Artist", + hasSongs = hasSongs, + onPlay = {}, + onShuffle = {}, + downloadStatus = status, + onDownloadAction = onAction, + onMore = {}, + topInset = 0.dp, + ) + } + } + } +}