feat: implement hero section with carousel and settings management for home screen

This commit is contained in:
tapframe 2026-03-11 23:05:12 +05:30
parent 5c998be2c2
commit 1e842826af
15 changed files with 620 additions and 158 deletions

View file

@ -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<HomeRoute> {
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<TabsRoute> {
Unit
}
composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>()
MetaDetailsScreen(
type = route.type,
id = route.id,
onBack = {
MetaDetailsRepository.clear()
navController.popBackStack()
},
onPlay = onPlay,
modifier = Modifier.fillMaxSize(),
)
}
composable<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
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<CatalogRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CatalogRoute>()
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<SearchRoute> {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
},
)
}
composable<SettingsRoute> {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
)
}
composable<DetailRoute> { backStackEntry ->
val route = backStackEntry.toRoute<DetailRoute>()
MetaDetailsScreen(
type = route.type,
id = route.id,
onBack = {
MetaDetailsRepository.clear()
navController.popBackStack()
},
onPlay = onPlay,
modifier = Modifier.fillMaxSize(),
)
}
composable<StreamRoute> { backStackEntry ->
val route = backStackEntry.toRoute<StreamRoute>()
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<CatalogRoute> { backStackEntry ->
val route = backStackEntry.toRoute<CatalogRoute>()
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<HomeRoute>() == true
AppScreenTab.Search -> destination?.hasRoute<SearchRoute>() == true
AppScreenTab.Settings -> destination?.hasRoute<SettingsRoute>() == 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
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<HomeCatalogSettingsItem> = 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<String, HomeCatalogPreference>,
)
@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<StoredHomeCatalogPreference> = emptyList(),
)
object HomeCatalogSettingsRepository {
private val json = Json {
ignoreUnknownKeys = true
@ -56,6 +77,7 @@ object HomeCatalogSettingsRepository {
private var hasLoaded = false
private var definitions: List<HomeCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
private var heroEnabled = true
fun syncCatalogs(addons: List<ManagedAddon>) {
ensureLoaded()
@ -65,14 +87,32 @@ object HomeCatalogSettingsRepository {
persist()
}
internal fun snapshot(): Map<String, HomeCatalogPreference> {
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<StoredHomeCatalogSettingsPayload>(payload)
}.getOrNull()
if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
return
}
val legacyItems = runCatching {
json.decodeFromString<List<StoredHomeCatalogPreference>>(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 },
),
),
)
}

View file

@ -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<MetaPreview> = emptyList(),
val sections: List<HomeCatalogSection> = emptyList(),
val errorMessage: String? = null,
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,8 +11,44 @@ import com.nuvio.app.features.home.components.HomeEmptyStateCard
internal fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean,
heroEnabled: Boolean,
items: List<HomeCatalogSettingsItem>,
) {
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(

View file

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

View file

@ -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<HomeCatalogSettingsItem>,
) {
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<HomeCatalogSettingsItem>,
) {
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,
)
}