diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt index 2058ef3e..c34d53ee 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreen.kt @@ -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, + 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) }, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt index 7bab59d3..ecffbd25 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenRepository.kt @@ -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>().build() + private val genresCache = Cache.Builder>().build() private val browseItemCache = - Cache.Builder>().build() + Cache.Builder, PaginationResult>() + .build() private val sublistItemCache = Cache.Builder, PaginationResult>() .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) } } } diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt index 2140b11e..f77c8d86 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/modules/home/HomeScreenViewModel.kt @@ -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 - val browseSections: List - val paginationStrategy: PaginationStrategy? - val totalItems: Int + val genres: List + val selectedGenreId: String? + val browseSections: Map?> + val paginationStrategies: Map data class Loaded( override val featuredItems: List, - override val browseSections: List, - override val paginationStrategy: PaginationStrategy?, - override val totalItems: Int, + override val genres: List, + override val selectedGenreId: String?, + override val browseSections: Map?>, + override val paginationStrategies: Map, ) : 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, - override val browseSections: List, - override val paginationStrategy: PaginationStrategy?, - override val totalItems: Int, + override val genres: List, + override val selectedGenreId: String?, + override val browseSections: Map?>, + override val paginationStrategies: Map, ) : 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?>() + val paginationStrategies = mutableMapOf() + 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 -> diff --git a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt index b1df8827..4c7d13a4 100644 --- a/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt +++ b/js_plugin_example/src/jsMain/kotlin/dev/krtirtho/js_plugin_example/plugin_apis/metadata/RealMetadataBrowseAPI.kt @@ -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 { + override suspend fun genres(): List { + 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 { return FakeMetadataStore.paginate(FakeMetadataStore.getBrowseSections(), pagination) } override suspend fun sublist( + genreId: String, sectionId: String, pagination: PaginationStrategy? ): PaginationResult { diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt index 41325ff4..13e00a08 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt +++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseAPI.kt @@ -24,6 +24,7 @@ const val MetadataBrowseAPI_SERVICE_NAME = "MetadataBrowseAPI" interface MetadataBrowseAPI: ZiplineService { suspend fun featured(): List - suspend fun list(pagination: PaginationStrategy? = null): PaginationResult - suspend fun sublist(sectionId: String, pagination: PaginationStrategy? = null): PaginationResult + suspend fun genres(): List + suspend fun list(genreId: String, pagination: PaginationStrategy? = null): PaginationResult + suspend fun sublist(genreId: String, sectionId: String, pagination: PaginationStrategy? = null): PaginationResult } \ No newline at end of file diff --git a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt index 445a4760..d02745a2 100644 --- a/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/dev/krtirtho/plugin_interfaces/plugin_apis/metadata/browse/MetadataBrowseModels.kt @@ -53,4 +53,10 @@ data class MetadataBrowseSection( val description: String? = null, val items: List, val moreLink: String? = null +) + +@Serializable +data class MetadataBrowseGenre( + val id: String, + val name: String, ) \ No newline at end of file