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, + ) + } + } + } +}