refactor: improve HomeScreen and related components for better layout handling

This commit is contained in:
tapframe 2026-04-07 19:59:58 +05:30
parent 7a76d9d38c
commit 4baa0504dd
5 changed files with 339 additions and 241 deletions

View file

@ -1,7 +1,6 @@
package com.nuvio.app
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@ -12,7 +11,6 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@ -43,6 +41,7 @@ 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.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
@ -1427,53 +1426,54 @@ private fun AppTabHost(
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {},
) {
val tabStateHolder = rememberSaveableStateHolder()
Box(modifier = modifier.fillMaxSize()) {
keepAliveTab(
selected = selectedTab == AppScreenTab.Home,
) {
HomeScreen(
modifier = Modifier.fillMaxSize(),
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
keepAliveTab(
selected = selectedTab == AppScreenTab.Search,
) {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
keepAliveTab(
selected = selectedTab == AppScreenTab.Library,
) {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onLibraryPosterClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
)
}
keepAliveTab(
selected = selectedTab == AppScreenTab.Settings,
) {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onCollectionsClick = onCollectionsSettingsClick,
)
tabStateHolder.SaveableStateProvider(selectedTab.name) {
when (selectedTab) {
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
AppScreenTab.Search -> {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
AppScreenTab.Library -> {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onLibraryPosterClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
)
}
AppScreenTab.Settings -> {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}
}
}
}
}
@ -1650,24 +1650,3 @@ private fun AppLaunchOverlay(
}
}
}
@Composable
private fun BoxScope.keepAliveTab(
selected: Boolean,
content: @Composable () -> Unit,
) {
val contentAlpha by animateFloatAsState(
targetValue = if (selected) 1f else 0f,
animationSpec = tween(durationMillis = 220),
label = "tab_content_alpha",
)
Box(
modifier = Modifier
.fillMaxSize()
.alpha(contentAlpha)
.zIndex(if (selected) 1f else 0f),
) {
content()
}
}

View file

