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:
austinried
2026-10-04 00:30:02 +00:00
parent 9266fc5edc
commit b2b92a388e
14 changed files with 527 additions and 18 deletions
@@ -4,6 +4,7 @@ import androidx.room3.Dao
import androidx.room3.Query import androidx.room3.Query
import androidx.room3.Upsert import androidx.room3.Upsert
import com.subtracks.data.model.DownloadArtwork import com.subtracks.data.model.DownloadArtwork
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -15,6 +16,52 @@ interface DownloadDao {
@Query("SELECT * FROM song_downloads") @Query("SELECT * FROM song_downloads")
suspend fun all(): List<SongDownload> 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( @Query(
"SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " + "SELECT d.sourceId AS sourceId, s.albumId AS albumId, s.artistId AS artistId, " +
"al.id AS albumRow, al.coverArt AS albumCoverArt, " + "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) } 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( data class DownloadArtwork(
val sourceId: Long, val sourceId: Long,
val albumId: String?, val albumId: String?,
@@ -8,7 +8,9 @@ import com.subtracks.data.download.DownloadEngine
import com.subtracks.data.download.EngineDownload import com.subtracks.data.download.EngineDownload
import com.subtracks.data.download.EngineRequest import com.subtracks.data.download.EngineRequest
import com.subtracks.data.download.EngineStatus import com.subtracks.data.download.EngineStatus
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
@@ -103,7 +105,7 @@ class DownloadRepository(
if (!enqueueIfAbsent(sourceId, songId)) return@withContext if (!enqueueIfAbsent(sourceId, songId)) return@withContext
// The media is already with the platform, so this only has to happen while we are still // The media is already with the platform, so this only has to happen while we are still
// in the foreground: artwork is fetched by us, and it must not hold up the download. // in the foreground: artwork is fetched by us, and it must not hold up the download.
fetchArtworkSafely(sourceId, songId) fetchArtworkSafely(sourceId, listOf(songId))
} }
private suspend fun enqueueIfAbsent( private suspend fun enqueueIfAbsent(
@@ -124,14 +126,66 @@ class DownloadRepository(
songId: String, songId: String,
) = withContext(dispatcher) { ) = withContext(dispatcher) {
mutex.withLock { mutex.withLock {
val row = db.downloadDao().find(sourceId, songId) ?: return@withLock if (!removeRow(sourceId, songId)) return@withLock
row.engineId?.let(engine::cancel)
file(sourceId, songId).delete()
db.downloadDao().delete(sourceId, songId)
sweep(db.downloadDao().all()) 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) = suspend fun removeSource(sourceId: Long) =
withContext(dispatcher) { withContext(dispatcher) {
mutex.withLock { mutex.withLock {
@@ -155,7 +209,7 @@ class DownloadRepository(
ensurePolling() ensurePolling()
resumed resumed
} }
resumed.forEach { fetchArtworkSafely(it.sourceId, it.songId) } resumed.forEach { fetchArtworkSafely(it.sourceId, listOf(it.songId)) }
} }
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -284,12 +338,44 @@ class DownloadRepository(
return resumed return resumed
} }
private suspend fun fetchArtworkSafely( private suspend fun removeRow(
sourceId: Long, sourceId: Long,
songId: String, 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 { try {
withTimeoutOrNull(ART_TIMEOUT_MS) { fetchArtwork(sourceId, songId) } withTimeoutOrNull(ART_TIMEOUT_MS) { songIds.forEach { fetchArtwork(sourceId, it) } }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (_: Exception) { } catch (_: Exception) {
@@ -343,7 +429,7 @@ class DownloadRepository(
} }
// Fetching artwork here would hold up the next tick, and with it the progress // Fetching artwork here would hold up the next tick, and with it the progress
// the queue is mirrored from. // the queue is mirrored from.
resumed.forEach { row -> scope.launch { fetchArtworkSafely(row.sourceId, 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 { SettingsViewModel(get(), get(), get()) }
viewModel { AddSourceViewModel(get(), get()) } viewModel { AddSourceViewModel(get(), get()) }
viewModel { params -> AlbumDetailViewModel(get(), get(), get(), get(), params.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 { params -> PlaylistDetailViewModel(get(), get(), get(), get(), params.get()) }
viewModel { QueueViewModel(get(), get()) } viewModel { QueueViewModel(get(), get()) }
} }
@@ -27,10 +27,13 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.Download import androidx.compose.material.icons.rounded.Download
import androidx.compose.material.icons.rounded.MoreHoriz import androidx.compose.material.icons.rounded.MoreHoriz
import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Shuffle import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.ui.theme.ArtworkColors import com.subtracks.ui.theme.ArtworkColors
import com.subtracks.ui.theme.ArtworkTheme import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient 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 @Composable
fun HeroHeader( fun HeroHeader(
art: CoverArtRef?, art: CoverArtRef?,
@@ -212,7 +225,8 @@ fun HeroHeader(
hasSongs: Boolean, hasSongs: Boolean,
onPlay: () -> Unit, onPlay: () -> Unit,
onShuffle: () -> Unit, onShuffle: () -> Unit,
onDownload: () -> Unit, downloadStatus: ListDownloadStatus,
onDownloadAction: (BulkDownloadAction) -> Unit,
onMore: () -> Unit, onMore: () -> Unit,
topInset: Dp, topInset: Dp,
controlsModifier: Modifier = Modifier, controlsModifier: Modifier = Modifier,
@@ -271,9 +285,32 @@ fun HeroHeader(
modifier = controlsModifier.fillMaxWidth(), modifier = controlsModifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton(onClick = onDownload, modifier = Modifier.padding(horizontal = 8.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)) Icon(Icons.Rounded.Download, contentDescription = "Download", modifier = Modifier.offset(y = 1.5.dp))
} }
}
}
Row( Row(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.Center,
@@ -47,6 +47,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.CoverArtRef
import com.subtracks.data.model.DownloadStatus import com.subtracks.data.model.DownloadStatus
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
@@ -84,6 +85,7 @@ sealed interface MenuTarget {
data class Artist( data class Artist(
val artist: ArtistModel, val artist: ArtistModel,
override val coverArt: CoverArtRef? = null, override val coverArt: CoverArtRef? = null,
val download: ListDownloadStatus? = null,
) : MenuTarget { ) : MenuTarget {
override val title: String get() = artist.name override val title: String get() = artist.name
@@ -125,7 +127,10 @@ class ItemActions(
val download: ((SongModel) -> Unit)? = null, val download: ((SongModel) -> Unit)? = null,
val cancelDownload: ((SongModel) -> Unit)? = null, val cancelDownload: ((SongModel) -> Unit)? = null,
val deleteDownload: ((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 viewAlbum: ((String) -> Unit)? = null,
val viewArtist: ((String) -> Unit)? = null, val viewArtist: ((String) -> Unit)? = null,
) )
@@ -235,6 +240,7 @@ fun ItemContextMenu(
} }
is MenuTarget.Artist -> { is MenuTarget.Artist -> {
BulkDownloadItem(target.download, target.artist, actions, ::dismiss)
StarItem( StarItem(
current = target.artist.starred, current = target.artist.starred,
onSet = { starring -> actions.setStar(StarType.Artist, target.artist.id, starring) }, 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 @Composable
private fun StarItem( private fun StarItem(
current: Long?, current: Long?,
@@ -23,11 +23,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc import com.subtracks.data.model.Disc
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.BulkDownloadAction
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
@@ -57,6 +59,7 @@ fun AlbumDetailRoute(
val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList()) val songs by viewModel.songs.collectAsStateWithLifecycle(initialValue = emptyList())
val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList()) val discs by viewModel.discs.collectAsStateWithLifecycle(initialValue = emptyList())
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
@@ -80,6 +83,14 @@ fun AlbumDetailRoute(
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(shortcutArt ?: viewModel.coverArt(album?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads, downloads = downloads,
downloadStatus = downloadStatus,
onDownloadAction = { action ->
when (action) {
BulkDownloadAction.Download -> viewModel.downloadAll()
BulkDownloadAction.Cancel -> viewModel.cancelDownloads()
BulkDownloadAction.Delete -> viewModel.deleteDownloads()
}
},
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) }, onSongLongClick = { contextMenuHost?.show(it, actions) },
@@ -108,7 +119,8 @@ fun AlbumDetailScreen(
onSongLongClick: (MenuTarget) -> Unit = {}, onSongLongClick: (MenuTarget) -> Unit = {},
onShuffle: () -> Unit = {}, onShuffle: () -> Unit = {},
onPlay: () -> Unit = { onSongClick(0) }, onPlay: () -> Unit = { onSongClick(0) },
onDownload: () -> Unit = {}, downloadStatus: ListDownloadStatus = ListDownloadStatus(),
onDownloadAction: (BulkDownloadAction) -> Unit = {},
onMore: () -> Unit = {}, onMore: () -> Unit = {},
onArtistClick: (() -> Unit)? = null, onArtistClick: (() -> Unit)? = null,
playingSongId: String? = null, playingSongId: String? = null,
@@ -161,7 +173,8 @@ fun AlbumDetailScreen(
hasSongs = songs.isNotEmpty(), hasSongs = songs.isNotEmpty(),
onPlay = onPlay, onPlay = onPlay,
onShuffle = onShuffle, onShuffle = onShuffle,
onDownload = onDownload, downloadStatus = downloadStatus,
onDownloadAction = onDownloadAction,
onMore = onMore, onMore = onMore,
topInset = topInset, topInset = topInset,
controlsModifier = controlsModifier, controlsModifier = controlsModifier,
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.Disc 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.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.repo.DownloadRepository import com.subtracks.data.repo.DownloadRepository
@@ -13,10 +15,12 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -37,6 +41,26 @@ class AlbumDetailViewModel(
val downloads: StateFlow<Map<String, SongDownload>> = downloadRepository.states() 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( fun coverArt(
coverArt: String?, coverArt: String?,
thumbnail: Boolean, thumbnail: Boolean,
@@ -138,6 +138,7 @@ fun ArtistDetailRoute(
val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList()) val albums by viewModel.albums.collectAsStateWithLifecycle(initialValue = emptyList())
val art by viewModel.art.collectAsStateWithLifecycle() val art by viewModel.art.collectAsStateWithLifecycle()
val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle() val artThumbnail by viewModel.artThumbnail.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } } val shortcutArt = remember(coverArtId) { coverArtId?.let { viewModel.coverArt(it, true) } }
val actions = val actions =
ItemActions( ItemActions(
@@ -145,6 +146,9 @@ fun ArtistDetailRoute(
shuffleAlbum = { viewModel.shuffleAlbum(it.id) }, shuffleAlbum = { viewModel.shuffleAlbum(it.id) },
playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) }, playNext = { playbackController.playNext(it.sourceId, it.kind, it.refId) },
addToQueue = { playbackController.addToQueue(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, setStar = setStar,
viewAlbum = onViewAlbum, viewAlbum = onViewAlbum,
) )
@@ -158,7 +162,7 @@ fun ArtistDetailRoute(
onBack = onBack, onBack = onBack,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onAlbumLongClick = { contextMenuHost?.show(it, actions) }, 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, starred = artist?.starred != null,
onToggleStar = artist?.let { a -> { setStar(StarType.Artist, a.id, a.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.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.CoverArtRef 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.LibraryRepository
import com.subtracks.data.repo.SourceRepository import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
@@ -12,11 +15,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -24,6 +29,7 @@ class ArtistDetailViewModel(
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val sourceRepository: SourceRepository, private val sourceRepository: SourceRepository,
private val playbackController: PlaybackController, private val playbackController: PlaybackController,
private val downloadRepository: DownloadRepository,
private val artistId: String, private val artistId: String,
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
@@ -32,6 +38,26 @@ class ArtistDetailViewModel(
val albums: Flow<List<Album>> = sourceId.flatMapLatest { libraryRepository.artistAlbums(it, artistId) } 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) private val _art = MutableStateFlow<CoverArtRef?>(null)
val art: StateFlow<CoverArtRef?> = _art val art: StateFlow<CoverArtRef?> = _art
@@ -12,12 +12,14 @@ import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemKey import androidx.paging.compose.itemKey
import com.subtracks.data.model.CoverArtRef import com.subtracks.data.model.CoverArtRef
import com.subtracks.data.model.ListDownloadStatus
import com.subtracks.data.model.Playlist import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.QueueKind import com.subtracks.data.model.QueueKind
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.source.StarType import com.subtracks.data.source.StarType
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import com.subtracks.ui.components.BulkDownloadAction
import com.subtracks.ui.components.ContextMenuHost import com.subtracks.ui.components.ContextMenuHost
import com.subtracks.ui.components.HeroDetailScaffold import com.subtracks.ui.components.HeroDetailScaffold
import com.subtracks.ui.components.HeroHeader import com.subtracks.ui.components.HeroHeader
@@ -45,6 +47,7 @@ fun PlaylistDetailRoute(
) { ) {
val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null) val playlist by viewModel.playlist.collectAsStateWithLifecycle(initialValue = null)
val downloads by viewModel.downloads.collectAsStateWithLifecycle() val downloads by viewModel.downloads.collectAsStateWithLifecycle()
val downloadStatus by viewModel.downloadStatus.collectAsStateWithLifecycle()
val playback by playbackController.state.collectAsStateWithLifecycle() val playback by playbackController.state.collectAsStateWithLifecycle()
val context = playback.context val context = playback.context
val actions = val actions =
@@ -66,6 +69,14 @@ fun PlaylistDetailRoute(
coverArt = viewModel::coverArt, coverArt = viewModel::coverArt,
artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS), artwork = rememberArtworkColors(viewModel.coverArt(playlist?.coverArt, true), THEME_TRANSITION_MS),
downloads = downloads, downloads = downloads,
downloadStatus = downloadStatus,
onDownloadAction = { action ->
when (action) {
BulkDownloadAction.Download -> viewModel.downloadAll()
BulkDownloadAction.Cancel -> viewModel.cancelDownloads()
BulkDownloadAction.Delete -> viewModel.deleteDownloads()
}
},
onBack = onBack, onBack = onBack,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onSongLongClick = { contextMenuHost?.show(it, actions) }, onSongLongClick = { contextMenuHost?.show(it, actions) },
@@ -83,6 +94,8 @@ fun PlaylistDetailScreen(
coverArt: (String?, Boolean) -> CoverArtRef?, coverArt: (String?, Boolean) -> CoverArtRef?,
artwork: ArtworkColors?, artwork: ArtworkColors?,
downloads: Map<String, SongDownload> = emptyMap(), downloads: Map<String, SongDownload> = emptyMap(),
downloadStatus: ListDownloadStatus = ListDownloadStatus(),
onDownloadAction: (BulkDownloadAction) -> Unit = {},
onBack: () -> Unit, onBack: () -> Unit,
onSongClick: (Int) -> Unit, onSongClick: (Int) -> Unit,
onSongLongClick: (MenuTarget) -> Unit = {}, onSongLongClick: (MenuTarget) -> Unit = {},
@@ -106,7 +119,8 @@ fun PlaylistDetailScreen(
hasSongs = songs.itemCount > 0, hasSongs = songs.itemCount > 0,
onPlay = onPlay, onPlay = onPlay,
onShuffle = onShuffle, onShuffle = onShuffle,
onDownload = {}, downloadStatus = downloadStatus,
onDownloadAction = onDownloadAction,
onMore = onMore, onMore = onMore,
topInset = topInset, topInset = topInset,
controlsModifier = controlsModifier, controlsModifier = controlsModifier,
@@ -5,6 +5,8 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.PagingData import androidx.paging.PagingData
import androidx.paging.cachedIn import androidx.paging.cachedIn
import com.subtracks.data.model.CoverArtRef 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.Playlist
import com.subtracks.data.model.PlaylistSongItem import com.subtracks.data.model.PlaylistSongItem
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
@@ -15,10 +17,12 @@ import com.subtracks.data.repo.SourceRepository
import com.subtracks.playback.PlaybackController import com.subtracks.playback.PlaybackController
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@@ -41,6 +45,26 @@ class PlaylistDetailViewModel(
val downloads: StateFlow<Map<String, SongDownload>> = val downloads: StateFlow<Map<String, SongDownload>> =
downloadRepository.states() 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) { fun download(song: Song) {
viewModelScope.launch { downloadRepository.download(song.sourceId, song.id) } 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.download.FakeDownloadEngine
import com.subtracks.data.model.Album import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist import com.subtracks.data.model.Artist
import com.subtracks.data.model.DownloadList
import com.subtracks.data.model.DownloadStatus 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.Song
import com.subtracks.data.model.SongDownload import com.subtracks.data.model.SongDownload
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.model.SubsonicSource import com.subtracks.data.model.SubsonicSource
import com.subtracks.data.model.coverArtKey import com.subtracks.data.model.coverArtKey
import com.subtracks.data.prefs.fakeUserPreferences import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -418,6 +423,97 @@ class DownloadRepositoryTest {
assertNotNull(artwork.uri(1, coverArtKey(1, ALBUM_ART, false))) 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?) = private fun album(coverArt: String?) =
Album( Album(
sourceId = 1, 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,
)
}
}
}
}