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

View File

@ -146,6 +146,7 @@ fun TrackList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true,
) { ) {
var filterQuery by rememberSaveable { mutableStateOf("") } var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -207,269 +208,307 @@ fun TrackList(
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackCardContent: @Composable () -> Unit = {
Box(modifier = modifier.fillMaxWidth()) { Card(
LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.widthIn(max = 1280.dp) .fillMaxWidth()
.align(Alignment.TopCenter) .padding(vertical = 8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
)
}
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListFeedbackRow(
message = error,
isError = true,
)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow("No tracks found")
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
}
}
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()
}
}
}
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) } },
)
}
}
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
}
} else {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
if (headerContent != null) { if (headerContent != null) {
item { headerContent()
headerContent()
}
} }
if (!simplified) if (!simplified) {
item { filterSortRow()
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") {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
)
}
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListFeedbackRow(
message = error,
isError = true,
)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow("No tracks found")
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
}
} }
trackCardContent()
if (footerContent != null) { if (footerContent != null) {
item { footerContent()
footerContent()
}
} }
} }
@ -487,9 +526,7 @@ fun TrackList(
) )
} }
} }
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
} }
} }
@OptIn(ExperimentalFoundationApi::class) @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.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -45,9 +46,11 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -59,12 +62,12 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Heart
import compose.icons.feathericons.Play import compose.icons.feathericons.Play
import compose.icons.feathericons.PlusSquare import compose.icons.feathericons.PlusSquare
import compose.icons.feathericons.User import compose.icons.feathericons.User
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum 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.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayer import dev.krtirtho.spotube.core.audioplayer.AudioPlayer
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue 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.share.ShareService
import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar 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.TrackList
import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction import dev.krtirtho.spotube.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.core.ui.component.TrackOptionsState import dev.krtirtho.spotube.core.ui.component.TrackOptionsState
import dev.krtirtho.spotube.core.ui.component.cards.PlayableCard 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.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.modules.downloads.DownloadsViewModel 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.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@ -101,9 +108,7 @@ fun ArtistScreen(artistId: String) {
) )
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
val artistInfo by viewModel.artistInfo.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val topTracksState by viewModel.topTracks.collectAsStateWithLifecycle()
val albumsState by viewModel.albums.collectAsStateWithLifecycle()
val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle() val queue by audioPlayerQueue.queueFlow.collectAsStateWithLifecycle()
val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle() val currentQueueEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
val playerState by audioPlayer.playerStateFlow.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) { fun handleTrackOptionsAction(track: MetadataTrack, action: TrackOptionsAction) {
viewModel.handleTrackOptionsAction(track, action) viewModel.handleTrackOptionsAction(track, action)
if (action is TrackOptionsAction.Share) { if (action is TrackOptionsAction.Share) {
@ -141,153 +144,285 @@ fun ArtistScreen(artistId: String) {
Scaffold( Scaffold(
topBar = { ApplicationMainBar() } topBar = { ApplicationMainBar() }
) { innerPadding -> ) { innerPadding ->
TrackList( when (val currentState = state) {
modifier = Modifier.padding(innerPadding), is ArtistScreenState.Loading -> {
headerContent = { ArtistLoadingContent(innerPadding)
Column( }
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(20.dp), 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),
) { ) {
ArtistHeaderCard( item {
artist = artist, ArtistHeaderCard(
isSaved = savedArtistIds.contains(artistId), artist = currentState.artist,
isLoading = artistInfo.isLoading, isSaved = savedArtistIds.contains(currentState.artist.id),
isSaving = artistInfo.isSaving, onFollowClick = viewModel::toggleSavedArtist,
error = artistInfo.error, )
onFollowClick = viewModel::toggleSavedArtist, }
)
ArtistAlbumsSection( item {
albums = albumsState.items, TopTracksHeader(
isLoading = albumsState.isLoading, onPlay = viewModel::playTopTracks,
error = albumsState.error, onAddToQueue = viewModel::addTopTracksToQueue,
hasMore = albumsState.hasNextPage, )
onViewAll = viewModel::loadNextAlbumsPage, }
)
TopTracksHeader( item {
onPlay = viewModel::playTopTracks, TrackList(
onAddToQueue = viewModel::addTopTracksToQueue, tracks = currentState.topTracks,
error = null,
hasMore = false,
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
)
)
},
onLoadNextPage = {},
simplified = true,
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(
tracks = topTracksState.items, modifier = Modifier.fillMaxWidth(),
error = topTracksState.error, verticalArrangement = Arrangement.spacedBy(8.dp),
hasMore = false, ) {
isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(), Box(
isLoadingNextPage = false, modifier = Modifier
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id, .fillMaxWidth()
isCurrentTrackPlaying = playerState == PlayerState.PLAYING, .padding(horizontal = 16.dp),
onTrackClick = viewModel::playTopTracksFromTrack, ) {
onTrackOptionsAction = ::handleTrackOptionsAction, Box(
trackOptionsState = ::getTrackOptionsState, modifier = Modifier
onArtistClick = { trackArtist -> navigationCommands.navigateTo(Routes.Artist(trackArtist.id)) }, .fillMaxWidth()
onAlbumClick = { album -> navigationCommands.navigateTo(Routes.Album(album.id)) }, .padding(vertical = 8.dp),
onLoadNextPage = { }, )
simplified = true, }
onBulkDownload = { tracks -> LazyRow(
downloadsViewModel.downloadTracks(tracks) horizontalArrangement = Arrangement.spacedBy(12.dp),
}, contentPadding = PaddingValues(horizontal = 16.dp),
onBulkAddToQueue = { tracks -> ) {
viewModel.addTracksToQueue(tracks) items(4) {
}, PlayableCard(
onBulkPlayNext = { tracks -> title = "Item Title",
viewModel.playTracksNext(tracks) 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 @Composable
private fun ArtistHeaderCard( private fun ArtistHeaderCard(
artist: MetadataArtist.Detailed?, artist: MetadataArtist.Detailed,
isSaved: Boolean, isSaved: Boolean,
isLoading: Boolean,
isSaving: Boolean,
error: String?,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
) { ) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp val isCompact = maxWidth < 600.dp
SkeletonTree(isLoading = isLoading) { Card(
Card( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(horizontal = 12.dp, vertical = 8.dp),
.padding(horizontal = 12.dp, vertical = 8.dp), colors = CardDefaults.cardColors(
colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceContainer,
containerColor = MaterialTheme.colorScheme.surfaceContainer, ),
), shape = RoundedCornerShape(20.dp),
shape = RoundedCornerShape(20.dp), ) {
) { if (isCompact) {
if (isCompact) { Column(
Column( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .padding(16.dp),
.padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), horizontalAlignment = Alignment.CenterHorizontally,
horizontalAlignment = Alignment.CenterHorizontally, ) {
) { ArtistAvatar(
ArtistAvatar( artist = artist,
artist = artist, size = 180.dp,
size = 180.dp, )
)
ArtistMeta(
artist = artist,
isCompact = true,
)
ArtistHeaderActions(
isSaved = isSaved,
onFollowClick = onFollowClick,
)
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.Top,
) {
ArtistAvatar(
artist = artist,
size = 220.dp,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ArtistMeta( ArtistMeta(
artist = artist, artist = artist,
isCompact = true, isCompact = false,
) )
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
) )
error?.let {
TextWithShimmer(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
} else {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.Top,
) {
ArtistAvatar(
artist = artist,
size = 220.dp,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
ArtistMeta(
artist = artist,
isCompact = false,
)
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 @Composable
private fun ArtistAvatar( private fun ArtistAvatar(
artist: MetadataArtist.Detailed?, artist: MetadataArtist.Detailed,
size: androidx.compose.ui.unit.Dp, size: androidx.compose.ui.unit.Dp,
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest) .background(MaterialTheme.colorScheme.surfaceContainerHighest),
.shimmerApply(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val imageUrl = artist?.thumbnails?.firstOrNull()?.url.orEmpty() val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
if (imageUrl.isNotBlank()) { if (imageUrl.isNotBlank()) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = artist?.name ?: "Artist", contentDescription = artist.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
) )
} else { } else {
Icon( Icon(
imageVector = FeatherIcons.User, imageVector = FeatherIcons.User,
contentDescription = artist?.name ?: "Artist", contentDescription = artist.name,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(size * 0.4f), modifier = Modifier.size(size * 0.4f),
) )
@ -329,7 +463,7 @@ private fun ArtistAvatar(
@Composable @Composable
private fun ArtistMeta( private fun ArtistMeta(
artist: MetadataArtist.Detailed?, artist: MetadataArtist.Detailed,
isCompact: Boolean, isCompact: Boolean,
) { ) {
Column( Column(
@ -337,38 +471,36 @@ private fun ArtistMeta(
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
TextWithShimmer( Text(
text = artist?.name ?: "Loading artist...", text = artist.name,
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge, style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.SemiBold, 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, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
artist?.let { Text(
TextWithShimmer( text = buildString {
text = buildString { append(formatFollowers(artist.followersCount))
append(formatFollowers(it.followersCount)) if (artist.genres.isNotEmpty()) {
if (it.genres.isNotEmpty()) { append("")
append("") append(artist.genres.joinToString(", "))
append(it.genres.joinToString(", ")) }
} },
}, style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start, )
)
}
artist?.biography?.takeIf { it.isNotBlank() }?.let { artist.biography?.takeIf { it.isNotBlank() }?.let {
TextWithShimmer( Text(
text = it, text = it,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = if (isCompact) 4 else 6, maxLines = if (isCompact) 4 else 6,
overflow = TextOverflow.Ellipsis, 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 @Composable
private fun ArtistHeaderActions( private fun ArtistHeaderActions(
isSaved: Boolean, isSaved: Boolean,
isSaving: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
) { ) {
Row( Row(
@ -385,91 +516,12 @@ private fun ArtistHeaderActions(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
if (isSaved) { if (isSaved) {
FilledTonalButton(onClick = onFollowClick, enabled = !isSaving) { FilledTonalButton(onClick = onFollowClick) {
TextWithShimmer("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text("Following", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
} }
} else { } else {
Button(onClick = onFollowClick, enabled = !isSaving) { Button(onClick = onFollowClick) {
TextWithShimmer("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text("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)
}
} }
} }
} }
@ -487,7 +539,7 @@ private fun TopTracksHeader(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
TextWithShimmer( Text(
text = "Top Tracks", text = "Top Tracks",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, 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 { private fun formatFollowers(count: Int?): String {
if (count == null) return "Followers unavailable" if (count == null) return "Followers unavailable"
return when { return when {
@ -529,4 +705,3 @@ private fun formatAbbreviatedCount(count: Int, divisor: Int): String {
rounded.toString() 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.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist 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.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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
import dev.krtirtho.spotube.core.audioplayer.QueueEntry 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.core.ui.component.TrackOptionsAction
import dev.krtirtho.spotube.modules.library.LibraryRepository import dev.krtirtho.spotube.modules.library.LibraryRepository
import dev.krtirtho.spotube.modules.plugin.PluginManager
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow 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 kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
data class ArtistInfoState( sealed interface ArtistScreenState {
val artist: MetadataArtist.Detailed? = null, data object Loading : ArtistScreenState
val isSaved: Boolean = false,
val isLoading: Boolean = false,
val isSaving: Boolean = false,
val error: String? = null,
)
data class ArtistTopTracksState( data class Loaded(
val items: List<MetadataTrack> = emptyList(), val artist: MetadataArtist.Detailed,
val isLoading: Boolean = false, val isArtistSaved: Boolean,
val error: String? = null, 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( data class Error(val message: String) : ArtistScreenState
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) @OptIn(ExperimentalStdlibApi::class)
class ArtistViewModel( class ArtistViewModel(
private val artistId: String, private val artistId: String,
private val pluginManager: PluginManager, private val repository: ArtistRepository,
private val savedTracksRepository: SavedTracksRepository,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository,
private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
) : ViewModel() { ) : ViewModel(), KoinComponent {
companion object { private val logger by injectLogger<ArtistViewModel>()
private const val ALBUMS_PAGE_SIZE = 20
}
private val _artistInfo = MutableStateFlow(ArtistInfoState()) private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.asStateFlow() val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
private val _topTracks = MutableStateFlow(ArtistTopTracksState())
val topTracks: StateFlow<ArtistTopTracksState> = _topTracks.asStateFlow()
private val _albums = MutableStateFlow(ArtistAlbumsState())
val albums: StateFlow<ArtistAlbumsState> = _albums.asStateFlow()
val savedArtistIds val savedArtistIds
get() = libraryRepository.savedArtistIdsFlow get() = libraryRepository.savedArtistIdsFlow
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
init { init {
viewModelScope.launch { viewModelScope.launch {
pluginManager.selectedMetadataPlugin libraryRepository.savedArtistIdsFlow.collect {
.filterNotNull() val currentState = _state.value
.distinctUntilChanged() if (currentState is ArtistScreenState.Loaded) {
.flatMapLatest { it.loggedInFlow } _state.value = currentState.copy(isArtistSaved = it.contains(artistId))
.collect {
_artistInfo.value = ArtistInfoState()
_topTracks.value = ArtistTopTracksState()
_albums.value = ArtistAlbumsState()
loadArtistInfo()
loadTopTracks()
loadAlbumsPage(reset = true)
} }
}
} }
loadOverview()
} }
fun refreshArtist() { fun refresh() {
viewModelScope.launch { repository.invalidateCaches()
loadArtistInfo() loadOverview()
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 toggleSavedArtist() { fun toggleSavedArtist() {
viewModelScope.launch { viewModelScope.launch {
val isLiked = val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
if (isLiked) { if (isLiked) {
libraryRepository.removeSavedArtists(listOf(artistId)) libraryRepository.removeSavedArtists(listOf(artistId))
} else { } 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() { fun addTopTracksToQueue() {
viewModelScope.launch { viewModelScope.launch {
val entries = resolveTopTrackEntries() val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch if (entries.isEmpty()) return@launch
audioPlayerQueue.addAllToQueue(entries) audioPlayerQueue.addAllToQueue(entries)
} }
} }
@ -143,7 +176,6 @@ class ArtistViewModel(
viewModelScope.launch { viewModelScope.launch {
val entries = resolveTopTrackEntries() val entries = resolveTopTrackEntries()
if (entries.isEmpty()) return@launch if (entries.isEmpty()) return@launch
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
@ -180,128 +212,17 @@ class ArtistViewModel(
} }
} }
private suspend fun loadArtistInfo() { fun addTracksToQueue(tracks: List<MetadataTrack>) {
val plugin = pluginManager.selectedMetadataPlugin.value viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
if (plugin == null) { audioPlayerQueue.addAllToQueue(entries)
_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",
)
} }
} }
private suspend fun loadTopTracks() { fun playTracksNext(tracks: List<MetadataTrack>) {
val plugin = pluginManager.selectedMetadataPlugin.value viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
if (plugin == null) { audioPlayerQueue.addAllAfterCurrent(entries)
_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 = "")
} }
} }
@ -352,28 +273,47 @@ class ArtistViewModel(
} }
} }
fun addTracksToQueue(tracks: List<MetadataTrack>) { private fun loadOverview() {
_state.value = ArtistScreenState.Loading
viewModelScope.launch { viewModelScope.launch {
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } runCatching {
audioPlayerQueue.addAllToQueue(entries) 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>) { private fun resolveTopTrackEntries(): List<QueueEntry> {
viewModelScope.launch { val currentState = _state.value
val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") } if (currentState !is ArtistScreenState.Loaded) return emptyList()
audioPlayerQueue.addAllAfterCurrent(entries) return currentState.topTracks.map { track ->
QueueEntry.StreamingTrack(track = track, url = "")
} }
} }
val savedTrackIds
get() = savedTracksRepository.savedTracksIdsFlow
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) { if (id.isNotBlank() && other.id.isNotBlank()) {
return id == other.id return id == other.id
} }
return title == other.title && return title == other.title &&
durationMs == other.durationMs && durationMs == other.durationMs &&
album?.id == other.album?.id && 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.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist 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.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.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult 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 import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
class RealMetadataArtistAPI : MetadataArtistAPI { class RealMetadataArtistAPI : MetadataArtistAPI {
@ -29,10 +31,28 @@ class RealMetadataArtistAPI : MetadataArtistAPI {
return FakeMetadataStore.getArtist(id) return FakeMetadataStore.getArtist(id)
} }
override suspend fun artistOverview(id: String): MetadataArtistOverview {
TODO("Not yet implemented")
}
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> { override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> {
return FakeMetadataStore.getArtistTopTracks(id) 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( override suspend fun getArtistAlbums(
id: String, id: String,
pagination: PaginationStrategy? 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.album.MetadataAlbum
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy 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.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI" const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI"
interface MetadataArtistAPI : ZiplineService { interface MetadataArtistAPI : ZiplineService {
suspend fun getArtist(id: String): MetadataArtist.Detailed suspend fun getArtist(id: String): MetadataArtist.Detailed
suspend fun artistOverview(id: String): MetadataArtistOverview
suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> 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( suspend fun getArtistAlbums(
id: String, id: String,
pagination: PaginationStrategy? = null pagination: PaginationStrategy? = null

View File

@ -16,7 +16,11 @@
package dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist 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.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.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -48,3 +52,12 @@ sealed class MetadataArtist {
override val externalUri: String? override val externalUri: String?
) : MetadataArtist() ) : 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>
)