feat: add genre selection and tabs to HomeScreen for improved content navigation

This commit is contained in:
Kingkor Roy Tirtho 2026-07-07 13:38:31 +06:00
parent 7d4d11705a
commit 8f9c2540a2
6 changed files with 194 additions and 46 deletions

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

@ -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

@ -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

@ -53,4 +53,10 @@ data class MetadataBrowseSection(
val description: String? = null,
val items: List<MetadataBrowseItem>,
val moreLink: String? = null
)
@Serializable
data class MetadataBrowseGenre(
val id: String,
val name: String,
)