diff --git a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt index ae7e80cd..f614ebb0 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/DownloadDao.kt @@ -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 " + diff --git a/app/src/main/kotlin/com/subtracks/data/model/Download.kt b/app/src/main/kotlin/com/subtracks/data/model/Download.kt index fa934e01..0bf21e35 100644 --- a/app/src/main/kotlin/com/subtracks/data/model/Download.kt +++ b/app/src/main/kotlin/com/subtracks/data/model/Download.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt index 1cacf94c..e4f3e123 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/DownloadRepository.kt @@ -193,6 +193,21 @@ class DownloadRepository( } } + suspend fun cancel( + sourceId: Long, + songIds: Collection, + ) = 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, 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 924c7abd..48e4da02 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsScreen.kt @@ -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) -> Unit, modifier: Modifier = Modifier, ) { var expanded by remember { mutableStateOf(emptySet()) } @@ -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(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), diff --git a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt index 3816d8e0..de986d91 100644 --- a/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/downloads/DownloadsViewModel.kt @@ -79,6 +79,13 @@ class DownloadsViewModel( } } + fun cancelSongs(songIds: List) { + viewModelScope.launch { + downloadRepository.cancel(sourceId.first(), songIds) + playbackController.refreshMediaItems() + } + } + suspend fun encoding(song: DownloadedSong): AudioEncoding? = downloadRepository.localFile(song.sourceId, song.songId)?.let { readAudioEncoding(it) } } diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt new file mode 100644 index 00000000..30311ab6 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsScreenTest.kt @@ -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() + + 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? = 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) -> 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() + } +} diff --git a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt index 42fa7121..b05847e9 100644 --- a/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/downloads/DownloadsViewModelTest.kt @@ -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 {