feat: implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists

This commit is contained in:
Kingkor Roy Tirtho 2026-07-07 17:31:16 +06:00
parent 8f9c2540a2
commit f05f0727b6
8 changed files with 1043 additions and 703 deletions

View File

@ -29,6 +29,7 @@ import dev.krtirtho.spotube.core.server.MatchedTracksRepository
import dev.krtirtho.spotube.core.server.StreamingUrlRepository
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.modules.artist.ArtistRepository
import dev.krtirtho.spotube.modules.artist.ArtistViewModel
import dev.krtirtho.spotube.modules.album.AlbumRepository
import dev.krtirtho.spotube.modules.album.AlbumViewModel
@ -127,12 +128,13 @@ val sharedModules = module {
viewModelOf(::SavedTracksViewModel)
// Artist
singleOf(::ArtistRepository)
viewModel { (artistId: String) ->
ArtistViewModel(
artistId = artistId,
pluginManager = get(),
savedTracksRepository = get(),
repository = get(),
libraryRepository = get(),
savedTracksRepository = get(),
audioPlayerQueue = get(),
)
}

View File

@ -146,6 +146,7 @@ fun TrackList(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false,
scrollable: Boolean = true,
) {
var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -207,145 +208,7 @@ fun TrackList(
val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) {
item {
headerContent()
}
}
if (!simplified)
item {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
.heightIn(max = 60.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
) {
if (showIndex || isSelectionMode) {
val allSelected =
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
val anySelected = selectedTrackIds.isNotEmpty()
val headerState = when {
allSelected -> CheckBoxState.SELECTED
anySelected -> CheckBoxState.INDETERMINATE
else -> CheckBoxState.UNSELECTED
}
CheckBox(
state = headerState,
onClick = {
if (allSelected) {
isSelectionMode = false
selectedTrackIds = emptySet()
} else {
isSelectionMode = true
selectedTrackIds = visibleTracks.map { it.id }.toSet()
}
},
)
}
Box(
modifier = Modifier
.weight(1f)
) {
TextField(
value = filterQuery,
onValueChange = { filterQuery = it },
modifier = Modifier
.widthIn(max = 400.dp)
.align(Alignment.CenterEnd),
placeholder = { TextWithShimmer("Filter") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Filter",
)
},
singleLine = true,
)
}
ButtonGroup {
Box(
contentAlignment = Alignment.Center,
) {
AdaptiveDropdownBottomSheet(
items = TrackSortOption.entries.map { option ->
AdaptiveMenuItem(
label = option.label,
onClick = { sortBy = option },
selected = sortBy == option,
)
},
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.IconsaxSort,
contentDescription = "Sort",
)
}
},
)
}
ButtonGroupDivider()
Box(
contentAlignment = Alignment.Center,
) {
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
visibleTracks.filter { selectedTrackIds.contains(it.id) }
} else {
visibleTracks
}
val trackCount = targetTracks.size
val isAll =
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
AdaptiveDropdownBottomSheet(
items = listOf(
AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) },
),
),
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Bulk actions",
)
}
},
)
}
}
}
}
item(key = "track-card") {
val trackCardContent: @Composable () -> Unit = {
Card(
modifier = Modifier
.fillMaxWidth()
@ -466,6 +329,147 @@ fun TrackList(
}
}
val filterSortRow: @Composable () -> Unit = {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
.heightIn(max = 60.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
) {
if (showIndex || isSelectionMode) {
val allSelected =
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
val anySelected = selectedTrackIds.isNotEmpty()
val headerState = when {
allSelected -> CheckBoxState.SELECTED
anySelected -> CheckBoxState.INDETERMINATE
else -> CheckBoxState.UNSELECTED
}
CheckBox(
state = headerState,
onClick = {
if (allSelected) {
isSelectionMode = false
selectedTrackIds = emptySet()
} else {
isSelectionMode = true
selectedTrackIds = visibleTracks.map { it.id }.toSet()
}
},
)
}
Box(
modifier = Modifier
.weight(1f)
) {
TextField(
value = filterQuery,
onValueChange = { filterQuery = it },
modifier = Modifier
.widthIn(max = 400.dp)
.align(Alignment.CenterEnd),
placeholder = { TextWithShimmer("Filter") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Filter",
)
},
singleLine = true,
)
}
ButtonGroup {
Box(
contentAlignment = Alignment.Center,
) {
AdaptiveDropdownBottomSheet(
items = TrackSortOption.entries.map { option ->
AdaptiveMenuItem(
label = option.label,
onClick = { sortBy = option },
selected = sortBy == option,
)
},
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.IconsaxSort,
contentDescription = "Sort",
)
}
},
)
}
ButtonGroupDivider()
Box(
contentAlignment = Alignment.Center,
) {
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
visibleTracks.filter { selectedTrackIds.contains(it.id) }
} else {
visibleTracks
}
val trackCount = targetTracks.size
val isAll =
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
AdaptiveDropdownBottomSheet(
items = listOf(
AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) },
),
),
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Bulk actions",
)
}
},
)
}
}
}
}
if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) {
item {
headerContent()
}
}
if (!simplified) {
item { filterSortRow() }
}
item(key = "track-card") { trackCardContent() }
if (footerContent != null) {
item {
footerContent()
@ -489,7 +493,40 @@ fun TrackList(
}
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
}
} else {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) {
headerContent()
}
if (!simplified) {
filterSortRow()
}
trackCardContent()
if (footerContent != null) {
footerContent()
}
}
if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet(
track = track,
state = trackOptionsState(track),
onDismiss = { selectedTrackForOptions = null },
onAction = { action ->
onTrackOptionsAction(track, action)
selectedTrackForOptions = null
},
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)

