From 5e074db83438bbbfb86c946c1d7fbc2c2273b348 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:26:32 +0000 Subject: [PATCH] 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. --- .../subtracks/ui/downloads/DownloadsScreen.kt | 39 ++++++++++- .../ui/downloads/DownloadsScreenTest.kt | 65 +++++++++++-------- 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt index 48e4da02..a1c2153a 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt @@ -146,11 +146,13 @@ fun DownloadsScreen( tree.artists.forEach { artist -> val artistKey = "artist:${artist.id}:${artist.name}" item(key = artistKey) { + val active = activeDownloads(artist.albums.flatMap { it.songs }) NodeRow( name = artist.name, subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}", expanded = artistKey in expanded, indent = 0, + progress = active.progress(), onToggle = { expanded = expanded.toggle(artistKey) }, onDelete = { onDelete( @@ -161,17 +163,20 @@ fun DownloadsScreen( }, ) }, + onCancel = { onCancel(active.map { it.songId }) }, ) } if (artistKey in expanded) { artist.albums.forEach { album -> val albumKey = "album:$artistKey:${album.id}:${album.name}" item(key = albumKey) { + val active = activeDownloads(album.songs) NodeRow( name = album.name, subtitle = "${album.songs.size} songs · ${size(album.bytes)}", expanded = albumKey in expanded, indent = 1, + progress = active.progress(), onToggle = { expanded = expanded.toggle(albumKey) }, onDelete = { onDelete( @@ -182,6 +187,7 @@ fun DownloadsScreen( }, ) }, + onCancel = { onCancel(active.map { it.songId }) }, ) } if (albumKey in expanded) { @@ -226,12 +232,24 @@ private fun NodeRow( subtitle: String, expanded: Boolean, indent: Int, + progress: Float?, onToggle: () -> Unit, onDelete: () -> Unit, + onCancel: () -> Unit, ) { ListItem( 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 = { IconButton(onClick = onToggle) { Icon( @@ -241,8 +259,14 @@ private fun NodeRow( } }, trailingContent = { - IconButton(onClick = onDelete) { - Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads") + if (progress != null) { + IconButton(onClick = onCancel) { + Icon(Icons.Rounded.Close, contentDescription = "Cancel download") + } + } else { + IconButton(onClick = onDelete) { + Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads") + } } }, modifier = Modifier.clickable(onClick = onToggle).padding(start = 16.dp * indent), @@ -250,6 +274,15 @@ private fun NodeRow( ) } +private fun activeDownloads(songs: List): List = + songs.filter { it.status == DownloadStatus.Queued || it.status == DownloadStatus.Running } + +private fun List.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 private fun SongRow( song: DownloadedSong, diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt index 30311ab6..924fd863 100644 --- a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt @@ -39,43 +39,56 @@ class DownloadsScreenTest { private val done = active.copy(songId = "s2", title = "Done", status = DownloadStatus.Completed) @Test - fun anInProgressDownloadShowsCancelAndACompletedOneShowsDelete() { + fun anInProgressNodeShowsCancelAndACompletedNodeShowsDelete() { var cancelled: List? = null - render(onCancel = { cancelled = it }) - - expandArtistAndAlbum() + render( + tree = + DownloadTree( + artists = + listOf( + artist("ar1", "Active", listOf(active)), + artist("ar2", "Done", listOf(done)), + ), + ), + onCancel = { cancelled = it }, + ) composeRule.onAllNodesWithContentDescription("Cancel download").assertCountEquals(1) - composeRule.onAllNodesWithContentDescription("Delete download").assertCountEquals(1) + composeRule.onAllNodesWithContentDescription("Delete downloads").assertCountEquals(1) composeRule.onNodeWithContentDescription("Cancel download").performClick() assertEquals(listOf("s1"), cancelled) } - private fun render(onCancel: (List) -> 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, + ) = 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) -> Unit, + ) { composeRule.setContent { SubtracksTheme { DownloadsScreen( - tree = - DownloadTree( - artists = - listOf( - DownloadArtistNode( - id = "ar1", - name = "Artist", - bytes = 0, - albums = - listOf( - DownloadAlbumNode( - id = "al1", - name = "Album", - bytes = 0, - songs = listOf(active, done), - ), - ), - ), - ), - ), + tree = tree, loadEncoding = { null }, onBack = {}, onDelete = {},