diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
index 8a574b73..c9780e9c 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/di/Modules.kt
@@ -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(),
)
}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt
index 44cb0044..603586fe 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/ui/component/TrackList.kt
@@ -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,269 +208,307 @@ fun TrackList(
val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact
-
- Box(modifier = modifier.fillMaxWidth()) {
- LazyColumn(
- state = listState,
+ val trackCardContent: @Composable () -> Unit = {
+ Card(
modifier = Modifier
- .widthIn(max = 1280.dp)
- .align(Alignment.TopCenter)
+ .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,
+ )
+ }
+ }
+ }
+ }
+
+ 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),
- contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) {
- item {
- headerContent()
- }
+ 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") {
- 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,
- )
- }
- }
- }
+ if (!simplified) {
+ filterSortRow()
}
-
+ trackCardContent()
if (footerContent != null) {
- item {
- footerContent()
- }
+ footerContent()
}
}
@@ -487,9 +526,7 @@ fun TrackList(
)
}
}
- VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
}
-
}
@OptIn(ExperimentalFoundationApi::class)
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt
new file mode 100644
index 00000000..f51cc187
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistRepository.kt
@@ -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 .
+ */
+
+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().build()
+ private val albumsCache =
+ Cache.Builder, PaginationResult>().build()
+ private val relatedArtistsCache =
+ Cache.Builder, PaginationResult>().build()
+ private val featuredPlaylistsCache =
+ Cache.Builder, PaginationResult>().build()
+ private val topTracksCache = Cache.Builder>().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)
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt
index 31fc21d9..4f2d5abd 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistScreen.kt
@@ -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()
- 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,153 +144,285 @@ 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),
) {
- ArtistHeaderCard(
- artist = artist,
- isSaved = savedArtistIds.contains(artistId),
- isLoading = artistInfo.isLoading,
- isSaving = artistInfo.isSaving,
- error = artistInfo.error,
- onFollowClick = viewModel::toggleSavedArtist,
- )
+ item {
+ ArtistHeaderCard(
+ 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,
+ )
+ }
- TopTracksHeader(
- onPlay = viewModel::playTopTracks,
- onAddToQueue = viewModel::addTopTracksToQueue,
+ item {
+ TrackList(
+ 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),
)
}
- },
- tracks = topTracksState.items,
- error = topTracksState.error,
- hasMore = false,
- isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(),
- 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,
- onBulkDownload = { tracks ->
- downloadsViewModel.downloadTracks(tracks)
- },
- onBulkAddToQueue = { tracks ->
- viewModel.addTracksToQueue(tracks)
- },
- onBulkPlayNext = { tracks ->
- viewModel.playTracksNext(tracks)
- },
- )
+ 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()
- .padding(horizontal = 12.dp, vertical = 8.dp),
- colors = CardDefaults.cardColors(
- containerColor = MaterialTheme.colorScheme.surfaceContainer,
- ),
- shape = RoundedCornerShape(20.dp),
- ) {
- if (isCompact) {
- Column(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp),
- verticalArrangement = Arrangement.spacedBy(14.dp),
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- ArtistAvatar(
- artist = artist,
- size = 180.dp,
- )
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp, vertical = 8.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surfaceContainer,
+ ),
+ shape = RoundedCornerShape(20.dp),
+ ) {
+ if (isCompact) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(14.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ ArtistAvatar(
+ artist = artist,
+ 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(
artist = artist,
- isCompact = true,
+ isCompact = false,
)
ArtistHeaderActions(
isSaved = isSaved,
- isSaving = isSaving,
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
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 = buildString {
- append(formatFollowers(it.followersCount))
- if (it.genres.isNotEmpty()) {
- append(" • ")
- append(it.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,
- )
- }
+ Text(
+ text = buildString {
+ append(formatFollowers(artist.followersCount))
+ if (artist.genres.isNotEmpty()) {
+ append(" • ")
+ append(artist.genres.joinToString(", "))
+ }
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ 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,
- 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,
+ 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,
+ 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,
+ 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()
}
}
-
diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt
index b670cd46..2fa42a74 100644
--- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/artist/ArtistViewModel.kt
@@ -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 = emptyList(),
- val isLoading: Boolean = false,
- val error: String? = null,
-)
+ data class Loaded(
+ val artist: MetadataArtist.Detailed,
+ val isArtistSaved: Boolean,
+ val topTracks: List,
+ val albums: List,
+ val albumsNextPagination: PaginationStrategy?,
+ val relatedArtists: List,
+ val relatedArtistsNextPagination: PaginationStrategy?,
+ val featuredPlaylists: List,
+ val featuredPlaylistsNextPagination: PaginationStrategy?,
+ ) : ArtistScreenState
-data class ArtistAlbumsState(
- val items: List = emptyList(),
- val nextPagination: PaginationStrategy? = null,
- val hasNextPage: Boolean = true,
- val isLoading: Boolean = false,
- val error: String? = null,
-)
+ data class Error(val message: String) : ArtistScreenState
+}
-@OptIn(ExperimentalCoroutinesApi::class)
+@OptIn(ExperimentalStdlibApi::class)
class ArtistViewModel(
private val artistId: String,
- private val pluginManager: PluginManager,
- private val savedTracksRepository: SavedTracksRepository,
+ private val repository: ArtistRepository,
private val libraryRepository: LibraryRepository,
+ private val savedTracksRepository: SavedTracksRepository,
private val audioPlayerQueue: AudioPlayerQueue,
-) : ViewModel() {
- companion object {
- private const val ALBUMS_PAGE_SIZE = 20
- }
+) : ViewModel(), KoinComponent {
+ private val logger by injectLogger()
- private val _artistInfo = MutableStateFlow(ArtistInfoState())
- val artistInfo: StateFlow = _artistInfo.asStateFlow()
+ private val _state = MutableStateFlow(ArtistScreenState.Loading)
+ val state: StateFlow = _state.asStateFlow()
- private val _topTracks = MutableStateFlow(ArtistTopTracksState())
- val topTracks: StateFlow = _topTracks.asStateFlow()
-
- private val _albums = MutableStateFlow(ArtistAlbumsState())
- val albums: StateFlow = _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) {
+ 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 {
- return _topTracks.value.items.map { track ->
- QueueEntry.StreamingTrack(track = track, url = "")
+ fun playTracksNext(tracks: List) {
+ viewModelScope.launch {
+ val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
+ audioPlayerQueue.addAllAfterCurrent(entries)
}
}
@@ -352,28 +273,47 @@ class ArtistViewModel(
}
}
- fun addTracksToQueue(tracks: List) {
+ 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) {
- viewModelScope.launch {
- val entries = tracks.map { QueueEntry.StreamingTrack(track = it, url = "") }
- audioPlayerQueue.addAllAfterCurrent(entries)
+ private fun resolveTopTrackEntries(): List {
+ 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 &&
diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt
index 1cc61edf..80af3ed4 100644
--- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt
+++ b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataArtistAPI.kt
@@ -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 {
return FakeMetadataStore.getArtistTopTracks(id)
}
+ override suspend fun relatedArtists(
+ id: String,
+ pagination: PaginationStrategy?
+ ): PaginationResult {
+ TODO("Not yet implemented")
+ }
+
+ override suspend fun featuredPlaylists(
+ id: String,
+ pagination: PaginationStrategy?
+ ): PaginationResult {
+ TODO("Not yet implemented")
+ }
+
override suspend fun getArtistAlbums(
id: String,
pagination: PaginationStrategy?
diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt
index c0dc9816..5d4aaa5a 100644
--- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt
+++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistAPI.kt
@@ -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
+
+ suspend fun relatedArtists(
+ id: String,
+ pagination: PaginationStrategy? = null
+ ): PaginationResult
+
+ suspend fun featuredPlaylists(
+ id: String,
+ pagination: PaginationStrategy? = null
+ ): PaginationResult
+
suspend fun getArtistAlbums(
id: String,
pagination: PaginationStrategy? = null
diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt
index f8ec18b0..750eb0b5 100644
--- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt
+++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/artist/MetadataArtistModels.kt
@@ -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
@@ -47,4 +51,13 @@ sealed class MetadataArtist {
override val thumbnails: List,
override val externalUri: String?
) : MetadataArtist()
-}
\ No newline at end of file
+}
+
+@Serializable
+data class MetadataArtistOverview(
+ val artist: MetadataArtist.Detailed,
+ val top10Tracks: List,
+ val albums: PaginationResult,
+ val relatedArtists: PaginationResult,
+ val featuredPlaylists: PaginationResult
+)
\ No newline at end of file