Compare commits

...

3 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
f012defd79 fix(plugin): reload Zipline service on same-version replace
- Add `generation` counter to serialized state so DataStore emits
  on every addPlugin (same-version replace produces identical JSON)
- Run all Zipline JNI calls (close, service.close) on ziplineDispatcher
  thread — wrong thread causes segfaults
- Stop ALL services in flatMapLatest finally block instead of only
  deselected ones (leaked zombie services)
- Wrap finally block in NonCancellable + try-catch per service
2026-07-07 19:06:44 +06:00
Kingkor Roy Tirtho
f05f0727b6 feat: implement artist overview screen with loading, error handling, and pagination for albums, related artists, and featured playlists 2026-07-07 17:31:16 +06:00
Kingkor Roy Tirtho
8f9c2540a2 feat: add genre selection and tabs to HomeScreen for improved content navigation 2026-07-07 13:38:31 +06:00
17 changed files with 1287 additions and 762 deletions

View File

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

View File

@ -146,6 +146,7 @@ fun TrackList(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true,
) { ) {
var filterQuery by rememberSaveable { mutableStateOf("") } var filterQuery by rememberSaveable { mutableStateOf("") }
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
@ -207,269 +208,307 @@ fun TrackList(
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackCardContent: @Composable () -> Unit = {
Box(modifier = modifier.fillMaxWidth()) { Card(
LazyColumn(
state = listState,
modifier = Modifier modifier = Modifier
.widthIn(max = 1280.dp) .fillMaxWidth()
.align(Alignment.TopCenter) .padding(vertical = 8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
)
}
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListFeedbackRow(
message = error,
isError = true,
)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow("No tracks found")
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
}
}
val filterSortRow: @Composable () -> Unit = {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
.heightIn(max = 60.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
) {
if (showIndex || isSelectionMode) {
val allSelected =
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
val anySelected = selectedTrackIds.isNotEmpty()
val headerState = when {
allSelected -> CheckBoxState.SELECTED
anySelected -> CheckBoxState.INDETERMINATE
else -> CheckBoxState.UNSELECTED
}
CheckBox(
state = headerState,
onClick = {
if (allSelected) {
isSelectionMode = false
selectedTrackIds = emptySet()
} else {
isSelectionMode = true
selectedTrackIds = visibleTracks.map { it.id }.toSet()
}
},
)
}
Box(
modifier = Modifier
.weight(1f)
) {
TextField(
value = filterQuery,
onValueChange = { filterQuery = it },
modifier = Modifier
.widthIn(max = 400.dp)
.align(Alignment.CenterEnd),
placeholder = { TextWithShimmer("Filter") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Filter",
)
},
singleLine = true,
)
}
ButtonGroup {
Box(
contentAlignment = Alignment.Center,
) {
AdaptiveDropdownBottomSheet(
items = TrackSortOption.entries.map { option ->
AdaptiveMenuItem(
label = option.label,
onClick = { sortBy = option },
selected = sortBy == option,
)
},
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.IconsaxSort,
contentDescription = "Sort",
)
}
},
)
}
ButtonGroupDivider()
Box(
contentAlignment = Alignment.Center,
) {
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
visibleTracks.filter { selectedTrackIds.contains(it.id) }
} else {
visibleTracks
}
val trackCount = targetTracks.size
val isAll =
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
AdaptiveDropdownBottomSheet(
items = listOf(
AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) },
),
),
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Bulk actions",
)
}
},
)
}
}
}
}
if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) {
LazyColumn(
state = listState,
modifier = Modifier
.widthIn(max = 1280.dp)
.align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
if (headerContent != null) {
item {
headerContent()
}
}
if (!simplified) {
item { filterSortRow() }
}
item(key = "track-card") { trackCardContent() }
if (footerContent != null) {
item {
footerContent()
}
}
}
if (!useDropdownForOptions) {
selectedTrackForOptions?.let { track ->
TrackOptionsBottomSheet(
track = track,
state = trackOptionsState(track),
onDismiss = { selectedTrackForOptions = null },
onAction = { action ->
onTrackOptionsAction(track, action)
selectedTrackForOptions = null
},
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
)
}
}
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
}
} else {
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
if (headerContent != null) { if (headerContent != null) {
item { headerContent()
headerContent()
}
} }
if (!simplified) if (!simplified) {
item { filterSortRow()
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
.heightIn(max = 60.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
) {
if (showIndex || isSelectionMode) {
val allSelected =
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
val anySelected = selectedTrackIds.isNotEmpty()
val headerState = when {
allSelected -> CheckBoxState.SELECTED
anySelected -> CheckBoxState.INDETERMINATE
else -> CheckBoxState.UNSELECTED
}
CheckBox(
state = headerState,
onClick = {
if (allSelected) {
isSelectionMode = false
selectedTrackIds = emptySet()
} else {
isSelectionMode = true
selectedTrackIds = visibleTracks.map { it.id }.toSet()
}
},
)
}
Box(
modifier = Modifier
.weight(1f)
) {
TextField(
value = filterQuery,
onValueChange = { filterQuery = it },
modifier = Modifier
.widthIn(max = 400.dp)
.align(Alignment.CenterEnd),
placeholder = { TextWithShimmer("Filter") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Filter",
)
},
singleLine = true,
)
}
ButtonGroup {
Box(
contentAlignment = Alignment.Center,
) {
AdaptiveDropdownBottomSheet(
items = TrackSortOption.entries.map { option ->
AdaptiveMenuItem(
label = option.label,
onClick = { sortBy = option },
selected = sortBy == option,
)
},
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.IconsaxSort,
contentDescription = "Sort",
)
}
},
)
}
ButtonGroupDivider()
Box(
contentAlignment = Alignment.Center,
) {
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
visibleTracks.filter { selectedTrackIds.contains(it.id) }
} else {
visibleTracks
}
val trackCount = targetTracks.size
val isAll =
selectedTrackIds.isEmpty() || trackCount == visibleTracks.size
AdaptiveDropdownBottomSheet(
items = listOf(
AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) },
),
AdaptiveMenuItem(
icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) },
),
),
trigger = { onClick ->
GroupIconButton(
onClick = onClick,
) {
Icon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Bulk actions",
)
}
},
)
}
}
}
}
item(key = "track-card") {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListRow(
index = displayedIndex + 1,
track = track,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying,
isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id),
onTrackClick = {
if (isSelectionMode) {
selectedTrackIds =
if (selectedTrackIds.contains(track.id)) {
selectedTrackIds - track.id
} else {
selectedTrackIds + track.id
}
} else {
onTrackClick(track)
}
},
onLongClick = {
if (!useDropdownForOptions && !isSelectionMode) {
isSelectionMode = true
selectedTrackIds = setOf(track.id)
} else if (!useDropdownForOptions) {
selectedTrackForOptions = track
}
},
onSelectionToggle = { checked ->
isSelectionMode = true
selectedTrackIds = if (checked) {
selectedTrackIds + track.id
} else {
selectedTrackIds - track.id
}
},
onTrackOptionsAction = { action ->
onTrackOptionsAction(
track,
action
)
},
trackOptionsState = trackOptionsState(track),
onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
)
}
if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = shimmerIndex + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
if (error != null) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
TrackListFeedbackRow(
message = error,
isError = true,
)
}
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
TrackListFeedbackRow("No tracks found")
}
if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) {
HorizontalDivider(
modifier = Modifier.padding(horizontal = 8.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
ShimmerTrackListRow(
index = visibleTracks.size + 1,
showIndex = showIndex,
showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions,
)
}
}
}
} }
trackCardContent()
if (footerContent != null) { if (footerContent != null) {
item { footerContent()
footerContent()
}
} }
} }
@ -487,9 +526,7 @@ fun TrackList(
) )
} }
} }
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
} }
} }
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)

