mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +01:00
bound the downloads handed to the platform, and confirm bulk deletes
Two things a 3k-song playlist turned up, plus the hero's downloaded state. - The platform runs about eight downloads at a time, but a bulk action handed it every song at once. Three thousand pending requests meant a notification flood, and a poll tick that rewrote every row's bytes/total, so the whole list churned ten times a second. Now a bulk action only records the songs and hands over at most IN_FLIGHT_LIMIT, oldest first, promoting the next as each finishes, so our row state mirrors what is actually running and a cancel only has to reach what is in flight. - Cancelling was therefore unusable at that size. It now touches at most the in-flight requests instead of thousands, and the rows wait behind it. - A queued row with no engine id is waiting its turn, not lost, so reconcile no longer re-enqueues the whole list on every pass; a row whose engine entry vanished goes back to waiting and the next promote picks it up. A song that cannot be handed over fails instead of being retried every tick. - Requests follow the order the screen shows: artist songs resolve through the same albums ordering the artist page uses, so they were the one list that did not. - The hero's fully-downloaded state shows the downloaded glyph rather than a bin, and tapping it asks before deleting, naming the album or playlist and the space it frees.
This commit is contained in:
@@ -13,7 +13,7 @@ interface DownloadDao {
|
|||||||
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
@Query("SELECT * FROM song_downloads WHERE sourceId = :sourceId")
|
||||||
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
fun downloads(sourceId: Long): Flow<List<SongDownload>>
|
||||||
|
|
||||||
@Query("SELECT * FROM song_downloads")
|
@Query("SELECT * FROM song_downloads ORDER BY rowid")
|
||||||
suspend fun all(): List<SongDownload>
|
suspend fun all(): List<SongDownload>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
|
|||||||
@@ -163,9 +163,10 @@ interface QueueDao {
|
|||||||
): List<String>
|
): List<String>
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
"SELECT id FROM songs WHERE sourceId = :sourceId " +
|
"SELECT songs.id FROM songs " +
|
||||||
"AND albumId IN (SELECT id FROM albums WHERE sourceId = :sourceId AND artistId = :artistId) " +
|
"JOIN albums ON albums.sourceId = songs.sourceId AND albums.id = songs.albumId " +
|
||||||
"ORDER BY albumId, disc, track, id",
|
"WHERE songs.sourceId = :sourceId AND albums.artistId = :artistId " +
|
||||||
|
"ORDER BY albums.year DESC, albums.name COLLATE NOCASE, albums.id, songs.disc, songs.track, songs.id",
|
||||||
)
|
)
|
||||||
suspend fun artistSongIds(
|
suspend fun artistSongIds(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
|
|||||||
@@ -103,29 +103,64 @@ class DownloadRepository(
|
|||||||
showMessage(NO_ADDRESS)
|
showMessage(NO_ADDRESS)
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
if (!enqueueIfAbsent(sourceId, songId)) return@withContext
|
val promoted =
|
||||||
|
mutex.withLock {
|
||||||
|
if (!queueIfAbsentLocked(sourceId, songId)) return@withContext
|
||||||
|
promote()
|
||||||
|
}
|
||||||
// The media is already with the platform, so this only has to happen while we are still
|
// The media is already with the platform, so this only has to happen while we are still
|
||||||
// in the foreground: artwork is fetched by us, and it must not hold up the download.
|
// in the foreground: artwork is fetched by us, and it must not hold up the download.
|
||||||
fetchArtworkSafely(sourceId, listOf(songId))
|
fetchArtworkSafely(sourceId, promoted.map { it.songId })
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun enqueueIfAbsent(
|
private suspend fun queueIfAbsentLocked(
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
): Boolean = mutex.withLock { enqueueIfAbsentLocked(sourceId, songId) }
|
|
||||||
|
|
||||||
private suspend fun enqueueIfAbsentLocked(
|
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return false
|
if (alreadyDownloadingOrDownloaded(sourceId, songId)) return false
|
||||||
val existing = db.downloadDao().find(sourceId, songId)
|
val existing = db.downloadDao().find(sourceId, songId)
|
||||||
existing?.engineId?.let(engine::cancel)
|
existing?.engineId?.let(engine::cancel)
|
||||||
val url = sourceRepository.downloadUri(sourceId, songId) ?: return false
|
dir(sourceId).mkdirs()
|
||||||
enqueue(sourceId, songId, url)
|
file(sourceId, songId).delete()
|
||||||
|
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||||
|
ensurePolling()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hands the platform as many waiting songs as it can run, oldest first, so the queue it holds
|
||||||
|
* mirrors the list on screen rather than the whole library. Everything else waits as a row
|
||||||
|
* without an engine id, and the next tick promotes it.
|
||||||
|
*/
|
||||||
|
private suspend fun promote(): List<SongDownload> {
|
||||||
|
val rows = db.downloadDao().all()
|
||||||
|
val free = IN_FLIGHT_LIMIT - rows.count { it.status.isActive && it.engineId != null }
|
||||||
|
if (free <= 0) return emptyList()
|
||||||
|
val promoted = ArrayList<SongDownload>()
|
||||||
|
rows
|
||||||
|
.filter { it.status == DownloadStatus.Queued && it.engineId == null }
|
||||||
|
.take(free)
|
||||||
|
.forEach { row ->
|
||||||
|
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return@forEach
|
||||||
|
val title = db.libraryDao().songOnce(row.sourceId, row.songId)?.title ?: row.songId
|
||||||
|
val engineId =
|
||||||
|
try {
|
||||||
|
engine.enqueue(
|
||||||
|
EngineRequest(uri = url, path = enginePath(row.sourceId, row.songId), title = title),
|
||||||
|
)
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Leave it failed rather than waiting, or the next tick would retry it forever.
|
||||||
|
markFailed(row, STOPPED)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
db.downloadDao().upsert(row.copy(engineId = engineId))
|
||||||
|
promoted += row
|
||||||
|
}
|
||||||
|
return promoted
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun remove(
|
suspend fun remove(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
songId: String,
|
songId: String,
|
||||||
@@ -167,20 +202,23 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
// Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and
|
// Under one lock, so a cancel that lands mid-loop waits for the queue to be complete and
|
||||||
// then removes all of it, rather than missing the songs enqueued after it looked.
|
// then removes all of it, rather than missing the songs enqueued after it looked.
|
||||||
// ponytail: the lock is held for the whole loop, so cancel latency and the poll tick scale
|
// ponytail: the lock is held for the whole insert pass, so cancel latency scales with the
|
||||||
// with the list length; a per-list cancel generation would lift that if it ever bites.
|
// list length; the promote below hands over at most IN_FLIGHT_LIMIT, which is what keeps
|
||||||
|
// the platform's queue (and our poll writes) bounded.
|
||||||
|
val promoted =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
songIds.forEach { songId ->
|
songIds.forEach { songId ->
|
||||||
try {
|
try {
|
||||||
enqueueIfAbsentLocked(sourceId, songId)
|
queueIfAbsentLocked(sourceId, songId)
|
||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// One song failing to enqueue must not abandon the rest of the list.
|
// One song failing to queue must not abandon the rest of the list.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
promote()
|
||||||
}
|
}
|
||||||
fetchArtworkSafely(sourceId, songIds)
|
fetchArtworkSafely(sourceId, promoted.map { it.songId })
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun applyAction(
|
suspend fun applyAction(
|
||||||
@@ -194,6 +232,20 @@ class DownloadRepository(
|
|||||||
BulkDownloadAction.Delete -> deleteAll(sourceId, list, refId)
|
BulkDownloadAction.Delete -> deleteAll(sourceId, list, refId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun downloadedBytes(
|
||||||
|
sourceId: Long,
|
||||||
|
list: DownloadList,
|
||||||
|
refId: String,
|
||||||
|
): Long =
|
||||||
|
withContext(dispatcher) {
|
||||||
|
val songIds = songIds(sourceId, list, refId).toHashSet()
|
||||||
|
db
|
||||||
|
.downloadDao()
|
||||||
|
.all()
|
||||||
|
.filter { it.sourceId == sourceId && it.songId in songIds && it.status == DownloadStatus.Completed }
|
||||||
|
.sumOf { file(it.sourceId, it.songId).length() }
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun cancelAll(
|
suspend fun cancelAll(
|
||||||
sourceId: Long,
|
sourceId: Long,
|
||||||
list: DownloadList,
|
list: DownloadList,
|
||||||
@@ -234,15 +286,15 @@ class DownloadRepository(
|
|||||||
|
|
||||||
suspend fun reconcile() =
|
suspend fun reconcile() =
|
||||||
withContext(dispatcher) {
|
withContext(dispatcher) {
|
||||||
val resumed =
|
val promoted =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val rows = db.downloadDao().all()
|
val rows = db.downloadDao().all()
|
||||||
val resumed = reconcileRows(rows)
|
reconcileRows(rows)
|
||||||
sweep(rows)
|
sweep(rows)
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
resumed
|
promote()
|
||||||
}
|
}
|
||||||
resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) }
|
warmArtwork(promoted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
@@ -316,59 +368,38 @@ class DownloadRepository(
|
|||||||
.toSet()
|
.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun enqueue(
|
private suspend fun recover(row: SongDownload) {
|
||||||
sourceId: Long,
|
|
||||||
songId: String,
|
|
||||||
url: String,
|
|
||||||
) {
|
|
||||||
dir(sourceId).mkdirs()
|
|
||||||
file(sourceId, songId).delete()
|
|
||||||
val title = db.libraryDao().songOnce(sourceId, songId)?.title ?: songId
|
|
||||||
val engineId =
|
|
||||||
engine.enqueue(
|
|
||||||
EngineRequest(uri = url, path = enginePath(sourceId, songId), title = title),
|
|
||||||
)
|
|
||||||
db.downloadDao().upsert(
|
|
||||||
SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued, engineId = engineId),
|
|
||||||
)
|
|
||||||
ensurePolling()
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun recover(row: SongDownload): Boolean =
|
|
||||||
when (row.status) {
|
when (row.status) {
|
||||||
DownloadStatus.Completed -> {
|
DownloadStatus.Completed -> {
|
||||||
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
|
if (!file(row.sourceId, row.songId).exists()) markFailed(row, MISSING_FILE)
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Waiting rows have no engine id by design; the next promote hands them over.
|
||||||
DownloadStatus.Queued -> {
|
DownloadStatus.Queued -> {
|
||||||
if (sourceRepository.activeSourceIdOnce() != row.sourceId) return false
|
if (row.engineId != null) db.downloadDao().upsert(row.copy(engineId = null))
|
||||||
val url = sourceRepository.downloadUri(row.sourceId, row.songId) ?: return false
|
|
||||||
enqueue(row.sourceId, row.songId, url)
|
|
||||||
true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadStatus.Running -> {
|
DownloadStatus.Running -> {
|
||||||
markFailed(row, STOPPED)
|
markFailed(row, STOPPED)
|
||||||
false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
DownloadStatus.Failed -> {
|
DownloadStatus.Failed -> {
|
||||||
false
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reconcileRows(rows: List<SongDownload>): List<SongDownload> {
|
private suspend fun reconcileRows(rows: List<SongDownload>) {
|
||||||
val resumed = ArrayList<SongDownload>()
|
|
||||||
rows.forEach { row ->
|
rows.forEach { row ->
|
||||||
val state = row.engineId?.let(engine::download)
|
val state = row.engineId?.let(engine::download)
|
||||||
if (state == null) {
|
if (state == null) recover(row) else apply(row, state)
|
||||||
if (recover(row)) resumed += row
|
|
||||||
} else {
|
|
||||||
apply(row, state)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resumed
|
|
||||||
|
private suspend fun warmArtwork(promoted: List<SongDownload>) {
|
||||||
|
promoted.groupBy { it.sourceId }.forEach { (sourceId, rows) ->
|
||||||
|
fetchArtworkSafely(sourceId, rows.map { it.songId })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun removeRow(
|
private suspend fun removeRow(
|
||||||
@@ -458,16 +489,19 @@ class DownloadRepository(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
while (true) {
|
while (true) {
|
||||||
delay(POLL_MS)
|
delay(POLL_MS)
|
||||||
val resumed =
|
val promoted =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val sourceId = sourceRepository.activeSourceIdOnce()
|
val sourceId = sourceRepository.activeSourceIdOnce()
|
||||||
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
|
val active = db.downloadDao().all().filter { it.status.isActive && it.sourceId == sourceId }
|
||||||
if (active.isEmpty()) return@launch
|
if (active.isEmpty()) return@launch
|
||||||
reconcileRows(active)
|
reconcileRows(active)
|
||||||
|
promote()
|
||||||
}
|
}
|
||||||
// Fetching artwork here would hold up the next tick, and with it the progress
|
// Fetching artwork here would hold up the next tick, and with it the progress
|
||||||
// the queue is mirrored from.
|
// the queue is mirrored from.
|
||||||
resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, listOf(row.songId)) } }
|
promoted.groupBy { it.sourceId }.forEach { (sourceId, rows) ->
|
||||||
|
scope.launch { fetchArtworkSafely(sourceId, rows.map { it.songId }) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -507,6 +541,7 @@ class DownloadRepository(
|
|||||||
const val ENGINE_DIR = "downloads"
|
const val ENGINE_DIR = "downloads"
|
||||||
const val NO_MEDIA = ".nomedia"
|
const val NO_MEDIA = ".nomedia"
|
||||||
const val POLL_MS = 1_000L
|
const val POLL_MS = 1_000L
|
||||||
|
const val IN_FLIGHT_LIMIT = 8
|
||||||
const val MISSING_FILE = "The downloaded file is missing"
|
const val MISSING_FILE = "The downloaded file is missing"
|
||||||
const val NO_ADDRESS = "Can't download: the server address is unavailable"
|
const val NO_ADDRESS = "Can't download: the server address is unavailable"
|
||||||
const val EMPTY_LIST = "Nothing left to download from this list"
|
const val EMPTY_LIST = "Nothing left to download from this list"
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
fun formatBytes(bytes: Long): String {
|
||||||
|
val units = listOf("B", "kB", "MB", "GB", "TB")
|
||||||
|
var value = bytes.toDouble()
|
||||||
|
var unit = 0
|
||||||
|
while (value >= 1000 && unit < units.lastIndex) {
|
||||||
|
value /= 1000
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
return if (unit == 0) "$bytes ${units[unit]}" else "${"%.1f".format(value)} ${units[unit]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeleteDownloadsDialog(
|
||||||
|
name: String,
|
||||||
|
bytes: Long,
|
||||||
|
onConfirm: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("Delete downloads") },
|
||||||
|
text = { Text("Delete the downloaded songs of \"$name\"? This frees ${formatBytes(bytes)}.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onConfirm()
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text("Delete") }
|
||||||
|
},
|
||||||
|
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -28,8 +28,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
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.Close
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
|
||||||
import androidx.compose.material.icons.rounded.Download
|
import androidx.compose.material.icons.rounded.Download
|
||||||
|
import androidx.compose.material.icons.rounded.DownloadDone
|
||||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||||
import androidx.compose.material.icons.rounded.PlayArrow
|
import androidx.compose.material.icons.rounded.PlayArrow
|
||||||
import androidx.compose.material.icons.rounded.Shuffle
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
@@ -291,7 +291,11 @@ fun HeroHeader(
|
|||||||
) {
|
) {
|
||||||
when (downloadStatus.action()) {
|
when (downloadStatus.action()) {
|
||||||
BulkDownloadAction.Delete -> {
|
BulkDownloadAction.Delete -> {
|
||||||
Icon(Icons.Rounded.Delete, contentDescription = "Delete download", modifier = Modifier.offset(y = 1.5.dp))
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.DownloadDone,
|
||||||
|
contentDescription = "Delete download",
|
||||||
|
modifier = Modifier.size(28.dp).offset(y = 1.5.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
BulkDownloadAction.Cancel -> {
|
BulkDownloadAction.Cancel -> {
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -31,12 +34,14 @@ import com.subtracks.data.model.SongDownload
|
|||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -60,6 +65,8 @@ fun AlbumDetailRoute(
|
|||||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||||
@@ -84,7 +91,13 @@ fun AlbumDetailRoute(
|
|||||||
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
||||||
downloads = downloads,
|
downloads = downloads,
|
||||||
downloadStatus = downloadStatus,
|
downloadStatus = downloadStatus,
|
||||||
onDownloadAction = viewModel::onDownloadAction,
|
onDownloadAction = { action ->
|
||||||
|
if (action == BulkDownloadAction.Delete) {
|
||||||
|
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||||
|
} else {
|
||||||
|
viewModel.onDownloadAction(action)
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
@@ -98,6 +111,15 @@ fun AlbumDetailRoute(
|
|||||||
?.let { id -> { onViewArtist(id) } },
|
?.let { id -> { onViewArtist(id) } },
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Album && context.refId == albumId },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pendingDelete?.let { bytes ->
|
||||||
|
DeleteDownloadsDialog(
|
||||||
|
name = album?.name.orEmpty(),
|
||||||
|
bytes = bytes,
|
||||||
|
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||||
|
onDismiss = { pendingDelete = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class AlbumDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) }
|
.flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||||
|
|
||||||
|
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Album, albumId)
|
||||||
|
|
||||||
fun onDownloadAction(action: BulkDownloadAction) {
|
fun onDownloadAction(action: BulkDownloadAction) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
downloadRepository.applyAction(sourceId.first(), DownloadList.Album, albumId, action)
|
downloadRepository.applyAction(sourceId.first(), DownloadList.Album, albumId, action)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
@@ -21,12 +22,14 @@ import com.subtracks.data.model.SongDownload
|
|||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.ui.components.ContextMenuHost
|
import com.subtracks.ui.components.ContextMenuHost
|
||||||
|
import com.subtracks.ui.components.DeleteDownloadsDialog
|
||||||
import com.subtracks.ui.components.HeroDetailScaffold
|
import com.subtracks.ui.components.HeroDetailScaffold
|
||||||
import com.subtracks.ui.components.HeroHeader
|
import com.subtracks.ui.components.HeroHeader
|
||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.theme.ArtworkColors
|
import com.subtracks.ui.theme.ArtworkColors
|
||||||
import com.subtracks.ui.theme.rememberArtworkColors
|
import com.subtracks.ui.theme.rememberArtworkColors
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -48,6 +51,8 @@ fun PlaylistDetailRoute(
|
|||||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var pendingDelete by remember { mutableStateOf<Long?>(null) }
|
||||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||||
val context = playback.context
|
val context = playback.context
|
||||||
val actions =
|
val actions =
|
||||||
@@ -70,7 +75,13 @@ fun PlaylistDetailRoute(
|
|||||||
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||||
downloads = downloads,
|
downloads = downloads,
|
||||||
downloadStatus = downloadStatus,
|
downloadStatus = downloadStatus,
|
||||||
onDownloadAction = viewModel::onDownloadAction,
|
onDownloadAction = { action ->
|
||||||
|
if (action == BulkDownloadAction.Delete) {
|
||||||
|
scope.launch { pendingDelete = viewModel.downloadedBytes() }
|
||||||
|
} else {
|
||||||
|
viewModel.onDownloadAction(action)
|
||||||
|
}
|
||||||
|
},
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
@@ -79,6 +90,15 @@ fun PlaylistDetailRoute(
|
|||||||
onMore = { playlist?.let { contextMenuHost?.show(MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)), actions) } },
|
onMore = { playlist?.let { contextMenuHost?.show(MenuTarget.Playlist(it, viewModel.coverArt(it.coverArt, true)), actions) } },
|
||||||
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
playingSongId = playback.item?.id.takeIf { context?.kind == QueueKind.Playlist && context.refId == playlistId },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
pendingDelete?.let { bytes ->
|
||||||
|
DeleteDownloadsDialog(
|
||||||
|
name = playlist?.name.orEmpty(),
|
||||||
|
bytes = bytes,
|
||||||
|
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
|
||||||
|
onDismiss = { pendingDelete = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class PlaylistDetailViewModel(
|
|||||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) }
|
.flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||||
|
|
||||||
|
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Playlist, playlistId)
|
||||||
|
|
||||||
fun onDownloadAction(action: BulkDownloadAction) {
|
fun onDownloadAction(action: BulkDownloadAction) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
downloadRepository.applyAction(sourceId.first(), DownloadList.Playlist, playlistId, action)
|
downloadRepository.applyAction(sourceId.first(), DownloadList.Playlist, playlistId, action)
|
||||||
|
|||||||
@@ -560,11 +560,10 @@ class DownloadRepositoryTest {
|
|||||||
fun cancellingWhileAListIsStillQueuingRemovesTheWholeList() =
|
fun cancellingWhileAListIsStillQueuingRemovesTheWholeList() =
|
||||||
runTest {
|
runTest {
|
||||||
seedLibrary()
|
seedLibrary()
|
||||||
seedSongs(3..40)
|
seedSongs(3..400)
|
||||||
engine.enqueueDelayMs = 1
|
|
||||||
|
|
||||||
val queuing = launch(Dispatchers.IO) { repository.downloadAll(1, DownloadList.Album, "al1") }
|
val queuing = launch(Dispatchers.IO) { repository.downloadAll(1, DownloadList.Album, "al1") }
|
||||||
await { engine.requests.size >= 3 }
|
await { allRows().size >= 50 }
|
||||||
val cancelling = launch(Dispatchers.IO) { repository.cancelAll(1, DownloadList.Album, "al1") }
|
val cancelling = launch(Dispatchers.IO) { repository.cancelAll(1, DownloadList.Album, "al1") }
|
||||||
queuing.join()
|
queuing.join()
|
||||||
cancelling.join()
|
cancelling.join()
|
||||||
@@ -572,6 +571,50 @@ class DownloadRepositoryTest {
|
|||||||
assertTrue("expected the whole list cancelled, ${db.downloadDao().all().size} rows left", db.downloadDao().all().isEmpty())
|
assertTrue("expected the whole list cancelled, ${db.downloadDao().all().size} rows left", db.downloadDao().all().isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aLongListHandsThePlatformOnlyTheInFlightLimit() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(3..40)
|
||||||
|
|
||||||
|
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
|
||||||
|
|
||||||
|
assertEquals(8, engine.requests.size)
|
||||||
|
assertEquals(32, db.downloadDao().all().count { it.status == DownloadStatus.Queued && it.engineId == null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun finishingADownloadHandsOverTheNextSongInListOrder() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(3..12)
|
||||||
|
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
|
||||||
|
val first = engine.requests.first()
|
||||||
|
|
||||||
|
engine.complete(first.first, total = 10)
|
||||||
|
writeFile(1, first.second.path.substringAfterLast('/'), "audio")
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
|
||||||
|
assertEquals(9, engine.requests.size)
|
||||||
|
assertEquals((1..9).map { "s$it" }, requestedSongIds())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reconcilingAWaitingListDoesNotHandItOverWholesale() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
seedSongs(3..40)
|
||||||
|
runBlocking {
|
||||||
|
(1..40).forEach { track ->
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s$track", DownloadStatus.Queued))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runBlocking { repository.reconcile() }
|
||||||
|
|
||||||
|
assertEquals(8, engine.requests.size)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun oneSongFailingToQueueDoesNotAbandonTheRest() =
|
fun oneSongFailingToQueueDoesNotAbandonTheRest() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -613,6 +656,19 @@ class DownloadRepositoryTest {
|
|||||||
assertNull(row(1, "s2"))
|
assertNull(row(1, "s2"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun downloadedBytesCountsTheCompletedFilesOfTheList() =
|
||||||
|
runTest {
|
||||||
|
seedLibrary()
|
||||||
|
writeFile(1, "s1", "12345")
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
|
||||||
|
writeFile(1, "s2", "1234")
|
||||||
|
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
|
||||||
|
writeFile(1, "s3", "123")
|
||||||
|
|
||||||
|
assertEquals(5L, repository.downloadedBytes(1, DownloadList.Album, "al1"))
|
||||||
|
}
|
||||||
|
|
||||||
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
|
private fun requestedSongIds(): List<String> = engine.requests.map { it.second.path.substringAfterLast('/') }
|
||||||
|
|
||||||
private fun seedSongs(tracks: IntRange) {
|
private fun seedSongs(tracks: IntRange) {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
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 DeleteDownloadsDialogTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theDialogNamesTheThingAndTheSpaceItFrees() {
|
||||||
|
render(bytes = 1_500_000)
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Kid A", substring = true).assertIsDisplayed()
|
||||||
|
composeRule.onNodeWithText("1.5 MB", substring = true).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deleteConfirms() {
|
||||||
|
var confirms = 0
|
||||||
|
render(onConfirm = { confirms++ })
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Delete").performClick()
|
||||||
|
|
||||||
|
assertEquals(1, confirms)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancelDismisses() {
|
||||||
|
var dismisses = 0
|
||||||
|
render(onDismiss = { dismisses++ })
|
||||||
|
|
||||||
|
composeRule.onNodeWithText("Cancel").performClick()
|
||||||
|
|
||||||
|
assertEquals(1, dismisses)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun bytesReadAsUnits() {
|
||||||
|
assertEquals("512 B", formatBytes(512))
|
||||||
|
assertEquals("1.0 kB", formatBytes(1_024))
|
||||||
|
assertEquals("1.5 MB", formatBytes(1_500_000))
|
||||||
|
assertEquals("2.0 GB", formatBytes(2_000_000_000))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun render(
|
||||||
|
bytes: Long = 0,
|
||||||
|
onConfirm: () -> Unit = {},
|
||||||
|
onDismiss: () -> Unit = {},
|
||||||
|
) {
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
DeleteDownloadsDialog(name = "Kid A", bytes = bytes, onConfirm = onConfirm, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user