From 7a0dd9aa5ba3191f6fb4533695fa90260e7dccde Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:24:44 +0000 Subject: [PATCH] show download status on the library tabs and offer it in their menus Album tiles, artist rows and playlist rows now carry the badge (lower right on tiles, trailing end on rows) and their long-press menus take the same bulk Download / Cancel / Delete entries the hero uses, driven by the shared actions rule so a partly downloaded item offers both rows and an in-flight one offers Cancel. The artist page's album cells get the badge and album menu too. The three list callbacks the artist menu used (and its artist-specific view-model calls) collapse into one bulkDownload action over a MenuTarget, since the same menu code now serves album, playlist and artist. --- .../main/kotlin/com/subtracks/di/AppModule.kt | 2 +- .../ui/components/DeleteDownloadsDialog.kt | 8 +++ .../ui/components/DownloadIndicator.kt | 70 +++++++++++++++++++ .../ui/components/ItemContextMenu.kt | 45 +++++++----- .../com/subtracks/ui/library/AlbumsContent.kt | 24 +++++-- .../ui/library/ArtistDetailScreen.kt | 54 ++++++++++---- .../ui/library/ArtistDetailViewModel.kt | 18 ++++- .../subtracks/ui/library/ArtistsContent.kt | 11 ++- .../com/subtracks/ui/library/LibraryScreen.kt | 43 ++++++++++++ .../subtracks/ui/library/LibraryViewModel.kt | 39 +++++++++++ .../subtracks/ui/library/PlaylistsContent.kt | 13 +++- .../data/repo/DownloadRepositoryTest.kt | 4 +- .../ui/components/DownloadIndicatorTest.kt | 32 +++++++++ 13 files changed, 320 insertions(+), 43 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt create mode 100644 app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt diff --git a/app/src/main/kotlin/com/subtracks/di/AppModule.kt b/app/src/main/kotlin/com/subtracks/di/AppModule.kt index 26006d05..3c200f0e 100644 --- a/app/src/main/kotlin/com/subtracks/di/AppModule.kt +++ b/app/src/main/kotlin/com/subtracks/di/AppModule.kt @@ -69,7 +69,7 @@ fun appModule( single { MediaSessionConnection(context.applicationContext, get(), get()) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } viewModel { RootViewModel(get()) } - viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } + viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) } viewModel { AddSourceViewModel(get(), get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } diff --git a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt index 91534939..2a0a962c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt +++ b/app/src/main/kotlin/com/subtracks/ui/components/DeleteDownloadsDialog.kt @@ -7,6 +7,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext +import com.subtracks.data.model.DownloadList + +data class PendingDownloadDelete( + val name: String, + val list: DownloadList, + val refId: String, + val bytes: Long, +) @Composable fun DeleteDownloadsDialog( diff --git a/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt b/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt new file mode 100644 index 00000000..8e194f8e --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/ui/components/DownloadIndicator.kt @@ -0,0 +1,70 @@ +package com.subtracks.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.DownloadDone +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.subtracks.data.model.ListDownloadStatus + +const val DOWNLOADED_INDICATOR_TAG = "Downloaded" +const val DOWNLOADING_INDICATOR_TAG = "Downloading" + +enum class DownloadIndicator { Hidden, InProgress, Complete } + +fun ListDownloadStatus.indicator(): DownloadIndicator = + when { + total == 0L || (downloaded == 0L && downloading == 0L) -> DownloadIndicator.Hidden + complete -> DownloadIndicator.Complete + else -> DownloadIndicator.InProgress + } + +@Composable +fun ListDownloadIndicator( + status: ListDownloadStatus?, + modifier: Modifier = Modifier, +) { + val current = status ?: return + val kind = current.indicator() + if (kind == DownloadIndicator.Hidden) return + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f), + modifier = modifier.size(24.dp), + ) { + Box(contentAlignment = Alignment.Center) { + when (kind) { + DownloadIndicator.Complete -> { + Icon( + imageVector = Icons.Rounded.DownloadDone, + contentDescription = DOWNLOADED_INDICATOR_TAG, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + } + + DownloadIndicator.InProgress -> { + CircularProgressIndicator( + progress = { current.downloaded.toFloat() / current.total }, + strokeWidth = 2.dp, + modifier = Modifier.size(14.dp).semantics { contentDescription = DOWNLOADING_INDICATOR_TAG }, + ) + } + + DownloadIndicator.Hidden -> { + Unit + } + } + } + } +} 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 1070cd19..68fd1d23 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.BulkDownloadAction import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.DownloadList import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.QueueKind @@ -77,6 +78,7 @@ sealed interface MenuTarget { data class Album( val album: AlbumModel, override val coverArt: CoverArtRef? = null, + val download: ListDownloadStatus? = null, ) : MenuTarget { override val title: String get() = album.name @@ -96,6 +98,7 @@ sealed interface MenuTarget { data class Playlist( val playlist: PlaylistModel, override val coverArt: CoverArtRef? = null, + val download: ListDownloadStatus? = null, ) : MenuTarget { override val title: String get() = playlist.name @@ -117,6 +120,22 @@ fun MenuTarget.queueRef(): QueueRef? = is MenuTarget.Artist -> null } +fun MenuTarget.bulkRef(): Pair? = + when (this) { + is MenuTarget.Album -> DownloadList.Album to album.id + is MenuTarget.Playlist -> DownloadList.Playlist to playlist.id + is MenuTarget.Artist -> DownloadList.Artist to artist.id + is MenuTarget.Song -> null + } + +fun MenuTarget.listDownload(): ListDownloadStatus? = + when (this) { + is MenuTarget.Album -> download + is MenuTarget.Playlist -> download + is MenuTarget.Artist -> download + is MenuTarget.Song -> null + } + class ItemActions( val playSong: ((SongModel) -> Unit)? = null, val playAlbum: (AlbumModel) -> Unit = {}, @@ -128,9 +147,7 @@ class ItemActions( val download: ((SongModel) -> Unit)? = null, val cancelDownload: ((SongModel) -> Unit)? = null, val deleteDownload: ((SongModel) -> Unit)? = null, - val downloadArtist: ((ArtistModel) -> Unit)? = null, - val cancelArtistDownload: ((ArtistModel) -> Unit)? = null, - val deleteArtistDownload: ((ArtistModel) -> Unit)? = null, + val bulkDownload: ((MenuTarget, BulkDownloadAction) -> Unit)? = null, val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> }, val viewAlbum: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null, @@ -226,6 +243,7 @@ fun ItemContextMenu( MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } } queueItems() + BulkDownloadItem(target, actions, ::dismiss) StarItem( current = target.album.starred, onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) }, @@ -241,7 +259,7 @@ fun ItemContextMenu( } is MenuTarget.Artist -> { - BulkDownloadItem(target.download, target.artist, actions, ::dismiss) + BulkDownloadItem(target, actions, ::dismiss) StarItem( current = target.artist.starred, onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, @@ -253,6 +271,7 @@ fun ItemContextMenu( MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } } queueItems() + BulkDownloadItem(target, actions, ::dismiss) } } Spacer(Modifier.height(16.dp)) @@ -327,29 +346,23 @@ private fun DownloadItem( @Composable private fun BulkDownloadItem( - download: ListDownloadStatus?, - artist: ArtistModel, + target: MenuTarget, actions: ItemActions, dismiss: (() -> Unit) -> Unit, ) { - for (action in (download ?: ListDownloadStatus()).actions()) { + val bulk = actions.bulkDownload ?: return + for (action in (target.listDownload() ?: ListDownloadStatus()).actions()) { when (action) { BulkDownloadAction.Delete -> { - actions.deleteArtistDownload?.let { delete -> - MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { delete(artist) } } - } + MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } } } BulkDownloadAction.Cancel -> { - actions.cancelArtistDownload?.let { cancel -> - MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { cancel(artist) } } - } + MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } } } BulkDownloadAction.Download -> { - actions.downloadArtist?.let { start -> - MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(artist) } } - } + MenuItem(Icons.Rounded.Download, "Download") { dismiss { bulk(target, action) } } } } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt index 2a4b2066..9a8c3e1a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/AlbumsContent.kt @@ -2,18 +2,21 @@ package com.subtracks.ui.library import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag @@ -24,9 +27,11 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.subtracks.data.model.Album import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState +import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange @@ -47,6 +52,7 @@ fun AlbumsContent( resetKey: Any? = null, topInset: Dp = 0.dp, onSync: () -> Unit = {}, + downloadStatuses: Map = emptyMap(), modifier: Modifier = Modifier, ) { when { @@ -84,9 +90,7 @@ fun AlbumsContent( if (album != null) { val art = coverArt(album.coverArt, true) PrefetchArtworkSeeds(art) - CoverArt( - ref = art, - name = album.name, + Box( modifier = Modifier .fillMaxWidth() @@ -94,9 +98,19 @@ fun AlbumsContent( .clip(RoundedCornerShape(2.dp)) .combinedClickable( onClick = { onAlbumClick(album) }, - onLongClick = { onLongClick(MenuTarget.Album(album, art)) }, + onLongClick = { onLongClick(MenuTarget.Album(album, art, downloadStatuses[album.id])) }, ).testTag(ALBUM_COVER_TAG), - ) + ) { + CoverArt( + ref = art, + name = album.name, + modifier = Modifier.fillMaxSize(), + ) + ListDownloadIndicator( + status = downloadStatuses[album.id], + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + ) + } } } item(span = { GridItemSpan(maxLineSpan) }) { 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 94223d12..b0d849a6 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailScreen.kt @@ -78,13 +78,17 @@ import com.subtracks.data.model.Album import com.subtracks.data.model.Artist import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.ItemActions +import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.PendingDownloadDelete +import com.subtracks.ui.components.bulkRef import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkTheme @@ -144,8 +148,9 @@ fun ArtistDetailRoute( val art by viewModel.art.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() + val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - var pendingDelete by remember { mutableStateOf(null) } + var pendingDelete by remember { mutableStateOf(null) } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val actions = ItemActions( @@ -153,9 +158,18 @@ 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.onDownloadAction(BulkDownloadAction.Download) }, - cancelArtistDownload = { viewModel.onDownloadAction(BulkDownloadAction.Cancel) }, - deleteArtistDownload = { scope.launch { pendingDelete = viewModel.downloadedBytes() } }, + bulkDownload = { target, action -> + target.bulkRef()?.let { (list, refId) -> + if (action == BulkDownloadAction.Delete) { + scope.launch { + pendingDelete = + PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId)) + } + } else { + viewModel.onDownloadAction(list, refId, action) + } + } + }, setStar = setStar, viewAlbum = onViewAlbum, ) @@ -168,17 +182,18 @@ fun ArtistDetailRoute( coverArt = viewModel::coverArt, onBack = onBack, onAlbumClick = onAlbumClick, + albumDownloads = albumDownloads, onAlbumLongClick = { contextMenuHost?.show(it, 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) } }, ) - pendingDelete?.let { bytes -> + pendingDelete?.let { pending -> DeleteDownloadsDialog( - name = artist?.name.orEmpty(), - bytes = bytes, - onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) }, + name = pending.name, + bytes = pending.bytes, + onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) }, onDismiss = { pendingDelete = null }, ) } @@ -195,6 +210,7 @@ fun ArtistDetailScreen( coverArt: (String?, Boolean) -> CoverArtRef?, onBack: () -> Unit, onAlbumClick: (Album) -> Unit, + albumDownloads: Map = emptyMap(), onAlbumLongClick: (MenuTarget) -> Unit = {}, onMore: () -> Unit = {}, starred: Boolean = false, @@ -372,16 +388,24 @@ fun ArtistDetailScreen( end = if (index % 2 == 1) 16.dp else 0.dp, ).combinedClickable( onClick = { onAlbumClick(album) }, - onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail)) }, + onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) }, ).onSizeChanged { albumHeightPx = it.height.toFloat() }, ) { - CoverArt( - ref = art, - name = album.name, - thumbnailRef = thumbnail, - showPlaceholder = art == null, + Box( modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), - ) + ) { + CoverArt( + ref = art, + name = album.name, + thumbnailRef = thumbnail, + showPlaceholder = art == null, + modifier = Modifier.fillMaxSize(), + ) + ListDownloadIndicator( + status = albumDownloads[album.id], + modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp), + ) + } Text( text = album.name, style = MaterialTheme.typography.bodyMedium, 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 e629e832..a400347c 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistDetailViewModel.kt @@ -44,11 +44,23 @@ class ArtistDetailViewModel( .flatMapLatest { downloadRepository.status(it, DownloadList.Artist, artistId) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) - suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Artist, artistId) + val albumDownloads: StateFlow> = + sourceId + .flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) - fun onDownloadAction(action: BulkDownloadAction) { + suspend fun downloadedBytes( + list: DownloadList, + refId: String, + ): Long = downloadRepository.downloadedBytes(sourceId.first(), list, refId) + + fun onDownloadAction( + list: DownloadList, + refId: String, + action: BulkDownloadAction, + ) { viewModelScope.launch { - downloadRepository.applyAction(sourceId.first(), DownloadList.Artist, artistId, action) + downloadRepository.applyAction(sourceId.first(), list, refId, action) if (action == BulkDownloadAction.Delete) playbackController.refreshMediaItems() } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt index 48c37126..db9cf9cb 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/ArtistsContent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState @@ -24,9 +25,11 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.itemKey import com.subtracks.data.model.Artist import com.subtracks.data.model.CoverArtRef +import com.subtracks.data.model.ListDownloadStatus import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState +import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange @@ -45,6 +48,7 @@ fun ArtistsContent( resetKey: Any? = null, topInset: Dp = 0.dp, onSync: () -> Unit = {}, + downloadStatuses: Map = emptyMap(), modifier: Modifier = Modifier, ) { when { @@ -95,10 +99,15 @@ fun ArtistsContent( modifier = Modifier.size(48.dp).clip(CircleShape), ) }, + trailingContent = { + ListDownloadIndicator(downloadStatuses[artist.id], Modifier.padding(end = 8.dp)) + }, modifier = Modifier.combinedClickable( onClick = { onArtistClick(artist) }, - onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) }, + onLongClick = { + onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id])) + }, ), colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt index d0d1e25d..23816ce7 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryScreen.kt @@ -62,6 +62,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow @@ -101,7 +102,10 @@ import androidx.paging.PagingData import androidx.paging.compose.collectAsLazyPagingItems import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.BulkDownloadAction 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.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort @@ -112,14 +116,18 @@ import com.subtracks.data.prefs.StarredFilter import com.subtracks.data.source.StarType import com.subtracks.playback.PlaybackController import com.subtracks.ui.components.ContextMenuHost +import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.MenuTarget +import com.subtracks.ui.components.PendingDownloadDelete +import com.subtracks.ui.components.bulkRef import com.subtracks.ui.components.statusBarScrim import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.librarySurfaceColor import com.subtracks.ui.theme.playerSurfaceColor import com.subtracks.ui.theme.rememberArtworkColors import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch import org.koin.compose.koinInject import org.koin.compose.viewmodel.koinViewModel import kotlin.math.abs @@ -208,6 +216,11 @@ fun LibraryRoute( } val playback by playbackController.state.collectAsStateWithLifecycle() val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true)) + val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle() + val artistDownloads by viewModel.artistDownloads.collectAsStateWithLifecycle() + val playlistDownloads by viewModel.playlistDownloads.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var pendingDelete by remember { mutableStateOf(null) } val itemActions = ItemActions( playAlbum = { viewModel.playAlbum(it.id) }, @@ -216,6 +229,18 @@ fun LibraryRoute( shufflePlaylist = { viewModel.shufflePlaylist(it.id) }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, + bulkDownload = { target, action -> + target.bulkRef()?.let { (list, refId) -> + if (action == BulkDownloadAction.Delete) { + scope.launch { + pendingDelete = + PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId)) + } + } else { + viewModel.onDownloadAction(list, refId, action) + } + } + }, setStar = setStar, viewAlbum = onViewAlbum, viewArtist = onViewArtist, @@ -257,7 +282,19 @@ fun LibraryRoute( }, search = search, onSearchChange = { viewModel.setSearch(listTab, it) }, + albumDownloads = albumDownloads, + artistDownloads = artistDownloads, + playlistDownloads = playlistDownloads, ) + + pendingDelete?.let { pending -> + DeleteDownloadsDialog( + name = pending.name, + bytes = pending.bytes, + onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) }, + onDismiss = { pendingDelete = null }, + ) + } } @OptIn(ExperimentalMaterial3Api::class) @@ -288,6 +325,9 @@ fun LibraryScreen( search: String = "", onSearchChange: (String) -> Unit = {}, artwork: ArtworkColors? = null, + albumDownloads: Map = emptyMap(), + artistDownloads: Map = emptyMap(), + playlistDownloads: Map = emptyMap(), modifier: Modifier = Modifier, ) { val pagerState = @@ -373,6 +413,7 @@ fun LibraryScreen( resetKey = resetKey, topInset = listTopInset, onSync = onSync, + downloadStatuses = albumDownloads, ) } @@ -391,6 +432,7 @@ fun LibraryScreen( resetKey = resetKey, topInset = listTopInset, onSync = onSync, + downloadStatuses = artistDownloads, ) } @@ -409,6 +451,7 @@ fun LibraryScreen( resetKey = resetKey, topInset = listTopInset, onSync = onSync, + downloadStatuses = playlistDownloads, ) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt index 40a16174..36fa1451 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/LibraryViewModel.kt @@ -6,7 +6,10 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import com.subtracks.data.model.Album import com.subtracks.data.model.Artist +import com.subtracks.data.model.BulkDownloadAction 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.prefs.AlbumSort import com.subtracks.data.prefs.ArtistSort @@ -14,6 +17,7 @@ import com.subtracks.data.prefs.LibraryListTab import com.subtracks.data.prefs.ListQuery import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.UserPreferences +import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.SourceRepository import com.subtracks.data.sync.SyncManager @@ -26,6 +30,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -38,6 +43,7 @@ class LibraryViewModel( private val syncManager: SyncManager, private val playbackController: PlaybackController, private val userPreferences: UserPreferences, + private val downloadRepository: DownloadRepository, ) : ViewModel() { private val listQueries: Map> = LibraryListTab.entries.associateWith { tab -> @@ -87,6 +93,39 @@ class LibraryViewModel( .map { it == SyncStatus.Running } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + private val activeSourceId = libraryRepository.activeSourceId.filterNotNull() + + val albumDownloads: StateFlow> = + activeSourceId + .flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + val artistDownloads: StateFlow> = + activeSourceId + .flatMapLatest { downloadRepository.statuses(it, DownloadList.Artist) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + val playlistDownloads: StateFlow> = + activeSourceId + .flatMapLatest { downloadRepository.statuses(it, DownloadList.Playlist) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap()) + + suspend fun downloadedBytes( + list: DownloadList, + refId: String, + ): Long = downloadRepository.downloadedBytes(activeSourceId.first(), list, refId) + + fun onDownloadAction( + list: DownloadList, + refId: String, + action: BulkDownloadAction, + ) { + viewModelScope.launch { + downloadRepository.applyAction(activeSourceId.first(), list, refId, action) + if (action == BulkDownloadAction.Delete) playbackController.refreshMediaItems() + } + } + fun listQuery(tab: LibraryListTab): StateFlow = listQueries.getValue(tab) fun setListQuery( diff --git a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt index e206a057..8a34428d 100644 --- a/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt +++ b/app/src/main/kotlin/com/subtracks/ui/library/PlaylistsContent.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState @@ -23,10 +24,12 @@ import androidx.paging.LoadState import androidx.paging.compose.LazyPagingItems 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.ui.components.CoverArt import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.FilteredEmptyState +import com.subtracks.ui.components.ListDownloadIndicator import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.ResetScrollOnChange @@ -44,6 +47,7 @@ fun PlaylistsContent( resetKey: Any? = null, topInset: Dp = 0.dp, onSync: () -> Unit = {}, + downloadStatuses: Map = emptyMap(), modifier: Modifier = Modifier, ) { when { @@ -93,10 +97,17 @@ fun PlaylistsContent( modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), ) }, + trailingContent = { + ListDownloadIndicator(downloadStatuses[playlist.id], Modifier.padding(end = 8.dp)) + }, modifier = Modifier.combinedClickable( onClick = { onPlaylistClick(playlist) }, - onLongClick = { onLongClick(MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true))) }, + onLongClick = { + onLongClick( + MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true), downloadStatuses[playlist.id]), + ) + }, ), colors = ListItemDefaults.colors(containerColor = Color.Transparent), ) 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 33b08eb9..45a0cdf8 100644 --- a/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/repo/DownloadRepositoryTest.kt @@ -772,7 +772,9 @@ class DownloadRepositoryTest { ), ), ) - db.libraryDao().upsertArtists(listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null))) + db.libraryDao().upsertArtists( + listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)), + ) db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed)) db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued)) } diff --git a/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt b/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt new file mode 100644 index 00000000..4bd3ef20 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/components/DownloadIndicatorTest.kt @@ -0,0 +1,32 @@ +package com.subtracks.ui.components + +import com.subtracks.data.model.ListDownloadStatus +import org.junit.Assert.assertEquals +import org.junit.Test + +class DownloadIndicatorTest { + @Test + fun anUntouchedListShowsNothing() { + assertEquals(DownloadIndicator.Hidden, ListDownloadStatus().indicator()) + } + + @Test + fun aListWithNothingStartedButNoSongsShowsNothing() { + assertEquals(DownloadIndicator.Hidden, ListDownloadStatus(total = 4).indicator()) + } + + @Test + fun aQueuedButNotYetDownloadedListShowsProgress() { + assertEquals(DownloadIndicator.InProgress, ListDownloadStatus(total = 4, downloading = 4).indicator()) + } + + @Test + fun aPartlyDownloadedListShowsProgress() { + assertEquals(DownloadIndicator.InProgress, ListDownloadStatus(total = 4, downloaded = 2).indicator()) + } + + @Test + fun aFullyDownloadedListShowsDone() { + assertEquals(DownloadIndicator.Complete, ListDownloadStatus(total = 4, downloaded = 4).indicator()) + } +}