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