show download status on the library tabs and offer it in their menus

Album tiles, artist rows and playlist rows now carry the badge (lower right on
tiles, trailing end on rows) and their long-press menus take the same bulk
Download / Cancel / Delete entries the hero uses, driven by the shared actions
rule so a partly downloaded item offers both rows and an in-flight one offers
Cancel. The artist page's album cells get the badge and album menu too.

The three list callbacks the artist menu used (and its artist-specific
view-model calls) collapse into one bulkDownload action over a MenuTarget, since
the same menu code now serves album, playlist and artist.
This commit is contained in:
austinried
2026-10-04 00:30:03 +00:00
parent edbcbeb380
commit 7a0dd9aa5b
13 changed files with 320 additions and 43 deletions
@@ -69,7 +69,7 @@ fun appModule(
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
single { PlaybackController(get(), get(), get(), get(), showMessage = toast) }
viewModel { RootViewModel(get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get()) }
viewModel { LibraryViewModel(get(), get(), get(), get(), get(), get()) }
viewModel { SettingsViewModel(get(), get(), get()) }
viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.get()) }
@@ -7,6 +7,14 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import com.subtracks.data.model.DownloadList
data class PendingDownloadDelete(
val name: String,
val list: DownloadList,
val refId: String,
val bytes: Long,
)
@Composable
fun DeleteDownloadsDialog(
@@ -0,0 +1,70 @@
package com.subtracks.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.DownloadDone
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.subtracks.data.model.ListDownloadStatus
const val DOWNLOADED_INDICATOR_TAG = "Downloaded"
const val DOWNLOADING_INDICATOR_TAG = "Downloading"
enum class DownloadIndicator { Hidden, InProgress, Complete }
fun ListDownloadStatus.indicator(): DownloadIndicator =
when {
total == 0L || (downloaded == 0L && downloading == 0L) -> DownloadIndicator.Hidden
complete -> DownloadIndicator.Complete
else -> DownloadIndicator.InProgress
}
@Composable
fun ListDownloadIndicator(
status: ListDownloadStatus?,
modifier: Modifier = Modifier,
) {
val current = status ?: return
val kind = current.indicator()
if (kind == DownloadIndicator.Hidden) return
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
modifier = modifier.size(24.dp),
) {
Box(contentAlignment = Alignment.Center) {
when (kind) {
DownloadIndicator.Complete -> {
Icon(
imageVector = Icons.Rounded.DownloadDone,
contentDescription = DOWNLOADED_INDICATOR_TAG,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
}
DownloadIndicator.InProgress -> {
CircularProgressIndicator(
progress = { current.downloaded.toFloat() / current.total },
strokeWidth = 2.dp,
modifier = Modifier.size(14.dp).semantics { contentDescription = DOWNLOADING_INDICATOR_TAG },
)
}
DownloadIndicator.Hidden -> {
Unit
}
}
}
}
}
@@ -47,6 +47,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.QueueKind
@@ -77,6 +78,7 @@ sealed interface MenuTarget {
data class Album(
val album: AlbumModel,
override val coverArt: CoverArtRef? = null,
val download: ListDownloadStatus? = null,
) : MenuTarget {
override val title: String get() = album.name
@@ -96,6 +98,7 @@ sealed interface MenuTarget {
data class Playlist(
val playlist: PlaylistModel,
override val coverArt: CoverArtRef? = null,
val download: ListDownloadStatus? = null,
) : MenuTarget {
override val title: String get() = playlist.name
@@ -117,6 +120,22 @@ fun MenuTarget.queueRef(): QueueRef? =
is MenuTarget.Artist -> null
}
fun MenuTarget.bulkRef(): Pair<DownloadList, String>? =
when (this) {
is MenuTarget.Album -> DownloadList.Album to album.id
is MenuTarget.Playlist -> DownloadList.Playlist to playlist.id
is MenuTarget.Artist -> DownloadList.Artist to artist.id
is MenuTarget.Song -> null
}
fun MenuTarget.listDownload(): ListDownloadStatus? =
when (this) {
is MenuTarget.Album -> download
is MenuTarget.Playlist -> download
is MenuTarget.Artist -> download
is MenuTarget.Song -> null
}
class ItemActions(
val playSong: ((SongModel) -> Unit)? = null,
val playAlbum: (AlbumModel) -> Unit = {},
@@ -128,9 +147,7 @@ class ItemActions(
val download: ((SongModel) -> Unit)? = null,
val cancelDownload: ((SongModel) -> Unit)? = null,
val deleteDownload: ((SongModel) -> Unit)? = null,
val downloadArtist: ((ArtistModel) -> Unit)? = null,
val cancelArtistDownload: ((ArtistModel) -> Unit)? = null,
val deleteArtistDownload: ((ArtistModel) -> Unit)? = null,
val bulkDownload: ((MenuTarget, BulkDownloadAction) -> Unit)? = null,
val setStar: (StarType, String, Boolean) -> Unit = { _, _, _ -> },
val viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null,
@@ -226,6 +243,7 @@ fun ItemContextMenu(
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playAlbum(target.album) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shuffleAlbum(target.album) } }
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.album.starred,
onSet = { starring -> actions.setStar(StarType.Album, target.album.id, starring) },
@@ -241,7 +259,7 @@ fun ItemContextMenu(
}
is MenuTarget.Artist -> {
BulkDownloadItem(target.download, target.artist, actions, ::dismiss)
BulkDownloadItem(target, actions, ::dismiss)
StarItem(
current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) },
@@ -253,6 +271,7 @@ fun ItemContextMenu(
MenuItem(Icons.Rounded.PlayArrow, "Play") { dismiss { actions.playPlaylist(target.playlist) } }
MenuItem(Icons.Rounded.Shuffle, "Shuffle") { dismiss { actions.shufflePlaylist(target.playlist) } }
queueItems()
BulkDownloadItem(target, actions, ::dismiss)
}
}
Spacer(Modifier.height(16.dp))
@@ -327,29 +346,23 @@ private fun DownloadItem(
@Composable
private fun BulkDownloadItem(
download: ListDownloadStatus?,
artist: ArtistModel,
target: MenuTarget,
actions: ItemActions,
dismiss: (() -> Unit) -> Unit,
) {
for (action in (download ?: ListDownloadStatus()).actions()) {
val bulk = actions.bulkDownload ?: return
for (action in (target.listDownload() ?: ListDownloadStatus()).actions()) {
when (action) {
BulkDownloadAction.Delete -> {
actions.deleteArtistDownload?.let { delete ->
MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { delete(artist) } }
}
MenuItem(Icons.Rounded.Delete, "Delete downloads") { dismiss { bulk(target, action) } }
}
BulkDownloadAction.Cancel -> {
actions.cancelArtistDownload?.let { cancel ->
MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { cancel(artist) } }
}
MenuItem(Icons.Rounded.Cancel, "Cancel downloads") { dismiss { bulk(target, action) } }
}
BulkDownloadAction.Download -> {
actions.downloadArtist?.let { start ->
MenuItem(Icons.Rounded.Download, "Download") { dismiss { start(artist) } }
}
MenuItem(Icons.Rounded.Download, "Download") { dismiss { bulk(target, action) } }
}
}
}
@@ -2,18 +2,21 @@ package com.subtracks.ui.library
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag
@@ -24,9 +27,11 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
@@ -47,6 +52,7 @@ fun AlbumsContent(
resetKey: Any? = null,
topInset: Dp = 0.dp,
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier,
) {
when {
@@ -84,9 +90,7 @@ fun AlbumsContent(
if (album != null) {
val art = coverArt(album.coverArt, true)
PrefetchArtworkSeeds(art)
CoverArt(
ref = art,
name = album.name,
Box(
modifier =
Modifier
.fillMaxWidth()
@@ -94,9 +98,19 @@ fun AlbumsContent(
.clip(RoundedCornerShape(2.dp))
.combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onLongClick(MenuTarget.Album(album, art)) },
onLongClick = { onLongClick(MenuTarget.Album(album, art, downloadStatuses[album.id])) },
).testTag(ALBUM_COVER_TAG),
)
) {
CoverArt(
ref = art,
name = album.name,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = downloadStatuses[album.id],
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
)
}
}
}
item(span = { GridItemSpan(maxLineSpan) }) {
@@ -78,13 +78,17 @@ import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme
@@ -144,8 +148,9 @@ fun ArtistDetailRoute(
val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<Long?>(null) }
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions =
ItemActions(
@@ -153,9 +158,18 @@ 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.onDownloadAction(BulkDownloadAction.Download) },
cancelArtistDownload = { viewModel.onDownloadAction(BulkDownloadAction.Cancel) },
deleteArtistDownload = { scope.launch { pendingDelete = viewModel.downloadedBytes() } },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
setStar = setStar,
viewAlbum = onViewAlbum,
)
@@ -168,17 +182,18 @@ fun ArtistDetailRoute(
coverArt = viewModel::coverArt,
onBack = onBack,
onAlbumClick = onAlbumClick,
albumDownloads = albumDownloads,
onAlbumLongClick = { contextMenuHost?.show(it, 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) } },
)
pendingDelete?.let { bytes ->
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = artist?.name.orEmpty(),
bytes = bytes,
onConfirm = { viewModel.onDownloadAction(BulkDownloadAction.Delete) },
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
}
@@ -195,6 +210,7 @@ fun ArtistDetailScreen(
coverArt: (String?, Boolean) -> CoverArtRef?,
onBack: () -> Unit,
onAlbumClick: (Album) -> Unit,
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
onAlbumLongClick: (MenuTarget) -> Unit = {},
onMore: () -> Unit = {},
starred: Boolean = false,
@@ -372,16 +388,24 @@ fun ArtistDetailScreen(
end = if (index % 2 == 1) 16.dp else 0.dp,
).combinedClickable(
onClick = { onAlbumClick(album) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail)) },
onLongClick = { onAlbumLongClick(MenuTarget.Album(album, thumbnail, albumDownloads[album.id])) },
).onSizeChanged { albumHeightPx = it.height.toFloat() },
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(2.dp)),
)
) {
CoverArt(
ref = art,
name = album.name,
thumbnailRef = thumbnail,
showPlaceholder = art == null,
modifier = Modifier.fillMaxSize(),
)
ListDownloadIndicator(
status = albumDownloads[album.id],
modifier = Modifier.align(Alignment.BottomEnd).padding(4.dp),
)
}
Text(
text = album.name,
style = MaterialTheme.typography.bodyMedium,
@@ -44,11 +44,23 @@ class ArtistDetailViewModel(
.flatMapLatest { downloadRepository.status(it, DownloadList.Artist, artistId) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ListDownloadStatus())
suspend fun downloadedBytes(): Long = downloadRepository.downloadedBytes(sourceId.first(), DownloadList.Artist, artistId)
val albumDownloads: StateFlow<Map<String, ListDownloadStatus>> =
sourceId
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
fun onDownloadAction(action: BulkDownloadAction) {
suspend fun downloadedBytes(
list: DownloadList,
refId: String,
): Long = downloadRepository.downloadedBytes(sourceId.first(), list, refId)
fun onDownloadAction(
list: DownloadList,
refId: String,
action: BulkDownloadAction,
) {
viewModelScope.launch {
downloadRepository.applyAction(sourceId.first(), DownloadList.Artist, artistId, action)
downloadRepository.applyAction(sourceId.first(), list, refId, action)
if (action == BulkDownloadAction.Delete) playbackController.refreshMediaItems()
}
}
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -24,9 +25,11 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.itemKey
import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
@@ -45,6 +48,7 @@ fun ArtistsContent(
resetKey: Any? = null,
topInset: Dp = 0.dp,
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier,
) {
when {
@@ -95,10 +99,15 @@ fun ArtistsContent(
modifier = Modifier.size(48.dp).clip(CircleShape),
)
},
trailingContent = {
ListDownloadIndicator(downloadStatuses[artist.id], Modifier.padding(end = 8.dp))
},
modifier =
Modifier.combinedClickable(
onClick = { onArtistClick(artist) },
onLongClick = { onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true))) },
onLongClick = {
onLongClick(MenuTarget.Artist(artist, coverArt(artist.coverArt, true), downloadStatuses[artist.id]))
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
@@ -62,6 +62,7 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
@@ -101,7 +102,10 @@ import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
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.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
@@ -112,14 +116,18 @@ import com.subtracks.data.prefs.StarredFilter
import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.DeleteDownloadsDialog
import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.PendingDownloadDelete
import com.subtracks.ui.components.bulkRef
import com.subtracks.ui.components.statusBarScrim
import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.librarySurfaceColor
import com.subtracks.ui.theme.playerSurfaceColor
import com.subtracks.ui.theme.rememberArtworkColors
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import kotlin.math.abs
@@ -208,6 +216,11 @@ fun LibraryRoute(
}
val playback by playbackController.state.collectAsStateWithLifecycle()
val artwork = rememberArtworkColors(playbackController.coverArt(playback.item, thumbnail = true))
val albumDownloads by viewModel.albumDownloads.collectAsStateWithLifecycle()
val artistDownloads by viewModel.artistDownloads.collectAsStateWithLifecycle()
val playlistDownloads by viewModel.playlistDownloads.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var pendingDelete by remember { mutableStateOf<PendingDownloadDelete?>(null) }
val itemActions =
ItemActions(
playAlbum = { viewModel.playAlbum(it.id) },
@@ -216,6 +229,18 @@ fun LibraryRoute(
shufflePlaylist = { viewModel.shufflePlaylist(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(it.sourceId, it.kind, it.refId) },
bulkDownload = { target, action ->
target.bulkRef()?.let { (list, refId) ->
if (action == BulkDownloadAction.Delete) {
scope.launch {
pendingDelete =
PendingDownloadDelete(target.title, list, refId, viewModel.downloadedBytes(list, refId))
}
} else {
viewModel.onDownloadAction(list, refId, action)
}
}
},
setStar = setStar,
viewAlbum = onViewAlbum,
viewArtist = onViewArtist,
@@ -257,7 +282,19 @@ fun LibraryRoute(
},
search = search,
onSearchChange = { viewModel.setSearch(listTab, it) },
albumDownloads = albumDownloads,
artistDownloads = artistDownloads,
playlistDownloads = playlistDownloads,
)
pendingDelete?.let { pending ->
DeleteDownloadsDialog(
name = pending.name,
bytes = pending.bytes,
onConfirm = { viewModel.onDownloadAction(pending.list, pending.refId, BulkDownloadAction.Delete) },
onDismiss = { pendingDelete = null },
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -288,6 +325,9 @@ fun LibraryScreen(
search: String = "",
onSearchChange: (String) -> Unit = {},
artwork: ArtworkColors? = null,
albumDownloads: Map<String, ListDownloadStatus> = emptyMap(),
artistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
playlistDownloads: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier,
) {
val pagerState =
@@ -373,6 +413,7 @@ fun LibraryScreen(
resetKey = resetKey,
topInset = listTopInset,
onSync = onSync,
downloadStatuses = albumDownloads,
)
}
@@ -391,6 +432,7 @@ fun LibraryScreen(
resetKey = resetKey,
topInset = listTopInset,
onSync = onSync,
downloadStatuses = artistDownloads,
)
}
@@ -409,6 +451,7 @@ fun LibraryScreen(
resetKey = resetKey,
topInset = listTopInset,
onSync = onSync,
downloadStatuses = playlistDownloads,
)
}
}
@@ -6,7 +6,10 @@ import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.BulkDownloadAction
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.prefs.AlbumSort
import com.subtracks.data.prefs.ArtistSort
@@ -14,6 +17,7 @@ import com.subtracks.data.prefs.LibraryListTab
import com.subtracks.data.prefs.ListQuery
import com.subtracks.data.prefs.PlaylistSort
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.sync.SyncManager
@@ -26,6 +30,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
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
@@ -38,6 +43,7 @@ class LibraryViewModel(
private val syncManager: SyncManager,
private val playbackController: PlaybackController,
private val userPreferences: UserPreferences,
private val downloadRepository: DownloadRepository,
) : ViewModel() {
private val listQueries: Map<LibraryListTab, StateFlow<ListQuery>> =
LibraryListTab.entries.associateWith { tab ->
@@ -87,6 +93,39 @@ class LibraryViewModel(
.map { it == SyncStatus.Running }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
val albumDownloads: StateFlow<Map<String, ListDownloadStatus>> =
activeSourceId
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Album) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
val artistDownloads: StateFlow<Map<String, ListDownloadStatus>> =
activeSourceId
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Artist) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
val playlistDownloads: StateFlow<Map<String, ListDownloadStatus>> =
activeSourceId
.flatMapLatest { downloadRepository.statuses(it, DownloadList.Playlist) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyMap())
suspend fun downloadedBytes(
list: DownloadList,
refId: String,
): Long = downloadRepository.downloadedBytes(activeSourceId.first(), list, refId)
fun onDownloadAction(
list: DownloadList,
refId: String,
action: BulkDownloadAction,
) {
viewModelScope.launch {
downloadRepository.applyAction(activeSourceId.first(), list, refId, action)
if (action == BulkDownloadAction.Delete) playbackController.refreshMediaItems()
}
}
fun listQuery(tab: LibraryListTab): StateFlow<ListQuery> = listQueries.getValue(tab)
fun setListQuery(
@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
@@ -23,10 +24,12 @@ import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
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.ui.components.CoverArt
import com.subtracks.ui.components.EmptyState
import com.subtracks.ui.components.FilteredEmptyState
import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange
@@ -44,6 +47,7 @@ fun PlaylistsContent(
resetKey: Any? = null,
topInset: Dp = 0.dp,
onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
modifier: Modifier = Modifier,
) {
when {
@@ -93,10 +97,17 @@ fun PlaylistsContent(
modifier = Modifier.size(48.dp).clip(RoundedCornerShape(8.dp)),
)
},
trailingContent = {
ListDownloadIndicator(downloadStatuses[playlist.id], Modifier.padding(end = 8.dp))
},
modifier =
Modifier.combinedClickable(
onClick = { onPlaylistClick(playlist) },
onLongClick = { onLongClick(MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true))) },
onLongClick = {
onLongClick(
MenuTarget.Playlist(playlist, coverArt(playlist.coverArt, true), downloadStatuses[playlist.id]),
)
},
),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
@@ -772,7 +772,9 @@ class DownloadRepositoryTest {
),
),
)
db.libraryDao().upsertArtists(listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)))
db.libraryDao().upsertArtists(
listOf(Artist(sourceId = 1, id = "ar2", name = "Other", albumCount = 1, starred = null, coverArt = null)),
)
db.downloadDao().upsert(SongDownload(1, "s1", DownloadStatus.Completed))
db.downloadDao().upsert(SongDownload(1, "s3", DownloadStatus.Queued))
}
@@ -0,0 +1,32 @@
package com.subtracks.ui.components
import com.subtracks.data.model.ListDownloadStatus
import org.junit.Assert.assertEquals
import org.junit.Test
class DownloadIndicatorTest {
@Test
fun anUntouchedListShowsNothing() {
assertEquals(DownloadIndicator.Hidden, ListDownloadStatus().indicator())
}
@Test
fun aListWithNothingStartedButNoSongsShowsNothing() {
assertEquals(DownloadIndicator.Hidden, ListDownloadStatus(total = 4).indicator())
}
@Test
fun aQueuedButNotYetDownloadedListShowsProgress() {
assertEquals(DownloadIndicator.InProgress, ListDownloadStatus(total = 4, downloading = 4).indicator())
}
@Test
fun aPartlyDownloadedListShowsProgress() {
assertEquals(DownloadIndicator.InProgress, ListDownloadStatus(total = 4, downloaded = 2).indicator())
}
@Test
fun aFullyDownloadedListShowsDone() {
assertEquals(DownloadIndicator.Complete, ListDownloadStatus(total = 4, downloaded = 4).indicator())
}
}