mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists
This commit is contained in:
parent
8f9c2540a2
commit
f05f0727b6
@ -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(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,145 +208,7 @@ fun TrackList(
|
|||||||
val showAlbum = !isCompact
|
val showAlbum = !isCompact
|
||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
|
val trackCardContent: @Composable () -> Unit = {
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 1280.dp)
|
|
||||||
.align(Alignment.TopCenter)
|
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
|
||||||
contentPadding = contentPadding,
|
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
) {
|
|
||||||
if (headerContent != null) {
|
|
||||||
item {
|
|
||||||
headerContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!simplified)
|
|
||||||
item {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
|
||||||
.heightIn(max = 60.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
|
||||||
) {
|
|
||||||
if (showIndex || isSelectionMode) {
|
|
||||||
val allSelected =
|
|
||||||
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
|
||||||
val anySelected = selectedTrackIds.isNotEmpty()
|
|
||||||
val headerState = when {
|
|
||||||
allSelected -> CheckBoxState.SELECTED
|
|
||||||
anySelected -> CheckBoxState.INDETERMINATE
|
|
||||||
else -> CheckBoxState.UNSELECTED
|
|
||||||
}
|
|
||||||
CheckBox(
|
|
||||||
state = headerState,
|
|
||||||
onClick = {
|
|
||||||
if (allSelected) {
|
|
||||||
isSelectionMode = false
|
|
||||||
selectedTrackIds = emptySet()
|
|
||||||
} else {
|
|
||||||
isSelectionMode = true
|
|
||||||
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = filterQuery,
|
|
||||||
onValueChange = { filterQuery = it },
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 400.dp)
|
|
||||||
.align(Alignment.CenterEnd),
|
|
||||||
placeholder = { TextWithShimmer("Filter") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Filter",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ButtonGroup {
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
AdaptiveDropdownBottomSheet(
|
|
||||||
items = TrackSortOption.entries.map { option ->
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
label = option.label,
|
|
||||||
onClick = { sortBy = option },
|
|
||||||
selected = sortBy == option,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trigger = { onClick ->
|
|
||||||
GroupIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxSort,
|
|
||||||
contentDescription = "Sort",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ButtonGroupDivider()
|
|
||||||
Box(
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
|
||||||
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
|
||||||
} else {
|
|
||||||
visibleTracks
|
|
||||||
}
|
|
||||||
val trackCount = targetTracks.size
|
|
||||||
val isAll =
|
|
||||||
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
|
||||||
AdaptiveDropdownBottomSheet(
|
|
||||||
items = listOf(
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
|
||||||
label = if (isAll) "Download All" else "Download $trackCount",
|
|
||||||
onClick = { onBulkDownload(targetTracks) },
|
|
||||||
),
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
|
||||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
|
||||||
onClick = { onBulkAddToQueue(targetTracks) },
|
|
||||||
),
|
|
||||||
AdaptiveMenuItem(
|
|
||||||
icon = Iconsax.IconsaxNext,
|
|
||||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
|
||||||
onClick = { onBulkPlayNext(targetTracks) },
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trigger = { onClick ->
|
|
||||||
GroupIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
|
||||||
contentDescription = "Bulk actions",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item(key = "track-card") {
|
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -466,6 +329,147 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val filterSortRow: @Composable () -> Unit = {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||||
|
.heightIn(max = 60.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
||||||
|
) {
|
||||||
|
if (showIndex || isSelectionMode) {
|
||||||
|
val allSelected =
|
||||||
|
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
||||||
|
val anySelected = selectedTrackIds.isNotEmpty()
|
||||||
|
val headerState = when {
|
||||||
|
allSelected -> CheckBoxState.SELECTED
|
||||||
|
anySelected -> CheckBoxState.INDETERMINATE
|
||||||
|
else -> CheckBoxState.UNSELECTED
|
||||||
|
}
|
||||||
|
CheckBox(
|
||||||
|
state = headerState,
|
||||||
|
onClick = {
|
||||||
|
if (allSelected) {
|
||||||
|
isSelectionMode = false
|
||||||
|
selectedTrackIds = emptySet()
|
||||||
|
} else {
|
||||||
|
isSelectionMode = true
|
||||||
|
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
value = filterQuery,
|
||||||
|
onValueChange = { filterQuery = it },
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 400.dp)
|
||||||
|
.align(Alignment.CenterEnd),
|
||||||
|
placeholder = { TextWithShimmer("Filter") },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxFilterSearch,
|
||||||
|
contentDescription = "Filter",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
ButtonGroup {
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AdaptiveDropdownBottomSheet(
|
||||||
|
items = TrackSortOption.entries.map { option ->
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
label = option.label,
|
||||||
|
onClick = { sortBy = option },
|
||||||
|
selected = sortBy == option,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trigger = { onClick ->
|
||||||
|
GroupIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxSort,
|
||||||
|
contentDescription = "Sort",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
ButtonGroupDivider()
|
||||||
|
Box(
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
||||||
|
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
||||||
|
} else {
|
||||||
|
visibleTracks
|
||||||
|
}
|
||||||
|
val trackCount = targetTracks.size
|
||||||
|
val isAll =
|
||||||
|
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
|
||||||
|
AdaptiveDropdownBottomSheet(
|
||||||
|
items = listOf(
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
|
label = if (isAll) "Download All" else "Download $trackCount",
|
||||||
|
onClick = { onBulkDownload(targetTracks) },
|
||||||
|
),
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
|
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||||
|
onClick = { onBulkAddToQueue(targetTracks) },
|
||||||
|
),
|
||||||
|
AdaptiveMenuItem(
|
||||||
|
icon = Iconsax.IconsaxNext,
|
||||||
|
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||||
|
onClick = { onBulkPlayNext(targetTracks) },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trigger = { onClick ->
|
||||||
|
GroupIconButton(
|
||||||
|
onClick = onClick,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
|
contentDescription = "Bulk actions",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scrollable) {
|
||||||
|
Box(modifier = modifier.fillMaxWidth()) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 1280.dp)
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
|
contentPadding = contentPadding,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
if (headerContent != null) {
|
||||||
|
item {
|
||||||
|
headerContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!simplified) {
|
||||||
|
item { filterSortRow() }
|
||||||
|
}
|
||||||
|
item(key = "track-card") { trackCardContent() }
|
||||||
if (footerContent != null) {
|
if (footerContent != null) {
|
||||||
item {
|
item {
|
||||||
footerContent()
|
footerContent()
|
||||||
@ -489,7 +493,40 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||||
|
) {
|
||||||
|
if (headerContent != null) {
|
||||||
|
headerContent()
|
||||||
|
}
|
||||||
|
if (!simplified) {
|
||||||
|
filterSortRow()
|
||||||
|
}
|
||||||
|
trackCardContent()
|
||||||
|
if (footerContent != null) {
|
||||||
|
footerContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!useDropdownForOptions) {
|
||||||
|
selectedTrackForOptions?.let { track ->
|
||||||
|
TrackOptionsBottomSheet(
|
||||||
|
track = track,
|
||||||
|
state = trackOptionsState(track),
|
||||||
|
onDismiss = { selectedTrackForOptions = null },
|
||||||
|
onAction = { action ->
|
||||||
|
onTrackOptionsAction(track, action)
|
||||||
|
selectedTrackForOptions = null
|
||||||
|
},
|
||||||
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,76 +144,227 @@ 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),
|
||||||
) {
|
) {
|
||||||
|
item {
|
||||||
ArtistHeaderCard(
|
ArtistHeaderCard(
|
||||||
artist = artist,
|
artist = currentState.artist,
|
||||||
isSaved = savedArtistIds.contains(artistId),
|
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||||
isLoading = artistInfo.isLoading,
|
|
||||||
isSaving = artistInfo.isSaving,
|
|
||||||
error = artistInfo.error,
|
|
||||||
onFollowClick = viewModel::toggleSavedArtist,
|
onFollowClick = viewModel::toggleSavedArtist,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ArtistAlbumsSection(
|
item {
|
||||||
albums = albumsState.items,
|
|
||||||
isLoading = albumsState.isLoading,
|
|
||||||
error = albumsState.error,
|
|
||||||
hasMore = albumsState.hasNextPage,
|
|
||||||
onViewAll = viewModel::loadNextAlbumsPage,
|
|
||||||
)
|
|
||||||
|
|
||||||
TopTracksHeader(
|
TopTracksHeader(
|
||||||
onPlay = viewModel::playTopTracks,
|
onPlay = viewModel::playTopTracks,
|
||||||
onAddToQueue = viewModel::addTopTracksToQueue,
|
onAddToQueue = viewModel::addTopTracksToQueue,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
tracks = topTracksState.items,
|
item {
|
||||||
error = topTracksState.error,
|
TrackList(
|
||||||
|
tracks = currentState.topTracks,
|
||||||
|
error = null,
|
||||||
hasMore = false,
|
hasMore = false,
|
||||||
isLoading = topTracksState.isLoading && topTracksState.items.isEmpty(),
|
isLoading = false,
|
||||||
isLoadingNextPage = false,
|
isLoadingNextPage = false,
|
||||||
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = (currentQueueEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
isCurrentTrackPlaying = playerState == PlayerState.PLAYING,
|
||||||
onTrackClick = viewModel::playTopTracksFromTrack,
|
onTrackClick = viewModel::playTopTracksFromTrack,
|
||||||
onTrackOptionsAction = ::handleTrackOptionsAction,
|
onTrackOptionsAction = ::handleTrackOptionsAction,
|
||||||
trackOptionsState = ::getTrackOptionsState,
|
trackOptionsState = ::getTrackOptionsState,
|
||||||
onArtistClick = { trackArtist -> navigationCommands.navigateTo(Routes.Artist(trackArtist.id)) },
|
onArtistClick = { trackArtist ->
|
||||||
onAlbumClick = { album -> navigationCommands.navigateTo(Routes.Album(album.id)) },
|
navigationCommands.navigateTo(
|
||||||
|
Routes.Artist(
|
||||||
|
trackArtist.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onAlbumClick = { album ->
|
||||||
|
navigationCommands.navigateTo(
|
||||||
|
Routes.Album(
|
||||||
|
album.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
onLoadNextPage = {},
|
onLoadNextPage = {},
|
||||||
simplified = true,
|
simplified = true,
|
||||||
onBulkDownload = { tracks ->
|
scrollable = false,
|
||||||
downloadsViewModel.downloadTracks(tracks)
|
onBulkDownload = { tracks -> downloadsViewModel.downloadTracks(tracks) },
|
||||||
},
|
onBulkAddToQueue = { tracks -> viewModel.addTracksToQueue(tracks) },
|
||||||
onBulkAddToQueue = { tracks ->
|
onBulkPlayNext = { tracks -> viewModel.playTracksNext(tracks) },
|
||||||
viewModel.addTracksToQueue(tracks)
|
|
||||||
},
|
|
||||||
onBulkPlayNext = { tracks ->
|
|
||||||
viewModel.playTracksNext(tracks)
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) {
|
||||||
|
item {
|
||||||
|
AlbumsSection(
|
||||||
|
albums = currentState.albums,
|
||||||
|
onAlbumClick = { album ->
|
||||||
|
navigationCommands.navigateTo(
|
||||||
|
Routes.Album(
|
||||||
|
album.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLoadMore = { viewModel.loadMoreAlbums() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentState.relatedArtists.isNotEmpty() || currentState.relatedArtistsNextPagination != null) {
|
||||||
|
item {
|
||||||
|
RelatedArtistsSection(
|
||||||
|
artists = currentState.relatedArtists,
|
||||||
|
onArtistClick = { artist ->
|
||||||
|
navigationCommands.navigateTo(
|
||||||
|
Routes.Artist(
|
||||||
|
artist.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLoadMore = { viewModel.loadMoreRelatedArtists() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentState.featuredPlaylists.isNotEmpty() || currentState.featuredPlaylistsNextPagination != null) {
|
||||||
|
item {
|
||||||
|
FeaturedPlaylistsSection(
|
||||||
|
playlists = currentState.featuredPlaylists,
|
||||||
|
onPlaylistClick = { playlist ->
|
||||||
|
navigationCommands.navigateTo(
|
||||||
|
Routes.Playlist(
|
||||||
|
playlist.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistLoadingContent(innerPadding: PaddingValues) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
SkeletonTree(true) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(200.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
LazyRow(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
|
) {
|
||||||
|
items(4) {
|
||||||
|
PlayableCard(
|
||||||
|
title = "Item Title",
|
||||||
|
subtitle = "Subtitle",
|
||||||
|
imageURL = "https://placehold.co/600x400",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ArtistErrorContent(
|
||||||
|
innerPadding: PaddingValues,
|
||||||
|
message: String,
|
||||||
|
onRetry: () -> Unit,
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
)
|
||||||
|
Button(onClick = onRetry) {
|
||||||
|
Text("Retry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@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()
|
||||||
@ -240,17 +394,8 @@ private fun ArtistHeaderCard(
|
|||||||
|
|
||||||
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 {
|
} else {
|
||||||
Row(
|
Row(
|
||||||
@ -276,18 +421,8 @@ private fun ArtistHeaderCard(
|
|||||||
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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(it.followersCount))
|
append(formatFollowers(artist.followersCount))
|
||||||
if (it.genres.isNotEmpty()) {
|
if (artist.genres.isNotEmpty()) {
|
||||||
append(" • ")
|
append(" • ")
|
||||||
append(it.genres.joinToString(", "))
|
append(artist.genres.joinToString(", "))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = if (isCompact) androidx.compose.ui.text.style.TextAlign.Center else androidx.compose.ui.text.style.TextAlign.Start,
|
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
artist?.biography?.takeIf { it.isNotBlank() }?.let {
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
|
||||||
class ArtistViewModel(
|
|
||||||
private val artistId: String,
|
|
||||||
private val pluginManager: PluginManager,
|
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
|
||||||
private val libraryRepository: LibraryRepository,
|
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
|
||||||
) : ViewModel() {
|
|
||||||
companion object {
|
|
||||||
private const val ALBUMS_PAGE_SIZE = 20
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _artistInfo = MutableStateFlow(ArtistInfoState())
|
@OptIn(ExperimentalStdlibApi::class)
|
||||||
val artistInfo: StateFlow<ArtistInfoState> = _artistInfo.asStateFlow()
|
class ArtistViewModel(
|
||||||
|
private val artistId: String,
|
||||||
|
private val repository: ArtistRepository,
|
||||||
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
|
) : ViewModel(), KoinComponent {
|
||||||
|
private val logger by injectLogger<ArtistViewModel>()
|
||||||
|
|
||||||
private val _topTracks = MutableStateFlow(ArtistTopTracksState())
|
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
||||||
val topTracks: StateFlow<ArtistTopTracksState> = _topTracks.asStateFlow()
|
val state: StateFlow<ArtistScreenState> = _state.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 &&
|
||||||
|
|||||||
@ -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?
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
)
|
||||||
Loading…
Reference in New Issue
Block a user