@ -1,5 +1,7 @@
package com.nuvio.app.features.home
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -45,6 +47,8 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
@Composable
fun HomeScreen(
@ -286,127 +290,148 @@ fun HomeScreen(
onFirstCatalogRendered?.invoke()
}
NuvioScreen(
modifier = modifier,
horizontalPadding = 0.dp,
topPadding = if (showHeroSlot) 0.dp else null,
) {
if (showHeroSlot) {
item {
when {
showHeroSkeleton -> HomeSkeletonHero(
modifier = Modifier,
)
val visibleCollections = remember(collections) {
collections.filter { it.folders.isNotEmpty() }
}
val collectionsMap = remember(visibleCollections) {
visibleCollections.associateBy { "collection_${it.id}" }
}
val sectionsMap = remember(homeUiState.sections) {
homeUiState.sections.associateBy(HomeCatalogSection::key)
}
val enabledHomeItems = remember(homeSettingsUiState.items) {
homeSettingsUiState.items.filter { it.enabled }
}
homeUiState.heroItems.isNotEmpty() -> HomeHeroSection(
items = homeUiState.heroItems,
modifier = Modifier,
onItemClick = onPosterClick,
)
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
else -> HomeHeroReservedSpace(modifier = Modifier)
}
}
}
when {
addonsUiState.addons.none { it.manifest != null } -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
NuvioScreen(
modifier = Modifier.fillMaxSize(),
horizontalPadding = 0.dp,
topPadding = if (showHeroSlot) 0.dp else null,
) {
if (showHeroSlot) {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No active addons",
message = "Install and validate at least one addon before loading catalog rows on Home.",
)
}
}
homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
when {
showHeroSkeleton -> HomeSkeletonHero(
modifier = Modifier,
)
homeUiState.heroItems.isNotEmpty() -> HomeHeroSection(
items = homeUiState.heroItems,
modifier = Modifier,
onItemClick = onPosterClick,
)
else -> HomeHeroReservedSpace(modifier = Modifier)
}
}
items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available",
message = homeUiState.errorMessage
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
)
}
}
else -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
modifier = Modifier.padding(bottom = 12.dp),
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
val collectionsMap = collections.filter { it.folders.isNotEmpty() }
.associateBy { "collection_${it.id}" }
val sectionsMap = homeUiState.sections.associateBy { it.key }
val orderedItems = homeSettingsUiState.items.filter { it.enabled }
orderedItems.forEach { settingsItem ->
if (settingsItem.isCollection) {
val collection = collectionsMap[settingsItem.key]
if (collection != null) {
item(key = settingsItem.key) {
HomeCollectionRowSection(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
onFolderClick = onFolderClick,
)
}
when {
addonsUiState.addons.none { it.manifest != null } -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
} else {
val section = sectionsMap[settingsItem.key]
if (section != null && section.items.isNotEmpty()) {
item(key = settingsItem.key) {
HomeCatalogRowSection(
section = section,
entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT),
modifier = Modifier.padding(bottom = 12.dp),
onViewAllClick = if (section.canOpenCatalog(HOME_CATALOG_PREVIEW_LIMIT)) {
onCatalogClick?.let { { it(section) } }
} else {
null
},
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No active addons",
message = "Install and validate at least one addon before loading catalog rows on Home.",
)
}
}
homeUiState.isLoading && homeUiState.sections.isEmpty() -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
items(3) {
HomeSkeletonRow(modifier = Modifier.padding(horizontal = 16.dp))
}
}
homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() &&
(!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> {
item {
HomeEmptyStateCard(
modifier = Modifier.padding(horizontal = 16.dp),
title = "No home rows available",
message = homeUiState.errorMessage
?: "Installed addons do not currently expose board-compatible catalogs without required extras.",
)
}
}
else -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
items = continueWatchingItems,
style = continueWatchingPreferences.style,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
layout = continueWatchingLayout,
onItemClick = onContinueWatchingClick,
onItemLongPress = onContinueWatchingLongPress,
)
}
}
enabledHomeItems.forEach { settingsItem ->
if (settingsItem.isCollection) {
val collection = collectionsMap[settingsItem.key]
if (collection != null) {
item(key = settingsItem.key) {
HomeCollectionRowSection(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
onFolderClick = onFolderClick,
)
}
}
} else {
val section = sectionsMap[settingsItem.key]
if (section != null && section.items.isNotEmpty()) {
item(key = settingsItem.key) {
HomeCatalogRowSection(
section = section,
entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT),
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
onViewAllClick = if (section.canOpenCatalog(HOME_CATALOG_PREVIEW_LIMIT)) {
onCatalogClick?.let { { it(section) } }
} else {
null
},
watchedKeys = watchedUiState.watchedKeys,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
}
}
}

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.features.home.HomeCatalogSection
@ -18,31 +19,67 @@ fun HomeCatalogRowSection(
modifier: Modifier = Modifier,
entries: List<MetaPreview> = section.items,
watchedKeys: Set<String> = emptySet(),
sectionPadding: Dp? = null,
onViewAllClick: (() -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
) {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
NuvioShelfSection(
title = section.title,
if (sectionPadding != null) {
HomeCatalogRowSectionContent(
section = section,
entries = entries,
modifier = Modifier.fillMaxWidth(),
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
watchedKeys = watchedKeys,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
onViewAllClick = onViewAllClick,
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> item.stableKey() },
) { item ->
HomePosterCard(
item = item,
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
} else {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
HomeCatalogRowSectionContent(
section = section,
entries = entries,
watchedKeys = watchedKeys,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
onViewAllClick = onViewAllClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
)
}
}
}
@Composable
private fun HomeCatalogRowSectionContent(
section: HomeCatalogSection,
entries: List<MetaPreview>,
watchedKeys: Set<String>,
modifier: Modifier,
sectionPadding: Dp,
onViewAllClick: (() -> Unit)?,
onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?,
) {
NuvioShelfSection(
title = section.title,
entries = entries,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
onViewAllClick = onViewAllClick,
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> item.stableKey() },
) { item ->
HomePosterCard(
item = item,
isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedKeys,
item = item,
),
onClick = onPosterClick?.let { { it(item) } },
onLongClick = onPosterLongClick?.let { { it(item) } },
)
}
}

View file

@ -34,28 +34,52 @@ import com.nuvio.app.features.home.PosterShape
fun HomeCollectionRowSection(
collection: Collection,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) {
if (collection.folders.isEmpty()) return
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
NuvioShelfSection(
title = collection.title,
entries = collection.folders,
modifier = Modifier.fillMaxWidth(),
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
) { folder ->
CollectionFolderCard(
folder = folder,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
if (sectionPadding != null) {
HomeCollectionRowSectionContent(
collection = collection,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
onFolderClick = onFolderClick,
)
} else {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
HomeCollectionRowSectionContent(
collection = collection,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
onFolderClick = onFolderClick,
)
}
}
}
@Composable
private fun HomeCollectionRowSectionContent(
collection: Collection,
modifier: Modifier,
sectionPadding: Dp,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
NuvioShelfSection(
title = collection.title,
entries = collection.folders,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
) { folder ->
CollectionFolderCard(
folder = folder,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
)
}
}
@Composable
private fun CollectionFolderCard(
folder: CollectionFolder,

View file

@ -45,41 +45,74 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlin.math.roundToInt
@Composable
fun HomeContinueWatchingSection(
internal fun HomeContinueWatchingSection(
items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
layout: ContinueWatchingLayout? = null,
onItemClick: ((ContinueWatchingItem) -> Unit)? = null,
onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null,
) {
if (items.isEmpty()) return
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val layout = rememberContinueWatchingLayout(maxWidth.value)
val sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
NuvioShelfSection(
title = "Continue Watching",
entries = items,
modifier = Modifier.fillMaxWidth(),
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
itemSpacing = layout.itemGap,
key = { item -> item.videoId },
) { item ->
when (style) {
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
}
if (sectionPadding != null && layout != null) {
HomeContinueWatchingSectionContent(
items = items,
style = style,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
layout = layout,
onItemClick = onItemClick,
onItemLongPress = onItemLongPress,
)
} else {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
HomeContinueWatchingSectionContent(
items = items,
style = style,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
layout = rememberContinueWatchingLayout(maxWidth.value),
onItemClick = onItemClick,
onItemLongPress = onItemLongPress,
)
}
}
}
@Composable
private fun HomeContinueWatchingSectionContent(
items: List<ContinueWatchingItem>,
style: ContinueWatchingSectionStyle,
modifier: Modifier,
sectionPadding: Dp,
layout: ContinueWatchingLayout,
onItemClick: ((ContinueWatchingItem) -> Unit)?,
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
) {
NuvioShelfSection(
title = "Continue Watching",
entries = items,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
itemSpacing = layout.itemGap,
key = { item -> item.videoId },
) { item ->
when (style) {
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
item = item,
layout = layout,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard(
item = item,
layout = layout,
onClick = onItemClick?.let { { it(item) } },
onLongClick = onItemLongPress?.let { { it(item) } },
)
}
}
}
@ -460,7 +493,7 @@ private fun UpNextBadge(
}
}
private data class ContinueWatchingLayout(
internal data class ContinueWatchingLayout(
val itemGap: Dp,
val wideCardWidth: Dp,
val wideCardHeight: Dp,
@ -479,7 +512,7 @@ private data class ContinueWatchingLayout(
val posterBadgeTextSize: androidx.compose.ui.unit.TextUnit,
)
private fun rememberContinueWatchingLayout(maxWidthDp: Float): ContinueWatchingLayout =
internal fun rememberContinueWatchingLayout(maxWidthDp: Float): ContinueWatchingLayout =
when {
maxWidthDp >= 1440f -> ContinueWatchingLayout(
itemGap = 20.dp,