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.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent edbcbeb380
commit 7a0dd9aa5b
13 changed files with 320 additions and 43 deletions
@@ -69,7 +69,7 @@ fun appModule(
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) } single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) } single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
viewModel { RootViewModel(get()) } viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) } viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) } viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AddSourceViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) } viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
@@ -7,6 +7,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext 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 @Composable
fun DeleteDownloadsDialog( fun DeleteDownloadsDialog(
@@ -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
}
}
}
}
}
@@ -47,6 +47,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.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
@@ -77,6 +78,7 @@ sealed interface MenuTarget {
data class Album( data class Album(
val album: AlbumModel, val album: AlbumModel,
override val coverArt: CoverArtRef? = null, override val coverArt: CoverArtRef? = null,
val download: ListDownloadStatus? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = album.name override val title: String get() = album.name
@@ -96,6 +98,7 @@ sealed interface MenuTarget {
data class Playlist( data class Playlist(
val playlist: PlaylistModel, val playlist: PlaylistModel,
override val coverArt: CoverArtRef? = null, override val coverArt: CoverArtRef? = null,
val download: ListDownloadStatus? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = playlist.name override val title: String get() = playlist.name
@@ -117,6 +120,22 @@ fun MenuTarget.queueRef(): QueueRef? =
is MenuTarget.Artist -> null is MenuTarget.Artist -> null
} }
fun MenuTarget.bulkRef(): Pair<DownloadList, String>? =
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( class ItemActions(
val playSong: ((SongModel) -> Unit)? = null, val playSong: ((SongModel) -> Unit)? = null,
val playAlbum: (AlbumModel) -> Unit = {}, val playAlbum: (AlbumModel) -> Unit = {},
@@ -128,9 +147,7 @@ class ItemActions(
val download: ((SongModel) -> Unit)? = null, val download: ((SongModel) -> Unit)? = null,
val cancelDownload: ((SongModel) -> Unit)? = null, val cancelDownload: ((SongModel) -> Unit)? = null,
val deleteDownload: ((SongModel) -> Unit)? = null, val deleteDownload: ((SongModel) -> Unit)? = null,
val downloadArtist: ((ArtistModel) -> Unit)? = null, val bulkDownload: ((MenuTarget, BulkDownloadAction) -> Unit)? = null,
val cancelArtistDownload: ((ArtistModel) -> Unit)? = null,
val deleteArtistDownload: ((ArtistModel) -> Unit)? = null,
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> }, val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val viewAlbum: ((String) -> Unit)? = null, val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((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.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } }
queueItems() queueItems()
BulkDownloadItem(target, actions, ::dismiss)
StarItem( StarItem(
current = target.album.starred, current = target.album.starred,
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) }, onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
@@ -241,7 +259,7 @@ fun ItemContextMenu(
} }
is MenuTarget.Artist -> { is MenuTarget.Artist -> {
BulkDownloadItem(target.download, target.artist, actions, ::dismiss) BulkDownloadItem(target, actions, ::dismiss)
StarItem( StarItem(
current = target.artist.starred, current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, 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.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } } MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
queueItems() queueItems()
BulkDownloadItem(target, actions, ::dismiss)
} }
} }
Spacer(Modifier.height(16.dp)) Spacer(Modifier.height(16.dp))
@@ -327,29 +346,23 @@ private fun DownloadItem(
@Composable @Composable
private fun BulkDownloadItem( private fun BulkDownloadItem(
download: ListDownloadStatus?, target: MenuTarget,
artist: ArtistModel,
actions: ItemActions, actions: ItemActions,
dismiss: (() -> Unit) -> Unit, dismiss: (() -> Unit) -> Unit,
) { ) {
for (action in (download ?: ListDownloadStatus()).actions()) { val bulk = actions.bulkDownload ?: return
for (action in (target.listDownload() ?: ListDownloadStatus()).actions()) {
when (action) { when (action) {
BulkDownloadAction.Delete -> { BulkDownloadAction.Delete -> {
actions.deleteArtistDownload?.let { delete -> MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } }
MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { delete(artist) } }
}
} }
BulkDownloadAction.Cancel -> { BulkDownloadAction.Cancel -> {
actions.cancelArtistDownload?.let { cancel -> MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } }
MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { cancel(artist) } }
}
} }
BulkDownloadAction.Download -> { BulkDownloadAction.Download -> {
actions.downloadArtist?.let { start -> MenuItem(Icons.Rounded.Download, "Download") { dismiss { bulk(target, action) } }
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(artist) } }
}
} }
} }
} }
@@ -2,18 +2,21 @@ package com.subtracks.ui.library
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height 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.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
@@ -24,9 +27,11 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
@@ -47,6 +52,7 @@ fun AlbumsContent(
resetKey: Any? = null, resetKey: Any? = null,
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when { when {
@@ -84,9 +90,7 @@ fun AlbumsContent(
if (album != null) { if (album != null) {
val art = coverArt(album.coverArt, true) val art = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(art) PrefetchArtworkSeeds(art)
CoverArt( Box(
ref = art,
name = album.name,
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -94,9 +98,19 @@ fun AlbumsContent(
.clip(RoundedCornerShape(2.dp)) .clip(RoundedCornerShape(2.dp))
.combinedClickable( .combinedClickable(
onClick = { onAlbumClick(album) }, onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album, art)) }, onLongClick = { onLongClick(MenuTarget.Album(album, art, downloadStatuses[album.id])) },
).testTag(ALBUM_COVER_TAG), ).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) }) { item(span = { GridItemSpan(maxLineSpan) }) {
@@ -78,13 +78,17 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.DeleteDownloadsDialog import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.MenuTarget 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.components.rememberViewportFill
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
@@ -144,8 +148,9 @@ fun ArtistDetailRoute(
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle() val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) } var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions = val actions =
ItemActions( ItemActions(
@@ -153,9 +158,18 @@ fun ArtistDetailRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) }, addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
downloadArtist = { viewModel.onDownloadAction(BulkDownloadAction.Download) }, bulkDownload = { target, action ->
cancelArtistDownload = { viewModel.onDownloadAction(BulkDownloadAction.Cancel) }, target.bulkRef()?.let { (list, refId) ->
deleteArtistDownload = { scope.launch { pendingDelete = viewModel.downloadedBytes() } }, if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
setStar = setStar, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
) )
@@ -168,17 +182,18 @@ fun ArtistDetailRoute(
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
albumDownloads = albumDownloads,
onAlbumLongClick = { contextMenuHost?.show(it, actions) }, onAlbumLongClick = { contextMenuHost?.show(it, actions) },
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } }, onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
starred = artist?.starred != null, starred = artist?.starred != null,
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } }, onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
) )
pendingDelete?.let { bytes -> pendingDelete?.let { pending ->
DeleteDownloadsDialog( DeleteDownloadsDialog(
name = artist?.name.orEmpty(), name = pending.name,
bytes = bytes, bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) }, onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null }, onDismiss = { pendingDelete = null },
) )
} }
@@ -195,6 +210,7 @@ fun ArtistDetailScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit, onBack: () -> Unit,
onAlbumClick: (Album) -> Unit, onAlbumClick: (Album) -> Unit,
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
onAlbumLongClick: (MenuTarget) -> Unit = {}, onAlbumLongClick: (MenuTarget) -> Unit = {},
onMore: () -> Unit = {}, onMore: () -> Unit = {},
starred: Boolean = false, starred: Boolean = false,
@@ -372,16 +388,24 @@ fun ArtistDetailScreen(
end = if (index % 2 == 1) 16.dp else 0.dp, end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable( ).combinedClickable(
onClick = { onAlbumClick(album) }, onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail)) }, onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
).onSizeChanged { albumHeightPx = it.height.toFloat() }, ).onSizeChanged { albumHeightPx = it.height.toFloat() },
) {
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
) { ) {
CoverArt( CoverArt(
ref = art, ref = art,
name = album.name, name = album.name,
thumbnailRef = thumbnail, thumbnailRef = thumbnail,
showPlaceholder = art == null, showPlaceholder = art == null,
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)), modifier = Modifier.fillMaxSize(),
) )
ListDownloadIndicator(
status = albumDownloads[album.id],
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
)
}
Text( Text(
text = album.name, text = album.name,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -44,11 +44,23 @@ class ArtistDetailViewModel(
.flatMapLatest { downloadRepository.status(it, DownloadList.Artist, artistId) } .flatMapLatest { downloadRepository.status(it, DownloadList.Artist, artistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Artist, artistId) val albumDownloads: StateFlow<Map<String, ListDownloadStatus>> =
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 { viewModelScope.launch {
downloadRepository.applyAction(sourceId.first(), DownloadList.Artist, artistId, action) downloadRepository.applyAction(sourceId.first(), list, refId, action)
if (action == BulkDownloadAction.Delete) playbackController.refreshMediaItems() if (action == BulkDownloadAction.Delete) playbackController.refreshMediaItems()
} }
} }
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -24,9 +25,11 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
@@ -45,6 +48,7 @@ fun ArtistsContent(
resetKey: Any? = null, resetKey: Any? = null,
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when { when {
@@ -95,10 +99,15 @@ fun ArtistsContent(
modifier = Modifier.size(48.dp).clip(CircleShape), modifier = Modifier.size(48.dp).clip(CircleShape),
) )
}, },
trailingContent = {
ListDownloadIndicator(downloadStatuses[artist.id], Modifier.padding(end = 8.dp))
},
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onArtistClick(artist) }, 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), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
@@ -62,6 +62,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
@@ -101,7 +102,10 @@ import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef 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.Playlist
import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort 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.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget 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.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.librarySurfaceColor import com.subtracks.ui.theme.librarySurfaceColor
import com.subtracks.ui.theme.playerSurfaceColor import com.subtracks.ui.theme.playerSurfaceColor
import com.subtracks.ui.theme.rememberArtworkColors import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs import kotlin.math.abs
@@ -208,6 +216,11 @@ fun LibraryRoute(
} }
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true)) 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<PendingDownloadDelete?>(null) }
val itemActions = val itemActions =
ItemActions( ItemActions(
playAlbum = { viewModel.playAlbum(it.id) }, playAlbum = { viewModel.playAlbum(it.id) },
@@ -216,6 +229,18 @@ fun LibraryRoute(
shufflePlaylist = { viewModel.shufflePlaylist(it.id) }, shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(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, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
viewArtist = onViewArtist, viewArtist = onViewArtist,
@@ -257,7 +282,19 @@ fun LibraryRoute(
}, },
search = search, search = search,
onSearchChange = { viewModel.setSearch(listTab, it) }, 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) @OptIn(ExperimentalMaterial3Api::class)
@@ -288,6 +325,9 @@ fun LibraryScreen(
search: String = "", search: String = "",
onSearchChange: (String) -> Unit = {}, onSearchChange: (String) -> Unit = {},
artwork: ArtworkColors? = null, artwork: ArtworkColors? = null,
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
artistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val pagerState = val pagerState =
@@ -373,6 +413,7 @@ fun LibraryScreen(
resetKey = resetKey, resetKey = resetKey,
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = albumDownloads,
) )
} }
@@ -391,6 +432,7 @@ fun LibraryScreen(
resetKey = resetKey, resetKey = resetKey,
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = artistDownloads,
) )
} }
@@ -409,6 +451,7 @@ fun LibraryScreen(
resetKey = resetKey, resetKey = resetKey,
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = playlistDownloads,
) )
} }
} }
@@ -6,7 +6,10 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef 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.Playlist
import com.subtracks.data.prefs.AlbumSort import com.subtracks.data.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort 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.ListQuery
import com.subtracks.data.prefs.PlaylistSort import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager import com.subtracks.data.sync.SyncManager
@@ -26,6 +30,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -38,6 +43,7 @@ class LibraryViewModel(
private val syncManager: SyncManager, private val syncManager: SyncManager,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
private val userPreferences: UserPreferences, private val userPreferences: UserPreferences,
private val downloadRepository: DownloadRepository,
) : ViewModel() { ) : ViewModel() {
private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> = private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> =
LibraryListTab.entries.associateWith { tab -> LibraryListTab.entries.associateWith { tab ->
@@ -87,6 +93,39 @@ class LibraryViewModel(
.map { it == SyncStatus.Running } .map { it == SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
val albumDownloads: StateFlow<Map<String, ListDownloadStatus>> =
activeSourceId
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
val artistDownloads: StateFlow<Map<String, ListDownloadStatus>> =
activeSourceId
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Artist) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
val playlistDownloads: StateFlow<Map<String, ListDownloadStatus>> =
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<ListQuery> = listQueries.getValue(tab) fun listQuery(tab: LibraryListTab): StateFlow<ListQuery> = listQueries.getValue(tab)
fun setListQuery( fun setListQuery(
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@@ -23,10 +24,12 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.ui.components.CoverArt import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
@@ -44,6 +47,7 @@ fun PlaylistsContent(
resetKey: Any? = null, resetKey: Any? = null,
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when { when {
@@ -93,10 +97,17 @@ fun PlaylistsContent(
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)), modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
) )
}, },
trailingContent = {
ListDownloadIndicator(downloadStatuses[playlist.id], Modifier.padding(end = 8.dp))
},
modifier = modifier =
Modifier.combinedClickable( Modifier.combinedClickable(
onClick = { onPlaylistClick(playlist) }, 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), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
@@ -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, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued)) db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
} }
@@ -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())
}
}