mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
download a whole album, playlist or artist at once
Third block of #65: bulk downloads and the list-level actions. - The hero's download button on albums and playlists is real now. It starts the songs the list is missing, cancels what is in flight, or deletes what is already downloaded, chosen from the list's own status. The artist has no hero button, so its bulk actions live in the context menu, which now carries the same status. - No new storage: a bulk job is the download rows for the list, and the status is counted from song_downloads joined to the list's songs, so cancel and delete stay well defined without anything to keep in sync. - downloadAll fetches artwork for the whole list under one shared budget, once per unique cover, and skips songs that are already downloaded so re-tapping Download resumes rather than restarting.
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.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@@ -15,6 +16,52 @@ interface DownloadDao {
|
||||
@Query("SELECT * FROM song_downloads")
|
||||
suspend fun all(): List<SongDownload>
|
||||
|
||||
@Query("SELECT id FROM songs WHERE sourceId = :sourceId AND artistId = :artistId ORDER BY albumId, disc, track, id")
|
||||
suspend fun artistSongIds(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): List<String>
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(songs.id) AS total, " +
|
||||
"COALESCE(SUM(CASE WHEN song_downloads.status = 'Completed' THEN 1 ELSE 0 END), 0) AS downloaded, " +
|
||||
"COALESCE(SUM(CASE WHEN song_downloads.status IN ('Queued', 'Running') THEN 1 ELSE 0 END), 0) AS downloading " +
|
||||
"FROM songs " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.albumId = :albumId",
|
||||
)
|
||||
fun albumStatus(
|
||||
sourceId: Long,
|
||||
albumId: String,
|
||||
): Flow<ListDownloadStatus>
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(DISTINCT songs.id) AS total, " +
|
||||
"COUNT(DISTINCT CASE WHEN song_downloads.status = 'Completed' THEN songs.id END) AS downloaded, " +
|
||||
"COUNT(DISTINCT CASE WHEN song_downloads.status IN ('Queued', 'Running') THEN songs.id END) AS downloading " +
|
||||
"FROM playlist_songs " +
|
||||
"JOIN songs ON songs.sourceId = playlist_songs.sourceId AND songs.id = playlist_songs.songId " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE playlist_songs.sourceId = :sourceId AND playlist_songs.playlistId = :playlistId",
|
||||
)
|
||||
fun playlistStatus(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
): Flow<ListDownloadStatus>
|
||||
|
||||
@Query(
|
||||
"SELECT COUNT(songs.id) AS total, " +
|
||||
"COALESCE(SUM(CASE WHEN song_downloads.status = 'Completed' THEN 1 ELSE 0 END), 0) AS downloaded, " +
|
||||
"COALESCE(SUM(CASE WHEN song_downloads.status IN ('Queued', 'Running') THEN 1 ELSE 0 END), 0) AS downloading " +
|
||||
"FROM songs " +
|
||||
"LEFT JOIN song_downloads ON song_downloads.sourceId = songs.sourceId AND song_downloads.songId = songs.id " +
|
||||
"WHERE songs.sourceId = :sourceId AND songs.artistId = :artistId",
|
||||
)
|
||||
fun artistStatus(
|
||||
sourceId: Long,
|
||||
artistId: String,
|
||||
): Flow<ListDownloadStatus>
|
||||
|
||||
@Query(
|
||||
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
|
||||
"al.id AS albumRow, al.coverArt AS albumCoverArt, " +
|
||||
|
||||
@@ -40,6 +40,16 @@ data class SongDownload(
|
||||
get() = total.takeIf { it > 0 }?.let { (bytes.toFloat() / it).coerceIn(0f, 1f) }
|
||||
}
|
||||
|
||||
enum class DownloadList { Album, Playlist, Artist }
|
||||
|
||||
data class ListDownloadStatus(
|
||||
val total: Long = 0,
|
||||
val downloaded: Long = 0,
|
||||
val downloading: Long = 0,
|
||||
) {
|
||||
val complete: Boolean get() = total > 0 && downloaded == total
|
||||
}
|
||||
|
||||
data class DownloadArtwork(
|
||||
val sourceId: Long,
|
||||
val albumId: String?,
|
||||
|
||||
@@ -8,7 +8,9 @@ import com.subtracks.data.download.DownloadEngine
|
||||
import com.subtracks.data.download.EngineDownload
|
||||
import com.subtracks.data.download.EngineRequest
|
||||
import com.subtracks.data.download.EngineStatus
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -103,7 +105,7 @@ class DownloadRepository(
|
||||
if (!enqueueIfAbsent(sourceId, songId)) return@withContext
|
||||
// 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.
|
||||
fetchArtworkSafely(sourceId, songId)
|
||||
fetchArtworkSafely(sourceId, listOf(songId))
|
||||
}
|
||||
|
||||
private suspend fun enqueueIfAbsent(
|
||||
@@ -124,14 +126,66 @@ class DownloadRepository(
|
||||
songId: String,
|
||||
) = withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
val row = db.downloadDao().find(sourceId, songId) ?: return@withLock
|
||||
row.engineId?.let(engine::cancel)
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
if (!removeRow(sourceId, songId)) return@withLock
|
||||
sweep(db.downloadDao().all())
|
||||
}
|
||||
}
|
||||
|
||||
fun status(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
): Flow<ListDownloadStatus> =
|
||||
when (list) {
|
||||
DownloadList.Album -> db.downloadDao().albumStatus(sourceId, refId)
|
||||
DownloadList.Playlist -> db.downloadDao().playlistStatus(sourceId, refId)
|
||||
DownloadList.Artist -> db.downloadDao().artistStatus(sourceId, refId)
|
||||
}
|
||||
|
||||
suspend fun downloadAll(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
) = withContext(dispatcher) {
|
||||
if (sourceRepository.activeSourceIdOnce() != sourceId) {
|
||||
showMessage(NOT_ACTIVE)
|
||||
return@withContext
|
||||
}
|
||||
val songIds = songIds(sourceId, list, refId)
|
||||
if (songIds.isEmpty()) return@withContext
|
||||
if (sourceRepository.downloadUri(sourceId, songIds.first()) == null) {
|
||||
showMessage(NO_ADDRESS)
|
||||
return@withContext
|
||||
}
|
||||
songIds.forEach { enqueueIfAbsent(sourceId, it) }
|
||||
fetchArtworkSafely(sourceId, songIds)
|
||||
}
|
||||
|
||||
suspend fun cancelAll(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
) = withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
val removed = rowsFor(sourceId, list, refId).filter { it.status.isActive }.map { removeRow(sourceId, it.songId) }
|
||||
if (removed.any { it }) sweep(db.downloadDao().all())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun deleteAll(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
) = withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
val removed =
|
||||
rowsFor(sourceId, list, refId)
|
||||
.filter { it.status == DownloadStatus.Completed }
|
||||
.map { removeRow(sourceId, it.songId) }
|
||||
if (removed.any { it }) sweep(db.downloadDao().all())
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeSource(sourceId: Long) =
|
||||
withContext(dispatcher) {
|
||||
mutex.withLock {
|
||||
@@ -155,7 +209,7 @@ class DownloadRepository(
|
||||
ensurePolling()
|
||||
resumed
|
||||
}
|
||||
resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) }
|
||||
resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) }
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -284,12 +338,44 @@ class DownloadRepository(
|
||||
return resumed
|
||||
}
|
||||
|
||||
private suspend fun fetchArtworkSafely(
|
||||
private suspend fun removeRow(
|
||||
sourceId: Long,
|
||||
songId: String,
|
||||
): Boolean {
|
||||
val row = db.downloadDao().find(sourceId, songId) ?: return false
|
||||
row.engineId?.let(engine::cancel)
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().delete(sourceId, songId)
|
||||
return true
|
||||
}
|
||||
|
||||
private suspend fun rowsFor(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
): List<SongDownload> {
|
||||
val ids = songIds(sourceId, list, refId).toHashSet()
|
||||
if (ids.isEmpty()) return emptyList()
|
||||
return db.downloadDao().all().filter { it.sourceId == sourceId && it.songId in ids }
|
||||
}
|
||||
|
||||
private suspend fun songIds(
|
||||
sourceId: Long,
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
): List<String> =
|
||||
when (list) {
|
||||
DownloadList.Album -> db.queueDao().albumSongIds(sourceId, refId)
|
||||
DownloadList.Playlist -> db.queueDao().playlistSongIds(sourceId, refId)
|
||||
DownloadList.Artist -> db.downloadDao().artistSongIds(sourceId, refId)
|
||||
}
|
||||
|
||||
private suspend fun fetchArtworkSafely(
|
||||
sourceId: Long,
|
||||
songIds: List<String>,
|
||||
) {
|
||||
try {
|
||||
withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) }
|
||||
withTimeoutOrNull(ART_TIMEOUT_MS) { songIds.forEach { fetchArtwork(sourceId, it) } }
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
@@ -343,7 +429,7 @@ class DownloadRepository(
|
||||
}
|
||||
// Fetching artwork here would hold up the next tick, and with it the progress
|
||||
// the queue is mirrored from.
|
||||
resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, row.songId) } }
|
||||
resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, listOf(row.songId)) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ fun appModule(
|
||||
viewModel { SettingsViewModel(get(), get(), get()) }
|
||||
viewModel { AddSourceViewModel(get(), get()) }
|
||||
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
|
||||
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), params.get()) }
|
||||
viewModel { params -> ArtistDetailViewModel(get(), get(), get(), get(), params.get()) }
|
||||
viewModel { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
|
||||
viewModel { QueueViewModel(get(), get()) }
|
||||
}
|
||||
|
||||
@@ -27,10 +27,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Download
|
||||
import androidx.compose.material.icons.rounded.MoreHoriz
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Shuffle
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -61,6 +64,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.ui.theme.ArtworkColors
|
||||
import com.subtracks.ui.theme.ArtworkTheme
|
||||
import com.subtracks.ui.theme.HeroGradient
|
||||
@@ -204,6 +208,15 @@ private fun HeroSubtitle(
|
||||
)
|
||||
}
|
||||
|
||||
enum class BulkDownloadAction { Download, Cancel, Delete }
|
||||
|
||||
fun ListDownloadStatus.action(): BulkDownloadAction =
|
||||
when {
|
||||
complete -> BulkDownloadAction.Delete
|
||||
downloading > 0 -> BulkDownloadAction.Cancel
|
||||
else -> BulkDownloadAction.Download
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun HeroHeader(
|
||||
art: CoverArtRef?,
|
||||
@@ -212,7 +225,8 @@ fun HeroHeader(
|
||||
hasSongs: Boolean,
|
||||
onPlay: () -> Unit,
|
||||
onShuffle: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
downloadStatus: ListDownloadStatus,
|
||||
onDownloadAction: (BulkDownloadAction) -> Unit,
|
||||
onMore: () -> Unit,
|
||||
topInset: Dp,
|
||||
controlsModifier: Modifier = Modifier,
|
||||
@@ -271,8 +285,31 @@ fun HeroHeader(
|
||||
modifier = controlsModifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = onDownload, modifier = Modifier.padding(horizontal = 8.dp)) {
|
||||
Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.offset(y = 1.5.dp))
|
||||
IconButton(
|
||||
onClick = { onDownloadAction(downloadStatus.action()) },
|
||||
enabled = hasSongs,
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
) {
|
||||
when (downloadStatus.action()) {
|
||||
BulkDownloadAction.Delete -> {
|
||||
Icon(Icons.Rounded.Delete, contentDescription = "Delete download", modifier = Modifier.offset(y = 1.5.dp))
|
||||
}
|
||||
|
||||
BulkDownloadAction.Cancel -> {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(
|
||||
progress = { downloadStatus.downloaded.toFloat() / downloadStatus.total },
|
||||
modifier = Modifier.size(26.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Cancel download", modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
|
||||
BulkDownloadAction.Download -> {
|
||||
Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.offset(y = 1.5.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.weight(1f),
|
||||
|
||||
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
@@ -84,6 +85,7 @@ sealed interface MenuTarget {
|
||||
data class Artist(
|
||||
val artist: ArtistModel,
|
||||
override val coverArt: CoverArtRef? = null,
|
||||
val download: ListDownloadStatus? = null,
|
||||
) : MenuTarget {
|
||||
override val title: String get() = artist.name
|
||||
|
||||
@@ -125,7 +127,10 @@ class ItemActions(
|
||||
val download: ((SongModel) -> Unit)? = null,
|
||||
val cancelDownload: ((SongModel) -> Unit)? = null,
|
||||
val deleteDownload: ((SongModel) -> Unit)? = null,
|
||||
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
|
||||
val downloadArtist: ((ArtistModel) -> Unit)? = null,
|
||||
val cancelArtistDownload: ((ArtistModel) -> Unit)? = null,
|
||||
val deleteArtistDownload: ((ArtistModel) -> Unit)? = null,
|
||||
val setStar: suspend (StarType, String, Boolean) -> Result<Unit> = { _, _, _ -> Result.success(Unit) },
|
||||
val viewAlbum: ((String) -> Unit)? = null,
|
||||
val viewArtist: ((String) -> Unit)? = null,
|
||||
)
|
||||
@@ -235,6 +240,7 @@ fun ItemContextMenu(
|
||||
}
|
||||
|
||||
is MenuTarget.Artist -> {
|
||||
BulkDownloadItem(target.download, target.artist, actions, ::dismiss)
|
||||
StarItem(
|
||||
current = target.artist.starred,
|
||||
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
|
||||
@@ -318,6 +324,34 @@ private fun DownloadItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BulkDownloadItem(
|
||||
download: ListDownloadStatus?,
|
||||
artist: ArtistModel,
|
||||
actions: ItemActions,
|
||||
dismiss: (() -> Unit) -> Unit,
|
||||
) {
|
||||
when ((download ?: ListDownloadStatus()).action()) {
|
||||
BulkDownloadAction.Delete -> {
|
||||
actions.deleteArtistDownload?.let { delete ->
|
||||
MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { delete(artist) } }
|
||||
}
|
||||
}
|
||||
|
||||
BulkDownloadAction.Cancel -> {
|
||||
actions.cancelArtistDownload?.let { cancel ->
|
||||
MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { cancel(artist) } }
|
||||
}
|
||||
}
|
||||
|
||||
BulkDownloadAction.Download -> {
|
||||
actions.downloadArtist?.let { start ->
|
||||
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(artist) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StarItem(
|
||||
current: Long?,
|
||||
|
||||
@@ -23,11 +23,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.BulkDownloadAction
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
@@ -57,6 +59,7 @@ fun AlbumDetailRoute(
|
||||
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
@@ -80,6 +83,14 @@ fun AlbumDetailRoute(
|
||||
coverArt = viewModel::coverArt,
|
||||
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
|
||||
downloads = downloads,
|
||||
downloadStatus = downloadStatus,
|
||||
onDownloadAction = { action ->
|
||||
when (action) {
|
||||
BulkDownloadAction.Download -> viewModel.downloadAll()
|
||||
BulkDownloadAction.Cancel -> viewModel.cancelDownloads()
|
||||
BulkDownloadAction.Delete -> viewModel.deleteDownloads()
|
||||
}
|
||||
},
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||
@@ -108,7 +119,8 @@ fun AlbumDetailScreen(
|
||||
onSongLongClick: (MenuTarget) -> Unit = {},
|
||||
onShuffle: () -> Unit = {},
|
||||
onPlay: () -> Unit = { onSongClick(0) },
|
||||
onDownload: () -> Unit = {},
|
||||
downloadStatus: ListDownloadStatus = ListDownloadStatus(),
|
||||
onDownloadAction: (BulkDownloadAction) -> Unit = {},
|
||||
onMore: () -> Unit = {},
|
||||
onArtistClick: (() -> Unit)? = null,
|
||||
playingSongId: String? = null,
|
||||
@@ -161,7 +173,8 @@ fun AlbumDetailScreen(
|
||||
hasSongs = songs.isNotEmpty(),
|
||||
onPlay = onPlay,
|
||||
onShuffle = onShuffle,
|
||||
onDownload = onDownload,
|
||||
downloadStatus = downloadStatus,
|
||||
onDownloadAction = onDownloadAction,
|
||||
onMore = onMore,
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.Disc
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
@@ -13,10 +15,12 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -37,6 +41,26 @@ class AlbumDetailViewModel(
|
||||
|
||||
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states()
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Album, albumId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||
|
||||
fun downloadAll() {
|
||||
viewModelScope.launch { downloadRepository.downloadAll(sourceId.first(), DownloadList.Album, albumId) }
|
||||
}
|
||||
|
||||
fun cancelDownloads() {
|
||||
viewModelScope.launch { downloadRepository.cancelAll(sourceId.first(), DownloadList.Album, albumId) }
|
||||
}
|
||||
|
||||
fun deleteDownloads() {
|
||||
viewModelScope.launch {
|
||||
downloadRepository.deleteAll(sourceId.first(), DownloadList.Album, albumId)
|
||||
playbackController.refreshMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
fun coverArt(
|
||||
coverArt: String?,
|
||||
thumbnail: Boolean,
|
||||
|
||||
@@ -138,6 +138,7 @@ fun ArtistDetailRoute(
|
||||
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
|
||||
val art by viewModel.art.collectAsStateWithLifecycle()
|
||||
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
|
||||
val actions =
|
||||
ItemActions(
|
||||
@@ -145,6 +146,9 @@ fun ArtistDetailRoute(
|
||||
shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
|
||||
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
|
||||
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
|
||||
downloadArtist = { viewModel.downloadAll() },
|
||||
cancelArtistDownload = { viewModel.cancelDownloads() },
|
||||
deleteArtistDownload = { viewModel.deleteDownloads() },
|
||||
setStar = setStar,
|
||||
viewAlbum = onViewAlbum,
|
||||
)
|
||||
@@ -158,7 +162,7 @@ fun ArtistDetailRoute(
|
||||
onBack = onBack,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onAlbumLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art), actions) } },
|
||||
onMore = { artist?.let { contextMenuHost?.show(MenuTarget.Artist(it, artThumbnail ?: art, downloadStatus), actions) } },
|
||||
starred = artist?.starred != null,
|
||||
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.starred == null) } },
|
||||
)
|
||||
|
||||
@@ -5,6 +5,9 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.repo.DownloadRepository
|
||||
import com.subtracks.data.repo.LibraryRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
@@ -12,11 +15,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -24,6 +29,7 @@ class ArtistDetailViewModel(
|
||||
private val libraryRepository: LibraryRepository,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val playbackController: PlaybackController,
|
||||
private val downloadRepository: DownloadRepository,
|
||||
private val artistId: String,
|
||||
) : ViewModel() {
|
||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
@@ -32,6 +38,26 @@ class ArtistDetailViewModel(
|
||||
|
||||
val albums: Flow<List<Album>> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) }
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Artist, artistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||
|
||||
fun downloadAll() {
|
||||
viewModelScope.launch { downloadRepository.downloadAll(sourceId.first(), DownloadList.Artist, artistId) }
|
||||
}
|
||||
|
||||
fun cancelDownloads() {
|
||||
viewModelScope.launch { downloadRepository.cancelAll(sourceId.first(), DownloadList.Artist, artistId) }
|
||||
}
|
||||
|
||||
fun deleteDownloads() {
|
||||
viewModelScope.launch {
|
||||
downloadRepository.deleteAll(sourceId.first(), DownloadList.Artist, artistId)
|
||||
playbackController.refreshMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
private val _art = MutableStateFlow<CoverArtRef?>(null)
|
||||
val art: StateFlow<CoverArtRef?> = _art
|
||||
|
||||
|
||||
@@ -12,12 +12,14 @@ import androidx.paging.compose.LazyPagingItems
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import androidx.paging.compose.itemKey
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
import com.subtracks.data.model.QueueKind
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.ui.components.BulkDownloadAction
|
||||
import com.subtracks.ui.components.ContextMenuHost
|
||||
import com.subtracks.ui.components.HeroDetailScaffold
|
||||
import com.subtracks.ui.components.HeroHeader
|
||||
@@ -45,6 +47,7 @@ fun PlaylistDetailRoute(
|
||||
) {
|
||||
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
|
||||
val downloads by viewModel.downloads.collectAsStateWithLifecycle()
|
||||
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
|
||||
val playback by playbackController.state.collectAsStateWithLifecycle()
|
||||
val context = playback.context
|
||||
val actions =
|
||||
@@ -66,6 +69,14 @@ fun PlaylistDetailRoute(
|
||||
coverArt = viewModel::coverArt,
|
||||
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
|
||||
downloads = downloads,
|
||||
downloadStatus = downloadStatus,
|
||||
onDownloadAction = { action ->
|
||||
when (action) {
|
||||
BulkDownloadAction.Download -> viewModel.downloadAll()
|
||||
BulkDownloadAction.Cancel -> viewModel.cancelDownloads()
|
||||
BulkDownloadAction.Delete -> viewModel.deleteDownloads()
|
||||
}
|
||||
},
|
||||
onBack = onBack,
|
||||
onSongClick = viewModel::play,
|
||||
onSongLongClick = { contextMenuHost?.show(it, actions) },
|
||||
@@ -83,6 +94,8 @@ fun PlaylistDetailScreen(
|
||||
coverArt: (String?, Boolean) -> CoverArtRef?,
|
||||
artwork: ArtworkColors?,
|
||||
downloads: Map<String, SongDownload> = emptyMap(),
|
||||
downloadStatus: ListDownloadStatus = ListDownloadStatus(),
|
||||
onDownloadAction: (BulkDownloadAction) -> Unit = {},
|
||||
onBack: () -> Unit,
|
||||
onSongClick: (Int) -> Unit,
|
||||
onSongLongClick: (MenuTarget) -> Unit = {},
|
||||
@@ -106,7 +119,8 @@ fun PlaylistDetailScreen(
|
||||
hasSongs = songs.itemCount > 0,
|
||||
onPlay = onPlay,
|
||||
onShuffle = onShuffle,
|
||||
onDownload = {},
|
||||
downloadStatus = downloadStatus,
|
||||
onDownloadAction = onDownloadAction,
|
||||
onMore = onMore,
|
||||
topInset = topInset,
|
||||
controlsModifier = controlsModifier,
|
||||
|
||||
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
import com.subtracks.data.model.Song
|
||||
@@ -15,10 +17,12 @@ import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@@ -41,6 +45,26 @@ class PlaylistDetailViewModel(
|
||||
val downloads: StateFlow<Map<String, SongDownload>> =
|
||||
downloadRepository.states()
|
||||
|
||||
val downloadStatus: StateFlow<ListDownloadStatus> =
|
||||
sourceId
|
||||
.flatMapLatest { downloadRepository.status(it, DownloadList.Playlist, playlistId) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
|
||||
|
||||
fun downloadAll() {
|
||||
viewModelScope.launch { downloadRepository.downloadAll(sourceId.first(), DownloadList.Playlist, playlistId) }
|
||||
}
|
||||
|
||||
fun cancelDownloads() {
|
||||
viewModelScope.launch { downloadRepository.cancelAll(sourceId.first(), DownloadList.Playlist, playlistId) }
|
||||
}
|
||||
|
||||
fun deleteDownloads() {
|
||||
viewModelScope.launch {
|
||||
downloadRepository.deleteAll(sourceId.first(), DownloadList.Playlist, playlistId)
|
||||
playbackController.refreshMediaItems()
|
||||
}
|
||||
}
|
||||
|
||||
fun download(song: Song) {
|
||||
viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) }
|
||||
}
|
||||
|
||||
@@ -12,13 +12,18 @@ import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.download.FakeDownloadEngine
|
||||
import com.subtracks.data.model.Album
|
||||
import com.subtracks.data.model.Artist
|
||||
import com.subtracks.data.model.DownloadList
|
||||
import com.subtracks.data.model.DownloadStatus
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.model.SubsonicSource
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -418,6 +423,97 @@ class DownloadRepositoryTest {
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadingAListQueuesEverySongAndStoresItsArtworkOnce() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
|
||||
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
|
||||
|
||||
assertEquals(2, engine.requests.size)
|
||||
assertEquals(4, requestedArt.size)
|
||||
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun downloadingAListSkipsTheSongsItAlreadyHas() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking { repository.download(1, "s1") }
|
||||
val before = engine.requests.size
|
||||
|
||||
runBlocking { repository.downloadAll(1, DownloadList.Album, "al1") }
|
||||
|
||||
assertEquals(before + 1, engine.requests.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelAllStopsTheRunningSongsAndKeepsTheCompletedOnes() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
writeFile(1, "s1", "audio")
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
|
||||
|
||||
runBlocking { repository.cancelAll(1, DownloadList.Album, "al1") }
|
||||
|
||||
assertNotNull(row(1, "s1"))
|
||||
assertNull(row(1, "s2"))
|
||||
assertTrue(engine.cancelled.contains(22L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteAllRemovesTheCompletedSongsAndKeepsTheRunningOnes() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
writeFile(1, "s1", "audio")
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Running, engineId = 22))
|
||||
|
||||
runBlocking { repository.deleteAll(1, DownloadList.Album, "al1") }
|
||||
|
||||
assertNull(row(1, "s1"))
|
||||
assertFalse(file(1, "s1").exists())
|
||||
assertNotNull(row(1, "s2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theListStatusCountsTheSongsAndTracksProgress() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
writeFile(1, "s1", "audio")
|
||||
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed, engineId = 11))
|
||||
|
||||
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 0), statusOf(DownloadList.Album, "al1"))
|
||||
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 0), statusOf(DownloadList.Artist, "ar1"))
|
||||
|
||||
db.downloadDao().upsert(SongDownload(1, "s2", DownloadStatus.Queued, engineId = 22))
|
||||
|
||||
assertEquals(ListDownloadStatus(total = 2, downloaded = 1, downloading = 1), statusOf(DownloadList.Album, "al1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thePlaylistStatusCountsASongThatAppearsTwiceOnce() =
|
||||
runTest {
|
||||
seedLibrary()
|
||||
runBlocking {
|
||||
db.libraryDao().upsertPlaylists(listOf(Playlist(1, "pl1", "Playlist", null, null, 2, 0)))
|
||||
db.libraryDao().upsertPlaylistSongs(
|
||||
listOf(
|
||||
PlaylistSong(1, "pl1", "s1", 0),
|
||||
PlaylistSong(1, "pl1", "s1", 1),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(ListDownloadStatus(total = 1, downloaded = 0, downloading = 0), statusOf(DownloadList.Playlist, "pl1"))
|
||||
}
|
||||
|
||||
private fun statusOf(
|
||||
list: DownloadList,
|
||||
refId: String,
|
||||
): ListDownloadStatus = runBlocking { repository.status(1, list, refId).first() }
|
||||
|
||||
private fun album(coverArt: String?) =
|
||||
Album(
|
||||
sourceId = 1,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
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 HeroDownloadTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun anUndownloadedListOffersDownload() {
|
||||
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3).action())
|
||||
assertEquals(BulkDownloadAction.Download, ListDownloadStatus().action())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aPartiallyDownloadedListStillOffersDownload() {
|
||||
assertEquals(BulkDownloadAction.Download, ListDownloadStatus(total = 3, downloaded = 1).action())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aListWithDownloadsInFlightOffersCancel() {
|
||||
assertEquals(BulkDownloadAction.Cancel, ListDownloadStatus(total = 3, downloaded = 1, downloading = 1).action())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFullyDownloadedListOffersDelete() {
|
||||
assertEquals(BulkDownloadAction.Delete, ListDownloadStatus(total = 3, downloaded = 3).action())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tappingTheButtonReportsTheActionItsStateOffers() {
|
||||
val actions = mutableListOf<BulkDownloadAction>()
|
||||
render(ListDownloadStatus(total = 3, downloaded = 1, downloading = 1)) { actions += it }
|
||||
|
||||
composeRule.onNodeWithContentDescription("Cancel download").performClick()
|
||||
|
||||
assertEquals(listOf(BulkDownloadAction.Cancel), actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFullyDownloadedListTapsThroughToDelete() {
|
||||
val actions = mutableListOf<BulkDownloadAction>()
|
||||
render(ListDownloadStatus(total = 3, downloaded = 3)) { actions += it }
|
||||
|
||||
composeRule.onNodeWithContentDescription("Delete download").performClick()
|
||||
|
||||
assertEquals(listOf(BulkDownloadAction.Delete), actions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theButtonIsDisabledWithoutSongs() {
|
||||
render(ListDownloadStatus(), hasSongs = false) {}
|
||||
|
||||
composeRule.onNodeWithContentDescription("Download").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
private fun render(
|
||||
status: ListDownloadStatus,
|
||||
hasSongs: Boolean = true,
|
||||
onAction: (BulkDownloadAction) -> Unit,
|
||||
) {
|
||||
composeRule.setContent {
|
||||
SubtracksTheme {
|
||||
HeroHeader(
|
||||
art = null,
|
||||
name = "Album",
|
||||
subtitle = "Artist",
|
||||
hasSongs = hasSongs,
|
||||
onPlay = {},
|
||||
onShuffle = {},
|
||||
downloadStatus = status,
|
||||
onDownloadAction = onAction,
|
||||
onMore = {},
|
||||
topInset = 0.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user