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

View File

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

View File

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

View File

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

View File

@ -49,8 +49,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
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.MetadataBrowseSection
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.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard
@ -80,6 +83,15 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
val listState = rememberLazyListState()
val selectedGenreId = when (val s = state) {
is HomeScreenState.Data -> s.selectedGenreId
else -> null
}
LaunchedEffect(selectedGenreId) {
listState.scrollToItem(0)
}
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo }
.map { layoutInfo ->
@ -119,6 +131,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
listState = listState,
state = state,
onRetry = { viewModel.refresh() },
onGenreSelected = { viewModel.selectGenre(it) },
)
} else {
PullToRefreshBox(
@ -132,6 +145,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
listState = listState,
state = state,
onRetry = { viewModel.refresh() },
onGenreSelected = { viewModel.selectGenre(it) },
)
}
}
@ -150,6 +164,7 @@ private fun HomeContent(
listState: androidx.compose.foundation.lazy.LazyListState,
state: HomeScreenState,
onRetry: () -> Unit,
onGenreSelected: (String) -> Unit,
) {
val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) {
@ -216,12 +231,54 @@ private fun HomeContent(
}
}
items(state.browseSections) { section ->
HomeSection(
title = section.title,
subtitle = section.description,
items = section.items,
)
if (state.genres.isNotEmpty()) {
item {
GenreTabs(
genres = state.genres,
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) {
@ -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
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.MetadataBrowseSection
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
@ -45,8 +46,10 @@ class HomeScreenRepository(
get() = pluginManager.selectedMetadataPlugin.value
private val featuredItemCache = Cache.Builder<String, List<MetadataBrowseItem>>().build()
private val genresCache = Cache.Builder<String, List<MetadataBrowseGenre>>().build()
private val browseItemCache =
Cache.Builder<PaginationStrategy, PaginationResult<MetadataBrowseSection>>().build()
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseSection>>()
.build()
private val sublistItemCache =
Cache.Builder<Pair<String, PaginationStrategy>, PaginationResult<MetadataBrowseItem>>()
.build()
@ -65,6 +68,7 @@ class HomeScreenRepository(
fun invalidateCaches() {
featuredItemCache.invalidateAll()
genresCache.invalidateAll()
browseItemCache.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 ->
browseItemCache.get(
key = paginationStrategy ?: PaginationStrategy.Offset(0, 20)
key = genreId to (paginationStrategy ?: PaginationStrategy.Offset(0, 20))
) {
pluginManager.withScope {
plugin.use {
metadataBrowseAPI.list(paginationStrategy)
metadataBrowseAPI.list(genreId, paginationStrategy)
}
}
}
}
suspend fun sublist(
genreId: String,
parentId: String,
paginationStrategy: PaginationStrategy? = null
) = plugin?.let { plugin ->
sublistItemCache.get(
parentId to (paginationStrategy ?: PaginationStrategy.Offset(
"$genreId/$parentId" to (paginationStrategy ?: PaginationStrategy.Offset(
0,
20
))
) {
pluginManager.withScope {
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.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.MetadataBrowseSection
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 kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
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 org.koin.core.component.KoinComponent
@ -48,29 +41,33 @@ sealed interface HomeScreenState {
sealed interface Data : HomeScreenState {
val featuredItems: List<MetadataBrowseItem>
val browseSections: List<MetadataBrowseSection>
val paginationStrategy: PaginationStrategy?
val totalItems: Int
val genres: List<MetadataBrowseGenre>
val selectedGenreId: String?
val browseSections: Map<String, List<MetadataBrowseSection>?>
val paginationStrategies: Map<String, PaginationStrategy?>
data class Loaded(
override val featuredItems: List<MetadataBrowseItem>,
override val browseSections: List<MetadataBrowseSection>,
override val paginationStrategy: PaginationStrategy?,
override val totalItems: Int,
override val genres: List<MetadataBrowseGenre>,
override val selectedGenreId: String?,
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
override val paginationStrategies: Map<String, PaginationStrategy?>,
) : Data {
fun toLoadingMore(): LoadingMore = LoadingMore(
featuredItems = featuredItems,
genres = genres,
selectedGenreId = selectedGenreId,
browseSections = browseSections,
paginationStrategy = paginationStrategy,
totalItems = totalItems,
paginationStrategies = paginationStrategies,
)
}
data class LoadingMore(
override val featuredItems: List<MetadataBrowseItem>,
override val browseSections: List<MetadataBrowseSection>,
override val paginationStrategy: PaginationStrategy?,
override val totalItems: Int,
override val genres: List<MetadataBrowseGenre>,
override val selectedGenreId: String?,
override val browseSections: Map<String, List<MetadataBrowseSection>?>,
override val paginationStrategies: Map<String, PaginationStrategy?>,
) : Data
}
@ -99,30 +96,67 @@ class HomeScreenViewModel(
private suspend fun loadInitialData() = runCatching {
state.value = HomeScreenState.Loading
val featuredItems = repository.featuredItems()
val browseSections = repository.list()
val featuredItems = repository.featuredItems() ?: emptyList()
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(
featuredItems = featuredItems ?: emptyList(),
browseSections = browseSections?.items ?: emptyList(),
paginationStrategy = browseSections?.nextPagination,
totalItems = browseSections?.items?.size ?: 0,
featuredItems = featuredItems,
genres = genres,
selectedGenreId = firstGenreId,
browseSections = browseSections,
paginationStrategies = paginationStrategies,
)
}.onFailure { e ->
logger.e(e) { "Failed to load home screen data" }
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 {
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()
val browseSectionsResult = repository.list(currentState.paginationStrategy)
val result = repository.list(genreId, pagination)
val existingSections = currentState.browseSections[genreId] ?: emptyList()
state.value = HomeScreenState.Data.Loaded(
featuredItems = currentState.featuredItems,
browseSections = currentState.browseSections + (browseSectionsResult?.items
?: emptyList()),
paginationStrategy = browseSectionsResult?.nextPagination,
totalItems = currentState.totalItems,
genres = currentState.genres,
selectedGenreId = genreId,
browseSections = currentState.browseSections + (genreId to (existingSections + (result?.items ?: emptyList()))),
paginationStrategies = currentState.paginationStrategies + (genreId to result?.nextPagination),
)
}
}.onFailure { e ->

View File

@ -34,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitCancellation
@ -147,9 +148,13 @@ class PluginManager(
@OptIn(ExperimentalCoroutinesApi::class)
val ziplineServices = state
.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()
.flatMapLatest { plugins ->
.flatMapLatest { (plugins, gen) ->
logger.d { "ziplineServices: flow restarting with generation=$gen, selectedPlugins=${plugins.keys}" }
flow {
val ziplineServices: MutableMap<PluginAbility, PluginService?> =
mutableMapOf()
@ -166,6 +171,7 @@ class PluginManager(
ziplineServicesByPluginID[plugin.id] = service
service.start()
} else {
logger.d { "ziplineServices: creating new ZiplinePluginService for ${plugin.name} (${plugin.id})" }
val service = ZiplinePluginService(
applicationName = plugin.name,
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.
awaitCancellation()
} finally {
// Stop services that are no longer selected
val selectedPluginIDs = plugins.values.map { it.id }.toSet()
ziplineServicesByPluginID.forEach { (pluginID, service) ->
if (!selectedPluginIDs.contains(pluginID)) {
service.stop()
withContext(NonCancellable) {
logger.d { "ziplineServices: finally block stopping ${ziplineServicesByPluginID.size} services" }
ziplineServicesByPluginID.forEach { (_, service) ->
try {
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) ->
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)
}
}
@ -510,7 +525,11 @@ class PluginManager(
val updatedPlugins = currentState.plugins.filterNot { it.id == plugin.id }
val updatedSelectedPlugins =
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) {
val pluginDir = pluginsDir / plugin.id.toPath()

View File

@ -55,7 +55,12 @@ sealed class PluginManagerStates {
@Serializable
data class Data(
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()
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.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistOverview
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
class RealMetadataArtistAPI : MetadataArtistAPI {
@ -29,10 +31,28 @@ class RealMetadataArtistAPI : MetadataArtistAPI {
return FakeMetadataStore.getArtist(id)
}
override suspend fun artistOverview(id: String): MetadataArtistOverview {
TODO("Not yet implemented")
}
override suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack> {
return FakeMetadataStore.getArtistTopTracks(id)
}
override suspend fun relatedArtists(
id: String,
pagination: PaginationStrategy?
): PaginationResult<MetadataArtist.Basic> {
TODO("Not yet implemented")
}
override suspend fun featuredPlaylists(
id: String,
pagination: PaginationStrategy?
): PaginationResult<MetadataPlaylist> {
TODO("Not yet implemented")
}
override suspend fun getArtistAlbums(
id: String,
pagination: PaginationStrategy?

View File

@ -17,6 +17,7 @@
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.MetadataBrowseGenre
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.common.PaginationStrategy
@ -28,11 +29,22 @@ class RealMetadataBrowseAPI : MetadataBrowseAPI {
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)
}
override suspend fun sublist(
genreId: String,
sectionId: String,
pagination: PaginationStrategy?
): 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.common.PaginationStrategy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.PaginationResult
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
const val MetadataArtistAPI_SERVICE_NAME = "MetadataArtistAPI"
interface MetadataArtistAPI : ZiplineService {
suspend fun getArtist(id: String): MetadataArtist.Detailed
suspend fun artistOverview(id: String): MetadataArtistOverview
suspend fun getArtistTop10Tracks(id: String): List<MetadataTrack>
suspend fun relatedArtists(
id: String,
pagination: PaginationStrategy? = null
): PaginationResult<MetadataArtist.Basic>
suspend fun featuredPlaylists(
id: String,
pagination: PaginationStrategy? = null
): PaginationResult<MetadataPlaylist>
suspend fun getArtistAlbums(
id: String,
pagination: PaginationStrategy? = null

View File

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

View File

@ -24,6 +24,7 @@ const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI"
interface MetadataBrowseAPI: ZiplineService {
suspend fun featured(): List<MetadataBrowseItem>
suspend fun list(pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseSection>
suspend fun sublist(sectionId: String, pagination: PaginationStrategy? = null): PaginationResult<MetadataBrowseItem>
suspend fun genres(): List<MetadataBrowseGenre>
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 moreLink: String? = null
)
@Serializable
data class MetadataBrowseGenre(
val id: String,
val name: String,
)