downloads: show progress and cancel on album and artist rows too

A node with any active download now shows the aggregate progress bar and
cancels all its active downloads instead of offering delete; a node with
only finished downloads keeps the trash.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent 814331c402
commit 5e074db834
2 changed files with 75 additions and 29 deletions
@@ -146,11 +146,13 @@ fun DownloadsScreen(
tree.artists.forEach { artist -> tree.artists.forEach { artist ->
val artistKey = "artist:${artist.id}:${artist.name}" val artistKey = "artist:${artist.id}:${artist.name}"
item(key = artistKey) { item(key = artistKey) {
val active = activeDownloads(artist.albums.flatMap { it.songs })
NodeRow( NodeRow(
name = artist.name, name = artist.name,
subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}", subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}",
expanded = artistKey in expanded, expanded = artistKey in expanded,
indent = 0, indent = 0,
progress = active.progress(),
onToggle = { expanded = expanded.toggle(artistKey) }, onToggle = { expanded = expanded.toggle(artistKey) },
onDelete = { onDelete = {
onDelete( onDelete(
@@ -161,17 +163,20 @@ fun DownloadsScreen(
}, },
) )
}, },
onCancel = { onCancel(active.map { it.songId }) },
) )
} }
if (artistKey in expanded) { if (artistKey in expanded) {
artist.albums.forEach { album -> artist.albums.forEach { album ->
val albumKey = "album:$artistKey:${album.id}:${album.name}" val albumKey = "album:$artistKey:${album.id}:${album.name}"
item(key = albumKey) { item(key = albumKey) {
val active = activeDownloads(album.songs)
NodeRow( NodeRow(
name = album.name, name = album.name,
subtitle = "${album.songs.size} songs · ${size(album.bytes)}", subtitle = "${album.songs.size} songs · ${size(album.bytes)}",
expanded = albumKey in expanded, expanded = albumKey in expanded,
indent = 1, indent = 1,
progress = active.progress(),
onToggle = { expanded = expanded.toggle(albumKey) }, onToggle = { expanded = expanded.toggle(albumKey) },
onDelete = { onDelete = {
onDelete( onDelete(
@@ -182,6 +187,7 @@ fun DownloadsScreen(
}, },
) )
}, },
onCancel = { onCancel(active.map { it.songId }) },
) )
} }
if (albumKey in expanded) { if (albumKey in expanded) {
@@ -226,12 +232,24 @@ private fun NodeRow(
subtitle: String, subtitle: String,
expanded: Boolean, expanded: Boolean,
indent: Int, indent: Int,
progress: Float?,
onToggle: () -> Unit, onToggle: () -> Unit,
onDelete: () -> Unit, onDelete: () -> Unit,
onCancel: () -> Unit,
) { ) {
ListItem( ListItem(
headlineContent = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) }, headlineContent = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis) }, supportingContent = {
Column {
Text(subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
if (progress != null) {
LinearProgressIndicator(
progress = { progress },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
}
},
leadingContent = { leadingContent = {
IconButton(onClick = onToggle) { IconButton(onClick = onToggle) {
Icon( Icon(
@@ -241,15 +259,30 @@ private fun NodeRow(
} }
}, },
trailingContent = { trailingContent = {
if (progress != null) {
IconButton(onClick = onCancel) {
Icon(Icons.Rounded.Close, contentDescription = "Cancel download")
}
} else {
IconButton(onClick = onDelete) { IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads") Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads")
} }
}
}, },
modifier = Modifier.clickable(onClick = onToggle).padding(start = 16.dp * indent), modifier = Modifier.clickable(onClick = onToggle).padding(start = 16.dp * indent),
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
) )
} }
private fun activeDownloads(songs: List<DownloadedSong>): List<DownloadedSong> =
songs.filter { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Running }
private fun List<DownloadedSong>.progress(): Float? {
if (isEmpty()) return null
val total = sumOf { it.total }
return if (total > 0) (sumOf { it.bytes }.toFloat() / total).coerceIn(0f, 1f) else 0f
}
@Composable @Composable
private fun SongRow( private fun SongRow(
song: DownloadedSong, song: DownloadedSong,
@@ -39,43 +39,56 @@ class DownloadsScreenTest {
private val done = active.copy(songId = "s2", title = "Done", status = DownloadStatus.Completed) private val done = active.copy(songId = "s2", title = "Done", status = DownloadStatus.Completed)
@Test @Test
fun anInProgressDownloadShowsCancelAndACompletedOneShowsDelete() { fun anInProgressNodeShowsCancelAndACompletedNodeShowsDelete() {
var cancelled: List<String>? = null var cancelled: List<String>? = null
render(onCancel = { cancelled = it }) render(
tree =
expandArtistAndAlbum() DownloadTree(
artists =
listOf(
artist("ar1", "Active", listOf(active)),
artist("ar2", "Done", listOf(done)),
),
),
onCancel = { cancelled = it },
)
composeRule.onAllNodesWithContentDescription("Cancel download").assertCountEquals(1) composeRule.onAllNodesWithContentDescription("Cancel download").assertCountEquals(1)
composeRule.onAllNodesWithContentDescription("Delete download").assertCountEquals(1) composeRule.onAllNodesWithContentDescription("Delete downloads").assertCountEquals(1)
composeRule.onNodeWithContentDescription("Cancel download").performClick() composeRule.onNodeWithContentDescription("Cancel download").performClick()
assertEquals(listOf("s1"), cancelled) assertEquals(listOf("s1"), cancelled)
} }
private fun render(onCancel: (List<String>) -> Unit) { @Test
fun songsUnderAnInProgressNodeAlsoShowCancel() {
render(tree = DownloadTree(artists = listOf(artist("ar1", "Artist", listOf(active, done)))), onCancel = {})
expandArtistAndAlbum()
composeRule.onAllNodesWithContentDescription("Cancel download").assertCountEquals(3)
composeRule.onAllNodesWithContentDescription("Delete download").assertCountEquals(1)
}
private fun artist(
id: String,
name: String,
songs: List<DownloadedSong>,
) = DownloadArtistNode(
id = id,
name = name,
bytes = 0,
albums = listOf(DownloadAlbumNode(id = "al-$id", name = "Album", bytes = 0, songs = songs)),
)
private fun render(
tree: DownloadTree,
onCancel: (List<String>) -> Unit,
) {
composeRule.setContent { composeRule.setContent {
SubtracksTheme { SubtracksTheme {
DownloadsScreen( DownloadsScreen(
tree = tree = tree,
DownloadTree(
artists =
listOf(
DownloadArtistNode(
id = "ar1",
name = "Artist",
bytes = 0,
albums =
listOf(
DownloadAlbumNode(
id = "al1",
name = "Album",
bytes = 0,
songs = listOf(active, done),
),
),
),
),
),
loadEncoding = { null }, loadEncoding = { null },
onBack = {}, onBack = {},
onDelete = {}, onDelete = {},