mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-08-05 19:59:51 +00:00
feat: add genre selection and tabs to HomeScreen for improved content navigation
This commit is contained in:
parent
7d4d11705a
commit
8f9c2540a2
@ -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,13 +231,55 @@ private fun HomeContent(
|
||||
}
|
||||
}
|
||||
|
||||
items(state.browseSections) { section ->
|
||||
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) {
|
||||
item {
|
||||
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ->
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
@ -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,
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user