View File

@ -125,6 +125,7 @@ open class ZiplinePluginService(
} }
private val lifecycleMutex = Mutex() private val lifecycleMutex = Mutex()
private var ziplineLoader: ZiplineLoader private var ziplineLoader: ZiplineLoader
private var ziplineInstance: Zipline? = null
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>() private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
init { init {
@ -271,6 +272,7 @@ open class ZiplinePluginService(
return return
} }
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
withContext(ziplineDispatcher.dispatcher) { withContext(ziplineDispatcher.dispatcher) {
trace("start(): inside zipline dispatcher before loadOnce") trace("start(): inside zipline dispatcher before loadOnce")
val result = ziplineLoader.loadOnce( val result = ziplineLoader.loadOnce(
@ -280,6 +282,8 @@ open class ZiplinePluginService(
) )
when (result) { when (result) {
is LoadResult.Success -> { is LoadResult.Success -> {
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
ziplineInstance = result.zipline
// Now we consume the initializer // Now we consume the initializer
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME) val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
// Bind host services before initialization, so plugins can use them in their initializer // Bind host services before initialization, so plugins can use them in their initializer
@ -310,10 +314,19 @@ open class ZiplinePluginService(
override suspend fun stop() { override suspend fun stop() {
lifecycleMutex.withLock { lifecycleMutex.withLock {
trace("stop(): entered") trace("stop(): entered")
for (service in serviceRegistry.values) { withContext(ziplineDispatcher.dispatcher) {
trace("stop(): closing service ${service::class.simpleName}") ziplineInstance?.close()
service.close() ziplineInstance = null
for (service in serviceRegistry.values) {
try {
trace("stop(): closing service ${service::class.simpleName}")
service.close()
} catch (_: Exception) {
trace("stop(): error closing service ${service::class.simpleName}")
}
}
} }
serviceRegistry.clear()
scope.cancel() scope.cancel()
loggedInStateFlow.value = false loggedInStateFlow.value = false
ziplineDispatcher.close() ziplineDispatcher.close()

View File

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

View File

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

View File

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

View File

@ -49,8 +49,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
import dev.krtirtho.spotube.PlatformType import dev.krtirtho.spotube.PlatformType
import dev.krtirtho.spotube.core.ui.base.ChipTab
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.ArtistCard
@ -80,6 +83,15 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState() val listState = rememberLazyListState()
val selectedGenreId = when (val s = state) {
is HomeScreenState.Data -> s.selectedGenreId
else -> null
}
LaunchedEffect(selectedGenreId) {
listState.scrollToItem(0)
}
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo } snapshotFlow { listState.layoutInfo }
.map { layoutInfo -> .map { layoutInfo ->
@ -119,6 +131,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
listState = listState, listState = listState,
state = state, state = state,
onRetry = { viewModel.refresh() }, onRetry = { viewModel.refresh() },
onGenreSelected = { viewModel.selectGenre(it) },
) )
} else { } else {
PullToRefreshBox( PullToRefreshBox(
@ -132,6 +145,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
listState = listState, listState = listState,
state = state, state = state,
onRetry = { viewModel.refresh() }, onRetry = { viewModel.refresh() },
onGenreSelected = { viewModel.selectGenre(it) },
) )
} }
} }
@ -150,6 +164,7 @@ private fun HomeContent(
listState: androidx.compose.foundation.lazy.LazyListState, listState: androidx.compose.foundation.lazy.LazyListState,
state: HomeScreenState, state: HomeScreenState,
onRetry: () -> Unit, onRetry: () -> Unit,
onGenreSelected: (String) -> Unit,
) { ) {
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) { val contentPadding = remember(shellBottomInset) {
@ -216,12 +231,54 @@ private fun HomeContent(
} }
} }
items(state.browseSections) { section -> if (state.genres.isNotEmpty()) {
HomeSection( item {
title = section.title, GenreTabs(
subtitle = section.description, genres = state.genres,
items = section.items, selectedGenreId = state.selectedGenreId,
) onGenreSelected = onGenreSelected,
)
}
}
val currentSections = state.selectedGenreId?.let { state.browseSections[it] }
if (currentSections == null) {
item {
SkeletonTree(true) {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(180.dp)
.padding(horizontal = 16.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",
)
}
}
}
}
}
} else {
items(currentSections) { section ->
HomeSection(
title = section.title,
subtitle = section.description,
items = section.items,
)
}
} }
if (state is HomeScreenState.Data.LoadingMore) { if (state is HomeScreenState.Data.LoadingMore) {
@ -307,3 +364,26 @@ private fun HomeSection(
} }
} }
@Composable
private fun GenreTabs(
genres: List<MetadataBrowseGenre>,
selectedGenreId: String?,
onGenreSelected: (String) -> Unit,
) {
val rowState = rememberLazyListState()
LazyRow(
state = rowState,
modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items(genres) { genre ->
ChipTab(
text = genre.name,
selected = selectedGenreId == genre.id,
onClick = { onGenreSelected(genre.id) },
)
}
}
}

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.modules.home package dev.krtirtho.spotube.modules.home
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
@ -45,8 +46,10 @@ class HomeScreenRepository(
get() = pluginManager.selectedMetadataPlugin.value get() = pluginManager.selectedMetadataPlugin.value
private val featuredItemCache = Cache.Builder<String, List<MetadataBrowseItem>>().build() private val featuredItemCache = Cache.Builder<String, List<MetadataBrowseItem>>().build()
private val genresCache = Cache.Builder<String, List<MetadataBrowseGenre>>().build()
private val browseItemCache = private val browseItemCache =
Cache.Builder<PaginationStrategy, PaginationResult<MetadataBrowseSection>>().build() Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseSection>>()
.build()
private val sublistItemCache = private val sublistItemCache =
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>() Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
.build() .build()
@ -65,6 +68,7 @@ class HomeScreenRepository(
fun invalidateCaches() { fun invalidateCaches() {
featuredItemCache.invalidateAll() featuredItemCache.invalidateAll()
genresCache.invalidateAll()
browseItemCache.invalidateAll() browseItemCache.invalidateAll()
sublistItemCache.invalidateAll() sublistItemCache.invalidateAll()
} }
@ -79,32 +83,43 @@ class HomeScreenRepository(
} }
} }
suspend fun list(paginationStrategy: PaginationStrategy? = null) = suspend fun genres() = plugin?.let { plugin ->
genresCache.get("genres") {
pluginManager.withScope {
plugin.use {
metadataBrowseAPI.genres()
}
}
}
}
suspend fun list(genreId: String, paginationStrategy: PaginationStrategy? = null) =
plugin?.let { plugin -> plugin?.let { plugin ->
browseItemCache.get( browseItemCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20) key = genreId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
) { ) {
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataBrowseAPI.list(paginationStrategy) metadataBrowseAPI.list(genreId, paginationStrategy)
} }
} }
} }
} }
suspend fun sublist( suspend fun sublist(
genreId: String,
parentId: String, parentId: String,
paginationStrategy: PaginationStrategy? = null paginationStrategy: PaginationStrategy? = null
) = plugin?.let { plugin -> ) = plugin?.let { plugin ->
sublistItemCache.get( sublistItemCache.get(
parentId to (paginationStrategy ?: PaginationStrategy.Offset( "$genreId/$parentId" to (paginationStrategy ?: PaginationStrategy.Offset(
0, 0,
20 20
)) ))
) { ) {
pluginManager.withScope { pluginManager.withScope {
plugin.use { plugin.use {
metadataBrowseAPI.sublist(parentId, paginationStrategy) metadataBrowseAPI.sublist(genreId, parentId, paginationStrategy)
} }
} }
} }

View File

@ -19,6 +19,7 @@ package dev.krtirtho.spotube.modules.home
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
@ -27,19 +28,11 @@ import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.modules.plugin.PluginManager import dev.krtirtho.spotube.modules.plugin.PluginManager
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.scan
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -48,29 +41,33 @@ sealed interface HomeScreenState {
sealed interface Data : HomeScreenState { sealed interface Data : HomeScreenState {
val featuredItems: List<MetadataBrowseItem> val featuredItems: List<MetadataBrowseItem>
val browseSections: List<MetadataBrowseSection> val genres: List<MetadataBrowseGenre>
val paginationStrategy: PaginationStrategy? val selectedGenreId: String?
val totalItems: Int val browseSections: Map<String, List<MetadataBrowseSection>?>
val paginationStrategies: Map<String, PaginationStrategy?>
data class Loaded( data class Loaded(
override val featuredItems: List<MetadataBrowseItem>, override val featuredItems: List<MetadataBrowseItem>,
override val browseSections: List<MetadataBrowseSection>, override val genres: List<MetadataBrowseGenre>,
override val paginationStrategy: PaginationStrategy?, override val selectedGenreId: String?,
override val totalItems: Int, override val browseSections: Map<String, List<MetadataBrowseSection>?>,
override val paginationStrategies: Map<String, PaginationStrategy?>,
) : Data { ) : Data {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
featuredItems = featuredItems, featuredItems = featuredItems,
genres = genres,
selectedGenreId = selectedGenreId,
browseSections = browseSections, browseSections = browseSections,
paginationStrategy = paginationStrategy, paginationStrategies = paginationStrategies,
totalItems = totalItems,
) )
} }
data class LoadingMore( data class LoadingMore(
override val featuredItems: List<MetadataBrowseItem>, override val featuredItems: List<MetadataBrowseItem>,
override val browseSections: List<MetadataBrowseSection>, override val genres: List<MetadataBrowseGenre>,
override val paginationStrategy: PaginationStrategy?, override val selectedGenreId: String?,
override val totalItems: Int, override val browseSections: Map<String, List<MetadataBrowseSection>?>,
override val paginationStrategies: Map<String, PaginationStrategy?>,
) : Data ) : Data
} }
@ -99,30 +96,67 @@ class HomeScreenViewModel(
private suspend fun loadInitialData() = runCatching { private suspend fun loadInitialData() = runCatching {
state.value = HomeScreenState.Loading state.value = HomeScreenState.Loading
val featuredItems = repository.featuredItems() val featuredItems = repository.featuredItems() ?: emptyList()
val browseSections = repository.list() val genres = repository.genres() ?: emptyList()
val firstGenreId = genres.firstOrNull()?.id
val browseSections = mutableMapOf<String, List<MetadataBrowseSection>?>()
val paginationStrategies = mutableMapOf<String, PaginationStrategy?>()
if (firstGenreId != null) {
val result = repository.list(firstGenreId)
browseSections[firstGenreId] = result?.items ?: emptyList()
paginationStrategies[firstGenreId] = result?.nextPagination
}
state.value = HomeScreenState.Data.Loaded( state.value = HomeScreenState.Data.Loaded(
featuredItems = featuredItems ?: emptyList(), featuredItems = featuredItems,
browseSections = browseSections?.items ?: emptyList(), genres = genres,
paginationStrategy = browseSections?.nextPagination, selectedGenreId = firstGenreId,
totalItems = browseSections?.items?.size ?: 0, browseSections = browseSections,
paginationStrategies = paginationStrategies,
) )
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load home screen data" } logger.e(e) { "Failed to load home screen data" }
state.value = HomeScreenState.Error(e.message ?: "Unknown error") state.value = HomeScreenState.Error(e.message ?: "Unknown error")
} }
fun selectGenre(genreId: String) {
val currentState = state.value
if (currentState is HomeScreenState.Data) {
state.value = when (currentState) {
is HomeScreenState.Data.Loaded -> currentState.copy(selectedGenreId = genreId)
is HomeScreenState.Data.LoadingMore -> currentState.copy(selectedGenreId = genreId)
}
if (currentState.browseSections[genreId] == null) {
viewModelScope.launch {
val result = repository.list(genreId)
val current = state.value
if (current is HomeScreenState.Data) {
state.value = HomeScreenState.Data.Loaded(
featuredItems = current.featuredItems,
genres = current.genres,
selectedGenreId = genreId,
browseSections = current.browseSections + (genreId to (result?.items ?: emptyList())),
paginationStrategies = current.paginationStrategies + (genreId to result?.nextPagination),
)
}
}
}
}
}
suspend fun loadMoreData() = runCatching { suspend fun loadMoreData() = runCatching {
val currentState = state.value val currentState = state.value
if (currentState is HomeScreenState.Data.Loaded && currentState.paginationStrategy != null) { if (currentState is HomeScreenState.Data.Loaded) {
val genreId = currentState.selectedGenreId ?: return@runCatching
val pagination = currentState.paginationStrategies[genreId] ?: return@runCatching
state.value = currentState.toLoadingMore() state.value = currentState.toLoadingMore()
val browseSectionsResult = repository.list(currentState.paginationStrategy) val result = repository.list(genreId, pagination)
val existingSections = currentState.browseSections[genreId] ?: emptyList()
state.value = HomeScreenState.Data.Loaded( state.value = HomeScreenState.Data.Loaded(
featuredItems = currentState.featuredItems, featuredItems = currentState.featuredItems,
browseSections = currentState.browseSections + (browseSectionsResult?.items genres = currentState.genres,
?: emptyList()), selectedGenreId = genreId,
paginationStrategy = browseSectionsResult?.nextPagination, browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))),
totalItems = currentState.totalItems, paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination),
) )
} }
}.onFailure { e -> }.onFailure { e ->

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.awaitCancellation
@ -147,9 +148,13 @@ class PluginManager(
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
val ziplineServices = state val ziplineServices = state
.filterIsInstance<PluginManagerStates.Data>() .filterIsInstance<PluginManagerStates.Data>()
.map { it.selectedPlugins } .map { it.selectedPlugins to it.generation }
// Paired with generation so same-version replaces (structurally equal
// PluginEntry) still trigger a restart. Without it the DataStore skips
// the emission (identical JSON) and the flow stays alive with stale code.
.distinctUntilChanged() .distinctUntilChanged()
.flatMapLatest { plugins -> .flatMapLatest { (plugins, gen) ->
logger.d { "ziplineServices: flow restarting with generation=$gen, selectedPlugins=${plugins.keys}" }
flow { flow {
val ziplineServices: MutableMap<PluginAbility, PluginService?> = val ziplineServices: MutableMap<PluginAbility, PluginService?> =
mutableMapOf() mutableMapOf()
@ -166,6 +171,7 @@ class PluginManager(
ziplineServicesByPluginID[plugin.id] = service ziplineServicesByPluginID[plugin.id] = service
service.start() service.start()
} else { } else {
logger.d { "ziplineServices: creating new ZiplinePluginService for ${plugin.name} (${plugin.id})" }
val service = ZiplinePluginService( val service = ZiplinePluginService(
applicationName = plugin.name, applicationName = plugin.name,
manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}", manifestUrl = "http://localhost?path=${(pluginsDir / plugin.id.toPath() / "manifest.zipline.json")}",
@ -180,12 +186,16 @@ class PluginManager(
// Keep the flow alive until the next plugin is selected. // Keep the flow alive until the next plugin is selected.
awaitCancellation() awaitCancellation()
} finally { } finally {
// Stop services that are no longer selected withContext(NonCancellable) {
val selectedPluginIDs = plugins.values.map { it.id }.toSet() logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" }
ziplineServicesByPluginID.forEach { (pluginID, service) -> ziplineServicesByPluginID.forEach { (_, service) ->
if (!selectedPluginIDs.contains(pluginID)) { try {
service.stop() service.stop()
} catch (_: Exception) {
logger.e { "ziplineServices: error stopping service" }
}
} }
logger.d { "ziplineServices: all services stopped" }
} }
} }
} }
@ -495,7 +505,12 @@ class PluginManager(
currentState.selectedPlugins.mapValues { (_, selectedPlugin) -> currentState.selectedPlugins.mapValues { (_, selectedPlugin) ->
if (selectedPlugin.id == plugin.id) plugin else selectedPlugin if (selectedPlugin.id == plugin.id) plugin else selectedPlugin
} }
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins) val newState = PluginManagerStates.Data(
plugins = updatedPlugins,
selectedPlugins = updatedSelectedPlugins,
generation = currentState.generation + 1
)
logger.d { "addPlugin: bumped generation to ${newState.generation} for plugin ${plugin.id}" }
updatePluginsState(newState) updatePluginsState(newState)
} }
} }
@ -510,7 +525,11 @@ class PluginManager(
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id } val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
val updatedSelectedPlugins = val updatedSelectedPlugins =
currentState.selectedPlugins.filterValues { it.id != plugin.id } currentState.selectedPlugins.filterValues { it.id != plugin.id }
val newState = PluginManagerStates.Data(updatedPlugins, updatedSelectedPlugins) val newState = PluginManagerStates.Data(
plugins = updatedPlugins,
selectedPlugins = updatedSelectedPlugins,
generation = currentState.generation
)
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val pluginDir = pluginsDir / plugin.id.toPath() val pluginDir = pluginsDir / plugin.id.toPath()

