diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt index a79692fe..a83f115d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MediaCard.kt @@ -485,6 +485,7 @@ fun MediaCard( * Placeholder card shown while Continue Watching data loads. * Displays a skeleton animation to indicate loading state. */ +@OptIn(ExperimentalTvMaterial3Api::class) @Composable private fun PlaceholderCard( width: Dp, @@ -548,6 +549,15 @@ fun PosterCard( modifier: Modifier = Modifier, useWhiteBorder: Boolean = true, ) { + if (item.isPlaceholder) { + PlaceholderCard( + width = width, + isLandscape = false, + modifier = modifier + ) + return + } + var isFocused by remember { mutableStateOf(false) } val visualFocused = isFocusedOverride || isFocused diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt index b0134000..2a760b86 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -393,7 +395,10 @@ private suspend fun androidx.compose.foundation.lazy.LazyListState.animateHomeSc animate( initialValue = 0f, targetValue = deltaPx, - animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing) + animationSpec = spring( + dampingRatio = 0.85f, + stiffness = 200f + ) ) { value, _ -> val step = value - previousValue if (abs(step) > 0.01f) { @@ -1119,6 +1124,9 @@ fun HomeScreen( clockFormat = uiState.clockFormat, syncStatus = uiState.syncStatus, hasUpdateBadge = uiState.hasUpdateBadge, + categoryHasMoreMap = uiState.categoryHasMoreMap, + smoothScrolling = uiState.smoothScrolling, + onLoadMoreCategory = { viewModel.loadNextPageForCategory(it) }, onItemFocusedPrefetch = {}, onMobileCategoryVisiblePosition = { categoryId, lastVisibleItemIndex -> viewModel.onMobileCategoryVisiblePosition(categoryId, lastVisibleItemIndex) @@ -2246,6 +2254,9 @@ private fun HomeInputLayer( clockFormat: String = "24h", syncStatus: com.arflix.tv.data.repository.CloudSyncStatus = com.arflix.tv.data.repository.CloudSyncStatus.NOT_SIGNED_IN, hasUpdateBadge: Boolean = false, + categoryHasMoreMap: Map = emptyMap(), + smoothScrolling: Boolean = true, + onLoadMoreCategory: (String) -> Unit = {}, onItemFocusedPrefetch: (MediaItem) -> Unit = {}, onMobileCategoryVisiblePosition: (String, Int) -> Unit = { _, _ -> }, onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit, @@ -2341,7 +2352,8 @@ private fun HomeInputLayer( } } - val focusedRowItemCount = categories.getOrNull(focusState.currentRowIndex)?.items?.size ?: 0 + val focusedCategory = categories.getOrNull(focusState.currentRowIndex) + val focusedRowItemCount = focusedCategory?.items?.size ?: 0 LaunchedEffect(focusState.currentRowIndex, focusedRowItemCount) { if (focusedRowItemCount <= 0) { if (focusState.currentItemIndex != 0) focusState.currentItemIndex = 0 @@ -2596,6 +2608,9 @@ private fun HomeInputLayer( fastScrollThresholdMs = fastScrollThresholdMs, usePosterCards = usePosterCards, isMobile = isMobile, + categoryHasMoreMap = categoryHasMoreMap, + smoothScrolling = smoothScrolling, + onLoadMoreCategory = onLoadMoreCategory, onItemFocusedPrefetch = onItemFocusedPrefetch, heroItem = heroItem, heroOverviewOverride = heroOverviewOverride, @@ -2632,6 +2647,9 @@ private fun HomeRowsLayer( fastScrollThresholdMs: Long, usePosterCards: Boolean, isMobile: Boolean = false, + categoryHasMoreMap: Map = emptyMap(), + smoothScrolling: Boolean = true, + onLoadMoreCategory: (String) -> Unit = {}, onItemFocusedPrefetch: (MediaItem) -> Unit = {}, heroItem: MediaItem? = null, heroOverviewOverride: String? = null, @@ -2648,6 +2666,8 @@ private fun HomeRowsLayer( cardLogoUrls = cardLogoUrls, contentStartPadding = contentStartPadding, usePosterCards = usePosterCards, + categoryHasMoreMap = categoryHasMoreMap, + onLoadMoreCategory = onLoadMoreCategory, onNavigateToDetails = onNavigateToDetails, onItemClick = onItemClick, onItemLongClick = onItemLongClick, @@ -2670,6 +2690,9 @@ private fun HomeRowsLayer( contentStartPadding = contentStartPadding, fastScrollThresholdMs = fastScrollThresholdMs, usePosterCards = usePosterCards, + categoryHasMoreMap = categoryHasMoreMap, + smoothScrolling = smoothScrolling, + onLoadMoreCategory = onLoadMoreCategory, onItemFocusedPrefetch = onItemFocusedPrefetch, onItemClick = onItemClick ) @@ -2683,6 +2706,8 @@ private fun MobileHomeRowsLayer( cardLogoUrls: Map, contentStartPadding: androidx.compose.ui.unit.Dp, usePosterCards: Boolean, + categoryHasMoreMap: Map = emptyMap(), + onLoadMoreCategory: (String) -> Unit = {}, onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit = { _, _, _, _ -> }, onItemClick: (MediaItem) -> Unit, onItemLongClick: ((MediaItem, Boolean) -> Unit)? = null, @@ -2749,6 +2774,27 @@ private fun MobileHomeRowsLayer( ) } + val rowHasMore = categoryHasMoreMap[category.id] == true + val isPortrait = if (isCollectionRow) { + category.items.firstOrNull()?.collectionTileShape == CollectionTileShape.POSTER + } else { + rowUsePosterCards + } + val skeletonCount = if (isPortrait) 12 else 7 + val itemsToRender = remember(category.items, rowHasMore, isPortrait) { + if (rowHasMore) { + category.items + List(skeletonCount) { idx -> + MediaItem( + id = -1000 - idx, + title = "", + isPlaceholder = true + ) + } + } else { + category.items + } + } + // Horizontal card row with touch scrolling LazyRow( state = rowState, @@ -2762,13 +2808,25 @@ private fun MobileHomeRowsLayer( horizontalArrangement = Arrangement.spacedBy(mobileItemSpacing) ) { itemsIndexed( - category.items, + itemsToRender, key = { _, item -> - val episodeSuffix = if (item.nextEpisode != null) "_S${item.nextEpisode.seasonNumber}E${item.nextEpisode.episodeNumber}" else "" - "${item.mediaType.name}-${item.id}${episodeSuffix}" + if (item.isPlaceholder) "placeholder_${category.id}_${item.id}" + else { + val episodeSuffix = if (item.nextEpisode != null) "_S${item.nextEpisode.seasonNumber}E${item.nextEpisode.episodeNumber}" else "" + "${item.mediaType.name}-${item.id}${episodeSuffix}" + } }, - contentType = { _, item -> "${item.mediaType.name}_mobile_card" } + contentType = { _, item -> if (item.isPlaceholder) "placeholder_card" else "${item.mediaType.name}_mobile_card" } ) { index, item -> + if (item.isPlaceholder) { + LaunchedEffect(item.id) { + onLoadMoreCategory(category.id) + } + } else if (rowHasMore && index >= category.items.size - 5) { + LaunchedEffect(category.items.size) { + onLoadMoreCategory(category.id) + } + } if (isRanked && index < 10) { Box( modifier = Modifier.width(rowMobileItemWidth) @@ -2832,6 +2890,9 @@ private fun TvHomeRowsLayer( contentStartPadding: androidx.compose.ui.unit.Dp, fastScrollThresholdMs: Long, usePosterCards: Boolean, + categoryHasMoreMap: Map = emptyMap(), + smoothScrolling: Boolean = true, + onLoadMoreCategory: (String) -> Unit = {}, onItemFocusedPrefetch: (MediaItem) -> Unit = {}, onItemClick: (MediaItem) -> Unit ) { @@ -2881,6 +2942,18 @@ private fun TvHomeRowsLayer( val localCurrentRowIndex = (currentRowIndex - rowWindowStart) .coerceIn(0, (renderedCategories.size - 1).coerceAtLeast(0)) + val density = LocalDensity.current + val rowLayoutModes = renderedCategories.map { category -> + rememberCatalogueRowLayoutMode("home:${category.id}") == CardLayoutMode.POSTER + } + val categoryHeightsPx = remember(renderedCategories, rowLayoutModes, density) { + renderedCategories.mapIndexed { idx, category -> + val usePoster = rowLayoutModes.getOrNull(idx) ?: false + val heightDp = if (usePoster) 252.dp else 202.dp + with(density) { heightDp.toPx() } + } + } + var isFastScrolling by remember { mutableStateOf(false) } LaunchedEffect(focusState) { snapshotFlow { focusState.lastNavEventTime } @@ -2924,11 +2997,26 @@ private fun TvHomeRowsLayer( if (initialPlacement || jumpDistance > 7) { listState.scrollToItem(index = targetIndex, scrollOffset = 0) } else { - val visibleTarget = listState.layoutInfo.visibleItemsInfo - .firstOrNull { it.index == targetIndex } - if (visibleTarget != null) { + if (smoothScrolling) { + val visibleTarget = listState.layoutInfo.visibleItemsInfo + .firstOrNull { it.index == targetIndex } + val deltaPx = if (visibleTarget != null) { + visibleTarget.offset.toFloat() + } else { + if (targetIndex < currentIndex) { + val intermediateSum = (targetIndex until currentIndex).sumOf { idx -> + categoryHeightsPx.getOrNull(idx)?.toDouble() ?: (202.0 * density.density) + }.toFloat() + -(intermediateSum + currentOffset) + } else { + val intermediateSum = (currentIndex until targetIndex).sumOf { idx -> + categoryHeightsPx.getOrNull(idx)?.toDouble() ?: (202.0 * density.density) + }.toFloat() + intermediateSum - currentOffset + } + } listState.animateHomeScrollDelta( - deltaPx = visibleTarget.offset.toFloat(), + deltaPx = deltaPx, durationMillis = if (jumpDistance >= 3) 180 else 150 ) if ( @@ -2991,13 +3079,11 @@ private fun TvHomeRowsLayer( isRanked = category.title.contains("Top 10", ignoreCase = true), usePosterCards = rowUsePosterCards, startPadding = contentStartPadding, + categoryHasMore = categoryHasMoreMap[category.id] == true, + smoothScrolling = smoothScrolling, + onLoadMore = { onLoadMoreCategory(category.id) }, focusedItemIndex = if (rowIsFocused) focusState.currentItemIndex else -1, isFastScrolling = rowIsFocused && isFastScrolling, - useViewportFocusOverlay = rowIsFocused && homeViewportFocusOverlayActive( - category = category, - focusedItemIndex = focusState.currentItemIndex, - usePosterCards = rowUsePosterCards - ), onItemClick = onItemClick, onItemFocused = { item, itemIdx -> focusState.currentRowIndex = actualRowIndex @@ -3009,40 +3095,10 @@ private fun TvHomeRowsLayer( } } } - val focusedCategory = categories.getOrNull(focusState.currentRowIndex) - if ( - !focusState.isSidebarFocused && - focusedCategory != null - ) { - val focusedRowKey = remember(focusedCategory.id) { "home:${focusedCategory.id}" } - val focusedRowUsePosterCards = rememberCatalogueRowLayoutMode(focusedRowKey) == CardLayoutMode.POSTER - - if (homeViewportFocusOverlayActive( - category = focusedCategory, - focusedItemIndex = focusState.currentItemIndex, - usePosterCards = focusedRowUsePosterCards - )) { - HomeViewportRailFocusOverlay( - category = focusedCategory, - usePosterCards = focusedRowUsePosterCards, - startPadding = contentStartPadding - ) - } - } } } } -@Composable -private fun homeViewportFocusOverlayActive( - category: Category, - focusedItemIndex: Int, - usePosterCards: Boolean -): Boolean { - if (focusedItemIndex < 0 || category.items.isEmpty()) return false - return category.items.size > 1 && focusedItemIndex <= category.items.lastIndex -} - @Composable private fun lockedHomeRailEndPadding( itemWidth: Dp, @@ -3054,40 +3110,6 @@ private fun lockedHomeRailEndPadding( .coerceAtLeast(minimum) } -@Composable -private fun HomeViewportRailFocusOverlay( - category: Category, - usePosterCards: Boolean, - startPadding: androidx.compose.ui.unit.Dp -) { - val isCollectionRow = category.id.startsWith("collection_row_") - val effectivePosterMode = if (isCollectionRow) { - category.items.firstOrNull()?.collectionTileShape == CollectionTileShape.POSTER - } else { - usePosterCards - } - val targetWidth = if (effectivePosterMode) 119.dp else 210.dp - val targetHeight = if (effectivePosterMode) 119.dp * (3f/2f) else 210.dp * (9f/16f) - - ArvioFocusableSurface( - modifier = Modifier - .padding(start = startPadding, top = 48.dp) - .size(width = targetWidth, height = targetHeight) - .zIndex(8f), - shape = rememberArvioCardShape(ArvioSkin.radius.md), - backgroundColor = Color.Transparent, - outlineColor = ArvioSkin.colors.focusOutline, - outlineWidth = 2.5.dp, - focusedScale = 1f, - pressedScale = 0.97f, - animateFocus = false, - enableSystemFocus = false, - isFocusedOverride = true - ) { - // Viewport-level focus lane: rows and cards move under this ring. - } -} - @Composable private fun ArcticFuseRatingBadge( label: String, @@ -3267,9 +3289,11 @@ private fun ContentRow( isRanked: Boolean = false, usePosterCards: Boolean = false, startPadding: androidx.compose.ui.unit.Dp = 12.dp, + categoryHasMore: Boolean = false, + smoothScrolling: Boolean = true, + onLoadMore: () -> Unit = {}, focusedItemIndex: Int, isFastScrolling: Boolean, - useViewportFocusOverlay: Boolean = false, onItemClick: (MediaItem) -> Unit, onItemFocused: (MediaItem, Int) -> Unit ) { @@ -3297,10 +3321,11 @@ private fun ContentRow( val itemSpanPx = remember(density, itemWidth, itemSpacing) { with(density) { (itemWidth + itemSpacing).toPx().coerceAtLeast(1f) } } - val railFocusOverlayActive = !useViewportFocusOverlay && - isCurrentRow && isScrollable && focusedItemIndex >= 0 && totalItems > 0 && - focusedItemIndex <= maxFirstIndex - val focusedCardIndex = if (railFocusOverlayActive || useViewportFocusOverlay) { + val railFocusOverlayActive = isCurrentRow && isScrollable && focusedItemIndex >= 0 && totalItems > 0 && + focusedItemIndex <= maxFirstIndex && + focusedItemIndex == rowState.firstVisibleItemIndex && + rowState.firstVisibleItemScrollOffset == 0 + val focusedCardIndex = if (railFocusOverlayActive) { -1 } else { focusedItemIndex @@ -3338,11 +3363,13 @@ private fun ContentRow( val extraOffset = 0 if (lastScrollIndex == scrollTargetIndex && lastScrollOffset == extraOffset) return@LaunchedEffect - if (lastScrollIndex == -1) { + val isFirstScroll = lastScrollIndex == -1 + lastScrollIndex = scrollTargetIndex + lastScrollOffset = extraOffset + + if (isFirstScroll) { // First time we jump directly to the correct position (no animation) rowState.scrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) - lastScrollIndex = scrollTargetIndex - lastScrollOffset = extraOffset return@LaunchedEffect } @@ -3357,28 +3384,30 @@ private fun ContentRow( targetOutsideViewport || offsetDelta > 1 ) { - val deltaPx = ((scrollTargetIndex - currentFirstIndex) * itemSpanPx) + (extraOffset - currentFirstOffset) - rowState.animateHomeScrollDelta( - deltaPx = deltaPx, - durationMillis = when { - isFastScrolling -> 115 - jumpDistance >= 3 -> 180 - else -> 150 + if (smoothScrolling) { + val deltaPx = ((scrollTargetIndex - currentFirstIndex) * itemSpanPx) + (extraOffset - currentFirstOffset) + rowState.animateHomeScrollDelta( + deltaPx = deltaPx, + durationMillis = when { + isFastScrolling -> 115 + jumpDistance >= 3 -> 180 + else -> 150 + } + ) + if ( + !isFastScrolling && ( + rowState.firstVisibleItemIndex != scrollTargetIndex || + abs(rowState.firstVisibleItemScrollOffset - extraOffset) > 6 + ) + ) { + rowState.scrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) } - ) - if ( - !isFastScrolling && ( - rowState.firstVisibleItemIndex != scrollTargetIndex || - abs(rowState.firstVisibleItemScrollOffset - extraOffset) > 6 - ) - ) { - rowState.scrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) + } else { + rowState.animateScrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) } } else { rowState.scrollToItem(index = scrollTargetIndex, scrollOffset = extraOffset) } - lastScrollIndex = scrollTargetIndex - lastScrollOffset = extraOffset } Column( @@ -3398,6 +3427,21 @@ private fun ContentRow( ) } + val skeletonCount = if (effectivePosterMode) 12 else 7 + val itemsToRender = remember(category.items, categoryHasMore, effectivePosterMode) { + if (categoryHasMore) { + category.items + List(skeletonCount) { idx -> + MediaItem( + id = -1000 - idx, + title = "", + isPlaceholder = true + ) + } + } else { + category.items + } + } + // Cards row - clipped to hide previous items when scrolling val clipModifier = if (isContinueWatching) Modifier else Modifier.clipToBounds() Box( @@ -3419,18 +3463,29 @@ private fun ContentRow( userScrollEnabled = false ) { itemsIndexed( - category.items, + itemsToRender, key = { _, item -> - homeRowItemKey(item) + if (item.isPlaceholder) "placeholder_${category.id}_${item.id}" + else homeRowItemKey(item) }, contentType = { index, item -> when { + item.isPlaceholder -> "placeholder_card" isCollectionRow -> "collection_tile" isRanked && index < 10 -> "${item.mediaType.name}_ranked_card" else -> "${item.mediaType.name}_card" } } ) { index, item -> + if (item.isPlaceholder) { + LaunchedEffect(item.id) { + onLoadMore() + } + } else if (categoryHasMore && index >= category.items.size - 5) { + LaunchedEffect(category.items.size) { + onLoadMore() + } + } val itemIsFocused = isCurrentRow && index == focusedCardIndex val onCardFocused = remember(item, index) { { latestOnItemFocused.value(item, index) } @@ -3457,7 +3512,7 @@ private fun ContentRow( raiseOnFocus = !isFastScrolling, showProgress = false, showTitle = isCollectionRow && !item.collectionHideTitle, - isFocusedOverride = itemIsFocused && !railFocusOverlayActive && !useViewportFocusOverlay, + isFocusedOverride = itemIsFocused && !railFocusOverlayActive, focusedScale = 1f, enableFocusedImageSwap = !isCollectionRow && !isFastScrolling, animateFocus = false, @@ -3488,7 +3543,7 @@ private fun ContentRow( raiseOnFocus = !isFastScrolling, showProgress = isContinueWatching, showTitle = isCollectionRow && !item.collectionHideTitle, - isFocusedOverride = itemIsFocused && !railFocusOverlayActive && !useViewportFocusOverlay, + isFocusedOverride = itemIsFocused && !railFocusOverlayActive, focusedScale = 1f, enableFocusedImageSwap = !isCollectionRow && !isFastScrolling, animateFocus = false, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 4d155e95..eaf4b6e4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -5,6 +5,7 @@ import android.content.Context import com.arflix.tv.util.settingsDataStore import androidx.compose.runtime.mutableStateMapOf import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey import android.os.SystemClock import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -98,7 +99,9 @@ data class HomeUiState( // App Updates val updateStatus: com.arflix.tv.updater.UpdateStatus = com.arflix.tv.updater.UpdateStatus.Idle, val showAppUpdateDialog: Boolean = false, - val hasUpdateBadge: Boolean = false + val hasUpdateBadge: Boolean = false, + val categoryHasMoreMap: Map = emptyMap(), + val smoothScrolling: Boolean = false ) data class HomeCollectionRow( @@ -213,8 +216,80 @@ class HomeViewModel @Inject constructor( } } - private fun catalogInitialLimit(catalog: CatalogConfig): Int { - return if (isHardCappedTop10Catalog(catalog.id)) TOP_10_ITEM_LIMIT else initialCategoryItemCap + private suspend fun isCatalogPosterMode(catalogId: String): Boolean { + return withContext(Dispatchers.IO) { + try { + val prefs = context.settingsDataStore.data.first() + val profileId = profileManager.getProfileIdSync().ifBlank { "default" } + + // 1. Check specific row layout mode + val rowKey = "home:$catalogId" + val normalizedRowKey = com.arflix.tv.ui.components.normalizeCatalogueRowLayoutKey(rowKey) + val rowPrefKey = stringPreferencesKey( + "profile_${profileId}_catalogue_row_layout_${normalizedRowKey}" + ) + val rowValue = prefs[rowPrefKey] + if (rowValue != null) { + return@withContext rowValue.trim().equals("Poster", ignoreCase = true) + } + + // 2. Check profile global default card layout mode + val profilePrefKey = stringPreferencesKey("profile_${profileId}_card_layout_mode") + val profileValue = prefs[profilePrefKey] + if (profileValue != null) { + return@withContext profileValue.trim().equals("Poster", ignoreCase = true) + } + + // 3. Check legacy global default card layout mode + val legacyPrefKey = stringPreferencesKey("card_layout_mode") + val legacyValue = prefs[legacyPrefKey] + if (legacyValue != null) { + return@withContext legacyValue.trim().equals("Poster", ignoreCase = true) + } + + // Default fallback + false + } catch (_: Exception) { + false + } + } + } + + private suspend fun catalogInitialLimit(catalog: CatalogConfig): Int { + if (isHardCappedTop10Catalog(catalog.id)) return TOP_10_ITEM_LIMIT + + if (isCatalogPosterMode(catalog.id)) { + // Dynamic limit calculation for portrait (poster) catalogs + val screenWidthDp = context.resources.configuration.screenWidthDp + val posterWidth = if (isTvDevice) 119 else 124 + val posterSpacing = if (isTvDevice) 14 else 10 + val padding = 16 + + // Calculate how many items fit on the screen + val fitCount = (screenWidthDp - padding) / (posterWidth + posterSpacing) + + // We want to load at least 12 items, or fitCount + 2 (comfort items), whichever is larger + return maxOf(12, fitCount + 2) + } + + return initialCategoryItemCap + } + + private suspend fun getCategoryPageSize(categoryId: String): Int { + if (isCatalogPosterMode(categoryId)) { + // Dynamic limit calculation for portrait (poster) catalogs + val screenWidthDp = context.resources.configuration.screenWidthDp + val posterWidth = if (isTvDevice) 119 else 124 + val posterSpacing = if (isTvDevice) 14 else 10 + val padding = 16 + + // Calculate how many items fit on the screen + val fitCount = (screenWidthDp - padding) / (posterWidth + posterSpacing) + + // We want to load at least 12 items, or fitCount + 2 (comfort items), whichever is larger + return maxOf(12, fitCount + 2) + } + return categoryPageSize } private fun continueWatchingShowKey(item: ContinueWatchingItem): String { @@ -785,7 +860,7 @@ class HomeViewModel @Inject constructor( }.getOrDefault(emptyList()) } // IO concurrency for network requests (logo fetches, catalog loads, etc.) - private val networkParallelism = if (isLowRamDevice) 1 else 2 + private val networkParallelism = if (isLowRamDevice) 4 else 8 private val networkDispatcher = Dispatchers.IO.limitedParallelism(networkParallelism) private var lastContinueWatchingItems: List = emptyList() private var lastContinueWatchingUpdateMs: Long = 0L @@ -1213,12 +1288,17 @@ class HomeViewModel @Inject constructor( val trailerDelaySeconds = (prefs.asMap().entries .firstOrNull { (key, _) -> key.name.endsWith("_trailer_delay_seconds") } ?.value as? String)?.toIntOrNull() ?: 2 + val smoothScrollingExplicit = prefs.asMap().entries + .firstOrNull { (key, _) -> key.name.endsWith("_smooth_scrolling") } + ?.value as? Boolean + val smoothScrolling = smoothScrollingExplicit ?: false _uiState.value = _uiState.value.copy( trailerAutoPlay = trailerEnabled, trailerSoundEnabled = trailerSoundEnabled, trailerDelaySeconds = trailerDelaySeconds, showBudget = showBudget, - clockFormat = clockFormat + clockFormat = clockFormat, + smoothScrolling = smoothScrolling ) } catch (_: Exception) {} } @@ -2098,7 +2178,7 @@ class HomeViewModel @Inject constructor( if (category.id != "continue_watching" && !category.id.startsWith("collection_row_")) { categoryPaginationStates[category.id] = CategoryPaginationState( loadedCount = category.items.size, - hasMore = category.items.size >= categoryPageSize && !isHardCappedTop10Catalog(category.id) + hasMore = category.items.size >= getCategoryPageSize(category.id) && !isHardCappedTop10Catalog(category.id) ) } } @@ -2199,7 +2279,8 @@ class HomeViewModel @Inject constructor( categories = categories, collectionRows = collectionRows, heroItem = heroItem, - heroLogoUrl = heroLogoFromCache ?: _uiState.value.heroLogoUrl + heroLogoUrl = heroLogoFromCache ?: _uiState.value.heroLogoUrl, + categoryHasMoreMap = categoryPaginationStates.mapValues { it.value.hasMore } ) heroItem?.let { item -> if (isStartupSettling()) { @@ -2266,6 +2347,7 @@ class HomeViewModel @Inject constructor( heroItem = heroItem, heroLogoUrl = heroLogoUrl, isAuthenticated = traktRepository.isAuthenticated.first(), + categoryHasMoreMap = categoryPaginationStates.mapValues { it.value.hasMore }, error = null ) heroItem?.let { item -> @@ -2460,7 +2542,10 @@ class HomeViewModel @Inject constructor( } if (!anyChange) return - _uiState.value = latestState.copy(categories = currentCategories) + _uiState.value = latestState.copy( + categories = currentCategories, + categoryHasMoreMap = categoryPaginationStates.mapValues { it.value.hasMore } + ) } suspend fun publishMergedThrottled(force: Boolean = false) { if (!force) { @@ -2576,7 +2661,7 @@ class HomeViewModel @Inject constructor( } } - private fun loadNextPageForCategory(categoryId: String) { + fun loadNextPageForCategory(categoryId: String) { if (isHardCappedTop10Catalog(categoryId)) return val pagination = categoryPaginationStates.getOrPut(categoryId) { CategoryPaginationState( @@ -2586,15 +2671,16 @@ class HomeViewModel @Inject constructor( if (!pagination.hasMore || pagination.isLoading) return pagination.isLoading = true - viewModelScope.launch(networkDispatcher) { + viewModelScope.launch(Dispatchers.IO) { try { val currentCategories = _uiState.value.categories val currentCategory = currentCategories.firstOrNull { it.id == categoryId } ?: return@launch val catalog = savedCatalogById[categoryId] + val pageSize = getCategoryPageSize(categoryId) val result = if (catalog?.isPreinstalled == true && catalog.sourceUrl.isNullOrBlank()) { // Pure TMDB preinstalled catalog (no MDBList source) - val nextPage = (currentCategory.items.size / categoryPageSize) + 1 + val nextPage = (currentCategory.items.size / 20) + 1 mediaRepository.loadHomeCategoryPage(categoryId, nextPage) } else { // MDBList/custom catalog (including preinstalled MDBList ones) @@ -2602,7 +2688,7 @@ class HomeViewModel @Inject constructor( mediaRepository.loadCustomCatalogPage( catalog = cfg, offset = currentCategory.items.size, - limit = categoryPageSize + limit = pageSize ) } @@ -2658,15 +2744,24 @@ class HomeViewModel @Inject constructor( ?: pagination.loadedCount pagination.hasMore = result.hasMore - _uiState.value = _uiState.value.copy(categories = updatedCategories) + _uiState.value = _uiState.value.copy( + categories = updatedCategories, + categoryHasMoreMap = _uiState.value.categoryHasMoreMap + (categoryId to result.hasMore) + ) } catch (_: Exception) { // Keep UI stable; user can retry naturally by continuing to browse the row. } finally { pagination.isLoading = false + updatePaginationStatesInUiState() } } } + private fun updatePaginationStatesInUiState() { + val hasMoreMap = categoryPaginationStates.mapValues { it.value.hasMore } + _uiState.value = _uiState.value.copy(categoryHasMoreMap = hasMoreMap) + } + private fun buildProfileSkeletonCategories( savedCatalogs: List, cachedContinueWatching: List diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 08aa98f8..26d174b9 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -214,7 +214,7 @@ private fun tvGeneralRowsForSection(section: String): List { "subtitles" -> listOf(1, 2, 4, 5, 6, 7, 8, 9) "ai_subtitles" -> listOf(28, 29, 30, 31, 32, 33) "playback" -> listOf(10, 11, 12, 13, 14, 34, 16, 15, 27) - "appearance" -> listOf(17, 18, 20, 21, 24, 23, 22) + "appearance" -> listOf(17, 18, 20, 21, 24, 23, 22, 36) "profiles" -> listOf(19) "network" -> listOf(25, 26, 35) else -> emptyList() @@ -833,6 +833,7 @@ fun SettingsScreen( 20 -> viewModel.setOledBlackBackground(!uiState.oledBlackBackground) 21 -> viewModel.cycleClockFormat() 22 -> viewModel.setShowBudget(!uiState.showBudget) + 36 -> viewModel.setSmoothScrolling(!uiState.smoothScrolling) 23 -> viewModel.setSpoilerBlurEnabled(!uiState.spoilerBlurEnabled) 24 -> viewModel.cycleAccentColor() 25 -> openDnsProviderPicker() @@ -1241,6 +1242,8 @@ fun SettingsScreen( onOledBlackBackgroundToggle = { viewModel.setOledBlackBackground(it) }, onClockFormatClick = { viewModel.cycleClockFormat() }, onShowBudgetToggle = { viewModel.setShowBudget(it) }, + smoothScrolling = uiState.smoothScrolling, + onSmoothScrollingToggle = { viewModel.setSmoothScrolling(it) }, spoilerBlurEnabled = uiState.spoilerBlurEnabled, onSpoilerBlurToggle = { viewModel.setSpoilerBlurEnabled(it) }, accentColor = uiState.accentColor, @@ -3662,7 +3665,7 @@ private fun MobileSettingsSubPage( title = stringResource(R.string.show_budget), value = if (uiState.showBudget) "On" else "Off", isFocused = false, - showDivider = false, + showDivider = true, onClick = { viewModel.setShowBudget(!uiState.showBudget) } ) MobileSettingsRow( @@ -3670,7 +3673,7 @@ private fun MobileSettingsSubPage( title = stringResource(R.string.spoiler_blur), value = if (uiState.spoilerBlurEnabled) "On" else "Off", isFocused = false, - showDivider = false, + showDivider = true, onClick = { viewModel.setSpoilerBlurEnabled(!uiState.spoilerBlurEnabled) } ) MobileSettingsRow( @@ -3678,9 +3681,18 @@ private fun MobileSettingsSubPage( title = stringResource(R.string.accent_color), value = uiState.accentColor, isFocused = false, - showDivider = false, + showDivider = true, onClick = { viewModel.cycleAccentColor() } ) + MobileSettingsRow( + icon = Icons.Default.Palette, + title = stringResource(R.string.smooth_scrolling), + subtitle = stringResource(R.string.smooth_scrolling_desc), + value = if (uiState.smoothScrolling) "On" else "Off", + isFocused = false, + showDivider = false, + onClick = { viewModel.setSmoothScrolling(!uiState.smoothScrolling) } + ) } } "Plugins & Extensions" -> { @@ -4523,6 +4535,7 @@ private fun TvGeneralSettingsRows( oledBlackBackground: Boolean = false, clockFormat: String = "24h", showBudget: Boolean = true, + smoothScrolling: Boolean = true, spoilerBlurEnabled: Boolean = false, accentColor: String = "White", volumeBoostDb: Int = 0, @@ -4542,6 +4555,7 @@ private fun TvGeneralSettingsRows( onOledBlackBackgroundToggle: (Boolean) -> Unit = {}, onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, + onSmoothScrollingToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, onAccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, @@ -4649,6 +4663,7 @@ private fun TvGeneralSettingsRows( 20 -> SettingsToggleRow(stringResource(R.string.oled_black_background), stringResource(R.string.oled_black_background_desc), oledBlackBackground, focusedIndex == localIndex, onOledBlackBackgroundToggle, Modifier.settingsFocusSlot(localIndex)) 21 -> SettingsRow(Icons.Default.Schedule, stringResource(R.string.clock_format), stringResource(R.string.clock_format_desc), if (clockFormat == "12h") "12-hour" else "24-hour", focusedIndex == localIndex, onClockFormatClick, Modifier.settingsFocusSlot(localIndex)) 22 -> SettingsToggleRow(stringResource(R.string.show_budget), stringResource(R.string.show_budget_desc), showBudget, focusedIndex == localIndex, onShowBudgetToggle, Modifier.settingsFocusSlot(localIndex)) + 36 -> SettingsToggleRow(stringResource(R.string.smooth_scrolling), stringResource(R.string.smooth_scrolling_desc), smoothScrolling, focusedIndex == localIndex, onSmoothScrollingToggle, Modifier.settingsFocusSlot(localIndex)) 23 -> SettingsToggleRow(stringResource(R.string.spoiler_blur), stringResource(R.string.spoiler_blur_desc), spoilerBlurEnabled, focusedIndex == localIndex, onSpoilerBlurToggle, Modifier.settingsFocusSlot(localIndex)) 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.accent_color), stringResource(R.string.accent_color_desc), accentColor, focusedIndex == localIndex, onAccentColorClick, Modifier.settingsFocusSlot(localIndex)) 25 -> SettingsRow(Icons.Default.Language, stringResource(R.string.dns_provider), stringResource(R.string.dns_desc), dnsProvider, focusedIndex == localIndex, onDnsProviderClick, Modifier.settingsFocusSlot(localIndex)) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 22d5b174..2a2b369c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -197,7 +197,8 @@ data class SettingsUiState( val subtitleAiApiKey: String = "", val subtitleAiModel: SubtitleAiModel = SubtitleAiModel.GROQ_LLAMA_70B, val subtitleRemoveHearingImpaired: Boolean = true, - val aiKeyServerState: AiKeyServerState = AiKeyServerState() + val aiKeyServerState: AiKeyServerState = AiKeyServerState(), + val smoothScrolling: Boolean = true ) @HiltViewModel @@ -260,6 +261,7 @@ class SettingsViewModel @Inject constructor( private fun trailerDelayKey() = profileManager.profileStringKey("trailer_delay_seconds") private fun showBudgetKey() = profileManager.profileBooleanKey("show_budget_on_home") private fun clockFormatKey() = profileManager.profileStringKey("clock_format") + private fun smoothScrollingKey() = profileManager.profileBooleanKey("smooth_scrolling") private fun spoilerBlurKey() = profileManager.profileBooleanKey("spoiler_blur") // Stored as a string because ProfileManager has no int helper and we only persist // a handful of discrete dB values. Parsed back to Int on read. @@ -455,6 +457,7 @@ class SettingsViewModel @Inject constructor( } val volumeBoostDb = prefs[volumeBoostDbKey()]?.toIntOrNull()?.coerceIn(0, 15) ?: 0 val showLoadingStats = prefs[showLoadingStatsKey()] ?: true + val smoothScrolling = prefs[smoothScrollingKey()] ?: true val subtitleSize = prefs[subtitleSizeKey()] ?: "Medium" val subtitleColor = prefs[subtitleColorKey()] ?: "White" @@ -551,7 +554,8 @@ class SettingsViewModel @Inject constructor( subtitleAiAutoSelect = subtitleAiAutoSelect, subtitleAiApiKey = subtitleAiApiKey, subtitleAiModel = subtitleAiModel, - subtitleRemoveHearingImpaired = subtitleRemoveHearingImpaired + subtitleRemoveHearingImpaired = subtitleRemoveHearingImpaired, + smoothScrolling = smoothScrolling ) } } @@ -1142,6 +1146,14 @@ class SettingsViewModel @Inject constructor( } } + fun setSmoothScrolling(enabled: Boolean) { + viewModelScope.launch { + context.settingsDataStore.edit { it[smoothScrollingKey()] = enabled } + _uiState.value = _uiState.value.copy(smoothScrolling = enabled) + syncLocalStateToCloud(silent = true) + } + } + fun setShowLoadingStats(enabled: Boolean) { viewModelScope.launch { context.settingsDataStore.edit { it[showLoadingStatsKey()] = enabled } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f06f460..c163af2c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -250,4 +250,6 @@ API key saved AI translation is ready to use Scan with your phone — choose Groq or Gemini + Smoother Scrolling + Use premium smooth transitions when navigating poster rows