downloads: show progress and a cancel button for in-progress downloads

In-progress rows on Manage downloads now render a determinate progress bar
and a cancel button; completed and failed rows keep the delete button.
Cancelling removes only the active rows, so any completed downloads stay
and can still be deleted.
This commit is contained in:
austinried
2026-10-04 00:30:07 +00:00
parent c3bfb612f8
commit 814331c402
7 changed files with 166 additions and 6 deletions
@@ -106,7 +106,8 @@ interface DownloadDao {
@Query(
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, d.total AS size " +
"al.artistId AS artistId, ar.name AS artistName, d.status AS status, " +
"d.bytes AS bytes, d.total AS total, d.total AS size " +
"FROM song_downloads d " +
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
"LEFT JOIN albums al ON al.sourceId = s.sourceId AND al.id = s.albumId " +
@@ -72,7 +72,12 @@ data class DownloadedSong(
val artistName: String?,
val status: DownloadStatus,
val size: Long = 0,
)
val bytes: Long = 0,
val total: Long = 0,
) {
val progress: Float?
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
}
data class DownloadArtwork(
val sourceId: Long,
@@ -193,6 +193,21 @@ class DownloadRepository(
}
}
suspend fun cancel(
sourceId: Long,
songIds: Collection<String>,
) = withContext(dispatcher) {
val ids = songIds.toHashSet()
mutex.withLock {
removeRows(
db
.downloadDao()
.all()
.filter { it.sourceId == sourceId && it.songId in ids && it.status.isActive },
)
}
}
fun status(
sourceId: Long,
list: DownloadList,
@@ -3,6 +3,7 @@ package com.subtracks.ui.downloads
import android.text.format.Formatter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
@@ -12,12 +13,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.ExpandLess
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
@@ -78,6 +81,7 @@ fun DownloadsRoute(
is DownloadDeleteTarget.All, is DownloadDeleteTarget.Node -> pending = target
}
},
onCancel = viewModel::cancelSongs,
)
when (val dialog = pending) {
is DownloadDeleteTarget.All -> {
@@ -111,6 +115,7 @@ fun DownloadsScreen(
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
onBack: () -> Unit,
onDelete: (DownloadDeleteTarget) -> Unit,
onCancel: (List<String>) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(emptySet<String>()) }
@@ -181,7 +186,12 @@ fun DownloadsScreen(
}
if (albumKey in expanded) {
items(album.songs, key = { "$albumKey:${it.songId}" }) { song ->
SongRow(song, loadEncoding) { onDelete(DownloadDeleteTarget.Songs(listOf(song.songId))) }
SongRow(
song = song,
loadEncoding = loadEncoding,
onDelete = { onDelete(DownloadDeleteTarget.Songs(listOf(song.songId))) },
onCancel = { onCancel(listOf(song.songId)) },
)
}
}
}
@@ -245,17 +255,35 @@ private fun SongRow(
song: DownloadedSong,
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
onDelete: () -> Unit,
onCancel: () -> Unit,
) {
var encoding by remember(song.songId) { mutableStateOf<AudioEncoding?>(null) }
LaunchedEffect(song.songId, song.status) {
encoding = if (song.status == DownloadStatus.Completed) loadEncoding(song) else null
}
val active = song.status == DownloadStatus.Queued || song.status == DownloadStatus.Running
ListItem(
headlineContent = { Text(song.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(songSubtitle(song, encoding), maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = {
Column {
Text(songSubtitle(song, encoding), maxLines = 1, overflow = TextOverflow.Ellipsis)
if (active) {
LinearProgressIndicator(
progress = { song.progress ?: 0f },
modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
)
}
}
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download")
if (active) {
IconButton(onClick = onCancel) {
Icon(Icons.Rounded.Close, contentDescription = "Cancel download")
}
} else {
IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download")
}
}
},
modifier = Modifier.padding(start = 32.dp),
@@ -79,6 +79,13 @@ class DownloadsViewModel(
}
}
fun cancelSongs(songIds: List<String>) {
viewModelScope.launch {
downloadRepository.cancel(sourceId.first(), songIds)
playbackController.refreshMediaItems()
}
}
suspend fun encoding(song: DownloadedSong): AudioEncoding? =
downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) }
}
@@ -0,0 +1,92 @@
package com.subtracks.ui.downloads
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import com.subtracks.ui.theme.SubtracksTheme
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.annotation.GraphicsMode
@RunWith(AndroidJUnit4::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
class DownloadsScreenTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private val active =
DownloadedSong(
songId = "s1",
sourceId = 1,
title = "Active",
albumId = "al1",
albumName = "Album",
artistId = "ar1",
artistName = "Artist",
status = DownloadStatus.Running,
size = 40,
bytes = 40,
total = 100,
)
private val done = active.copy(songId = "s2", title = "Done", status = DownloadStatus.Completed)
@Test
fun anInProgressDownloadShowsCancelAndACompletedOneShowsDelete() {
var cancelled: List<String>? = null
render(onCancel = { cancelled = it })
expandArtistAndAlbum()
composeRule.onAllNodesWithContentDescription("Cancel download").assertCountEquals(1)
composeRule.onAllNodesWithContentDescription("Delete download").assertCountEquals(1)
composeRule.onNodeWithContentDescription("Cancel download").performClick()
assertEquals(listOf("s1"), cancelled)
}
private fun render(onCancel: (List<String>) -> 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),
),
),
),
),
),
loadEncoding = { null },
onBack = {},
onDelete = {},
onCancel = onCancel,
)
}
}
}
private fun expandArtistAndAlbum() {
composeRule.onAllNodesWithContentDescription("Expand")[0].performClick()
composeRule.onAllNodesWithContentDescription("Expand")[0].performClick()
}
}
@@ -138,6 +138,18 @@ class DownloadsViewModelTest {
assertNotNull("an unnamed song should survive", row("s2"))
}
@Test
fun cancelSongsRemovesOnlyTheActiveDownload() {
seedDownloads()
runBlocking { db.downloadDao().upsert(SongDownload(sourceId, "s1", DownloadStatus.Running, engineId = 7)) }
viewModel.cancelSongs(listOf("s1", "s2"))
await { row("s1") == null }
assertNull(row("s1"))
assertNotNull("a completed download should survive a cancel", row("s2"))
}
private fun seedDownloads() {
sourceId =
runBlocking {