View File

@ -55,7 +55,12 @@ sealed class PluginManagerStates {
@Serializable @Serializable
data class Data( data class Data(
val plugins: List<PluginEntry>, val plugins: List<PluginEntry>,
val selectedPlugins: Map<PluginAbility, PluginEntry> = emptyMap() val selectedPlugins: Map<PluginAbility, PluginEntry> = emptyMap(),
// Bumped on every addPlugin so the DataStore always detects a structural
// change — without it, same-version replaces produce identical JSON and
// the DataStore's internal distinctUntilChanged blocks the state emission,
// leaving the ziplineServices flow stuck with the old code.
val generation: Long = 0L
) : PluginManagerStates() ) : PluginManagerStates()
data object Loading : PluginManagerStates() data object Loading : PluginManagerStates()

View File

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

View File

@ -17,6 +17,7 @@
package dev.krtirtho.js_plugin_example.plugin_apis.metadata package dev.krtirtho.js_plugin_example.plugin_apis.metadata
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseGenre
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseItem
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseSection
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
@ -28,11 +29,22 @@ class RealMetadataBrowseAPI : MetadataBrowseAPI {
return FakeMetadataStore.getFeaturedItems() return FakeMetadataStore.getFeaturedItems()
} }
override suspend fun list(pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> { override suspend fun genres(): List<MetadataBrowseGenre> {
return listOf(
MetadataBrowseGenre(id = "1", name = "Pop"),
MetadataBrowseGenre(id = "2", name = "Rock"),
MetadataBrowseGenre(id = "3", name = "Hip-Hop"),
MetadataBrowseGenre(id = "4", name = "Jazz"),
MetadataBrowseGenre(id = "5", name = "Classical")
)
}
override suspend fun list(genreId: String, pagination: PaginationStrategy?): PaginationResult<MetadataBrowseSection> {
return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination) return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination)
} }
override suspend fun sublist( override suspend fun sublist(
genreId: String,
sectionId: String, sectionId: String,
pagination: PaginationStrategy? pagination: PaginationStrategy?
): PaginationResult<MetadataBrowseItem> { ): PaginationResult<MetadataBrowseItem> {

View File

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

View File

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

View File

@ -24,6 +24,7 @@ const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI"
interface MetadataBrowseAPI: ZiplineService { interface MetadataBrowseAPI: ZiplineService {
suspend fun featured(): List<MetadataBrowseItem> suspend fun featured(): List<MetadataBrowseItem>
suspend fun list(pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection> suspend fun genres(): List<MetadataBrowseGenre>
suspend fun sublist(sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem> suspend fun list(genreId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
suspend fun sublist(genreId: String, sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
} }

View File

@ -54,3 +54,9 @@ data class MetadataBrowseSection(
val items: List<MetadataBrowseItem>, val items: List<MetadataBrowseItem>,
val moreLink: String? = null val moreLink: String? = null
) )
@Serializable
data class MetadataBrowseGenre(
val id: String,
val name: String,
)