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