mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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(
|
@Query(
|
||||||
"SELECT d.sourceId AS sourceId, d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
|
"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 " +
|
"FROM song_downloads d " +
|
||||||
"JOIN songs s ON s.sourceId = d.sourceId AND s.id = d.songId " +
|
"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 " +
|
"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 artistName: String?,
|
||||||
val status: DownloadStatus,
|
val status: DownloadStatus,
|
||||||
val size: Long = 0,
|
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(
|
data class DownloadArtwork(
|
||||||
val sourceId: Long,
|
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(
|
fun status(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
list: DownloadList,
|
list: DownloadList,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.subtracks.ui.downloads
|
|||||||
import android.text.format.Formatter
|
import android.text.format.Formatter
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
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.Delete
|
||||||
import androidx.compose.material.icons.rounded.ExpandLess
|
import androidx.compose.material.icons.rounded.ExpandLess
|
||||||
import androidx.compose.material.icons.rounded.ExpandMore
|
import androidx.compose.material.icons.rounded.ExpandMore
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.ListItem
|
import androidx.compose.material3.ListItem
|
||||||
import androidx.compose.material3.ListItemDefaults
|
import androidx.compose.material3.ListItemDefaults
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -78,6 +81,7 @@ fun DownloadsRoute(
|
|||||||
is DownloadDeleteTarget.All, is DownloadDeleteTarget.Node -> pending = target
|
is DownloadDeleteTarget.All, is DownloadDeleteTarget.Node -> pending = target
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onCancel = viewModel::cancelSongs,
|
||||||
)
|
)
|
||||||
when (val dialog = pending) {
|
when (val dialog = pending) {
|
||||||
is DownloadDeleteTarget.All -> {
|
is DownloadDeleteTarget.All -> {
|
||||||
@@ -111,6 +115,7 @@ fun DownloadsScreen(
|
|||||||
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
|
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onDelete: (DownloadDeleteTarget) -> Unit,
|
onDelete: (DownloadDeleteTarget) -> Unit,
|
||||||
|
onCancel: (List<String>) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
var expanded by remember { mutableStateOf(emptySet<String>()) }
|
var expanded by remember { mutableStateOf(emptySet<String>()) }
|
||||||
@@ -181,7 +186,12 @@ fun DownloadsScreen(
|
|||||||
}
|
}
|
||||||
if (albumKey in expanded) {
|
if (albumKey in expanded) {
|
||||||
items(album.songs, key = { "$albumKey:${it.songId}" }) { song ->
|
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,
|
song: DownloadedSong,
|
||||||
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
|
loadEncoding: suspend (DownloadedSong) -> AudioEncoding?,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var encoding by remember(song.songId) { mutableStateOf<AudioEncoding?>(null) }
|
var encoding by remember(song.songId) { mutableStateOf<AudioEncoding?>(null) }
|
||||||
LaunchedEffect(song.songId, song.status) {
|
LaunchedEffect(song.songId, song.status) {
|
||||||
encoding = if (song.status == DownloadStatus.Completed) loadEncoding(song) else null
|
encoding = if (song.status == DownloadStatus.Completed) loadEncoding(song) else null
|
||||||
}
|
}
|
||||||
|
val active = song.status == DownloadStatus.Queued || song.status == DownloadStatus.Running
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(song.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
|
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 = {
|
trailingContent = {
|
||||||
IconButton(onClick = onDelete) {
|
if (active) {
|
||||||
Icon(Icons.Rounded.Delete, contentDescription = "Delete download")
|
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),
|
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? =
|
suspend fun encoding(song: DownloadedSong): AudioEncoding? =
|
||||||
downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) }
|
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"))
|
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() {
|
private fun seedDownloads() {
|
||||||
sourceId =
|
sourceId =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
Reference in New Issue
Block a user