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