mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user