View File

@ -0,0 +1,140 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package dev.krtirtho.spotube.modules.artist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.modules.plugin.PluginManager
import io.github.reactivecircus.cache4k.Cache
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class ArtistRepository(
private val pluginManager: PluginManager
) {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val plugin
get() = pluginManager.selectedMetadataPlugin.value
private val overviewCache = Cache.Builder<String, MetadataArtistOverview>().build()
private val albumsCache =
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataAlbum.Detailed>>().build()
private val relatedArtistsCache =
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataArtist.Basic>>().build()
private val featuredPlaylistsCache =
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataPlaylist>>().build()
private val topTracksCache = Cache.Builder<String, List<MetadataTrack>>().build()
init {
scope.launch {
pluginManager.selectedMetadataPlugin
.filterNotNull()
.flatMapLatest { it.loggedInFlow }
.distinctUntilChanged()
.collect {
invalidateCaches()
}
}
}
fun invalidateCaches() {
overviewCache.invalidateAll()
albumsCache.invalidateAll()
relatedArtistsCache.invalidateAll()
featuredPlaylistsCache.invalidateAll()
topTracksCache.invalidateAll()
}
suspend fun artistOverview(artistId: String) = plugin?.let { plugin ->
overviewCache.get(artistId) {
pluginManager.withScope {
plugin.use {
metadataArtistAPI.artistOverview(artistId)
}
}
}
}
suspend fun topTracks(artistId: String) = plugin?.let { plugin ->
topTracksCache.get(artistId) {
pluginManager.withScope {
plugin.use {
metadataArtistAPI.getArtistTop10Tracks(artistId)
}
}
}
}
suspend fun albums(
artistId: String,
paginationStrategy: PaginationStrategy? = null
) = plugin?.let { plugin ->
albumsCache.get(
artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
) {
pluginManager.withScope {
plugin.use {
metadataArtistAPI.getArtistAlbums(artistId, paginationStrategy)
}
}
}
}
suspend fun relatedArtists(
artistId: String,
paginationStrategy: PaginationStrategy? = null
) = plugin?.let { plugin ->
relatedArtistsCache.get(
artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
) {
pluginManager.withScope {
plugin.use {
metadataArtistAPI.relatedArtists(artistId, paginationStrategy)
}
}
}
}
suspend fun featuredPlaylists(
artistId: String,
paginationStrategy: PaginationStrategy? = null
) = plugin?.let { plugin ->
featuredPlaylistsCache.get(
artistId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
) {
pluginManager.withScope {
plugin.use {
metadataArtistAPI.featuredPlaylists(artistId, paginationStrategy)
}
}
}
}
}

View File

