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

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

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