add the downloads management screen

A Downloads screen reachable from Settings shows the active source's downloads
as an artist > album > song tree with per-node sizes and song counts, each node
collapsible. Artist and album nodes delete with a confirmation showing what they
free; song rows delete straight away, and a summary row offers Delete all.

Sizes come from the files on disk rather than the engine's reported total, so
they agree with the byte count the delete dialog already quotes. The tree is
built from one query joining the download rows to song, album and artist names.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent 7a0dd9aa5b
commit fedc502c3c
11 changed files with 526 additions and 0 deletions
@@ -4,6 +4,7 @@ import androidx.room3.Dao
import androidx.room3.Query
import androidx.room3.Upsert
import com.subtracks.data.model.DownloadArtwork
import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.model.EntityDownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload
@@ -96,6 +97,17 @@ interface DownloadDao {
)
fun playlistStatuses(sourceId: Long): Flow<List<EntityDownloadStatus>>
@Query(
"SELECT d.songId AS songId, s.title AS title, s.albumId AS albumId, al.name AS albumName, " +
"s.artistId AS artistId, ar.name AS artistName, d.status AS status, 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 " +
"LEFT JOIN artists ar ON ar.sourceId = s.sourceId AND ar.id = s.artistId " +
"WHERE d.sourceId = :sourceId",
)
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>>
@Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
@@ -61,6 +61,17 @@ data class EntityDownloadStatus(
fun toListStatus(): ListDownloadStatus = ListDownloadStatus(total, downloaded, downloading)
}
data class DownloadedSong(
val songId: String,
val title: String,
val albumId: String?,
val albumName: String?,
val artistId: String?,
val artistName: String?,
val status: DownloadStatus,
val size: Long = 0,
)
data class DownloadArtwork(
val sourceId: Long,
val albumId: String?,
@@ -11,6 +11,7 @@ import com.subtracks.data.download.EngineStatus
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.coverArtKey
@@ -30,6 +31,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -195,6 +197,13 @@ class DownloadRepository(
DownloadList.Artist -> db.downloadDao().artistStatuses(sourceId)
}.map { rows -> rows.associate { it.id to it.toListStatus() } }
fun downloadedSongs(sourceId: Long): Flow<List<DownloadedSong>> =
db
.downloadDao()
.downloadedSongs(sourceId)
.map { rows -> rows.map { it.copy(size = file(sourceId, it.songId).length()) } }
.flowOn(dispatcher)
suspend fun downloadAll(
sourceId: Long,
list: DownloadList,
@@ -293,6 +302,14 @@ class DownloadRepository(
sweep(db.downloadDao().all())
}
suspend fun removeList(
sourceId: Long,
list: DownloadList,
refId: String,
) = withContext(dispatcher) {
mutex.withLock { removeListRows(sourceId, list, refId) { true } }
}
suspend fun removeSource(sourceId: Long) =
withContext(dispatcher) {
mutex.withLock {
@@ -24,6 +24,7 @@ import com.subtracks.playback.MediaSessionConnection
import com.subtracks.playback.PlaybackController
import com.subtracks.playback.PlayerConnection
import com.subtracks.ui.RootViewModel
import com.subtracks.ui.downloads.DownloadsViewModel
import com.subtracks.ui.library.AlbumDetailViewModel
import com.subtracks.ui.library.ArtistDetailViewModel
import com.subtracks.ui.library.LibraryViewModel
@@ -71,6 +72,7 @@ fun appModule(
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { DownloadsViewModel(get(), get(), get()) }
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
@@ -65,6 +65,7 @@ import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.ItemContextMenu
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.downloads.DownloadsRoute
import com.subtracks.ui.library.AlbumDetailRoute
import com.subtracks.ui.library.ArtistDetailRoute
import com.subtracks.ui.library.LibraryRoute
@@ -105,6 +106,7 @@ private const val SCRIM_FADE_START = 0.85f
private object Routes {
const val LIBRARY = "library"
const val SETTINGS = "settings"
const val DOWNLOADS = "downloads"
const val ADD_SERVER = "add-server"
const val ALBUM_DETAIL = "album/{albumId}?coverArt={coverArt}"
const val ARTIST_DETAIL = "artist/{artistId}?coverArt={coverArt}"
@@ -305,9 +307,13 @@ private fun MainNavigation() {
composable(Routes.SETTINGS) {
SettingsRoute(
onAddServer = { navController.navigate(Routes.ADD_SERVER) },
onOpenDownloads = { navController.navigate(Routes.DOWNLOADS) },
onBack = { navController.popBackStack() },
)
}
composable(Routes.DOWNLOADS) {
DownloadsRoute(onBack = { navController.popBackStack() })
}
composable(
route = Routes.ALBUM_DETAIL,
arguments =
@@ -0,0 +1,271 @@
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.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.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.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.EmptyState
import org.koin.compose.viewmodel.koinViewModel
sealed interface DownloadDeleteTarget {
data object All : DownloadDeleteTarget
data class Node(
val list: DownloadList,
val refId: String,
val name: String,
val bytes: Long,
) : DownloadDeleteTarget
data class Songs(
val songIds: List<String>,
) : DownloadDeleteTarget
}
@Composable
fun DownloadsRoute(
onBack: () -> Unit,
viewModel: DownloadsViewModel = koinViewModel(),
) {
val tree by viewModel.tree.collectAsStateWithLifecycle()
var pending by remember { mutableStateOf<DownloadDeleteTarget?>(null) }
DownloadsScreen(
tree = tree,
onBack = onBack,
onDelete = { target ->
when (target) {
is DownloadDeleteTarget.Songs -> viewModel.deleteSongs(target.songIds)
is DownloadDeleteTarget.All, is DownloadDeleteTarget.Node -> pending = target
}
},
)
when (val dialog = pending) {
is DownloadDeleteTarget.All -> {
DeleteDownloadsDialog(
name = "all downloads",
bytes = tree.bytes,
onConfirm = viewModel::deleteAll,
onDismiss = { pending = null },
)
}
is DownloadDeleteTarget.Node -> {
DeleteDownloadsDialog(
name = dialog.name,
bytes = dialog.bytes,
onConfirm = { viewModel.delete(dialog.list, dialog.refId) },
onDismiss = { pending = null },
)
}
else -> {
Unit
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DownloadsScreen(
tree: DownloadTree,
onBack: () -> Unit,
onDelete: (DownloadDeleteTarget) -> Unit,
modifier: Modifier = Modifier,
) {
var expanded by remember { mutableStateOf(emptySet<String>()) }
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
title = { Text("Downloads") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back")
}
},
)
},
) { padding ->
if (tree.songs == 0) {
EmptyState("No downloads yet.", Modifier.padding(padding))
return@Scaffold
}
LazyColumn(
modifier = Modifier.padding(padding).fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp),
) {
item {
SummaryRow(tree, onDelete)
}
tree.artists.forEach { artist ->
val artistKey = "artist:${artist.id}:${artist.name}"
item(key = artistKey) {
NodeRow(
name = artist.name,
subtitle = "${artist.albums.size} albums · ${size(artist.bytes)}",
expanded = artistKey in expanded,
indent = 0,
onToggle = { expanded = expanded.toggle(artistKey) },
onDelete = {
onDelete(
if (artist.id.isBlank()) {
DownloadDeleteTarget.Songs(artist.albums.flatMap { album -> album.songs.map { it.songId } })
} else {
DownloadDeleteTarget.Node(DownloadList.Artist, artist.id, artist.name, artist.bytes)
},
)
},
)
}
if (artistKey in expanded) {
artist.albums.forEach { album ->
val albumKey = "album:$artistKey:${album.id}:${album.name}"
item(key = albumKey) {
NodeRow(
name = album.name,
subtitle = "${album.songs.size} songs · ${size(album.bytes)}",
expanded = albumKey in expanded,
indent = 1,
onToggle = { expanded = expanded.toggle(albumKey) },
onDelete = {
onDelete(
if (album.id.isBlank()) {
DownloadDeleteTarget.Songs(album.songs.map { it.songId })
} else {
DownloadDeleteTarget.Node(DownloadList.Album, album.id, album.name, album.bytes)
},
)
},
)
}
if (albumKey in expanded) {
items(album.songs, key = { "$albumKey:${it.songId}" }) { song ->
SongRow(song) { onDelete(DownloadDeleteTarget.Songs(listOf(song.songId))) }
}
}
}
}
}
}
}
}
@Composable
private fun SummaryRow(
tree: DownloadTree,
onDelete: (DownloadDeleteTarget) -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = "${tree.songs} songs · ${size(tree.bytes)}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { onDelete(DownloadDeleteTarget.All) }) { Text("Delete all") }
}
}
@Composable
private fun NodeRow(
name: String,
subtitle: String,
expanded: Boolean,
indent: Int,
onToggle: () -> Unit,
onDelete: () -> Unit,
) {
ListItem(
headlineContent = { Text(name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis) },
leadingContent = {
IconButton(onClick = onToggle) {
Icon(
imageVector = if (expanded) Icons.Rounded.ExpandLess else Icons.Rounded.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand",
)
}
},
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete downloads")
}
},
modifier = Modifier.clickable(onClick = onToggle).padding(start = 16.dp * indent),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@Composable
private fun SongRow(
song: DownloadedSong,
onDelete: () -> Unit,
) {
ListItem(
headlineContent = { Text(song.title, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(statusLabel(song), maxLines = 1, overflow = TextOverflow.Ellipsis) },
trailingContent = {
IconButton(onClick = onDelete) {
Icon(Icons.Rounded.Delete, contentDescription = "Delete download")
}
},
modifier = Modifier.padding(start = 32.dp),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
@Composable
private fun statusLabel(song: DownloadedSong): String {
val size = size(song.size)
return when (song.status) {
DownloadStatus.Completed -> size
DownloadStatus.Queued -> "Queued · $size"
DownloadStatus.Running -> "Downloading · $size"
DownloadStatus.Failed -> "Failed · $size"
}
}
@Composable
private fun size(bytes: Long): String = Formatter.formatFileSize(LocalContext.current, bytes)
private fun Set<String>.toggle(key: String): Set<String> = if (key in this) this - key else this + key
@@ -0,0 +1,104 @@
package com.subtracks.ui.downloads
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadedSong
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class DownloadTree(
val artists: List<DownloadArtistNode> = emptyList(),
) {
val songs: Int get() = artists.sumOf { it.albums.sumOf { album -> album.songs.size } }
val bytes: Long get() = artists.sumOf { it.bytes }
}
data class DownloadArtistNode(
val id: String,
val name: String,
val bytes: Long,
val albums: List<DownloadAlbumNode>,
)
data class DownloadAlbumNode(
val id: String,
val name: String,
val bytes: Long,
val songs: List<DownloadedSong>,
)
@OptIn(ExperimentalCoroutinesApi::class)
class DownloadsViewModel(
private val libraryRepository: LibraryRepository,
private val downloadRepository: DownloadRepository,
private val playbackController: PlaybackController,
) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val tree: StateFlow<DownloadTree> =
sourceId
.flatMapLatest { downloadRepository.downloadedSongs(it) }
.map(::buildTree)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadTree())
fun deleteAll() {
viewModelScope.launch {
downloadRepository.removeSource(sourceId.first())
playbackController.refreshMediaItems()
}
}
fun delete(
list: DownloadList,
refId: String,
) {
viewModelScope.launch {
downloadRepository.removeList(sourceId.first(), list, refId)
playbackController.refreshMediaItems()
}
}
fun deleteSongs(songIds: List<String>) {
viewModelScope.launch {
val source = sourceId.first()
songIds.forEach { downloadRepository.remove(source, it) }
playbackController.refreshMediaItems()
}
}
}
internal fun buildTree(songs: List<DownloadedSong>): DownloadTree =
DownloadTree(
artists =
songs
.groupBy { it.artistId.orEmpty() to it.artistName.orEmpty() }
.map { (artistKey, artistSongs) ->
DownloadArtistNode(
id = artistKey.first,
name = artistKey.second.ifBlank { "Unknown artist" },
bytes = artistSongs.sumOf { it.size },
albums =
artistSongs
.groupBy { it.albumId.orEmpty() to it.albumName.orEmpty() }
.map { (albumKey, albumSongs) ->
DownloadAlbumNode(
id = albumKey.first,
name = albumKey.second.ifBlank { "Unknown album" },
bytes = albumSongs.sumOf { it.size },
songs = albumSongs.sortedBy { it.title.lowercase() },
)
}.sortedBy { it.name.lowercase() },
)
}.sortedBy { it.name.lowercase() },
)
@@ -59,6 +59,7 @@ private enum class SettingsDialog { WifiQuality, MobileQuality, SyncConcurrency
@Composable
fun SettingsRoute(
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onBack: () -> Unit,
viewModel: SettingsViewModel = koinViewModel(),
) {
@@ -79,6 +80,7 @@ fun SettingsRoute(
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
onBack = onBack,
)
}
@@ -97,6 +99,7 @@ fun SettingsScreen(
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -180,6 +183,15 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { SectionHeader("Downloads") }
item {
ListItem(
headlineContent = { Text("Manage downloads") },
supportingContent = { Text("Sizes and deletion") },
modifier = Modifier.clickable(onClick = onOpenDownloads),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { Spacer(Modifier.height(fill)) }
}
}
@@ -791,6 +791,31 @@ class DownloadRepositoryTest {
assertEquals(ListDownloadStatus(total = 2, downloaded = 1), playlists["pl1"])
}
@Test
fun theManagementViewReportsFileSizesAndDeletesAWholeList() =
runTest {
seedLibrary()
runBlocking {
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, bytes = 10, total = 10))
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Queued))
}
writeFile(1, "s1", "audio")
writeFile(1, "s2", "partial")
val songs = runBlocking { repository.downloadedSongs(1).first() }
assertEquals(setOf("s1", "s2"), songs.map { it.songId }.toSet())
val completed = songs.first { it.songId == "s1" }
assertEquals("Album", completed.albumName)
assertEquals("Artist", completed.artistName)
assertEquals(5L, completed.size)
runBlocking { repository.removeList(1, DownloadList.Album, "al1") }
assertTrue(allRows().isEmpty())
assertFalse(file(1, "s1").exists())
assertFalse(file(1, "s2").exists())
}
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
private fun seedSongs(tracks: IntRange) {
@@ -313,6 +313,7 @@ class AppScreenshotTest {
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onAddServer = {},
onOpenDownloads = {},
onBack = {},
)
}
@@ -0,0 +1,65 @@
package com.subtracks.ui.downloads
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.DownloadedSong
import org.junit.Assert.assertEquals
import org.junit.Test
class DownloadTreeTest {
@Test
fun songsAreGroupedByArtistAndAlbumWithSizesSummed() {
val tree =
buildTree(
listOf(
song("s1", "One", "ar1", "Artist", "al1", "First", size = 10),
song("s2", "Two", "ar1", "Artist", "al1", "First", size = 20),
song("s3", "Three", "ar1", "Artist", "al2", "Second", size = 5),
song("s4", "Four", "ar2", "Other", "al3", "Third", size = 1),
),
)
assertEquals(listOf("Artist", "Other"), tree.artists.map { it.name })
val artist = tree.artists.first()
assertEquals(35L, artist.bytes)
assertEquals(listOf("First", "Second"), artist.albums.map { it.name })
assertEquals(30L, artist.albums.first().bytes)
assertEquals(
listOf("One", "Two"),
artist.albums
.first()
.songs
.map { it.title },
)
assertEquals(4, tree.songs)
assertEquals(36L, tree.bytes)
}
@Test
fun aSongWithNoArtistOrAlbumIsStillShown() {
val tree = buildTree(listOf(song("s1", "One", null, null, null, null, size = 3)))
val artist = tree.artists.single()
assertEquals("Unknown artist", artist.name)
assertEquals("", artist.id)
assertEquals("Unknown album", artist.albums.single().name)
}
private fun song(
id: String,
title: String,
artistId: String?,
artistName: String?,
albumId: String?,
albumName: String?,
size: Long,
) = DownloadedSong(
songId = id,
title = title,
albumId = albumId,
albumName = albumName,
artistId = artistId,
artistName = artistName,
status = DownloadStatus.Completed,
size = size,
)
}