mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-14 19:40:23 +00:00
feat: implement hero section with carousel and settings management for home screen
This commit is contained in:
parent
5c998be2c2
commit
1e842826af
15 changed files with 620 additions and 158 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue