From 1e842826aff91f1af43eb6e59c5cf36bf340cd13 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 11 Mar 2026 23:05:12 +0530 Subject: [PATCH] feat: implement hero section with carousel and settings management for home screen --- .../commonMain/kotlin/com/nuvio/app/App.kt | 274 +++++++++--------- .../com/nuvio/app/core/ui/NuvioComponents.kt | 3 +- .../app/features/catalog/CatalogScreen.kt | 4 +- .../details/components/DetailActionButtons.kt | 4 +- .../app/features/home/HomeCatalogParser.kt | 2 + .../home/HomeCatalogSettingsRepository.kt | 84 +++++- .../com/nuvio/app/features/home/HomeModels.kt | 3 + .../nuvio/app/features/home/HomeRepository.kt | 19 +- .../com/nuvio/app/features/home/HomeScreen.kt | 13 +- .../home/components/HomeHeroSection.kt | 266 +++++++++++++++++ .../home/components/HomePosterCard.kt | 2 +- .../features/search/SearchDiscoverContent.kt | 2 +- .../settings/HomescreenSettingsPage.kt | 36 +++ .../features/settings/SettingsComponents.kt | 52 ++++ .../app/features/settings/SettingsScreen.kt | 14 +- 15 files changed, 620 insertions(+), 158 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 2dc1a9882..b9d55a814 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.tween import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Search @@ -19,16 +17,14 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.zIndex +import androidx.navigation.NavDestination +import androidx.navigation.NavDestination.Companion.hasRoute +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import coil3.ImageLoader @@ -41,7 +37,6 @@ import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsScreen import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.HomeScreen -import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.streams.StreamsRepository @@ -49,7 +44,13 @@ import com.nuvio.app.features.streams.StreamsScreen import kotlinx.serialization.Serializable @Serializable -object TabsRoute +object HomeRoute + +@Serializable +object SearchRoute + +@Serializable +object SettingsRoute @Serializable data class DetailRoute(val type: String, val id: String) @@ -85,29 +86,6 @@ enum class AppScreenTab { Settings, } -@Composable -fun AppScreen( - tab: AppScreenTab, - modifier: Modifier = Modifier, - onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, - onPosterClick: ((MetaPreview) -> Unit)? = null, -) { - when (tab) { - AppScreenTab.Home -> HomeScreen( - modifier = modifier, - onCatalogClick = onCatalogClick, - onPosterClick = onPosterClick, - ) - AppScreenTab.Search -> SearchScreen( - modifier = modifier, - onPosterClick = onPosterClick, - ) - AppScreenTab.Settings -> SettingsScreen( - modifier = modifier, - ) - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable @Preview @@ -119,7 +97,10 @@ fun App() { } NuvioTheme { val navController = rememberNavController() - var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + val navBackStackEntry = navController.currentBackStackEntryAsState().value + val currentDestination = navBackStackEntry?.destination + val currentTab = AppScreenTab.entries.firstOrNull { tab -> tab.matches(currentDestination) } + val showBottomBar = currentTab != null val onPlay: (String, String, String, String?, String?, String?, Int?, Int?, String?, String?) -> Unit = { type, videoId, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail -> @@ -162,119 +143,148 @@ fun App() { containerColor = MaterialTheme.colorScheme.background, contentWindowInsets = WindowInsets(0), bottomBar = { - NavigationBar( - containerColor = MaterialTheme.colorScheme.surface, - windowInsets = WindowInsets(0), - ) { - NavigationBarItem( - selected = selectedTab == AppScreenTab.Home, - onClick = { selectedTab = AppScreenTab.Home }, - icon = { Icon(Icons.Rounded.Home, contentDescription = null) }, - label = { Text("Home") }, - ) - NavigationBarItem( - selected = selectedTab == AppScreenTab.Search, - onClick = { selectedTab = AppScreenTab.Search }, - icon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - label = { Text("Search") }, - ) - NavigationBarItem( - selected = selectedTab == AppScreenTab.Settings, - onClick = { selectedTab = AppScreenTab.Settings }, - icon = { Icon(Icons.Rounded.Settings, contentDescription = null) }, - label = { Text("Settings") }, - ) + if (showBottomBar) { + NavigationBar( + containerColor = MaterialTheme.colorScheme.surface, + windowInsets = WindowInsets(0), + ) { + AppScreenTab.entries.forEach { tab -> + NavigationBarItem( + selected = currentTab == tab, + onClick = { navController.navigateToTab(tab) }, + icon = { + Icon( + imageVector = tab.icon(), + contentDescription = null, + ) + }, + label = { Text(tab.label()) }, + ) + } + } } }, ) { innerPadding -> - Box( + NavHost( + navController = navController, + startDestination = HomeRoute, modifier = Modifier .fillMaxSize() .padding(innerPadding), ) { - AppScreenTab.entries.forEach { tab -> - val tabAlpha by animateFloatAsState( - targetValue = if (selectedTab == tab) 1f else 0f, - animationSpec = tween(durationMillis = 220), - label = "tab_alpha_${tab.name}", - ) - AppScreen( - tab = tab, - modifier = Modifier - .fillMaxSize() - .alpha(tabAlpha) - .zIndex(if (selectedTab == tab) 1f else 0f), + composable { + HomeScreen( + modifier = Modifier.fillMaxSize(), onCatalogClick = onCatalogClick, onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, ) } - } - } - - NavHost( - navController = navController, - startDestination = TabsRoute, - modifier = Modifier.fillMaxSize(), - ) { - composable { - Unit - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - MetaDetailsScreen( - type = route.type, - id = route.id, - onBack = { - MetaDetailsRepository.clear() - navController.popBackStack() - }, - onPlay = onPlay, - modifier = Modifier.fillMaxSize(), - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - StreamsScreen( - type = route.type, - videoId = route.videoId, - title = route.title, - logo = route.logo, - poster = route.poster, - background = route.background, - seasonNumber = route.seasonNumber, - episodeNumber = route.episodeNumber, - episodeTitle = route.episodeTitle, - episodeThumbnail = route.episodeThumbnail, - onBack = { - StreamsRepository.clear() - navController.popBackStack() - }, - modifier = Modifier.fillMaxSize(), - ) - } - composable { backStackEntry -> - val route = backStackEntry.toRoute() - CatalogScreen( - title = route.title, - subtitle = route.subtitle, - manifestUrl = route.manifestUrl, - type = route.type, - catalogId = route.catalogId, - supportsPagination = route.supportsPagination, - genre = route.genre, - onBack = { - CatalogRepository.clear() - navController.popBackStack() - }, - onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) - }, - modifier = Modifier.fillMaxSize(), - ) + composable { + SearchScreen( + modifier = Modifier.fillMaxSize(), + onPosterClick = { meta -> + navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + }, + ) + } + composable { + SettingsScreen( + modifier = Modifier.fillMaxSize(), + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + MetaDetailsScreen( + type = route.type, + id = route.id, + onBack = { + MetaDetailsRepository.clear() + navController.popBackStack() + }, + onPlay = onPlay, + modifier = Modifier.fillMaxSize(), + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + StreamsScreen( + type = route.type, + videoId = route.videoId, + title = route.title, + logo = route.logo, + poster = route.poster, + background = route.background, + seasonNumber = route.seasonNumber, + episodeNumber = route.episodeNumber, + episodeTitle = route.episodeTitle, + episodeThumbnail = route.episodeThumbnail, + onBack = { + StreamsRepository.clear() + navController.popBackStack() + }, + modifier = Modifier.fillMaxSize(), + ) + } + composable { backStackEntry -> + val route = backStackEntry.toRoute() + CatalogScreen( + title = route.title, + subtitle = route.subtitle, + manifestUrl = route.manifestUrl, + type = route.type, + catalogId = route.catalogId, + supportsPagination = route.supportsPagination, + genre = route.genre, + onBack = { + CatalogRepository.clear() + navController.popBackStack() + }, + onPosterClick = { meta -> + navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + }, + modifier = Modifier.fillMaxSize(), + ) + } } } } } } + +private fun AppScreenTab.matches(destination: NavDestination?): Boolean = + when (this) { + AppScreenTab.Home -> destination?.hasRoute() == true + AppScreenTab.Search -> destination?.hasRoute() == true + AppScreenTab.Settings -> destination?.hasRoute() == true + } + +private fun AppScreenTab.icon() = + when (this) { + AppScreenTab.Home -> Icons.Rounded.Home + AppScreenTab.Search -> Icons.Rounded.Search + AppScreenTab.Settings -> Icons.Rounded.Settings + } + +private fun AppScreenTab.label(): String = + when (this) { + AppScreenTab.Home -> "Home" + AppScreenTab.Search -> "Search" + AppScreenTab.Settings -> "Settings" + } + +private fun androidx.navigation.NavHostController.navigateToTab(tab: AppScreenTab) { + val route = when (tab) { + AppScreenTab.Home -> HomeRoute + AppScreenTab.Search -> SearchRoute + AppScreenTab.Settings -> SettingsRoute + } + navigate(route) { + launchSingleTop = true + restoreState = true + popUpTo(graph.findStartDestination().id) { + saveState = true + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index 134c3b805..70488a823 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -54,6 +54,7 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack fun NuvioScreen( modifier: Modifier = Modifier, horizontalPadding: Dp = 16.dp, + topPadding: Dp? = null, content: LazyListScope.() -> Unit, ) { val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -63,7 +64,7 @@ fun NuvioScreen( .background(MaterialTheme.colorScheme.background), contentPadding = PaddingValues( start = horizontalPadding, - top = 10.dp + statusBarTop + nuvioPlatformExtraTopPadding, + top = topPadding ?: 10.dp + statusBarTop + nuvioPlatformExtraTopPadding, end = horizontalPadding, bottom = 18.dp + nuvioPlatformExtraBottomPadding, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index 85b902dd1..da8b7530f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -75,7 +75,7 @@ fun CatalogScreen( catalogId = catalogId, genre = genre, supportsPagination = supportsPagination, - force = true, + force = false, ) } @@ -225,7 +225,7 @@ private fun CatalogPosterTile( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" } + val detail = item.releaseInfo if (detail != null) { Text( text = detail, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 8d515bf78..6d77bb617 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -38,7 +38,7 @@ fun DetailActionButtons( .weight(1f) .height(50.dp), - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(40.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.onBackground, contentColor = MaterialTheme.colorScheme.background, @@ -61,7 +61,7 @@ fun DetailActionButtons( modifier = Modifier .weight(1f) .height(50.dp), - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(40.dp), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline), ) { Icon( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 8d2d82e48..1d8885ab1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -30,6 +30,8 @@ internal object HomeCatalogParser { type = type, name = name, poster = meta.string("poster"), + banner = meta.string("banner") ?: meta.string("background"), + logo = meta.string("logo"), posterShape = meta.string("posterShape").toPosterShape(), description = meta.string("description"), releaseInfo = meta.string("releaseInfo"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index c56a5a343..95eeb63ad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -15,6 +15,7 @@ data class HomeCatalogSettingsItem( val addonName: String, val customTitle: String = "", val enabled: Boolean = true, + val heroSourceEnabled: Boolean = true, val order: Int = 0, ) { val displayTitle: String @@ -22,28 +23,48 @@ data class HomeCatalogSettingsItem( } data class HomeCatalogSettingsUiState( + val heroEnabled: Boolean = true, val items: List = emptyList(), ) { val signature: String - get() = items.joinToString(separator = "|") { item -> - "${item.key}:${item.order}:${item.enabled}:${item.customTitle}" + get() = buildString { + append(heroEnabled) + append('|') + append( + items.joinToString(separator = "|") { item -> + "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" + } + ) } } internal data class HomeCatalogPreference( val customTitle: String, val enabled: Boolean, + val heroSourceEnabled: Boolean, val order: Int, ) +internal data class HomeCatalogSettingsSnapshot( + val heroEnabled: Boolean, + val preferences: Map, +) + @Serializable private data class StoredHomeCatalogPreference( val key: String, val customTitle: String = "", val enabled: Boolean = true, + val heroSourceEnabled: Boolean = true, val order: Int = 0, ) +@Serializable +private data class StoredHomeCatalogSettingsPayload( + val heroEnabled: Boolean = true, + val items: List = emptyList(), +) + object HomeCatalogSettingsRepository { private val json = Json { ignoreUnknownKeys = true @@ -56,6 +77,7 @@ object HomeCatalogSettingsRepository { private var hasLoaded = false private var definitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() + private var heroEnabled = true fun syncCatalogs(addons: List) { ensureLoaded() @@ -65,14 +87,32 @@ object HomeCatalogSettingsRepository { persist() } - internal fun snapshot(): Map { + internal fun snapshot(): HomeCatalogSettingsSnapshot { ensureLoaded() - return preferences.mapValues { (_, value) -> - HomeCatalogPreference( - customTitle = value.customTitle, - enabled = value.enabled, - order = value.order, - ) + return HomeCatalogSettingsSnapshot( + heroEnabled = heroEnabled, + preferences = preferences.mapValues { (_, value) -> + HomeCatalogPreference( + customTitle = value.customTitle, + enabled = value.enabled, + heroSourceEnabled = value.heroSourceEnabled, + order = value.order, + ) + }, + ) + } + + fun setHeroEnabled(enabled: Boolean) { + ensureLoaded() + heroEnabled = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + } + + fun setHeroSourceEnabled(key: String, enabled: Boolean) { + updatePreference(key) { preference -> + preference.copy(heroSourceEnabled = enabled) } } @@ -103,11 +143,21 @@ object HomeCatalogSettingsRepository { val payload = HomeCatalogSettingsStorage.loadPayload().orEmpty().trim() if (payload.isEmpty()) return - val stored = runCatching { + val parsedPayload = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + if (parsedPayload != null) { + heroEnabled = parsedPayload.heroEnabled + preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() + return + } + + val legacyItems = runCatching { json.decodeFromString>(payload) }.getOrDefault(emptyList()) - preferences = stored.associateBy { it.key }.toMutableMap() + preferences = legacyItems.associateBy { it.key }.toMutableMap() } private fun normalizePreferences() { @@ -132,6 +182,7 @@ object HomeCatalogSettingsRepository { key = definition.key, customTitle = stored?.customTitle.orEmpty(), enabled = stored?.enabled ?: true, + heroSourceEnabled = stored?.heroSourceEnabled ?: true, order = index, ) } @@ -149,17 +200,24 @@ object HomeCatalogSettingsRepository { addonName = definition.addonName, customTitle = preference?.customTitle.orEmpty(), enabled = preference?.enabled ?: true, + heroSourceEnabled = preference?.heroSourceEnabled ?: true, order = preference?.order ?: 0, ) } - _uiState.value = HomeCatalogSettingsUiState(items = items) + _uiState.value = HomeCatalogSettingsUiState( + heroEnabled = heroEnabled, + items = items, + ) } private fun persist() { HomeCatalogSettingsStorage.savePayload( json.encodeToString( - preferences.values.sortedBy { it.order }, + StoredHomeCatalogSettingsPayload( + heroEnabled = heroEnabled, + items = preferences.values.sortedBy { it.order }, + ), ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index 9d87d2c0d..49aee223f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -8,6 +8,8 @@ data class MetaPreview( val type: String, val name: String, val poster: String? = null, + val banner: String? = null, + val logo: String? = null, val posterShape: PosterShape = PosterShape.Poster, val description: String? = null, val releaseInfo: String? = null, @@ -38,6 +40,7 @@ fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean = data class HomeUiState( val isLoading: Boolean = false, + val heroItems: List = emptyList(), val sections: List = emptyList(), val errorMessage: String? = null, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index c7c6212cb..3968def22 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlin.random.Random object HomeRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -64,7 +65,8 @@ object HomeRepository { } fun applyCurrentSettings() { - val preferences = HomeCatalogSettingsRepository.snapshot() + val snapshot = HomeCatalogSettingsRepository.snapshot() + val preferences = snapshot.preferences val sections = currentDefinitions .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } .mapNotNull { definition -> @@ -78,8 +80,21 @@ object HomeRepository { ) } + val heroItems = if (snapshot.heroEnabled) { + currentDefinitions + .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } + .mapNotNull { definition -> cachedSections[definition.key] } + .flatMap { section -> section.items } + .distinctBy { item -> "${item.type}:${item.id}" } + .shuffled(Random.Default) + .take(HOME_HERO_ITEM_LIMIT) + } else { + emptyList() + } + _uiState.value = HomeUiState( isLoading = false, + heroItems = heroItems, sections = sections, errorMessage = if (sections.isEmpty()) lastErrorMessage else null, ) @@ -107,3 +122,5 @@ object HomeRepository { ) } } + +private const val HOME_HERO_ITEM_LIMIT = 8 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 8e8e54dff..8c9ab3e52 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -12,6 +12,7 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard +import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonRow @Composable @@ -48,6 +49,7 @@ fun HomeScreen( NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, + topPadding = if (homeUiState.heroItems.isNotEmpty()) 0.dp else null, ) { when { addonsUiState.addons.none { it.manifest != null } -> { @@ -66,7 +68,7 @@ fun HomeScreen( } } - homeUiState.sections.isEmpty() -> { + homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() -> { item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), @@ -78,6 +80,15 @@ fun HomeScreen( } else -> { + if (homeUiState.heroItems.isNotEmpty()) { + item { + HomeHeroSection( + items = homeUiState.heroItems, + modifier = Modifier.padding(bottom = 0.dp), + onItemClick = onPosterClick, + ) + } + } items( count = homeUiState.sections.size, key = { index -> homeUiState.sections[index].key }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt new file mode 100644 index 000000000..0494d429f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -0,0 +1,266 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.nuvio.app.features.home.MetaPreview +import kotlinx.coroutines.launch + +@Composable +fun HomeHeroSection( + items: List, + modifier: Modifier = Modifier, + onItemClick: ((MetaPreview) -> Unit)? = null, +) { + if (items.isEmpty()) return + + val pagerState = rememberPagerState(pageCount = { items.size }) + val coroutineScope = rememberCoroutineScope() + + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)), + ) { + val heroHeight = (maxWidth.value * 1.22f).dp.coerceIn(440.dp, 800.dp) + val currentItem = items[pagerState.currentPage.coerceIn(items.indices)] + + Box( + modifier = Modifier + .fillMaxWidth() + .height(heroHeight), + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .alpha(0.01f), + ) { + Box(modifier = Modifier.fillMaxSize()) + } + + Box( + modifier = Modifier.fillMaxSize(), + ) { + Crossfade( + targetState = currentItem, + animationSpec = tween(durationMillis = 320), + label = "home-hero-background", + ) { item -> + AsyncImage( + model = item.banner ?: item.poster, + contentDescription = item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.background.copy(alpha = 0.02f), + MaterialTheme.colorScheme.background.copy(alpha = 0.12f), + MaterialTheme.colorScheme.background.copy(alpha = 0.34f), + MaterialTheme.colorScheme.background.copy(alpha = 0.78f), + ), + ), + ), + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(220.dp) + .align(Alignment.BottomCenter) + .background( + Brush.verticalGradient( + colors = listOf( + MaterialTheme.colorScheme.background.copy(alpha = 0f), + MaterialTheme.colorScheme.background, + ), + ), + ), + ) + + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Crossfade( + targetState = currentItem, + animationSpec = tween(durationMillis = 240), + label = "home-hero-content", + ) { item -> + HeroContentBlock( + item = item, + onItemClick = onItemClick, + ) + } + + Spacer(modifier = Modifier.height(14.dp)) + Surface( + modifier = Modifier + .clickable(enabled = onItemClick != null) { + onItemClick?.invoke(currentItem) + }, + color = MaterialTheme.colorScheme.onBackground, + contentColor = MaterialTheme.colorScheme.background, + shape = RoundedCornerShape(40.dp), + ) { + Text( + text = "View Details", + modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + if (items.size > 1) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + items.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .clickable { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + } + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground) + .alpha(if (pagerState.currentPage == index) 0.92f else 0.35f) + .then( + if (pagerState.currentPage == index) { + Modifier.width(32.dp) + } else { + Modifier.width(8.dp) + }, + ) + .height(8.dp), + ) + } + } + } + } + } + } + } +} + +@Composable +private fun HeroContentBlock( + item: MetaPreview, + onItemClick: ((MetaPreview) -> Unit)?, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (item.logo != null) { + AsyncImage( + model = item.logo, + contentDescription = item.name, + modifier = Modifier + .fillMaxWidth(0.62f) + .aspectRatio(2.6f) + .clickable(enabled = onItemClick != null) { + onItemClick?.invoke(item) + }, + contentScale = ContentScale.Fit, + ) + } else { + Text( + text = item.name, + modifier = Modifier.clickable(enabled = onItemClick != null) { + onItemClick?.invoke(item) + }, + style = MaterialTheme.typography.displaySmall, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.Black, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.height(12.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HeroMetaText(text = item.type.replaceFirstChar(Char::uppercase)) + item.genres.firstOrNull()?.let { genre -> + HeroMetaDot() + HeroMetaText(text = genre) + } + item.releaseInfo?.takeIf { it.isNotBlank() }?.let { info -> + HeroMetaDot() + HeroMetaText(text = info) + } + } + } +} + +@Composable +private fun HeroMetaText(text: String) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onBackground, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) +} + +@Composable +private fun HeroMetaDot() { + Box( + modifier = Modifier + .size(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f)), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt index 8498122b8..c9febac6f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomePosterCard.kt @@ -18,7 +18,7 @@ fun HomePosterCard( imageUrl = item.poster, modifier = modifier, shape = item.posterShape.toNuvioPosterShape(), - detailLine = listOfNotNull(item.releaseInfo, item.imdbRating?.let { "IMDb $it" }).joinToString(" • "), + detailLine = item.releaseInfo, onClick = onClick, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 30c151ec8..b38122291 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -279,7 +279,7 @@ private fun DiscoverPosterTile( maxLines = 2, overflow = TextOverflow.Ellipsis, ) - val detail = item.releaseInfo ?: item.imdbRating?.let { "IMDb $it" } + val detail = item.releaseInfo if (detail != null) { Text( text = detail, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index 0efde0a54..4f76fbe0b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -11,8 +11,44 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, + heroEnabled: Boolean, items: List, ) { + item { + SettingsSection( + title = "HERO", + isTablet = isTablet, + ) { + SettingsSwitchRow( + title = "Show Hero", + description = "Display a featured hero carousel at the top of Home.", + checked = heroEnabled, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, + ) + } + } + item { + if (items.isNotEmpty()) { + SettingsSection( + title = "HERO SOURCES", + isTablet = isTablet, + ) { + items.forEachIndexed { index, item -> + SettingsSwitchRow( + title = item.displayTitle, + description = item.addonName, + checked = item.heroSourceEnabled, + isTablet = isTablet, + onCheckedChange = { HomeCatalogSettingsRepository.setHeroSourceEnabled(item.key, it) }, + ) + if (index < items.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + } + } + } + } item { if (items.isEmpty()) { HomeEmptyStateCard( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index cbaa8b714..82ac25daa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -220,6 +220,58 @@ internal fun SettingsNavigationRow( } } +@Composable +internal fun SettingsSwitchRow( + title: String, + description: String? = null, + checked: Boolean, + isTablet: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + val verticalPadding = if (isTablet) 16.dp else 14.dp + val horizontalPadding = if (isTablet) 20.dp else 16.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onCheckedChange(!checked) } + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .padding(end = 12.dp) + .widthIn(max = if (isTablet) 560.dp else 280.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + if (!description.isNullOrBlank()) { + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, + uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + ), + ) + } +} + @Composable internal fun HomescreenCatalogRow( item: HomeCatalogSettingsItem, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 5b5048620..8a7bc9d21 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -73,12 +73,14 @@ fun SettingsScreen( TabletSettingsScreen( page = page, onPageChange = { currentPage = it.name }, + heroEnabled = homescreenSettingsUiState.heroEnabled, homescreenSettings = homescreenSettingsUiState.items, ) } else { MobileSettingsScreen( page = page, onPageChange = { currentPage = it.name }, + heroEnabled = homescreenSettingsUiState.heroEnabled, homescreenSettings = homescreenSettingsUiState.items, ) } @@ -89,6 +91,7 @@ fun SettingsScreen( private fun MobileSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, + heroEnabled: Boolean, homescreenSettings: List, ) { NuvioScreen { @@ -111,10 +114,11 @@ private fun MobileSettingsScreen( onHomescreenClick = { onPageChange(SettingsPage.Homescreen) }, ) SettingsPage.Addons -> addonsSettingsContent() - SettingsPage.Homescreen -> homescreenSettingsContent( - isTablet = false, - items = homescreenSettings, - ) + SettingsPage.Homescreen -> homescreenSettingsContent( + isTablet = false, + heroEnabled = heroEnabled, + items = homescreenSettings, + ) } } } @@ -123,6 +127,7 @@ private fun MobileSettingsScreen( private fun TabletSettingsScreen( page: SettingsPage, onPageChange: (SettingsPage) -> Unit, + heroEnabled: Boolean, homescreenSettings: List, ) { var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } @@ -196,6 +201,7 @@ private fun TabletSettingsScreen( SettingsPage.Addons -> addonsSettingsContent() SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, + heroEnabled = heroEnabled, items = homescreenSettings, ) }