@ -24,12 +24,13 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -45,9 +46,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -59,12 +62,12 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import compose.icons.FeatherIcons
import compose.icons.feathericons.Heart
import compose.icons.feathericons.Play
import compose.icons.feathericons.PlusSquare
import compose.icons.feathericons.User
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
@ -75,15 +78,19 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.share.ShareService
import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard
import dev.krtirtho.spotube.core.ui.component.PlaylistCard
import dev.krtirtho.spotube.core.ui.component.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard
import dev.krtirtho.spotube.core.ui.component.dragScrollable
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel
import dev.krtirtho.spotube.modules.library.LibraryRepository
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
@ -101,9 +108,7 @@ fun ArtistScreen(artistId: String) {
)
val navigationCommands = koinInject<NavigationCommands>()
val artistInfo by viewModel.artistInfo.collectAsStateWithLifecycle()
val topTracksState by viewModel.topTracks.collectAsStateWithLifecycle()
val albumsState by viewModel.albums.collectAsStateWithLifecycle()
val state by viewModel.state.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.collectAsStateWithLifecycle()
@ -123,8 +128,6 @@ fun ArtistScreen(artistId: String) {
)
}
val artist = artistInfo.artist
fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) {
@ -141,76 +144,227 @@ fun ArtistScreen(artistId: String) {
Scaffold(
topBar = { ApplicationMainBar() }
) { innerPadding ->
TrackList(
modifier = Modifier.padding(innerPadding),
headerContent = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(20.dp),
when (val currentState = state) {
is ArtistScreenState.Loading -> {
ArtistLoadingContent(innerPadding)
}
is ArtistScreenState.Error -> {
ArtistErrorContent(
innerPadding = innerPadding,
message = currentState.message,
onRetry = { viewModel.refresh() }
)
}
is ArtistScreenState.Loaded -> {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
ArtistHeaderCard(
artist = artist,
isSaved = savedArtistIds.contains(artistId),
isLoading = artistInfo.isLoading,
isSaving = artistInfo.isSaving,
error = artistInfo.error,
artist = currentState.artist,
isSaved = savedArtistIds.contains(currentState.artist.id),
onFollowClick = viewModel::toggleSavedArtist,
)
}
ArtistAlbumsSection(
albums = albumsState.items,
isLoading = albumsState.isLoading,
error = albumsState.error,
hasMore = albumsState.hasNextPage,
onViewAll = viewModel::loadNextAlbumsPage,
)
item {
TopTracksHeader(
onPlay = viewModel::playTopTracks,
onAddToQueue = viewModel::addTopTracksToQueue,
)
}
},
tracks = topTracksState.items,
error = topTracksState.error,
item {
TrackList(
tracks = currentState.topTracks,
error = null,
hasMore = false,
isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(),
isLoading = false,
isLoadingNextPage = false,
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
onTrackClick = viewModel::playTopTracksFromTrack,
onTrackOptionsAction = ::handleTrackOptionsAction,
trackOptionsState = ::getTrackOptionsState,
onArtistClick = { trackArtist -> navigationCommands.navigateTo(Routes.Artist(trackArtist.id)) },
onAlbumClick = { album -> navigationCommands.navigateTo(Routes.Album(album.id)) },
onArtistClick = { trackArtist ->
navigationCommands.navigateTo(
Routes.Artist(
trackArtist.id
)
)
},
onAlbumClick = { album ->
navigationCommands.navigateTo(
Routes.Album(
album.id
)
)
},
onLoadNextPage = {},
simplified = true,
onBulkDownload = { tracks ->
downloadsViewModel.downloadTracks(tracks)
},
onBulkAddToQueue = { tracks ->
viewModel.addTracksToQueue(tracks)
},
onBulkPlayNext = { tracks ->
viewModel.playTracksNext(tracks)
},
scrollable = false,
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
)
}
if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) {
item {
AlbumsSection(
albums = currentState.albums,
onAlbumClick = { album ->
navigationCommands.navigateTo(
Routes.Album(
album.id
)
)
},
onLoadMore = { viewModel.loadMoreAlbums() },
)
}
}
if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) {
item {
RelatedArtistsSection(
artists = currentState.relatedArtists,
onArtistClick = { artist ->
navigationCommands.navigateTo(
Routes.Artist(
artist.id
)
)
},
onLoadMore = { viewModel.loadMoreRelatedArtists() },
)
}
}
if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) {
item {
FeaturedPlaylistsSection(
playlists = currentState.featuredPlaylists,
onPlaylistClick = { playlist ->
navigationCommands.navigateTo(
Routes.Playlist(
playlist.id
)
)
},
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() },
)
}
}
}
}
}
}
}
@Composable
private fun ArtistLoadingContent(innerPadding: PaddingValues) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
SkeletonTree(true) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier
.size(200.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
)
}
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
)
}
LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 16.dp),
) {
items(4) {
PlayableCard(
title = "Item Title",
subtitle = "Subtitle",
imageURL = "https://placehold.co/600x400",
)
}
}
}
}
}
}
}
@Composable
private fun ArtistErrorContent(
innerPadding: PaddingValues,
message: String,
onRetry: () -> Unit,
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(
text = message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyLarge,
)
Button(onClick = onRetry) {
Text("Retry")
}
}
}
}
@Composable
private fun ArtistHeaderCard(
artist: MetadataArtist.Detailed?,
artist: MetadataArtist.Detailed,
isSaved: Boolean,
isLoading: Boolean,
isSaving: Boolean,
error: String?,
onFollowClick: () -> Unit,
) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp
SkeletonTree(isLoading = isLoading) {
Card(
modifier = Modifier
.fillMaxWidth()
@ -240,17 +394,8 @@ private fun ArtistHeaderCard(
ArtistHeaderActions(
isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick,
)
error?.let {
TextWithShimmer(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
} else {
Row(
@ -276,18 +421,8 @@ private fun ArtistHeaderCard(
ArtistHeaderActions(
isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick,
)
error?.let {
TextWithShimmer(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@ -297,29 +432,28 @@ private fun ArtistHeaderCard(
@Composable
private fun ArtistAvatar(
artist: MetadataArtist.Detailed?,
artist: MetadataArtist.Detailed,
size: androidx.compose.ui.unit.Dp,
) {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.shimmerApply(),
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
val imageUrl = artist?.thumbnails?.firstOrNull()?.url.orEmpty()
val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
if (imageUrl.isNotBlank()) {
AsyncImage(
model = imageUrl,
contentDescription = artist?.name ?: "Artist",
contentDescription = artist.name,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Icon(
imageVector = FeatherIcons.User,
contentDescription = artist?.name ?: "Artist",
contentDescription = artist.name,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size * 0.4f),
)
@ -329,7 +463,7 @@ private fun ArtistAvatar(
@Composable
private fun ArtistMeta(
artist: MetadataArtist.Detailed?,
artist: MetadataArtist.Detailed,
isCompact: Boolean,
) {
Column(
@ -337,38 +471,36 @@ private fun ArtistMeta(
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
TextWithShimmer(
text = artist?.name ?: "Loading artist...",
Text(
text = artist.name,
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.SemiBold,
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
artist?.let {
TextWithShimmer(
Text(
text = buildString {
append(formatFollowers(it.followersCount))
if (it.genres.isNotEmpty()) {
append(formatFollowers(artist.followersCount))
if (artist.genres.isNotEmpty()) {
append("")
append(it.genres.joinToString(", "))
append(artist.genres.joinToString(", "))
}
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
)
}
artist?.biography?.takeIf { it.isNotBlank() }?.let {
TextWithShimmer(
artist.biography?.takeIf { it.isNotBlank() }?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (isCompact) 4 else 6,
overflow = TextOverflow.Ellipsis,
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
)
}
}
@ -377,7 +509,6 @@ private fun ArtistMeta(
@Composable
private fun ArtistHeaderActions(
isSaved: Boolean,
isSaving: Boolean,
onFollowClick: () -> Unit,
) {
Row(
@ -385,91 +516,12 @@ private fun ArtistHeaderActions(
verticalAlignment = Alignment.CenterVertically,
) {
if (isSaved) {
FilledTonalButton(onClick = onFollowClick, enabled = !isSaving) {
TextWithShimmer("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
FilledTonalButton(onClick = onFollowClick) {
Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
}
} else {
Button(onClick = onFollowClick, enabled = !isSaving) {
TextWithShimmer("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
}
}
}
}
@Composable
private fun ArtistAlbumsSection(
albums: List<MetadataAlbum.Detailed>,
isLoading: Boolean,
error: String?,
hasMore: Boolean,
onViewAll: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextWithShimmer(
text = "Albums",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
TextButton(
onClick = onViewAll,
enabled = hasMore && !isLoading,
) {
TextWithShimmer("View all")
}
}
if (isLoading && albums.isEmpty()) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items(4) {
SkeletonTree(true) {
PlayableCard(
title = "Album Title",
subtitle = "Artist Name",
imageURL = "https://placehold.co/600x400",
)
}
}
}
} else if (error != null && albums.isEmpty()) {
TextWithShimmer(
text = error,
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.error,
)
} else if (albums.isEmpty()) {
TextWithShimmer(
text = "No albums found",
modifier = Modifier.padding(horizontal = 16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
val rowState = rememberLazyListState()
LazyRow(
state = rowState,
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items(albums, key = { it.id }) { album ->
AlbumCard(album = album)
}
Button(onClick = onFollowClick) {
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
}
}
}
@ -487,7 +539,7 @@ private fun TopTracksHeader(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
TextWithShimmer(
Text(
text = "Top Tracks",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
@ -510,6 +562,130 @@ private fun TopTracksHeader(
}
}
@Composable
private fun AlbumsSection(
albums: List<MetadataAlbum.Detailed>,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onLoadMore: () -> Unit,
) {
val rowState = rememberLazyListState()
LaunchedEffect(rowState) {
snapshotFlow { rowState.layoutInfo }
.map { layoutInfo ->
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
val totalItems = layoutInfo.totalItemsCount
Pair(lastVisibleIndex, totalItems)
}
.distinctUntilChanged()
.collect { (lastVisibleIndex, totalItems) ->
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) {
onLoadMore()
}
}
}
SectionHeader(title = "Albums")
Spacer(modifier = Modifier.size(8.dp))
LazyRow(
state = rowState,
modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items(albums, key = { it.id }) { album ->
AlbumCard(album = album)
}
}
}
@Composable
private fun RelatedArtistsSection(
artists: List<MetadataArtist.Basic>,
onArtistClick: (MetadataArtist.Basic) -> Unit,
onLoadMore: () -> Unit,
) {
val rowState = rememberLazyListState()
LaunchedEffect(rowState) {
snapshotFlow { rowState.layoutInfo }
.map { layoutInfo ->
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
val totalItems = layoutInfo.totalItemsCount
Pair(lastVisibleIndex, totalItems)
}
.distinctUntilChanged()
.collect { (lastVisibleIndex, totalItems) ->
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) {
onLoadMore()
}
}
}
SectionHeader(title = "Related Artists")
Spacer(modifier = Modifier.size(8.dp))
LazyRow(
state = rowState,
modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items(artists, key = { it.id }) { artist ->
ArtistCard(artist = artist)
}
}
}
@Composable
private fun FeaturedPlaylistsSection(
playlists: List<MetadataPlaylist>,
onPlaylistClick: (MetadataPlaylist) -> Unit,
onLoadMore: () -> Unit,
) {
val rowState = rememberLazyListState()
LaunchedEffect(rowState) {
snapshotFlow { rowState.layoutInfo }
.map { layoutInfo ->
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index
val totalItems = layoutInfo.totalItemsCount
Pair(lastVisibleIndex, totalItems)
}
.distinctUntilChanged()
.collect { (lastVisibleIndex, totalItems) ->
if (lastVisibleIndex != null && totalItems > 0 && lastVisibleIndex >= totalItems - 2) {
onLoadMore()
}
}
}
SectionHeader(title = "Featured Playlists")
Spacer(modifier = Modifier.size(8.dp))
LazyRow(
state = rowState,
modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top,
) {
items(playlists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist)
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp),
)
}
private fun formatFollowers(count: Int?): String {
if (count == null) return "Followers unavailable"
return when {
@ -529,4 +705,3 @@ private fun formatAbbreviatedCount(count: Int, divisor: Int): String {
rounded.toString()
}
}

View File

@ -22,106 +22,77 @@ import androidx.lifecycle.viewModelScope
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
data class ArtistInfoState(
val artist: MetadataArtist.Detailed? = null,
val isSaved: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
)
sealed interface ArtistScreenState {
data object Loading : ArtistScreenState
data class ArtistTopTracksState(
val items: List<MetadataTrack> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
)
data class Loaded(
val artist: MetadataArtist.Detailed,
val isArtistSaved: Boolean,
val topTracks: List<MetadataTrack>,
val albums: List<MetadataAlbum.Detailed>,
val albumsNextPagination: PaginationStrategy?,
val relatedArtists: List<MetadataArtist.Basic>,
val relatedArtistsNextPagination: PaginationStrategy?,
val featuredPlaylists: List<MetadataPlaylist>,
val featuredPlaylistsNextPagination: PaginationStrategy?,
) : ArtistScreenState
data class ArtistAlbumsState(
val items: List<MetadataAlbum.Detailed> = emptyList(),
val nextPagination: PaginationStrategy? = null,
val hasNextPage: Boolean = true,
val isLoading: Boolean = false,
val error: String? = null,
)
@OptIn(ExperimentalCoroutinesApi::class)
class ArtistViewModel(
private val artistId: String,
private val pluginManager: PluginManager,
private val savedTracksRepository: SavedTracksRepository,
private val libraryRepository: LibraryRepository,
private val audioPlayerQueue: AudioPlayerQueue,
) : ViewModel() {
companion object {
private const val ALBUMS_PAGE_SIZE = 20
data class Error(val message: String) : ArtistScreenState
}
private val _artistInfo = MutableStateFlow(ArtistInfoState())
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.asStateFlow()
@OptIn(ExperimentalStdlibApi::class)
class ArtistViewModel(
private val artistId: String,
private val repository: ArtistRepository,
private val libraryRepository: LibraryRepository,
private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue,
) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>()
private val _topTracks = MutableStateFlow(ArtistTopTracksState())
val topTracks: StateFlow<ArtistTopTracksState> = _topTracks.asStateFlow()
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
private val _albums = MutableStateFlow(ArtistAlbumsState())
val albums: StateFlow<ArtistAlbumsState> = _albums.asStateFlow()
val savedArtistIds
get() = libraryRepository.savedArtistIdsFlow
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
init {
viewModelScope.launch {
pluginManager.selectedMetadataPlugin
.filterNotNull()
.distinctUntilChanged()
.flatMapLatest { it.loggedInFlow }
.collect {
_artistInfo.value = ArtistInfoState()
_topTracks.value = ArtistTopTracksState()
_albums.value = ArtistAlbumsState()
loadArtistInfo()
loadTopTracks()
loadAlbumsPage(reset = true)
libraryRepository.savedArtistIdsFlow.collect {
val currentState = _state.value
if (currentState is ArtistScreenState.Loaded) {
_state.value = currentState.copy(isArtistSaved = it.contains(artistId))
}
}
}
loadOverview()
}
fun refreshArtist() {
viewModelScope.launch {
loadArtistInfo()
loadTopTracks()
loadAlbumsPage(reset = true)
}
}
fun loadNextAlbumsPage() {
val current = _albums.value
if (current.isLoading || !current.hasNextPage || current.nextPagination == null) return
viewModelScope.launch {
loadAlbumsPage(reset = false)
}
fun refresh() {
repository.invalidateCaches()
loadOverview()
}
fun toggleSavedArtist() {
viewModelScope.launch {
val isLiked =
libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
if (isLiked) {
libraryRepository.removeSavedArtists(listOf(artistId))
} else {
@ -130,11 +101,73 @@ class ArtistViewModel(
}
}
fun loadMoreAlbums() {
val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return
val pagination = currentState.albumsNextPagination ?: return
viewModelScope.launch {
runCatching {
repository.albums(artistId, pagination)
}.onSuccess { result ->
if (result != null) {
_state.value = currentState.copy(
albums = currentState.albums + result.items,
albumsNextPagination = result.nextPagination,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more albums" }
}
}
}
fun loadMoreRelatedArtists() {
val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return
val pagination = currentState.relatedArtistsNextPagination ?: return
viewModelScope.launch {
runCatching {
repository.relatedArtists(artistId, pagination)
}.onSuccess { result ->
if (result != null) {
_state.value = currentState.copy(
relatedArtists = currentState.relatedArtists + result.items,
relatedArtistsNextPagination = result.nextPagination,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more related artists" }
}
}
}
fun loadMoreFeaturedPlaylists() {
val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return
val pagination = currentState.featuredPlaylistsNextPagination ?: return
viewModelScope.launch {
runCatching {
repository.featuredPlaylists(artistId, pagination)
}.onSuccess { result ->
if (result != null) {
_state.value = currentState.copy(
featuredPlaylists = currentState.featuredPlaylists + result.items,
featuredPlaylistsNextPagination = result.nextPagination,
)
}
}.onFailure { e ->
logger.e(e) { "Failed to load more featured playlists" }
}
}
}
fun addTopTracksToQueue() {
viewModelScope.launch {
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
audioPlayerQueue.addAllToQueue(entries)
}
}
@ -143,7 +176,6 @@ class ArtistViewModel(
viewModelScope.launch {
val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch
audioPlayerQueue.load(
entries = entries,
autoPlay = true,
@ -180,128 +212,17 @@ class ArtistViewModel(
}
}
private suspend fun loadArtistInfo() {
val plugin = pluginManager.selectedMetadataPlugin.value
if (plugin == null) {
_artistInfo.value = ArtistInfoState(
artist = null,
isSaved = false,
isLoading = false,
isSaving = false,
error = null,
)
return
}
_artistInfo.value = _artistInfo.value.copy(isLoading = true, error = null)
runCatching {
pluginManager.asyncTask {
plugin.use {
val artist = metadataArtistAPI.getArtist(artistId)
val isSaved =
metadataArtistAPI.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
artist to isSaved
}
}.await()
}.onSuccess { (artist, isSaved) ->
_artistInfo.value = _artistInfo.value.copy(
artist = artist,
isSaved = isSaved,
isLoading = false,
error = null,
)
libraryRepository.isSavedArtists(listOf(artistId))
}.onFailure { throwable ->
_artistInfo.value = _artistInfo.value.copy(
isLoading = false,
error = throwable.message ?: "Failed to load artist",
)
fun addTracksToQueue(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
}
}
private suspend fun loadTopTracks() {
val plugin = pluginManager.selectedMetadataPlugin.value
if (plugin == null) {
_topTracks.value =
ArtistTopTracksState(items = emptyList(), isLoading = false, error = null)
return
}
_topTracks.value = _topTracks.value.copy(isLoading = true, error = null)
runCatching {
pluginManager.asyncTask {
plugin.use {
metadataArtistAPI.getArtistTop10Tracks(artistId)
}
}.await()
}.onSuccess { tracks ->
savedTracksRepository.isSavedTracks(tracks.map { item -> item.id })
_topTracks.value = ArtistTopTracksState(
items = tracks,
isLoading = false,
error = null,
)
}.onFailure { throwable ->
_topTracks.value = _topTracks.value.copy(
isLoading = false,
error = throwable.message ?: "Failed to load artist top tracks",
)
}
}
private suspend fun loadAlbumsPage(reset: Boolean) {
val plugin = pluginManager.selectedMetadataPlugin.value
if (plugin == null) {
_albums.value = ArtistAlbumsState(
items = emptyList(),
nextPagination = null,
hasNextPage = false,
isLoading = false,
error = null,
)
return
}
val current = _albums.value
val offset = if (reset) 0 else current.nextPagination ?: return
_albums.value = if (reset) {
current.copy(items = emptyList(), isLoading = true, error = null)
} else {
current.copy(isLoading = true, error = null)
}
runCatching {
pluginManager.asyncTask {
plugin.use {
metadataArtistAPI.getArtistAlbums(artistId)
}
}.await()
}.onSuccess { page ->
val mergedItems = if (reset) page.items else _albums.value.items + page.items
_albums.value = ArtistAlbumsState(
items = mergedItems,
nextPagination = page.nextPagination,
hasNextPage = page.nextPagination != null,
isLoading = false,
error = null,
)
}.onFailure { throwable ->
_albums.value = _albums.value.copy(
isLoading = false,
error = throwable.message ?: "Failed to load artist albums",
)
}
}
private fun resolveTopTrackEntries(): List<QueueEntry> {
return _topTracks.value.items.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
}
}
@ -352,28 +273,47 @@ class ArtistViewModel(
}
}
fun addTracksToQueue(tracks: List<MetadataTrack>) {
private fun loadOverview() {
_state.value = ArtistScreenState.Loading
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllToQueue(entries)
runCatching {
repository.artistOverview(artistId)
}.onSuccess { overview ->
if (overview != null) {
savedTracksRepository.isSavedTracks(overview.top10Tracks.map { it.id })
_state.value = ArtistScreenState.Loaded(
artist = overview.artist,
isArtistSaved = libraryRepository.savedArtistIdsFlow.value.contains(artistId),
topTracks = overview.top10Tracks,
albums = overview.albums.items,
albumsNextPagination = overview.albums.nextPagination,
relatedArtists = overview.relatedArtists.items,
relatedArtistsNextPagination = overview.relatedArtists.nextPagination,
featuredPlaylists = overview.featuredPlaylists.items,
featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination,
)
} else {
_state.value = ArtistScreenState.Error("Failed to load artist overview")
}
}.onFailure { e ->
logger.e(e) { "Failed to load artist overview" }
_state.value = ArtistScreenState.Error(e.message ?: "Unknown error")
}
}
}
fun playTracksNext(tracks: List<MetadataTrack>) {
viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
audioPlayerQueue.addAllAfterCurrent(entries)
private fun resolveTopTrackEntries(): List<QueueEntry> {
val currentState = _state.value
if (currentState !is ArtistScreenState.Loaded) return emptyList()
return currentState.topTracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
}
}
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) {
return id == other.id
}
return title == other.title &&
durationMs == other.durationMs &&
album?.id == other.album?.id &&

View File

@ -19,8 +19,10 @@ package dev.krtirtho.js_plugin_example.plugin_apis.metadata
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
class RealMetadataArtistAPI : MetadataArtistAPI {
@ -29,10 +31,28 @@ class RealMetadataArtistAPI : MetadataArtistAPI {
return FakeMetadataStore.getArtist(id)
}
override suspend fun artistOverview(id: String): MetadataArtistOverview {
TODO("Not yet implemented")
}
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> {
return FakeMetadataStore.getArtistTopTracks(id)
}
override suspend fun relatedArtists(
id: String,
pagination: PaginationStrategy?
): PaginationResult<MetadataArtist.Basic> {
TODO("Not yet implemented")
}
override suspend fun featuredPlaylists(
id: String,
pagination: PaginationStrategy?
): PaginationResult<MetadataPlaylist> {
TODO("Not yet implemented")
}
override suspend fun getArtistAlbums(
id: String,
pagination: PaginationStrategy?

View File

@ -20,13 +20,26 @@ import app.cash.zipline.ZiplineService
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI"
interface MetadataArtistAPI : ZiplineService {
suspend fun getArtist(id: String): MetadataArtist.Detailed
suspend fun artistOverview(id: String): MetadataArtistOverview
suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack>
suspend fun relatedArtists(
id: String,
pagination: PaginationStrategy? = null
): PaginationResult<MetadataArtist.Basic>
suspend fun featuredPlaylists(
id: String,
pagination: PaginationStrategy? = null
): PaginationResult<MetadataPlaylist>
suspend fun getArtistAlbums(
id: String,
pagination: PaginationStrategy? = null

View File

@ -16,7 +16,11 @@
package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -48,3 +52,12 @@ sealed class MetadataArtist {
override val externalUri: String?
) : MetadataArtist()
}
@Serializable
data class MetadataArtistOverview(
val artist: MetadataArtist.Detailed,
val top10Tracks: List<MetadataTrack>,
val albums: PaginationResult<MetadataAlbum.Detailed>,
val relatedArtists: PaginationResult<MetadataArtist.Basic>,
val featuredPlaylists: PaginationResult<MetadataPlaylist>
)