From fc933c8e91f7e0577dbe7503e47332d84ebee969 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Fri, 21 Aug 2026 20:47:09 +0530 Subject: [PATCH 01/12] feat(mobile): implement progressive catalog loading and stable first-launch UX - Publish mobile catalog rows progressively as metadata arrives without blocking on slower catalogs or image decodes - Add SkeletonMobileHeroBanner and preserve top header in MobileHeroCarousel to prevent frame-0 layout shifts - Decouple card image loading with smooth Coil crossfade and branded gradient fallbacks with titles - Filter locked sports addons, IPTV, and placeholders from mobile hero candidates - Add slow network detector and retry button on mobile - Keep TV loading pipeline completely untouched --- .../tv/data/model/SportsAddonCapabilities.kt | 3 + .../tv/data/repository/MediaRepository.kt | 16 ++ .../tv/data/repository/SportsRepository.kt | 3 +- .../com/arflix/tv/ui/components/MediaCard.kt | 85 ++++--- .../tv/ui/components/MobileHeroBanner.kt | 25 +- .../arflix/tv/ui/components/SkeletonLoader.kt | 54 ++++ .../arflix/tv/ui/screens/home/HomeScreen.kt | 99 +++++++- .../tv/ui/screens/home/HomeViewModel.kt | 238 +++++++++++++++++- 8 files changed, 464 insertions(+), 59 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt b/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt index baa0ae397..4d00452e4 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/model/SportsAddonCapabilities.kt @@ -41,6 +41,9 @@ object SportsAddonCapabilities { value.startsWith(SPORTS_EVENT_STATUS_PREFIX) } + fun isSportsLockedStatus(status: String?): Boolean = + status?.startsWith(SPORTS_LOCKED_STATUS_PREFIX) == true + fun isSportsCategoryStatus(status: String?): Boolean = status?.startsWith(SPORTS_STATUS_PREFIX) == true diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 7a5730c65..9734245e0 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -1753,6 +1753,22 @@ class MediaRepository @Inject constructor( ) } + suspend fun loadSingleBuiltinCategory(categoryId: String): Category? { + val pageResult = loadHomeCategoryPage(categoryId, 1) + if (pageResult.items.isEmpty()) return null + val title = when (categoryId) { + "trending_movies" -> context.getString(R.string.trending_movies) + "trending_tv" -> context.getString(R.string.trending_series) + "trending_anime" -> context.getString(R.string.trending_anime) + else -> categoryId + } + return Category( + id = categoryId, + title = title, + items = pageResult.items + ) + } + suspend fun loadCustomCatalog(catalog: CatalogConfig, maxItems: Int = 40): Category? = coroutineScope { if (catalog.kind == CatalogKind.COLLECTION) { val page = loadCollectionCatalogPage(catalog = catalog, offset = 0, limit = maxItems) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index 9776d37c0..d513703d0 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -390,7 +390,8 @@ class SportsRepository @Inject constructor( overview = overview, mediaType = MediaType.TV, badge = badge, - status = "${SportsAddonCapabilities.SPORTS_LOCKED_STATUS_PREFIX}$key" + status = "${SportsAddonCapabilities.SPORTS_LOCKED_STATUS_PREFIX}$key", + isPlaceholder = true ) private fun candidateCatalogs(addon: Addon, selectedSportId: String?): List { 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 e98fa6c7b..5532981bf 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 @@ -149,8 +149,7 @@ fun MediaCard( val context = LocalContext.current val density = LocalDensity.current val overlayBrush: Brush? = null // Gradient removed per user feedback - // Performance: Removed context/density from keys - they're stable CompositionLocals - val imageRequest = remember(rawImageUrl, width, aspectRatio) { + val imageRequest = remember(rawImageUrl, width, aspectRatio, isMobile) { if (rawImageUrl == null) return@remember null val widthPx = with(density) { width.roundToPx() } val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1) @@ -162,12 +161,12 @@ fun MediaCard( .allowHardware(true) .memoryCacheKey(cacheKey) .placeholderMemoryCacheKey(cacheKey) - .crossfade(false) + .crossfade(if (isMobile) 250 else 0) .build() } // Performance: Removed context/density from keys val effectiveLogoImageUrl = logoImageUrl.takeIf { showLogoImage } - val logoRequest = remember(effectiveLogoImageUrl) { + val logoRequest = remember(effectiveLogoImageUrl, isMobile) { val logoWidthPx = with(density) { 220.dp.roundToPx() }.coerceAtLeast(1) val logoHeightPx = with(density) { 64.dp.roundToPx() }.coerceAtLeast(1) if (effectiveLogoImageUrl.isNullOrBlank()) { @@ -181,7 +180,7 @@ fun MediaCard( .allowHardware(true) .memoryCacheKey(cacheKey) .placeholderMemoryCacheKey(cacheKey) - .crossfade(false) + .crossfade(if (isMobile) 200 else 0) .build() } } @@ -214,11 +213,24 @@ fun MediaCard( }, ) { _ -> Box(modifier = Modifier.fillMaxSize()) { - // Only render AsyncImage when we have a valid image URL. - // When imageRequest is null (no poster/backdrop from TMDB), - // render a branded gradient fallback with the title centered - // so the card conveys what it's for instead of showing as a - // blank rectangle that used to look broken. + // Branded gradient fallback with title that sits behind AsyncImage. + // When AsyncImage loads, it fades in smoothly over this background; + // if image fails or is slow, the title remains visible instead of a black box. + Box( + modifier = Modifier + .fillMaxSize() + .background(missingArtworkBrush), + contentAlignment = Alignment.Center + ) { + Text( + text = item.title, + style = ArvioSkin.typography.cardTitle, + color = Color.White.copy(alpha = 0.72f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 10.dp) + ) + } if (imageRequest != null) { AsyncImage( model = imageRequest, @@ -226,22 +238,6 @@ fun MediaCard( contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) - } else { - Box( - modifier = Modifier - .fillMaxSize() - .background(missingArtworkBrush), - contentAlignment = Alignment.Center - ) { - Text( - text = item.title, - style = ArvioSkin.typography.cardTitle, - color = Color.White.copy(alpha = 0.82f), - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = 10.dp) - ) - } } if (overlayBrush != null) { Box( @@ -611,9 +607,9 @@ fun PosterCard( val context = LocalContext.current val density = LocalDensity.current val aspectRatio = 2f / 3f + val isMobile = LocalDeviceType.current.isTouchDevice() val posterUrl = item.image.takeIf { it.isNotBlank() } - // Performance: Removed context/density from keys - val imageRequest = remember(posterUrl, width) { + val imageRequest = remember(posterUrl, width, isMobile) { if (posterUrl == null) return@remember null val widthPx = with(density) { width.roundToPx() } val heightPx = (widthPx / aspectRatio).toInt().coerceAtLeast(1) @@ -625,7 +621,7 @@ fun PosterCard( .allowHardware(true) .memoryCacheKey(cacheKey) .placeholderMemoryCacheKey(cacheKey) - .crossfade(false) + .crossfade(if (isMobile) 250 else 0) .build() } @@ -646,13 +642,30 @@ fun PosterCard( if (it) onFocused() }, ) { _ -> - if (imageRequest != null) { - AsyncImage( - model = imageRequest, - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .fillMaxSize() + .background(missingArtworkBrush), + contentAlignment = Alignment.Center + ) { + Text( + text = item.title, + style = ArvioSkin.typography.cardTitle, + color = Color.White.copy(alpha = 0.72f), + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } + if (imageRequest != null) { + AsyncImage( + model = imageRequest, + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt index 945dbd397..9b88f703a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt @@ -82,21 +82,24 @@ fun MobileHeroBanner( .aspectRatio(3f / 4f) .shadow(elevation = 8.dp, shape = BannerShape, clip = false) .clip(BannerShape) + .background(Color(0xFF141419)) .then(if (onClick != null) Modifier.clickable { onClick() } else Modifier) .border(width = 1.dp, color = CardBorder, shape = BannerShape) ) { // ── Layer 1: Full-bleed background image ──────────────────────────── - AsyncImage( - model = ImageRequest.Builder(context) - .data(imageUrl) - .precision(Precision.INEXACT) - .allowHardware(true) - .crossfade(400) - .build(), - contentDescription = title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + if (imageUrl.isNotBlank()) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(imageUrl) + .precision(Precision.INEXACT) + .allowHardware(true) + .crossfade(300) + .build(), + contentDescription = title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } // ── Layer 2: Bottom scrim spanning the lower 65% of the card ──────── Spacer( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt index 7bf532445..ee99da6e1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt @@ -7,6 +7,7 @@ import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -495,6 +496,59 @@ fun SkeletonHomePage( } } +/** + * Skeleton for mobile hero banner (3:4 aspect ratio with rounded corners) + */ +@Composable +fun SkeletonMobileHeroBanner( + modifier: Modifier = Modifier +) { + val bannerShape = RoundedCornerShape(24.dp) + Box( + modifier = modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .clip(bannerShape) + .background(Color(0xFF141419)) + .border(width = 1.dp, color = Color(0xFF2B2B2B), shape = bannerShape) + ) { + SkeletonBox( + modifier = Modifier.fillMaxSize(), + shape = bannerShape + ) + + Column( + modifier = Modifier + .align(androidx.compose.ui.Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = 20.dp) + .padding(bottom = 28.dp), + horizontalAlignment = androidx.compose.ui.Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + SkeletonBox( + modifier = Modifier + .fillMaxWidth(0.65f) + .height(32.dp), + shape = RoundedCornerShape(6.dp) + ) + SkeletonBox( + modifier = Modifier + .fillMaxWidth(0.40f) + .height(14.dp), + shape = RoundedCornerShape(4.dp) + ) + SkeletonBox( + modifier = Modifier + .fillMaxWidth(0.25f) + .height(12.dp), + shape = RoundedCornerShape(4.dp) + ) + } + } +} + enum class SkeletonCardType { POSTER, MEDIA, EPISODE, CAST } + 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 7e2b67e3c..e2e043a08 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 @@ -142,6 +142,9 @@ import com.arflix.tv.ui.components.TrailerPlayer import com.arflix.tv.ui.components.CardLayoutMode import com.arflix.tv.ui.components.AppTopBar import com.arflix.tv.ui.components.AppTopBarContentTopInset +import com.arflix.tv.data.model.SportsAddonCapabilities +import com.arflix.tv.ui.components.SkeletonMobileHeroBanner +import androidx.compose.material3.TextButton import com.arflix.tv.ui.components.MobileHeroBanner import com.arflix.tv.ui.components.ProfileAvatarVisual import com.arflix.tv.util.LocalDeviceType @@ -1203,6 +1206,8 @@ fun HomeScreen( hasUpdateBadge = uiState.hasUpdateBadge, categoryHasMoreMap = uiState.categoryHasMoreMap, smoothScrolling = uiState.smoothScrolling, + isSlowLoading = uiState.isMobileSlowLoading, + onRetry = { viewModel.retryMobileHomeLoading() }, onLoadMoreCategory = { viewModel.loadNextPageForCategory(it) }, onItemFocusedPrefetch = {}, onMobileCategoryVisiblePosition = { categoryId, lastVisibleItemIndex -> @@ -2055,12 +2060,17 @@ private fun MobileHeroCarousel( onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit ) { val heroItems = remember(categories) { - val nonCwCats = categories.filter { it.id != "continue_watching" } - val firstCat = nonCwCats.getOrNull(0) - ?.items?.filter { it.id > 0 && !it.isPlaceholder }?.take(5) + val eligibleRows = categories.filter { + it.id != "continue_watching" && + !it.id.startsWith("collection_row_") && + it.id != SportsAddonCapabilities.SPORTS_CATEGORY_ROW_ID && + it.id != SportsAddonCapabilities.POPULAR_LIVE_TV_ROW_ID + } + val firstCat = eligibleRows.getOrNull(0) + ?.items?.filter { !it.isPlaceholder && it.id > 0 && !SportsAddonCapabilities.isSportsHomeStatus(it.status) && !SportsAddonCapabilities.isSportsLockedStatus(it.status) }?.take(5) .orEmpty() - val secondCat = nonCwCats.getOrNull(1) - ?.items?.filter { it.id > 0 && !it.isPlaceholder }?.take(5) + val secondCat = eligibleRows.getOrNull(1) + ?.items?.filter { !it.isPlaceholder && it.id > 0 && !SportsAddonCapabilities.isSportsHomeStatus(it.status) && !SportsAddonCapabilities.isSportsLockedStatus(it.status) }?.take(5) .orEmpty() // Interleave: first[0], second[0], first[1], second[1], … buildList { @@ -2072,7 +2082,53 @@ private fun MobileHeroCarousel( }.distinctBy { "${it.mediaType}_${it.id}" } } - if (heroItems.isEmpty()) return + if (heroItems.isEmpty()) { + Column(modifier = Modifier.fillMaxWidth()) { + // Profile avatar + search icon row — above the pager, respects status bar + Row( + modifier = Modifier + .fillMaxWidth() + .statusBarsPadding() + .padding(start = 26.dp, end = 26.dp, top = 12.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + if (currentProfile != null) { + Box( + modifier = Modifier + .size(38.dp) + .clip(CircleShape) + .clickable { onSwitchProfile() } + ) { + ProfileAvatarVisual( + profile = currentProfile, + letterFontSize = 15.sp, + iconPadding = 5.dp + ) + } + } else { + Spacer(modifier = Modifier.size(38.dp)) + } + Icon( + imageVector = Icons.Filled.Search, + contentDescription = stringResource(R.string.search), + tint = Color.White, + modifier = Modifier + .size(26.dp) + .clickable { onNavigateToSearch() } + ) + } + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 64.dp) + ) { + SkeletonMobileHeroBanner() + } + } + return + } // Circular paging: use a large virtual page count that's a multiple of heroItems.size // so page % heroItems.size always maps correctly and starts at item[0]. @@ -2242,6 +2298,8 @@ private fun HomeInputLayer( hasUpdateBadge: Boolean = false, categoryHasMoreMap: Map = emptyMap(), smoothScrolling: Boolean = true, + isSlowLoading: Boolean = false, + onRetry: () -> Unit = {}, onLoadMoreCategory: (String) -> Unit = {}, onItemFocusedPrefetch: (MediaItem) -> Unit = {}, onMobileCategoryVisiblePosition: (String, Int) -> Unit = { _, _ -> }, @@ -2664,6 +2722,8 @@ private fun HomeInputLayer( isMobile = isMobile, categoryHasMoreMap = categoryHasMoreMap, smoothScrolling = smoothScrolling, + isSlowLoading = isSlowLoading, + onRetry = onRetry, onLoadMoreCategory = onLoadMoreCategory, onItemFocusedPrefetch = onItemFocusedPrefetch, heroItem = heroItem, @@ -2721,6 +2781,8 @@ private fun HomeRowsLayer( isMobile: Boolean = false, categoryHasMoreMap: Map = emptyMap(), smoothScrolling: Boolean = true, + isSlowLoading: Boolean = false, + onRetry: () -> Unit = {}, onLoadMoreCategory: (String) -> Unit = {}, onItemFocusedPrefetch: (MediaItem) -> Unit = {}, heroItem: MediaItem? = null, @@ -2748,6 +2810,8 @@ private fun HomeRowsLayer( onSwitchProfile = onSwitchProfile, usePosterCards = usePosterCards, categoryHasMoreMap = categoryHasMoreMap, + isSlowLoading = isSlowLoading, + onRetry = onRetry, onLoadMoreCategory = onLoadMoreCategory, onNavigateToDetails = onNavigateToDetails, onItemClick = onItemClick, @@ -2794,6 +2858,8 @@ private fun MobileHomeRowsLayer( onNavigateToSearch: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, categoryHasMoreMap: Map = emptyMap(), + isSlowLoading: Boolean = false, + onRetry: () -> Unit = {}, onLoadMoreCategory: (String) -> Unit = {}, onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit = { _, _, _, _ -> }, onItemClick: (MediaItem) -> Unit, @@ -2986,6 +3052,27 @@ private fun MobileHomeRowsLayer( } } } + + if (isSlowLoading) { + item(key = "mobile_slow_loading_indicator", contentType = "mobile_slow_loading") { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = "Still loading your catalogue…", + color = Color.White.copy(alpha = 0.7f), + fontSize = 14.sp + ) + TextButton(onClick = onRetry) { + Text("Retry", color = Color(0xFF00F0D0), fontWeight = FontWeight.Bold) + } + } + } + } } } 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 6af8e2214..ba9741af9 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 @@ -113,7 +113,8 @@ data class HomeUiState( val showAppUpdateDialog: Boolean = false, val hasUpdateBadge: Boolean = false, val categoryHasMoreMap: Map = emptyMap(), - val smoothScrolling: Boolean = false + val smoothScrolling: Boolean = false, + val isMobileSlowLoading: Boolean = false ) @androidx.compose.runtime.Immutable @@ -866,15 +867,26 @@ class HomeViewModel @Inject constructor( return category?.items?.any { !it.isPlaceholder } == true } + private fun isEligibleHeroItem(item: MediaItem?): Boolean { + if (item == null) return false + if (item.id <= 0 || item.isPlaceholder) return false + if (item.title.isBlank() || item.title.equals("Unknown", ignoreCase = true)) return false + if (isSportsHomeItem(item) || SportsAddonCapabilities.isSportsLockedStatus(item.status)) return false + if (isIptvItem(item) || isCollectionItem(item)) return false + return true + } + private fun chooseInitialHero(categories: List): MediaItem? { val preferredRow = categories.firstOrNull { category -> - !category.id.startsWith("collection_row_") && category.items.any { !it.isPlaceholder } + !category.id.startsWith("collection_row_") && + !isSportsCatalogRow(category.id) && + category.items.any { isEligibleHeroItem(it) } } - return preferredRow?.items?.firstOrNull { !it.isPlaceholder } + return preferredRow?.items?.firstOrNull { isEligibleHeroItem(it) } ?: categories.asSequence() + .filterNot { isSportsCatalogRow(it.id) || it.id.startsWith("collection_row_") } .flatMap { it.items.asSequence() } - .firstOrNull { !it.isPlaceholder } - ?: categories.firstOrNull()?.items?.firstOrNull() + .firstOrNull { isEligibleHeroItem(it) } } /** @@ -2274,6 +2286,11 @@ class HomeViewModel @Inject constructor( savedCatalogs.forEach { savedCatalogById[it.id] = it } categoryPaginationStates.clear() + if (!isTvDevice) { + loadMobileHomeDataProgressive(requestId, savedCatalogs, cachedContinueWatching) + return@loadHome + } + // When Home is opened from profile selection, avoid an empty frame by showing // profile-ordered skeleton rows immediately while real catalogs load. if (_uiState.value.categories.isEmpty()) { @@ -2882,6 +2899,217 @@ class HomeViewModel @Inject constructor( } } + private fun updateMobileCategoryRow( + categoryId: String, + newCategory: Category, + hasMore: Boolean = false + ) { + val currentCategories = _uiState.value.categories.toMutableList() + val index = currentCategories.indexOfFirst { it.id == categoryId } + if (index >= 0) { + currentCategories[index] = newCategory + } else { + currentCategories.add(newCategory) + } + categoryPaginationStates[categoryId] = CategoryPaginationState( + loadedCount = newCategory.items.size, + hasMore = hasMore + ) + val currentHero = _uiState.value.heroItem + val newHero = if (currentHero == null || !isEligibleHeroItem(currentHero)) { + chooseInitialHero(currentCategories) + } else { + currentHero + } + val heroLogo = newHero?.let { getCachedLogo("${it.mediaType}_${it.id}") } + + _uiState.value = _uiState.value.copy( + isLoading = false, + isInitialLoad = false, + categories = currentCategories, + heroItem = newHero, + heroLogoUrl = heroLogo ?: _uiState.value.heroLogoUrl, + categoryHasMoreMap = categoryPaginationStates.mapValues { it.value.hasMore }, + error = null, + isMobileSlowLoading = false + ) + if (newHero != null && _uiState.value.heroLogoUrl == null) { + hydrateHeroDetailsIfNeeded(newHero) + } + preloadLogosForCategoryItems(newCategory.items.take(8)) + } + + private fun preloadLogosForCategoryItems(items: List) { + viewModelScope.launch(networkDispatcher) { + items.filter { isActionableMediaItem(it) && !isIptvItem(it) }.forEach { item -> + val key = "${item.mediaType}_${item.id}" + if (!cardLogoUrls.containsKey(key)) { + val cached = getCachedLogo(key) + if (cached != null) { + withContext(Dispatchers.Main.immediate) { + cardLogoUrls[key] = cached + } + } else { + try { + val url = mediaRepository.getLogoUrl(item.mediaType, item.id) + if (url != null) { + withContext(Dispatchers.Main.immediate) { + cardLogoUrls[key] = url + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + } + } + } + } + + fun retryMobileHomeLoading() { + _uiState.value = _uiState.value.copy(isMobileSlowLoading = false, isLoading = true) + loadHomeData() + } + + private fun loadMobileHomeDataProgressive( + requestId: Long, + savedCatalogs: List, + cachedContinueWatching: List + ) { + // 1. Initial Skeleton Setup: render structured skeletons in saved catalog order immediately + val skeletonCategories = buildProfileSkeletonCategories( + savedCatalogs = savedCatalogs, + cachedContinueWatching = cachedContinueWatching + ) + if (_uiState.value.categories.isEmpty()) { + val skeletonHero = chooseInitialHero(skeletonCategories) + _uiState.value = _uiState.value.copy( + isLoading = true, + isInitialLoad = false, + categories = skeletonCategories, + heroItem = skeletonHero, + heroLogoUrl = null, + error = null, + isMobileSlowLoading = false + ) + } else { + _uiState.value = _uiState.value.copy(isLoading = false, error = null, isMobileSlowLoading = false) + } + + // 2. Resolve Collection Rails immediately (Services, Franchises, Genres) + val collectionConfigs = savedCatalogs.filter { cfg -> + isCollectionTileConfig(cfg) && CollectionTemplateManifest.isValidCollectionConfig(cfg) + } + val collectionRows = savedCatalogs.mapNotNull { cfg -> + if (!isCollectionRailConfig(cfg) || !CollectionTemplateManifest.isValidCollectionConfig(cfg)) null + else { + val group = cfg.collectionGroup ?: return@mapNotNull null + val items = collectionConfigs.filter { it.collectionGroup == group } + if (items.isEmpty()) null + else HomeCollectionRow(id = collectionRowId(group), title = cfg.title, items = items) + } + } + _uiState.value = _uiState.value.copy(collectionRows = collectionRows) + collectionRows.forEach { colRow -> + updateMobileCategoryRow(colRow.id, toCollectionCategory(colRow)) + } + + // 3. Resolve Continue Watching from cache & launch live sync + if (cachedContinueWatching.isNotEmpty()) { + viewModelScope.launch { + val merged = mergeContinueWatchingResumeData(cachedContinueWatching) + val cwCat = Category( + id = "continue_watching", + title = "Continue Watching", + items = merged.map { it.toMediaItem() } + ) + withContext(Dispatchers.Main.immediate) { + if (requestId == loadHomeRequestId) { + updateMobileCategoryRow("continue_watching", cwCat) + } + } + } + } + launchContinueWatchingFetch() + + // 4. Favorite TV (IPTV) on IO + viewModelScope.launch(Dispatchers.IO) { + val favCat = runCatching { buildFavoriteTvCategory() }.getOrNull() + if (favCat != null && favCat.items.isNotEmpty()) { + withContext(Dispatchers.Main.immediate) { + if (requestId == loadHomeRequestId) { + updateMobileCategoryRow(FAVORITE_TV_CATEGORY_ID, favCat) + } + } + } + } + + // 5. TMDB Built-in Categories (Trending Movies, Shows, Anime) - independent fetches + val tmdbConfigs = savedCatalogs.filter { + it.isPreinstalled && it.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(it) && !isCollectionTileConfig(it) + } + tmdbConfigs.forEach { cfg -> + viewModelScope.launch(networkDispatcher) { + val category = runCatching { + mediaRepository.loadSingleBuiltinCategory(cfg.id) + }.getOrNull() + if (category != null && category.items.isNotEmpty()) { + val titled = if (cfg.title.isNotBlank() && cfg.title != category.title) { + category.copy(title = cfg.title) + } else { + category + } + withContext(Dispatchers.Main.immediate) { + if (requestId == loadHomeRequestId) { + updateMobileCategoryRow(cfg.id, titled.withTop10CapIfNeeded(), hasMore = true) + persistCategoriesCache(_uiState.value.categories) + } + } + } + } + } + + // 6. MDBList and Custom/Addon Catalogs - progressive fetch with semaphore + val customConfigs = savedCatalogs.filter { cfg -> + isCustomCatalogConfig(cfg) || (cfg.isPreinstalled && !cfg.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(cfg) && !isCollectionTileConfig(cfg)) + } + val customSemaphore = Semaphore(if (isLowRamDevice) 2 else 3) + customConfigs.forEach { cfg -> + viewModelScope.launch(networkDispatcher) { + customSemaphore.withPermit { + try { + val limit = if (isHardCappedTop10Catalog(cfg.id)) TOP_10_ITEM_LIMIT else catalogInitialLimit(cfg) + val result = mediaRepository.loadCustomCatalogPage(catalog = cfg, offset = 0, limit = limit) + if (result.items.isNotEmpty()) { + val category = Category(id = cfg.id, title = cfg.title, items = result.items).withTop10CapIfNeeded() + withContext(Dispatchers.Main.immediate) { + if (requestId == loadHomeRequestId) { + updateMobileCategoryRow(cfg.id, category, hasMore = result.hasMore && !isHardCappedTop10Catalog(cfg.id)) + persistCategoriesCache(_uiState.value.categories) + } + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + } + } + + // 7. Slow loading watcher for first cold start + viewModelScope.launch { + delay(8000L) + if (requestId == loadHomeRequestId) { + val hasReal = _uiState.value.categories.any { cat -> + cat.id != "continue_watching" && cat.items.any { !it.isPlaceholder } + } + if (!hasReal) { + _uiState.value = _uiState.value.copy(isMobileSlowLoading = true) + } + } + } + } + /** * Warm critical details assets while the card is focused so opening Details feels instant: * - clearlogo URL From 59f877bbc56d00f4194a3447270489ac99081743 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sat, 22 Aug 2026 17:56:34 +0530 Subject: [PATCH 02/12] fix(mobile): stabilize catalog rows and eliminate infinite pagination refresh loop --- .../arflix/tv/ui/screens/home/HomeScreen.kt | 183 +++++++++--------- .../tv/ui/screens/home/HomeViewModel.kt | 18 +- 2 files changed, 108 insertions(+), 93 deletions(-) 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 e2e043a08..d2252f250 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 @@ -144,6 +144,8 @@ import com.arflix.tv.ui.components.AppTopBar import com.arflix.tv.ui.components.AppTopBarContentTopInset import com.arflix.tv.data.model.SportsAddonCapabilities import com.arflix.tv.ui.components.SkeletonMobileHeroBanner +import com.arflix.tv.ui.components.SkeletonPosterCard +import com.arflix.tv.ui.components.SkeletonMediaCard import androidx.compose.material3.TextButton import com.arflix.tv.ui.components.MobileHeroBanner import com.arflix.tv.ui.components.ProfileAvatarVisual @@ -2942,79 +2944,100 @@ private fun MobileHomeRowsLayer( } else { rowUsePosterCards } - val itemsToRender = remember(category.items, rowHasMore, isPortrait) { - if (category.items.isEmpty()) { - (1..8).map { index -> - MediaItem( - id = -index, - title = "", - mediaType = MediaType.MOVIE, - isPlaceholder = true - ) - } - } else if (rowHasMore) { - val skeletonCount = if (isPortrait) 12 else 7 - category.items + List(skeletonCount) { idx -> - MediaItem( - id = -1000 - idx, - title = "", - isPlaceholder = true - ) + val isRowSkeleton = category.items.isEmpty() || category.items.all { it.isPlaceholder } + + if (isRowSkeleton) { + // Render structured skeleton cards while category metadata is loading + LazyRow( + state = rowState, + modifier = Modifier.arvioDpadFocusGroup(), + contentPadding = PaddingValues( + start = contentStartPadding, + end = 16.dp, + top = 4.dp, + bottom = 4.dp + ), + horizontalArrangement = Arrangement.spacedBy(mobileItemSpacing) + ) { + items(8, key = { "skeleton_${category.id}_$it" }) { + if (isPortrait) { + SkeletonPosterCard(width = rowMobileItemWidth) + } else { + SkeletonMediaCard(width = rowMobileItemWidth) + } } - } else { - category.items } - } - val itemKeys = remember(category.id, itemsToRender) { - stableHomeRowItemKeys(category.id, itemsToRender) - } + } else { + val realItems = remember(category.items) { + category.items.filter { !it.isPlaceholder } + } + val itemKeys = remember(category.id, realItems) { + stableHomeRowItemKeys(category.id, realItems) + } - // Horizontal card row with touch scrolling - LazyRow( - state = rowState, - modifier = Modifier.arvioDpadFocusGroup(), - contentPadding = PaddingValues( - start = contentStartPadding, - end = 16.dp, - top = 4.dp, - bottom = 4.dp - ), - horizontalArrangement = Arrangement.spacedBy(mobileItemSpacing) - ) { - itemsIndexed( - itemsToRender, - key = { index, _ -> itemKeys[index] }, - 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) - } - } - val currentItem = rememberUpdatedState(item) - val onCardClick = remember { - { onItemClick(currentItem.value) } - } - val onCardLongClick = if (onItemLongClick != null) { - remember { - { onItemLongClick(currentItem.value, isContinueWatching) } + // Horizontal card row with touch scrolling + LazyRow( + state = rowState, + modifier = Modifier.arvioDpadFocusGroup(), + contentPadding = PaddingValues( + start = contentStartPadding, + end = 16.dp, + top = 4.dp, + bottom = 4.dp + ), + horizontalArrangement = Arrangement.spacedBy(mobileItemSpacing) + ) { + itemsIndexed( + realItems, + key = { index, _ -> itemKeys[index] }, + contentType = { _, item -> "${item.mediaType.name}_mobile_card" } + ) { index, item -> + val currentItem = rememberUpdatedState(item) + val onCardClick = remember { + { onItemClick(currentItem.value) } } - } else null - if (isRanked && index < 10) { - Box( - modifier = Modifier.width(rowMobileItemWidth) - ) { + val onCardLongClick = if (onItemLongClick != null) { + remember { + { onItemLongClick(currentItem.value, isContinueWatching) } + } + } else null + + if (isRanked && index < 10) { + Box( + modifier = Modifier.width(rowMobileItemWidth) + ) { + val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] + ArvioMediaCard( + item = item, + width = rowMobileItemWidth, + isLandscape = !isPortrait, + logoImageUrl = cardLogoUrl, + showProgress = false, + showTitle = !item.collectionHideTitle, + isFocusedOverride = false, + enableSystemFocus = false, + onFocused = {}, + onClick = onCardClick, + onLongClick = onCardLongClick, + ) + TopRankRibbon( + rank = index + 1, + isFocused = false, + compact = true, + modifier = Modifier + .align(Alignment.TopStart) + .zIndex(2f) + .padding(start = 6.dp) + ) + } + } else { val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] ArvioMediaCard( item = item, width = rowMobileItemWidth, isLandscape = !isPortrait, logoImageUrl = cardLogoUrl, - showProgress = false, + showProgress = isContinueWatching, showTitle = !item.collectionHideTitle, isFocusedOverride = false, enableSystemFocus = false, @@ -3022,31 +3045,17 @@ private fun MobileHomeRowsLayer( onClick = onCardClick, onLongClick = onCardLongClick, ) - TopRankRibbon( - rank = index + 1, - isFocused = false, - compact = true, - modifier = Modifier - .align(Alignment.TopStart) - .zIndex(2f) - .padding(start = 6.dp) - ) } - } else { - val cardLogoUrl = if (isCollectionRow) null else cardLogoUrls["${item.mediaType}_${item.id}"] - ArvioMediaCard( - item = item, - width = rowMobileItemWidth, - isLandscape = !isPortrait, - logoImageUrl = cardLogoUrl, - showProgress = isContinueWatching, - showTitle = !item.collectionHideTitle, - isFocusedOverride = false, - enableSystemFocus = false, - onFocused = {}, - onClick = onCardClick, - onLongClick = onCardLongClick, - ) + } + + if (rowHasMore) { + item(key = "${category.id}_loading_more", contentType = "loading_more_card") { + if (isPortrait) { + SkeletonPosterCard(width = rowMobileItemWidth) + } else { + SkeletonMediaCard(width = rowMobileItemWidth) + } + } } } } 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 ba9741af9..99bd9c4fe 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 @@ -3327,9 +3327,13 @@ class HomeViewModel @Inject constructor( fun loadNextPageForCategory(categoryId: String) { if (isHardCappedTop10Catalog(categoryId)) return + val currentCategory = _uiState.value.categories.firstOrNull { it.id == categoryId } ?: return + if (currentCategory.items.isEmpty() || currentCategory.items.all { it.isPlaceholder }) return + + val realItemsCount = currentCategory.items.count { !it.isPlaceholder } val pagination = categoryPaginationStates.getOrPut(categoryId) { CategoryPaginationState( - loadedCount = _uiState.value.categories.firstOrNull { it.id == categoryId }?.items?.size ?: 0 + loadedCount = realItemsCount ) } if (!pagination.hasMore || pagination.isLoading) return @@ -3338,20 +3342,22 @@ class HomeViewModel @Inject constructor( viewModelScope.launch(Dispatchers.IO) { try { val currentCategories = _uiState.value.categories - val currentCategory = currentCategories.firstOrNull { it.id == categoryId } ?: return@launch + val latestCategory = currentCategories.firstOrNull { it.id == categoryId } ?: return@launch + val realItems = latestCategory.items.filter { !it.isPlaceholder } + if (realItems.isEmpty()) 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 / 20) + 1 + val nextPage = (realItems.size / 20) + 1 mediaRepository.loadHomeCategoryPage(categoryId, nextPage) } else { // MDBList/custom catalog (including preinstalled MDBList ones) val cfg = catalog ?: return@launch mediaRepository.loadCustomCatalogPage( catalog = cfg, - offset = currentCategory.items.size, + offset = realItems.size, limit = pageSize ) } @@ -3361,7 +3367,7 @@ class HomeViewModel @Inject constructor( return@launch } - val seen = currentCategory.items + val seen = realItems .map { "${it.mediaType.name}_${it.id}" } .toHashSet() val uniqueNewItems = result.items.filter { item -> @@ -3374,7 +3380,7 @@ class HomeViewModel @Inject constructor( val updatedCategories = currentCategories.map { category -> if (category.id == categoryId) { - category.copy(items = category.items + uniqueNewItems) + category.copy(items = realItems + uniqueNewItems) } else { category } From d769255fb6191e819e47a14a55960da9f5282505 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 23 Aug 2026 09:46:43 +0530 Subject: [PATCH 03/12] feat(mobile): optimize mobile home startup skeleton, hero SVG branding, and background IMDb rating hydration --- .../tv/data/repository/SportsRepository.kt | 2 + .../tv/ui/components/MobileHeroBanner.kt | 42 ++++---- .../arflix/tv/ui/screens/home/HomeScreen.kt | 43 +++++++- .../tv/ui/screens/home/HomeViewModel.kt | 100 ++++++++++++++---- 4 files changed, 143 insertions(+), 44 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index d513703d0..306598a33 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -89,6 +89,8 @@ class SportsRepository @Inject constructor( fun defaultHomeRows(): List = buildLockedRows() + fun sportsCategoryOnlyRow(locked: Boolean = true): Category = sportsCategoryRow(locked = locked) + suspend fun buildHomeRows( addons: List, selectedSportId: String? = null diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt index 9b88f703a..6479bc776 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/MobileHeroBanner.kt @@ -1,5 +1,6 @@ package com.arflix.tv.ui.components +import android.graphics.Bitmap import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -14,9 +15,11 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -31,6 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.arflix.tv.R import coil.compose.AsyncImage import coil.request.ImageRequest import coil.size.Precision @@ -169,6 +173,15 @@ private fun BannerMeta(year: String, rating: String) { val hasRating = rating.isNotEmpty() if (!hasYear && !hasRating) return + val context = LocalContext.current + val imdbSvgRequest = remember(context) { + ImageRequest.Builder(context) + .data(R.raw.logo_imdb_rectangle) + .bitmapConfig(Bitmap.Config.ARGB_8888) + .allowRgb565(false) + .build() + } + Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) @@ -182,27 +195,20 @@ private fun BannerMeta(year: String, rating: String) { ) } if (hasRating) { - // IMDb logo pill - Box( + AsyncImage( + model = imdbSvgRequest, + contentDescription = "IMDb", + contentScale = ContentScale.Fit, modifier = Modifier - .clip(RoundedCornerShape(3.dp)) - .background(ImdbYellow) - .padding(horizontal = 5.dp, vertical = 2.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = "IMDb", - color = Color.Black, - fontSize = 9.sp, - fontWeight = FontWeight.ExtraBold, - letterSpacing = 0.3.sp - ) - } + .width(28.dp) + .height(14.dp) + .clip(RoundedCornerShape(2.dp)) + ) Text( text = rating, - color = Color.White.copy(alpha = 0.85f), - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold + color = Color.White.copy(alpha = 0.9f), + fontSize = 12.sp, + fontWeight = FontWeight.Bold ) } } 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 d2252f250..139585bef 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 @@ -634,6 +634,7 @@ fun HomeScreen( // Per-card logo reads now come from a stable snapshotStateMap so a single // logo arriving no longer recomposes the full home surface. val cardLogoUrls = viewModel.cardLogoUrls + val cardImdbRatings = viewModel.cardImdbRatings val profileCount = if (currentProfile != null) 1 else 0 val usePosterCards = rememberCardLayoutMode() == CardLayoutMode.POSTER val lifecycleOwner = LocalLifecycleOwner.current @@ -1163,6 +1164,8 @@ fun HomeScreen( HomeInputLayer( categories = displayCategories, cardLogoUrls = cardLogoUrls, + cardImdbRatings = cardImdbRatings, + onPreloadHeroImdbRatings = viewModel::preloadImdbRatingsForHeroItems, focusState = focusState, limitRowsDuringStartup = limitRowsDuringStartup, suppressSelectUntilMs = suppressSelectUntilMs, @@ -1742,7 +1745,12 @@ private fun formatBudgetCompact(budget: Long): String { private fun imdbRatingFor(item: MediaItem): String { val imdbValue = parseRatingValue(item.imdbRating) - return if (imdbValue > 0f) item.imdbRating else "" + if (imdbValue > 0f) return item.imdbRating + val tmdbValue = parseRatingValue(item.tmdbRating) + if (tmdbValue > 0f) return item.tmdbRating + val ratingValue = parseRatingValue(item.rating) + if (ratingValue > 0f) return item.rating + return "" } @Composable @@ -2056,10 +2064,12 @@ private fun MobileHeroOverlay( private fun MobileHeroCarousel( categories: List, cardLogoUrls: Map = emptyMap(), + cardImdbRatings: Map = emptyMap(), currentProfile: com.arflix.tv.data.model.Profile? = null, onNavigateToSearch: () -> Unit = {}, onSwitchProfile: () -> Unit = {}, - onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit + onNavigateToDetails: (MediaType, Int, Int?, Int?) -> Unit, + onPreloadHeroImdbRatings: (List) -> Unit = {} ) { val heroItems = remember(categories) { val eligibleRows = categories.filter { @@ -2084,6 +2094,12 @@ private fun MobileHeroCarousel( }.distinctBy { "${it.mediaType}_${it.id}" } } + LaunchedEffect(heroItems) { + if (heroItems.isNotEmpty()) { + onPreloadHeroImdbRatings(heroItems) + } + } + if (heroItems.isEmpty()) { Column(modifier = Modifier.fillMaxWidth()) { // Profile avatar + search icon row — above the pager, respects status bar @@ -2214,7 +2230,14 @@ private fun MobileHeroCarousel( item.year } } - val rating = remember(item.id, item.imdbRating) { imdbRatingFor(item) } + val dynamicImdb = cardImdbRatings["${item.mediaType}_${item.id}"] + val rating = remember(item.id, dynamicImdb, item.imdbRating, item.tmdbRating, item.rating) { + if (!dynamicImdb.isNullOrBlank() && parseRatingValue(dynamicImdb) > 0f) { + dynamicImdb + } else { + imdbRatingFor(item) + } + } val logoUrl = remember(item.id) { cardLogoUrls["${item.mediaType}_${item.id}"] } // Scale down cards that aren't in the center; animate smoothly as they scroll in/out @@ -2279,6 +2302,8 @@ private fun MobileHeroCarousel( private fun HomeInputLayer( categories: List, cardLogoUrls: Map, + cardImdbRatings: Map = emptyMap(), + onPreloadHeroImdbRatings: (List) -> Unit = {}, focusState: HomeFocusState, limitRowsDuringStartup: Boolean, suppressSelectUntilMs: Long, @@ -2716,6 +2741,8 @@ private fun HomeInputLayer( HomeRowsLayer( categories = categories, cardLogoUrls = cardLogoUrls, + cardImdbRatings = cardImdbRatings, + onPreloadHeroImdbRatings = onPreloadHeroImdbRatings, focusState = focusState, limitRowsDuringStartup = limitRowsDuringStartup, contentStartPadding = contentStartPadding, @@ -2775,6 +2802,8 @@ private fun HomeInputLayer( private fun HomeRowsLayer( categories: List, cardLogoUrls: Map, + cardImdbRatings: Map = emptyMap(), + onPreloadHeroImdbRatings: (List) -> Unit = {}, focusState: HomeFocusState, limitRowsDuringStartup: Boolean, contentStartPadding: androidx.compose.ui.unit.Dp, @@ -2806,6 +2835,8 @@ private fun HomeRowsLayer( MobileHomeRowsLayer( categories = categories, cardLogoUrls = cardLogoUrls, + cardImdbRatings = cardImdbRatings, + onPreloadHeroImdbRatings = onPreloadHeroImdbRatings, contentStartPadding = contentStartPadding, currentProfile = currentProfile, onNavigateToSearch = onNavigateToSearch, @@ -2854,6 +2885,8 @@ private fun HomeRowsLayer( private fun MobileHomeRowsLayer( categories: List, cardLogoUrls: Map, + cardImdbRatings: Map = emptyMap(), + onPreloadHeroImdbRatings: (List) -> Unit = {}, contentStartPadding: androidx.compose.ui.unit.Dp, usePosterCards: Boolean, currentProfile: com.arflix.tv.data.model.Profile? = null, @@ -2880,10 +2913,12 @@ private fun MobileHomeRowsLayer( MobileHeroCarousel( categories = categories, cardLogoUrls = cardLogoUrls, + cardImdbRatings = cardImdbRatings, currentProfile = currentProfile, onNavigateToSearch = onNavigateToSearch, onSwitchProfile = onSwitchProfile, - onNavigateToDetails = onNavigateToDetails + onNavigateToDetails = onNavigateToDetails, + onPreloadHeroImdbRatings = onPreloadHeroImdbRatings ) } 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 99bd9c4fe..57504a6f4 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 @@ -809,8 +809,9 @@ class HomeViewModel @Inject constructor( } private suspend fun buildFavoriteTvCategory(): Category? { - // Use non-blocking memory read first; fall back to mutex-guarded disk read + // Use non-blocking memory read first; fall back to disk snapshot read val snapshot = iptvRepository.getMemoryCachedSnapshot() + ?: iptvRepository.getCachedSnapshotOrNull() ?: return null val favoriteIds = snapshot.favoriteChannels.toHashSet() if (favoriteIds.isEmpty()) return null @@ -821,6 +822,7 @@ class HomeViewModel @Inject constructor( .filter { favoriteIds.contains(it.id) } .map { it.id } .toSet() + if (favoriteChannelIds.isEmpty()) return null iptvRepository.reDeriveCachedNowNext(favoriteChannelIds) // Re-read snapshot after re-derive to get updated nowNext val freshSnapshot = iptvRepository.getMemoryCachedSnapshot() ?: snapshot @@ -1124,6 +1126,7 @@ class HomeViewModel @Inject constructor( private val _uiState = MutableStateFlow(HomeUiState()) val uiState: StateFlow = _uiState.asStateFlow() val cardLogoUrls = mutableStateMapOf() + val cardImdbRatings = mutableStateMapOf() // Debounce job for hero updates (Phase 6.1) private var heroUpdateJob: Job? = null @@ -2158,6 +2161,8 @@ class HomeViewModel @Inject constructor( continueWatchingUpdates.revision == localUpdateRevision ) { publishContinueWatching(fresh) + } else if (cached.isEmpty() && instant.isEmpty() && fresh.isEmpty()) { + publishContinueWatching(emptyList()) } val traktConnected = try { traktRepository.hasTrakt() @@ -2183,6 +2188,18 @@ class HomeViewModel @Inject constructor( } private suspend fun publishContinueWatching(items: List) { + if (items.isEmpty()) { + withContext(Dispatchers.IO) { + persistContinueWatchingCache(emptyList()) + } + withContext(Dispatchers.Main) { + val current = _uiState.value.categories.filterNot { it.id == "continue_watching" } + if (current.size != _uiState.value.categories.size) { + _uiState.value = _uiState.value.copy(categories = current) + } + } + return + } withContext(Dispatchers.IO) { persistContinueWatchingCache(items) } @@ -2231,9 +2248,13 @@ class HomeViewModel @Inject constructor( }.getOrDefault(emptySet()) val skeletonDefaults = mediaRepository.getDefaultCatalogConfigs() .filterNot { cfg -> cfg.isPreinstalled && cfg.id in hiddenForSkeleton } + val earlyHasRemote = runCatching { + traktRepository.hasTrakt() || traktRepository.isAlternativeRemoteActive() + }.getOrDefault(false) val earlySkeleton = buildProfileSkeletonCategories( savedCatalogs = skeletonDefaults, - cachedContinueWatching = emptyList() + cachedContinueWatching = emptyList(), + hasRemoteContinueWatching = earlyHasRemote ) if (requestId != loadHomeRequestId) return@loadHome if (earlySkeleton.isNotEmpty()) { @@ -2286,8 +2307,12 @@ class HomeViewModel @Inject constructor( savedCatalogs.forEach { savedCatalogById[it.id] = it } categoryPaginationStates.clear() + val hasRemoteContinueWatching = runCatching { + traktRepository.hasTrakt() || traktRepository.isAlternativeRemoteActive() + }.getOrDefault(false) + if (!isTvDevice) { - loadMobileHomeDataProgressive(requestId, savedCatalogs, cachedContinueWatching) + loadMobileHomeDataProgressive(requestId, savedCatalogs, cachedContinueWatching, hasRemoteContinueWatching) return@loadHome } @@ -2296,7 +2321,8 @@ class HomeViewModel @Inject constructor( if (_uiState.value.categories.isEmpty()) { val skeletonCategories = buildProfileSkeletonCategories( savedCatalogs = savedCatalogs, - cachedContinueWatching = cachedContinueWatching + cachedContinueWatching = cachedContinueWatching, + hasRemoteContinueWatching = hasRemoteContinueWatching ) if (requestId != loadHomeRequestId) return@loadHome if (skeletonCategories.isNotEmpty()) { @@ -2966,6 +2992,26 @@ class HomeViewModel @Inject constructor( } } + fun preloadImdbRatingsForHeroItems(items: List) { + viewModelScope.launch(networkDispatcher) { + items.filter { isActionableMediaItem(it) && !isIptvItem(it) }.forEach { item -> + val key = "${item.mediaType}_${item.id}" + if (!cardImdbRatings.containsKey(key)) { + try { + val rating = mediaRepository.getImdbRating(item.mediaType, item.id) + if (!rating.isNullOrBlank()) { + withContext(Dispatchers.Main.immediate) { + cardImdbRatings[key] = rating + } + } + } catch (e: Exception) { + if (e is CancellationException) throw e + } + } + } + } + } + fun retryMobileHomeLoading() { _uiState.value = _uiState.value.copy(isMobileSlowLoading = false, isLoading = true) loadHomeData() @@ -2974,12 +3020,14 @@ class HomeViewModel @Inject constructor( private fun loadMobileHomeDataProgressive( requestId: Long, savedCatalogs: List, - cachedContinueWatching: List + cachedContinueWatching: List, + hasRemoteContinueWatching: Boolean = false ) { // 1. Initial Skeleton Setup: render structured skeletons in saved catalog order immediately val skeletonCategories = buildProfileSkeletonCategories( savedCatalogs = savedCatalogs, - cachedContinueWatching = cachedContinueWatching + cachedContinueWatching = cachedContinueWatching, + hasRemoteContinueWatching = hasRemoteContinueWatching ) if (_uiState.value.categories.isEmpty()) { val skeletonHero = chooseInitialHero(skeletonCategories) @@ -3044,9 +3092,10 @@ class HomeViewModel @Inject constructor( } } - // 5. TMDB Built-in Categories (Trending Movies, Shows, Anime) - independent fetches + // 5. TMDB Built-in Categories (Trending Movies, Shows, Anime) - fast independent single-request fetches + val builtinTmdbIds = setOf("trending_movies", "trending_tv", "trending_anime") val tmdbConfigs = savedCatalogs.filter { - it.isPreinstalled && it.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(it) && !isCollectionTileConfig(it) + (it.id in builtinTmdbIds) || (it.isPreinstalled && it.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(it) && !isCollectionTileConfig(it)) } tmdbConfigs.forEach { cfg -> viewModelScope.launch(networkDispatcher) { @@ -3069,11 +3118,12 @@ class HomeViewModel @Inject constructor( } } - // 6. MDBList and Custom/Addon Catalogs - progressive fetch with semaphore + // 6. MDBList and Custom/Addon Catalogs - progressive fetch with higher concurrency val customConfigs = savedCatalogs.filter { cfg -> - isCustomCatalogConfig(cfg) || (cfg.isPreinstalled && !cfg.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(cfg) && !isCollectionTileConfig(cfg)) + cfg.id !in builtinTmdbIds && + (isCustomCatalogConfig(cfg) || (cfg.isPreinstalled && !cfg.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(cfg) && !isCollectionTileConfig(cfg))) } - val customSemaphore = Semaphore(if (isLowRamDevice) 2 else 3) + val customSemaphore = Semaphore(if (isLowRamDevice) 3 else 6) customConfigs.forEach { cfg -> viewModelScope.launch(networkDispatcher) { customSemaphore.withPermit { @@ -3446,31 +3496,37 @@ class HomeViewModel @Inject constructor( private fun buildProfileSkeletonCategories( savedCatalogs: List, - cachedContinueWatching: List + cachedContinueWatching: List, + hasRemoteContinueWatching: Boolean = false ): List { val placeholderItems = createPlaceholderItems() val rows = mutableListOf() - if (cachedContinueWatching.isNotEmpty()) { + // Include Continue Watching in startup skeleton if cached items exist OR user is connected to Trakt / Cloud + val hasContinueWatchingPotential = cachedContinueWatching.isNotEmpty() || hasRemoteContinueWatching + if (hasContinueWatchingPotential) { rows.add( Category( id = "continue_watching", title = "Continue Watching", - items = cachedContinueWatching.map { it.toMediaItem() } - ) - ) - } else { - rows.add( - Category( - id = "continue_watching", - title = "Continue Watching", - items = placeholderItems + items = if (cachedContinueWatching.isNotEmpty()) { + cachedContinueWatching.map { it.toMediaItem() } + } else { + placeholderItems + } ) ) } savedCatalogs.forEach { cfg -> if (isCollectionTileConfig(cfg)) return@forEach + // Omit Favorite TV and Popular Live Sports from initial skeleton + if (cfg.id == FAVORITE_TV_CATEGORY_ID) return@forEach + if (cfg.id == SportsAddonCapabilities.POPULAR_LIVE_TV_ROW_ID) return@forEach + if (cfg.id == SportsAddonCapabilities.SPORTS_CATEGORY_ROW_ID) { + rows.add(sportsRepository.sportsCategoryOnlyRow(locked = true)) + return@forEach + } val rowItems = if (isCollectionRailConfig(cfg)) { val group = cfg.collectionGroup val matchingConfigs = savedCatalogs.filter { From bfccd16912bc3ead54a532a92c30df07f81b2e39 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 23 Aug 2026 12:39:35 +0530 Subject: [PATCH 04/12] feat(mobile): redesign profile selection loading transition and unify persistent viewport across screens --- .../main/kotlin/com/arflix/tv/MainActivity.kt | 26 +- .../arflix/tv/ui/screens/home/HomeScreen.kt | 2 +- .../screens/profile/ProfileSelectionScreen.kt | 443 +++++++++++------- .../tv/ui/screens/search/SearchScreen.kt | 2 +- 4 files changed, 308 insertions(+), 165 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 8a5b3ca6b..bd6b83cfe 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -647,6 +647,8 @@ fun ArflixApp( !currentRoute.contains("profile") && !currentRoute.contains("login") + val isPlayerRoute = iptvFullscreen || currentRoute?.contains("player") == true + Column( modifier = Modifier .fillMaxSize() @@ -668,7 +670,7 @@ fun ArflixApp( // Applied AFTER background so the gradient fills behind the bars. // systemBarsPadding() reads live WindowInsets, so it automatically // becomes 0 when the player hides the bars. - .then(if (isMobile) Modifier.systemBarsPadding() else Modifier) + .then(if (isMobile && !isPlayerRoute) Modifier.systemBarsPadding() else Modifier) ) { Box(modifier = Modifier.weight(1f)) { AppNavigation( @@ -697,16 +699,28 @@ fun ArflixApp( onExitApp = onExitApp ) } - if (showBottomBar) { + + if (isMobile && !isPlayerRoute) { + val bottomBarAlpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (showBottomBar) 1f else 0f, + animationSpec = androidx.compose.animation.core.tween(250), + label = "bottom_bar_alpha" + ) AppBottomBar( currentRoute = currentRoute, onNavigate = { route -> - navController.navigate(route) { - popUpTo("home") { inclusive = false } - launchSingleTop = true + if (showBottomBar) { + navController.navigate(route) { + popUpTo("home") { inclusive = false } + launchSingleTop = true + } } }, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + alpha = bottomBarAlpha + } ) } } 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 139585bef..909caf6f0 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 @@ -2905,7 +2905,7 @@ private fun MobileHomeRowsLayer( LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = 80.dp), + contentPadding = PaddingValues(bottom = 16.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { // Hero carousel — profile/search row + banner card pager diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileSelectionScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileSelectionScreen.kt index 66af09b71..490452b16 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileSelectionScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/profile/ProfileSelectionScreen.kt @@ -1,15 +1,12 @@ package com.arflix.tv.ui.screens.profile +import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import kotlinx.coroutines.delay import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.ui.draw.clip import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -18,9 +15,12 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -28,11 +28,13 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -42,13 +44,20 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.lerp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.tv.material3.ClickableSurfaceDefaults @@ -56,13 +65,14 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Icon import androidx.tv.material3.Surface import androidx.tv.material3.Text +import com.arflix.tv.R import com.arflix.tv.data.model.Profile import com.arflix.tv.ui.components.ProfileAvatarVisual import com.arflix.tv.ui.components.Toast import com.arflix.tv.ui.theme.appBackgroundDark import com.arflix.tv.util.LocalDeviceType -import androidx.compose.ui.res.stringResource -import com.arflix.tv.R +import com.arflix.tv.util.PinUtil +import kotlinx.coroutines.delay @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -78,13 +88,48 @@ fun ProfileSelectionScreen( // Create focus requesters for each profile slot (max 5 profiles + 1 add button) val focusRequesters = remember { List(6) { FocusRequester() } } - // Track if profile was selected in this session to trigger navigation - var navigateTriggered by remember { mutableStateOf(false) } - // Guard against Enter key events from previous screen (TV only — touch devices don't need this) val isTouchDevice = LocalDeviceType.current.isTouchDevice() var isReadyForInput by remember { mutableStateOf(isTouchDevice) } + val density = LocalDensity.current + val verticalCenterOffsetDp = if (isTouchDevice) 28.dp else 0.dp + val verticalCenterOffsetPx = with(density) { verticalCenterOffsetDp.toPx() } + + // Coordinate tracking & 3-step transition states + var rootCoordinates by remember { mutableStateOf(null) } + val avatarCoordinatesMap = remember { mutableMapOf() } + var transitionProfile by remember { mutableStateOf(null) } + var initialDeltaOffset by remember { mutableStateOf(Offset.Zero) } + var isTransitioning by remember { mutableStateOf(false) } + var minAnimationCompleted by remember { mutableStateOf(false) } + + val transitionProgress by animateFloatAsState( + targetValue = if (isTransitioning) 1f else 0f, + animationSpec = tween( + durationMillis = 420, + easing = FastOutSlowInEasing + ), + label = "profile_transition_progress" + ) + + fun startTransitionForProfile(profile: Profile) { + val coords = avatarCoordinatesMap[profile.id] + val root = rootCoordinates + if (coords != null && root != null && root.isAttached && coords.isAttached) { + val avatarCenterInRoot = root.localPositionOf(coords, Offset(coords.size.width / 2f, coords.size.height / 2f)) + val rootCenter = Offset(root.size.width / 2f, root.size.height / 2f + verticalCenterOffsetPx) + initialDeltaOffset = Offset( + x = avatarCenterInRoot.x - rootCenter.x, + y = avatarCenterInRoot.y - rootCenter.y + ) + } else { + initialDeltaOffset = Offset.Zero + } + transitionProfile = profile + isTransitioning = true + } + // Set ready for input after a short delay to ignore stray key events (TV only) LaunchedEffect(Unit) { if (!isTouchDevice) { @@ -102,20 +147,24 @@ fun ProfileSelectionScreen( } } - // Navigate after the user picks a profile. - // `navigateTriggered` MUST be a key. Keying only on the uiState values meant the tap itself - // never re-evaluated this, so navigation depended on *observing* a change in one of them — - // and re-selecting the already-active profile changes neither: activeProfile.id stays put, - // and isSwitchingProfile goes true→false inside a single frame, which the StateFlow conflates - // away before Compose ever reads it. The effect then never restarted and the tap did nothing, - // until some unrelated hitch let a frame land mid-switch. (Re-selecting the active profile - // became fast enough to hit this when d9e49b10 dropped its setActiveProfile disk write.) - LaunchedEffect(navigateTriggered, uiState.activeProfile?.id, uiState.isSwitchingProfile) { + // Guarantee full completion of the expand/center animation before moving to Home + LaunchedEffect(isTransitioning) { + if (isTransitioning) { + minAnimationCompleted = false + delay(620) + minAnimationCompleted = true + } + } + + // Navigate to Home once the expand-to-center animation has finished AND profile data loading is complete + LaunchedEffect(isTransitioning, minAnimationCompleted, uiState.isSwitchingProfile, uiState.activeProfile?.id) { if ( - navigateTriggered && + isTransitioning && + minAnimationCompleted && + !uiState.isSwitchingProfile && uiState.activeProfile != null && !uiState.isManageMode && - !uiState.isSwitchingProfile + !uiState.showPinDialog ) { onProfileSelected() } @@ -124,7 +173,6 @@ fun ProfileSelectionScreen( // Request focus on the first available item (profile or add button) LaunchedEffect(uiState.isLoading) { if (!uiState.isLoading) { - // Brief delay for layout, with retry logic delay(50) val targetIndex = if (uiState.profiles.isNotEmpty()) { uiState.activeProfile?.let { active -> @@ -136,7 +184,6 @@ fun ProfileSelectionScreen( try { focusRequesters.getOrNull(targetIndex)?.requestFocus() } catch (e: IllegalStateException) { - // Retry after a bit more time delay(100) try { focusRequesters.getOrNull(targetIndex)?.requestFocus() @@ -147,170 +194,239 @@ fun ProfileSelectionScreen( } } + val handleProfileClick: (Profile) -> Unit = { profile -> + if (!uiState.isSwitchingProfile && !isTransitioning && (isTouchDevice || isReadyForInput)) { + if (uiState.isManageMode) { + viewModel.showEditDialog(profile) + } else if (profile.isLocked && !profile.pin.isNullOrEmpty()) { + viewModel.selectProfileWithLockCheck(profile) + } else { + startTransitionForProfile(profile) + viewModel.selectProfile(profile) + } + } + } + Box( modifier = Modifier .fillMaxSize() - .background(appBackgroundDark()), + .background(appBackgroundDark()) + .onGloballyPositioned { rootCoordinates = it }, contentAlignment = Alignment.Center ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - // Title - Text( - text = "ARVIO", - fontSize = 32.sp, - fontWeight = FontWeight.Bold, - color = Color.White, - letterSpacing = 6.sp - ) + val backgroundAlpha = if (isTransitioning) { + (1f - transitionProgress * 2.5f).coerceIn(0f, 1f) + } else 1f - Spacer(modifier = Modifier.height(16.dp)) - - Text( - text = if (uiState.isManageMode) stringResource(R.string.manage_profiles) else stringResource(R.string.whos_watching), - fontSize = 20.sp, - fontWeight = FontWeight.Normal, - color = Color.White.copy(alpha = 0.8f) - ) + if (backgroundAlpha > 0f) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .offset(y = verticalCenterOffsetDp) + .graphicsLayer { alpha = backgroundAlpha } + ) { + // Title + Text( + text = "ARVIO", + fontSize = 32.sp, + fontWeight = FontWeight.Bold, + color = Color.White, + letterSpacing = 6.sp + ) - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(16.dp)) - // Profile avatars row - val avatarSize = if (isTouchDevice) 90.dp else 120.dp - val avatarSpacing = if (isTouchDevice) 16.dp else 24.dp + Text( + text = if (uiState.isManageMode) stringResource(R.string.manage_profiles) else stringResource(R.string.whos_watching), + fontSize = 20.sp, + fontWeight = FontWeight.Normal, + color = Color.White.copy(alpha = 0.8f) + ) - if (uiState.isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(avatarSize), - contentAlignment = Alignment.Center - ) { - Text( - text = stringResource(R.string.loading_profile), - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.72f), - textAlign = TextAlign.Center - ) - } - } else if (isTouchDevice) { - // Mobile: use LazyRow so profiles scroll horizontally on small screens - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(avatarSpacing, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically - ) { - itemsIndexed(uiState.profiles) { index, profile -> - ProfileAvatar( - profile = profile, - isManageMode = uiState.isManageMode, - isActiveProfile = uiState.activeProfile?.id == profile.id, - avatarSize = avatarSize, - modifier = Modifier.focusRequester(focusRequesters[index]), - onClick = { - if (uiState.isSwitchingProfile) return@ProfileAvatar - if (uiState.isManageMode) { - viewModel.showEditDialog(profile) - } else { - navigateTriggered = true - viewModel.selectProfileWithLockCheck(profile) - } - }, - onFocus = { viewModel.preloadForProfile(profile) }, - onDelete = { viewModel.deleteProfile(profile) } + Spacer(modifier = Modifier.height(48.dp)) + + // Profile avatars row + val avatarSize = if (isTouchDevice) 90.dp else 120.dp + val avatarSpacing = if (isTouchDevice) 16.dp else 24.dp + + if (uiState.isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(avatarSize), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.loading_profile), + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + color = Color.White.copy(alpha = 0.72f), + textAlign = TextAlign.Center ) } + } else if (isTouchDevice) { + // Mobile: use LazyRow so profiles scroll horizontally on small screens + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(avatarSpacing, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + itemsIndexed(uiState.profiles) { index, profile -> + ProfileAvatar( + profile = profile, + isManageMode = uiState.isManageMode, + isActiveProfile = uiState.activeProfile?.id == profile.id, + avatarSize = avatarSize, + isTransitionSelected = isTransitioning && transitionProfile?.id == profile.id, + modifier = Modifier.focusRequester(focusRequesters[index]), + onAvatarPositioned = { coords -> + avatarCoordinatesMap[profile.id] = coords + }, + onClick = { handleProfileClick(profile) }, + onFocus = { viewModel.preloadForProfile(profile) }, + onDelete = { viewModel.deleteProfile(profile) } + ) + } + + // Add profile button (max 5 profiles) + if (uiState.profiles.size < 5) { + item { + AddProfileButton( + avatarSize = avatarSize, + modifier = Modifier.focusRequester(focusRequesters[uiState.profiles.size]), + onClick = { if (!isTransitioning) viewModel.showAddDialog() } + ) + } + } + } + } else { + // TV: original Row layout with fixed spacing + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + uiState.profiles.forEachIndexed { index, profile -> + ProfileAvatar( + profile = profile, + isManageMode = uiState.isManageMode, + isActiveProfile = uiState.activeProfile?.id == profile.id, + avatarSize = avatarSize, + isTransitionSelected = isTransitioning && transitionProfile?.id == profile.id, + modifier = Modifier.focusRequester(focusRequesters[index]), + onAvatarPositioned = { coords -> + avatarCoordinatesMap[profile.id] = coords + }, + onClick = { handleProfileClick(profile) }, + onFocus = { viewModel.preloadForProfile(profile) }, + onDelete = { viewModel.deleteProfile(profile) } + ) - // Add profile button (max 5 profiles) - if (uiState.profiles.size < 5) { - item { + if (index < uiState.profiles.size - 1 || uiState.profiles.size < 5) { + Spacer(modifier = Modifier.width(avatarSpacing)) + } + } + + // Add profile button (max 5 profiles) + if (uiState.profiles.size < 5) { AddProfileButton( avatarSize = avatarSize, modifier = Modifier.focusRequester(focusRequesters[uiState.profiles.size]), - onClick = { viewModel.showAddDialog() } + onClick = { if (isReadyForInput && !uiState.isSwitchingProfile && !isTransitioning) viewModel.showAddDialog() } ) } } } - } else { - // TV: original Row layout with fixed spacing - Row( - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - uiState.profiles.forEachIndexed { index, profile -> - ProfileAvatar( - profile = profile, - isManageMode = uiState.isManageMode, - isActiveProfile = uiState.activeProfile?.id == profile.id, - avatarSize = avatarSize, - modifier = Modifier.focusRequester(focusRequesters[index]), - onClick = { - // Guard against stray Enter key events from previous screen - if (!isReadyForInput || uiState.isSwitchingProfile) return@ProfileAvatar - - if (uiState.isManageMode) { - viewModel.showEditDialog(profile) - } else { - navigateTriggered = true - viewModel.selectProfileWithLockCheck(profile) - } - }, - onFocus = { viewModel.preloadForProfile(profile) }, - onDelete = { viewModel.deleteProfile(profile) } - ) - if (index < uiState.profiles.size - 1 || uiState.profiles.size < 5) { - Spacer(modifier = Modifier.width(avatarSpacing)) + Spacer(modifier = Modifier.height(48.dp)) + + // Manage Profiles button + ManageProfilesButton( + isManageMode = uiState.isManageMode, + onClick = { + if ((isTouchDevice || isReadyForInput) && !uiState.isSwitchingProfile && !isTransitioning) { + viewModel.toggleManageMode() } } + ) - // Add profile button (max 5 profiles) - if (uiState.profiles.size < 5) { - AddProfileButton( - avatarSize = avatarSize, - modifier = Modifier.focusRequester(focusRequesters[uiState.profiles.size]), - onClick = { if (isReadyForInput && !uiState.isSwitchingProfile) viewModel.showAddDialog() } - ) - } + if (!isCloudConnected) { + Spacer(modifier = Modifier.height(24.dp)) + + // Cloud connect button — focusable on TV, tappable on mobile + CloudConnectButton( + onClick = { + if ((isTouchDevice || isReadyForInput) && !uiState.isSwitchingProfile && !isTransitioning) { + onConnectCloud() + } + } + ) } } + } - Spacer(modifier = Modifier.height(48.dp)) + // ── 3-Step Profile Selection Transition Overlay ── + if (transitionProfile != null) { + val targetScale = if (isTouchDevice) 1.85f else 1.70f + val currentScale = lerp(1f, targetScale, transitionProgress) + val currentOffsetX = lerp(initialDeltaOffset.x, 0f, transitionProgress) + val currentOffsetY = lerp(initialDeltaOffset.y, 0f, transitionProgress) + val avatarBaseSize = if (isTouchDevice) 90.dp else 120.dp - // Manage Profiles button - ManageProfilesButton( - isManageMode = uiState.isManageMode, - onClick = { - if ((isTouchDevice || isReadyForInput) && !uiState.isSwitchingProfile) { - viewModel.toggleManageMode() - } + Box( + modifier = Modifier + .fillMaxSize() + .offset(y = verticalCenterOffsetDp), + contentAlignment = Alignment.Center + ) { + // The enlarged profile card: centered directly at (0, 0) + Box( + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + translationX = currentOffsetX + translationY = currentOffsetY + scaleX = currentScale + scaleY = currentScale + } + .size(avatarBaseSize) + .clip(RoundedCornerShape(8.dp)), + contentAlignment = Alignment.Center + ) { + ProfileAvatarVisual( + profile = transitionProfile!!, + letterFontSize = 48.sp, + iconPadding = 12.dp + ) } - ) - if (!isCloudConnected) { - Spacer(modifier = Modifier.height(24.dp)) - - // Cloud connect button — focusable on TV, tappable on mobile - CloudConnectButton( - onClick = { - if ((isTouchDevice || isReadyForInput) && !uiState.isSwitchingProfile) { - onConnectCloud() - } + // Loading ring: appears only if loading is still in progress once the card is centered + val showSpinner by produceState(initialValue = false, key1 = isTransitioning, key2 = uiState.isSwitchingProfile) { + if (isTransitioning && uiState.isSwitchingProfile) { + delay(200) // Smooth delay so instantaneous cached data doesn't flash the spinner + value = isTransitioning && uiState.isSwitchingProfile + } else { + value = false } - ) - } + } - if (uiState.isSwitchingProfile) { - Spacer(modifier = Modifier.height(18.dp)) - Text( - text = stringResource(R.string.loading_profile), - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - color = Color.White.copy(alpha = 0.72f) + val spinnerAlpha by animateFloatAsState( + targetValue = if (transitionProgress >= 0.95f && showSpinner) 1f else 0f, + animationSpec = tween(220), + label = "spinner_alpha" ) + + if (spinnerAlpha > 0f) { + CircularProgressIndicator( + modifier = Modifier + .align(Alignment.Center) + .offset(y = (avatarBaseSize * targetScale / 2) + 26.dp) + .size(26.dp) + .graphicsLayer { alpha = spinnerAlpha }, + color = Color.White.copy(alpha = 0.85f), + strokeWidth = 2.5.dp, + trackColor = Color.White.copy(alpha = 0.12f) + ) + } } } @@ -367,7 +483,13 @@ fun ProfileSelectionScreen( if (uiState.pinDialogMode == "verify") { PinEntryDialog( title = stringResource(R.string.enter_pin_to_unlock), - onPinConfirmed = { pin -> viewModel.verifyPinAndSelectProfile(pin) }, + onPinConfirmed = { pin -> + val pending = uiState.pendingProfileForPin + if (pending != null && PinUtil.verifyPin(pin, pending.pin)) { + startTransitionForProfile(pending) + } + viewModel.verifyPinAndSelectProfile(pin) + }, onDismiss = { viewModel.hidePinDialog() }, isSetup = false, pinError = uiState.pinError @@ -391,7 +513,9 @@ private fun ProfileAvatar( isManageMode: Boolean, isActiveProfile: Boolean = false, avatarSize: Dp = 120.dp, + isTransitionSelected: Boolean = false, modifier: Modifier = Modifier, + onAvatarPositioned: (LayoutCoordinates) -> Unit = {}, onClick: () -> Unit, onFocus: () -> Unit = {}, onDelete: () -> Unit @@ -406,9 +530,14 @@ private fun ProfileAvatar( val isTouchDevice = LocalDeviceType.current.isTouchDevice() Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = modifier + modifier = modifier.graphicsLayer { + alpha = if (isTransitionSelected) 0f else 1f + } ) { - Box(contentAlignment = Alignment.Center) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.onGloballyPositioned { onAvatarPositioned(it) } + ) { val avatarContent: @Composable () -> Unit = { ProfileAvatarVisual( profile = profile, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 04a4a1edd..4f46cd5cc 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -946,7 +946,7 @@ private fun RowsLayer( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { LazyColumn( state = listState, - contentPadding = PaddingValues(top = focusBleedPadding / 2, bottom = maxHeight * 0.6f), + contentPadding = PaddingValues(top = focusBleedPadding / 2, bottom = if (isTouchDevice) 16.dp else maxHeight * 0.6f), modifier = Modifier.fillMaxSize().arvioDpadFocusGroup(), verticalArrangement = Arrangement.spacedBy(0.dp) ) { From 7d3a34d7ce6bb5603948173d31aadbd035f93395 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 23 Aug 2026 13:13:58 +0530 Subject: [PATCH 05/12] feat(mobile): align search card title ellipsis and add smooth fade transitions and logo crossfade --- .../com/arflix/tv/navigation/AppNavigation.kt | 8 +- .../tv/ui/screens/details/DetailsScreen.kt | 207 +++++++++++------- .../tv/ui/screens/search/SearchScreen.kt | 6 +- 3 files changed, 138 insertions(+), 83 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt index 1a88b69ff..695425d1c 100644 --- a/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt +++ b/app/src/main/kotlin/com/arflix/tv/navigation/AppNavigation.kt @@ -163,10 +163,10 @@ fun AppNavigation( // Netflix TV uses ~250ms fade; this is tuned for Android TV's 60fps. // Pure crossfade — no horizontal slides (those feel mobile, not TV). // Netflix TV uses ~250ms crossfade for all screen transitions. - enterTransition = { fadeIn(androidx.compose.animation.core.tween(250)) }, - exitTransition = { fadeOut(androidx.compose.animation.core.tween(200)) }, - popEnterTransition = { fadeIn(androidx.compose.animation.core.tween(250)) }, - popExitTransition = { fadeOut(androidx.compose.animation.core.tween(200)) } + enterTransition = { fadeIn(androidx.compose.animation.core.tween(280, easing = androidx.compose.animation.core.FastOutSlowInEasing)) }, + exitTransition = { fadeOut(androidx.compose.animation.core.tween(240, easing = androidx.compose.animation.core.FastOutSlowInEasing)) }, + popEnterTransition = { fadeIn(androidx.compose.animation.core.tween(280, easing = androidx.compose.animation.core.FastOutSlowInEasing)) }, + popExitTransition = { fadeOut(androidx.compose.animation.core.tween(240, easing = androidx.compose.animation.core.FastOutSlowInEasing)) } ) { // Login screen composable(Screen.Login.route) { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 2452ec7b4..7d9ef4ab7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.SystemClock +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Animatable @@ -958,58 +959,64 @@ fun DetailsScreen( .then(keyModifier) ) { // Main content - full screen with sidebar overlay (same as HomeScreen) - if (uiState.isLoading || uiState.item == null) { - // Use skeleton loader for better UX - SkeletonDetailsPage( - isTV = mediaType == MediaType.TV, - isMobile = isMobile, - modifier = Modifier.fillMaxSize() - ) - } else { - uiState.item?.let { item -> - DetailsContent( - item = item, - logoUrl = uiState.logoUrl, - episodes = uiState.episodes, - totalSeasons = uiState.totalSeasons, - currentSeason = uiState.currentSeason, - cast = uiState.cast, - reviews = uiState.reviews, - similar = uiState.similar, - similarLogoUrls = uiState.similarLogoUrls, - collectionItems = uiState.collectionItems, - collectionName = uiState.collectionName, - hasCollectionAction = uiState.collectionId != null, - collectionIndex = collectionIndex, - focusedSection = focusedSection, - buttonIndex = buttonIndex, - episodeIndex = episodeIndex, - ratingsIndex = ratingsIndex, - seasonIndex = seasonIndex, - castIndex = castIndex, - reviewIndex = reviewIndex, - similarIndex = similarIndex, - isInWatchlist = uiState.isInWatchlist, - genres = uiState.genres, - budget = uiState.budget, - externalRatings = uiState.externalRatings, - seasonProgress = uiState.seasonProgress, - playLabel = uiState.playLabel, - showEpisodeRatings = uiState.showEpisodeRatings, - hasTrailer = uiState.trailerKey != null, - contentHasFocus = !isSidebarFocused, - usePosterCards = usePosterCards, + Crossfade( + targetState = uiState.isLoading || uiState.item == null, + animationSpec = tween(durationMillis = 280, easing = FastOutSlowInEasing), + label = "details_loading_crossfade" + ) { loading -> + if (loading) { + // Use skeleton loader for better UX + SkeletonDetailsPage( + isTV = mediaType == MediaType.TV, isMobile = isMobile, - spoilerBlurEnabled = spoilerBlurEnabled, - onBack = onBack, - onButtonClick = onButtonClickRemembered, - onSeasonClick = onSeasonClickRemembered, - onSeasonLongClick = onSeasonLongClickRemembered, - onEpisodeClick = onEpisodeClickRemembered, - onCastClick = onCastClickRemembered, - onSimilarClick = onSimilarClickRemembered, - onCollectionClick = onCollectionClickRemembered + modifier = Modifier.fillMaxSize() ) + } else { + uiState.item?.let { item -> + DetailsContent( + item = item, + logoUrl = uiState.logoUrl, + episodes = uiState.episodes, + totalSeasons = uiState.totalSeasons, + currentSeason = uiState.currentSeason, + cast = uiState.cast, + reviews = uiState.reviews, + similar = uiState.similar, + similarLogoUrls = uiState.similarLogoUrls, + collectionItems = uiState.collectionItems, + collectionName = uiState.collectionName, + hasCollectionAction = uiState.collectionId != null, + collectionIndex = collectionIndex, + focusedSection = focusedSection, + buttonIndex = buttonIndex, + episodeIndex = episodeIndex, + ratingsIndex = ratingsIndex, + seasonIndex = seasonIndex, + castIndex = castIndex, + reviewIndex = reviewIndex, + similarIndex = similarIndex, + isInWatchlist = uiState.isInWatchlist, + genres = uiState.genres, + budget = uiState.budget, + externalRatings = uiState.externalRatings, + seasonProgress = uiState.seasonProgress, + playLabel = uiState.playLabel, + showEpisodeRatings = uiState.showEpisodeRatings, + hasTrailer = uiState.trailerKey != null, + contentHasFocus = !isSidebarFocused, + usePosterCards = usePosterCards, + isMobile = isMobile, + spoilerBlurEnabled = spoilerBlurEnabled, + onBack = onBack, + onButtonClick = onButtonClickRemembered, + onSeasonClick = onSeasonClickRemembered, + onSeasonLongClick = onSeasonLongClickRemembered, + onEpisodeClick = onEpisodeClickRemembered, + onCastClick = onCastClickRemembered, + onSimilarClick = onSimilarClickRemembered, + onCollectionClick = onCollectionClickRemembered + ) + } } } @@ -1350,12 +1357,24 @@ private fun DetailsContent( .height(backdropHeight) .zIndex(10f) ) { - AsyncImage( - model = item.backdrop ?: item.image, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize() - ) + val backdropRequest = remember(item.backdrop, item.image, context) { + val url = item.backdrop ?: item.image + if (url.isNullOrBlank()) null else { + ImageRequest.Builder(context) + .data(url) + .crossfade(250) + .allowHardware(true) + .build() + } + } + if (backdropRequest != null) { + AsyncImage( + model = backdropRequest, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } // Strong bottom gradient Box( modifier = Modifier @@ -1425,16 +1444,35 @@ private fun DetailsContent( } } ) { - if (logoUrl != null) { - AsyncImage( - model = logoUrl, - contentDescription = item.title, - contentScale = ContentScale.Fit, - alignment = Alignment.Center, - modifier = Modifier - .fillMaxWidth(0.78f) - .height(86.dp) - ) + Crossfade( + targetState = logoUrl, + animationSpec = tween(300, easing = FastOutSlowInEasing), + label = "tv_logo_crossfade" + ) { currentLogoUrl -> + if (!currentLogoUrl.isNullOrBlank()) { + AsyncImage( + model = currentLogoUrl, + contentDescription = item.title, + contentScale = ContentScale.Fit, + alignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth(0.78f) + .height(86.dp) + ) + } else { + Text( + text = item.title, + style = ArflixTypography.heroTitle.copy( + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ), + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.85f) + ) + } } } @@ -1942,16 +1980,33 @@ private fun DetailsContent( modifier = Modifier.height(72.dp), contentAlignment = Alignment.CenterStart ) { - if (logoUrl != null) { - AsyncImage( - model = logoUrl, - contentDescription = item.title, - contentScale = ContentScale.Fit, - alignment = Alignment.CenterStart, - modifier = Modifier - .height(72.dp) - .width(320.dp) - ) + Crossfade( + targetState = logoUrl, + animationSpec = tween(300, easing = FastOutSlowInEasing), + label = "mobile_logo_crossfade" + ) { currentLogoUrl -> + if (!currentLogoUrl.isNullOrBlank()) { + AsyncImage( + model = currentLogoUrl, + contentDescription = item.title, + contentScale = ContentScale.Fit, + alignment = Alignment.CenterStart, + modifier = Modifier + .height(72.dp) + .width(320.dp) + ) + } else { + Text( + text = item.title, + style = ArflixTypography.heroTitle.copy( + fontSize = 24.sp, + fontWeight = FontWeight.Bold + ), + color = Color.White, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 4f46cd5cc..a9093ce9a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -962,7 +962,7 @@ private fun RowsLayer( if (isPortrait) 105.dp else 210.dp } val baseRowHeight = if (isTouchDevice) { - if (isPortrait) 260.dp else 190.dp + if (isPortrait) 275.dp else 225.dp } else if (isPortrait) { // Poster cards (2:3) need extra vertical room for title + date below the image if (screenHeight <= 640) 271.dp else 309.dp @@ -1045,7 +1045,7 @@ private fun RowsLayer( start = focusBleedPadding, end = itemWidth + 56.dp, top = 8.dp, - bottom = focusBleedPadding + 12.dp + bottom = if (isTouchDevice) 8.dp else (focusBleedPadding + 12.dp) ), horizontalArrangement = Arrangement.spacedBy(18.dp) ) { @@ -1062,7 +1062,7 @@ private fun RowsLayer( isLandscape = !isPortrait, logoImageUrl = cardLogoUrls["${item.mediaType}_${item.id}"], showProgress = false, - titleMaxLines = 2, + titleMaxLines = 1, subtitleMaxLines = 1, isFocusedOverride = itemIsFocused, enableSystemFocus = false, From f7bb2705e716fd47655598e2ad430f50ff1727a6 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 23 Aug 2026 13:37:13 +0530 Subject: [PATCH 06/12] feat(mobile): center details logo without pre-text and add smooth metadata/button transitions --- .../tv/ui/screens/details/DetailsScreen.kt | 248 ++++++++++-------- 1 file changed, 145 insertions(+), 103 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 7d9ef4ab7..4b77e3c72 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -7,8 +7,16 @@ import android.content.Intent import android.net.Uri import android.os.Build import android.os.SystemClock +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring @@ -1417,6 +1425,7 @@ private fun DetailsContent( val statusBarsTop = WindowInsets.statusBars.getTop(density) Box( modifier = Modifier + .fillMaxWidth() .zIndex(11f) .height(86.dp) .onGloballyPositioned { coords -> @@ -1442,12 +1451,13 @@ private fun DetailsContent( scaleX = scale scaleY = scale } - } + }, + contentAlignment = Alignment.Center ) { Crossfade( targetState = logoUrl, animationSpec = tween(300, easing = FastOutSlowInEasing), - label = "tv_logo_crossfade" + label = "mobile_logo_crossfade" ) { currentLogoUrl -> if (!currentLogoUrl.isNullOrBlank()) { AsyncImage( @@ -1460,96 +1470,110 @@ private fun DetailsContent( .height(86.dp) ) } else { - Text( - text = item.title, - style = ArflixTypography.heroTitle.copy( - fontSize = 28.sp, - fontWeight = FontWeight.Bold - ), - color = Color.White, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(0.85f) - ) + Spacer(modifier = Modifier.fillMaxWidth(0.78f).height(86.dp)) } } } - Spacer(modifier = Modifier.height(12.dp)) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), - verticalAlignment = Alignment.CenterVertically, + Column( modifier = Modifier .fillMaxWidth() - .horizontalScroll(rememberScrollState()) + .animateContentSize( + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow + ) + ), + horizontalAlignment = Alignment.CenterHorizontally ) { - if (ratingValue > 0f) { - DetailsImdbSvgRatingBadge( - rating = rating, - imageLoader = metadataLogoImageLoader, - ratingFontSize = 13, - logoWidth = 34.dp, - logoHeight = 14.dp, - textShadow = textShadow - ) + Spacer(modifier = Modifier.height(12.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + AnimatedVisibility( + visible = ratingValue > 0f, + enter = fadeIn(tween(250)) + expandHorizontally(tween(250)), + exit = fadeOut(tween(150)) + shrinkHorizontally(tween(150)) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + DetailsImdbSvgRatingBadge( + rating = rating, + imageLoader = metadataLogoImageLoader, + ratingFontSize = 13, + logoWidth = 34.dp, + logoHeight = 14.dp, + textShadow = textShadow + ) + if (displayDate.isNotEmpty() || hasDuration) { + MobileMetadataSeparator() + } + } + } + if (displayDate.isNotEmpty()) { + Text( + text = displayDate, + style = ArflixTypography.caption.copy( + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + shadow = textShadow + ), + color = Color.White.copy(alpha = 0.78f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + if (hasDuration) { + if (displayDate.isNotEmpty()) { + MobileMetadataSeparator() + } + Text( + text = item.duration, + style = ArflixTypography.caption.copy( + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + shadow = textShadow + ), + color = Color.White.copy(alpha = 0.78f), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } - if (displayDate.isNotEmpty()) { - MobileMetadataSeparator() - Text( - text = displayDate, - style = ArflixTypography.caption.copy( - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - shadow = textShadow - ), - color = Color.White.copy(alpha = 0.78f), - maxLines = 1, - overflow = TextOverflow.Ellipsis + + if (externalRatings.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + MdbExternalRatingsRow( + ratings = externalRatings, + centered = true, + textShadow = textShadow ) } - if (hasDuration) { - MobileMetadataSeparator() + + if (genreText.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) Text( - text = item.duration, + text = genreText, style = ArflixTypography.caption.copy( fontSize = 13.sp, fontWeight = FontWeight.SemiBold, shadow = textShadow ), - color = Color.White.copy(alpha = 0.78f), + color = Color.White.copy(alpha = 0.74f), + textAlign = TextAlign.Center, maxLines = 1, - overflow = TextOverflow.Ellipsis + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.9f) ) } } - - if (externalRatings.isNotEmpty()) { - Spacer(modifier = Modifier.height(8.dp)) - MdbExternalRatingsRow( - ratings = externalRatings, - centered = true, - textShadow = textShadow - ) - } - - if (genreText.isNotEmpty()) { - Spacer(modifier = Modifier.height(6.dp)) - Text( - text = genreText, - style = ArflixTypography.caption.copy( - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - shadow = textShadow - ), - color = Color.White.copy(alpha = 0.74f), - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.fillMaxWidth(0.9f) - ) - } } } @@ -3601,14 +3625,18 @@ private fun MobileActionButton( onClick: () -> Unit ) { val shape = RoundedCornerShape(percent = 50) - val bgColor = when { + val targetBgColor = when { isPrimary -> Color.White isOutlined -> Color.Transparent isActive -> Color.White.copy(alpha = 0.15f) else -> Color.White.copy(alpha = 0.08f) } - val contentColor = if (isPrimary) Color.Black else Color.White.copy(alpha = 0.92f) - val borderColor = if (isOutlined) Color.White.copy(alpha = if (isActive) 0.55f else 0.22f) else Color.Transparent + val targetContentColor = if (isPrimary) Color.Black else Color.White.copy(alpha = 0.92f) + val targetBorderColor = if (isOutlined) Color.White.copy(alpha = if (isActive) 0.55f else 0.22f) else Color.Transparent + + val bgColor by animateColorAsState(targetValue = targetBgColor, animationSpec = tween(200), label = "btn_bg") + val contentColor by animateColorAsState(targetValue = targetContentColor, animationSpec = tween(200), label = "btn_content") + val borderColor by animateColorAsState(targetValue = targetBorderColor, animationSpec = tween(200), label = "btn_border") Row( modifier = modifier @@ -3616,29 +3644,37 @@ private fun MobileActionButton( .background(bgColor, shape) .border(1.dp, borderColor, shape) .clickable(onClick = onClick) - .padding(horizontal = 18.dp, vertical = 10.dp), + .padding(horizontal = 18.dp, vertical = 10.dp) + .animateContentSize(animationSpec = tween(200)), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = contentColor, - modifier = Modifier.size(if (isPrimary) 24.dp else 22.dp) - ) + Crossfade(targetState = icon, animationSpec = tween(200), label = "btn_icon") { currentIcon -> + Icon( + imageVector = currentIcon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(if (isPrimary) 24.dp else 22.dp) + ) + } Spacer(modifier = Modifier.width(8.dp)) - Text( - text = text, - style = ArvioSkin.typography.button.copy( - fontSize = 18.sp, - fontWeight = FontWeight.Bold - ), - color = contentColor, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier.weight(1f, fill = false) - ) + AnimatedContent( + targetState = text, + transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(150)) }, + label = "btn_text" + ) { currentText -> + Text( + text = currentText, + style = ArvioSkin.typography.button.copy( + fontSize = 18.sp, + fontWeight = FontWeight.Bold + ), + color = contentColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center + ) + } } } @@ -3652,22 +3688,26 @@ private fun MobileIconActionButton( onClick: () -> Unit ) { val shape = RoundedCornerShape(20.dp) - val backgroundColor = when { + val targetBackgroundColor = when { !enabled -> Color.White.copy(alpha = 0.04f) isActive -> Color.White.copy(alpha = 0.18f) else -> Color.White.copy(alpha = 0.08f) } - val contentColor = if (enabled) { + val targetContentColor = if (enabled) { Color.White.copy(alpha = if (isActive) 0.96f else 0.88f) } else { Color.White.copy(alpha = 0.3f) } - val borderColor = if (isActive) { + val targetBorderColor = if (isActive) { Color.White.copy(alpha = 0.28f) } else { Color.White.copy(alpha = 0.12f) } + val backgroundColor by animateColorAsState(targetValue = targetBackgroundColor, animationSpec = tween(200), label = "icon_btn_bg") + val contentColor by animateColorAsState(targetValue = targetContentColor, animationSpec = tween(200), label = "icon_btn_content") + val borderColor by animateColorAsState(targetValue = targetBorderColor, animationSpec = tween(200), label = "icon_btn_border") + Box( modifier = modifier .clip(shape) @@ -3676,12 +3716,14 @@ private fun MobileIconActionButton( .clickable(enabled = enabled, onClick = onClick), contentAlignment = Alignment.Center ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = contentColor, - modifier = Modifier.size(24.dp) - ) + Crossfade(targetState = icon, animationSpec = tween(200), label = "icon_btn_crossfade") { currentIcon -> + Icon( + imageVector = currentIcon, + contentDescription = contentDescription, + tint = contentColor, + modifier = Modifier.size(24.dp) + ) + } } } From 9aca44253a94d9e06172a0b040867d865fde9bd4 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 23 Aug 2026 15:51:35 +0530 Subject: [PATCH 07/12] feat(mobile): integrate telegram as settings subpage and enable predictive back for plugins and telegram --- .../tv/ui/screens/plugin/PluginScreen.kt | 8 +- .../tv/ui/screens/settings/SettingsScreen.kt | 10 ++- .../telegram/TelegramSettingsScreen.kt | 78 +++++++++++-------- 3 files changed, 61 insertions(+), 35 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt index 950b8a222..3a85d2348 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/plugin/PluginScreen.kt @@ -124,7 +124,13 @@ fun PluginScreen( } } - BackHandler { + BackHandler(enabled = modalOpen) { + if (showAddDialog) showAddDialog = false + else if (showResetDialog) showResetDialog = false + else if (repoToDelete != null) repoToDelete = null + } + + BackHandler(enabled = !isMobile && !modalOpen) { onBackPressed() } 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 ba82da5fa..d1eb40926 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 @@ -4087,9 +4087,9 @@ private fun MobileSettingsMainPage( iconRes = R.drawable.ic_telegram, title = "Telegram", value = "", - isExternalLink = true, + isExternalLink = false, isFocused = false, - onClick = onNavigateToTelegram + onClick = { onNavigate("Telegram") } ) val isDiscordLoggedIn by com.arflix.tv.ui.screens.details.discord.DiscordRpcManager.isLoggedInFlow.collectAsStateWithLifecycle(initialValue = false) val discordUsername by com.arflix.tv.ui.screens.details.discord.DiscordRpcManager.usernameFlow.collectAsStateWithLifecycle(initialValue = null) @@ -4657,6 +4657,12 @@ private fun MobileSettingsSubPage( context = LocalContext.current ) } + "Telegram" -> { + com.arflix.tv.ui.screens.settings.telegram.TelegramSettingsScreen( + onBack = { onNavigate("MAIN") }, + showHeader = false + ) + } } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt index 186989fb2..d6a1ff00a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/telegram/TelegramSettingsScreen.kt @@ -71,6 +71,9 @@ import com.arflix.tv.ui.theme.SuccessGreen import com.arflix.tv.ui.theme.TextPrimary import com.arflix.tv.ui.theme.TextSecondary import com.arflix.tv.ui.theme.appBackgroundDark +import androidx.compose.foundation.layout.PaddingValues +import com.arflix.tv.ui.motion.rememberArvioPredictiveBack +import com.arflix.tv.ui.motion.arvioBackSurface import com.arflix.tv.util.LocalDeviceType import com.google.zxing.BarcodeFormat import com.google.zxing.qrcode.QRCodeWriter @@ -79,48 +82,59 @@ import com.google.zxing.qrcode.QRCodeWriter @Composable fun TelegramSettingsScreen( onBack: () -> Unit, + showHeader: Boolean = true, viewModel: TelegramSettingsViewModel = hiltViewModel() ) { val authState by viewModel.authState.collectAsState() val cacheSizeBytes by viewModel.cacheSizeBytes.collectAsState() var showDisconnectConfirm by remember { mutableStateOf(false) } + val isMobile = LocalDeviceType.current.isTouchDevice() + val backMotion = rememberArvioPredictiveBack(enabled = isMobile && showHeader && !showDisconnectConfirm) { + onBack() + } - Box( - modifier = Modifier + val rootModifier = if (showHeader) { + Modifier .fillMaxSize() + .arvioBackSurface(backMotion) .background(appBackgroundDark()) - ) { - Box(modifier = Modifier.fillMaxSize().padding(horizontal = 32.dp, vertical = 24.dp)) { - Column(modifier = Modifier.fillMaxSize()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 24.dp) - ) { - Box( - modifier = Modifier - .size(36.dp) - .background(Color.White.copy(alpha = 0.08f), RoundedCornerShape(8.dp)) - .clickable { onBack() }, - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Default.ArrowBack, - contentDescription = stringResource(R.string.back), - tint = TextPrimary, - modifier = Modifier.size(20.dp) - ) - } - Spacer(modifier = Modifier.width(16.dp)) - Text( - text = "Telegram", - style = ArflixTypography.sectionTitle, - color = TextPrimary - ) - } + } else { + Modifier.fillMaxSize() + } + + val contentPadding = if (isMobile && !showHeader) { + PaddingValues(horizontal = 20.dp, vertical = 8.dp) + } else { + PaddingValues(horizontal = 32.dp, vertical = 24.dp) + } - val isMobile = LocalDeviceType.current.isTouchDevice() + Box(modifier = rootModifier) { + Box(modifier = Modifier.fillMaxSize().padding(contentPadding)) { + Column(modifier = Modifier.fillMaxSize()) { + if (showHeader) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = TextPrimary, + modifier = Modifier + .clickable { onBack() } + .padding(end = 16.dp) + ) + Text( + text = "Telegram", + style = ArflixTypography.sectionTitle, + color = TextPrimary + ) + } + } - when (val state = authState) { + when (val state = authState) { is TelegramAuthState.Idle -> IdleContent(onConnect = { viewModel.startAuth() }) is TelegramAuthState.Initializing -> LoadingContent(stringResource(R.string.telegram_connecting)) is TelegramAuthState.WaitPhone -> { From 300fd750d4bf2d2844213c41aaa78306423b96ab Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Sun, 23 Aug 2026 16:11:27 +0530 Subject: [PATCH 08/12] feat(mobile): instant media details header hydration and show title fallback --- .../tv/data/repository/MediaRepository.kt | 35 +++++++++++++++++-- .../tv/ui/screens/details/DetailsScreen.kt | 16 +++++++++ .../tv/ui/screens/search/SearchViewModel.kt | 22 ++++++++++-- 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 9734245e0..cffe100da 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -3199,10 +3199,41 @@ class MediaRepository @Inject constructor( return item.mediaType to match.id } - /** Instant synchronous peek into the in-memory logo cache. */ + /** Instant synchronous peek into the in-memory or persisted logo cache. */ fun peekCachedLogoUrl(mediaType: MediaType, mediaId: Int): String? { val cacheKey = "${mediaType}_logo_$mediaId" - return if (logoCache.containsKey(cacheKey)) getFromCache(logoCache, cacheKey) else null + if (logoCache.containsKey(cacheKey)) { + val cached = getFromCache(logoCache, cacheKey) + if (!cached.isNullOrBlank()) return cached + } + val altKey = "${mediaType}_$mediaId" + if (logoCache.containsKey(altKey)) { + val cached = getFromCache(logoCache, altKey) + if (!cached.isNullOrBlank()) return cached + } + try { + val json = context.getSharedPreferences("logo_cache", Context.MODE_PRIVATE).getString("urls", null) + if (!json.isNullOrBlank()) { + val jsonObject = org.json.JSONObject(json) + val url = when { + jsonObject.has(altKey) -> jsonObject.optString(altKey) + jsonObject.has(cacheKey) -> jsonObject.optString(cacheKey) + jsonObject.has("${mediaType.name.lowercase()}_$mediaId") -> jsonObject.optString("${mediaType.name.lowercase()}_$mediaId") + else -> null + } + if (!url.isNullOrBlank()) { + logoCache[cacheKey] = CacheEntry(url, System.currentTimeMillis()) + return url + } + } + } catch (_: Throwable) {} + return null + } + + fun cacheLogoUrl(mediaType: MediaType, mediaId: Int, logoUrl: String) { + if (logoUrl.isBlank()) return + val cacheKey = "${mediaType}_logo_$mediaId" + logoCache[cacheKey] = CacheEntry(logoUrl, System.currentTimeMillis()) } /** Instant synchronous peek into the in-memory season episodes cache. */ diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 4b77e3c72..b56fe3567 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -1015,6 +1015,7 @@ fun DetailsScreen( usePosterCards = usePosterCards, isMobile = isMobile, spoilerBlurEnabled = spoilerBlurEnabled, + isLoading = uiState.isLoading, onBack = onBack, onButtonClick = onButtonClickRemembered, onSeasonClick = onSeasonClickRemembered, @@ -1288,6 +1289,7 @@ private fun DetailsContent( usePosterCards: Boolean = false, showEpisodeRatings: Boolean = true, isMobile: Boolean = false, + isLoading: Boolean = false, // Persistent back callback used by the phone-layout back button overlay // (issue #43). No-op by default so tablet/TV callers don't need to pass it. onBack: () -> Unit = {}, @@ -1469,6 +1471,20 @@ private fun DetailsContent( .fillMaxWidth(0.78f) .height(86.dp) ) + } else if (!isLoading) { + Text( + text = item.title, + style = ArflixTypography.heroTitle.copy( + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + shadow = textShadow + ), + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(0.85f) + ) } else { Spacer(modifier = Modifier.fillMaxWidth(0.78f).height(86.dp)) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt index 33e2531d9..9ba6539b4 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchViewModel.kt @@ -145,6 +145,7 @@ class SearchViewModel @Inject constructor( listOfNotNull(row1.await(), row2.await(), row3.await(), row4.await(), row5.await()) } } + categories.forEach { cat -> cat.items.forEach { mediaRepository.cacheItem(it) } } _uiState.value = _uiState.value.copy(discoverCategories = categories, isDiscoverLoading = false) // Fetch logos for top items in each row (background, non-blocking) launch(Dispatchers.IO) { @@ -153,7 +154,10 @@ class SearchViewModel @Inject constructor( async { val key = "${item.mediaType}_${item.id}" val logo = runCatching { mediaRepository.getLogoUrl(item.mediaType, item.id) }.getOrNull() - if (logo.isNullOrBlank()) null else key to logo + if (!logo.isNullOrBlank()) { + mediaRepository.cacheLogoUrl(item.mediaType, item.id, logo) + key to logo + } else null } }.awaitAll().filterNotNull().toMap() _uiState.value = _uiState.value.copy(discoverLogoUrls = _uiState.value.discoverLogoUrls + logos) @@ -302,8 +306,21 @@ class SearchViewModel @Inject constructor( } val movies = sorted.filter { it.mediaType == MediaType.MOVIE }; val tv = sorted.filter { it.mediaType == MediaType.TV } val personItems = peopleRows.flatMap { it.items } + sorted.forEach { mediaRepository.cacheItem(it) } + personItems.forEach { mediaRepository.cacheItem(it) } val top = (personItems.take(24) + movies.take(16) + tv.take(16)).distinctBy { "${it.mediaType}_${it.id}" } - val logos = withContext(Dispatchers.IO) { top.map { item -> async { val k = "${item.mediaType}_${item.id}"; val l = runCatching { mediaRepository.getLogoUrl(item.mediaType, item.id) }.getOrNull(); if (l.isNullOrBlank()) null else k to l } }.awaitAll().filterNotNull().toMap() } + val logos = withContext(Dispatchers.IO) { + top.map { item -> + async { + val k = "${item.mediaType}_${item.id}" + val l = runCatching { mediaRepository.getLogoUrl(item.mediaType, item.id) }.getOrNull() + if (!l.isNullOrBlank()) { + mediaRepository.cacheLogoUrl(item.mediaType, item.id, l) + k to l + } else null + } + }.awaitAll().filterNotNull().toMap() + } _uiState.value = _uiState.value.copy(isLoading = false, results = sorted, movieResults = movies, tvResults = tv, personResults = peopleRows, cardLogoUrls = logos) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e _uiState.value = _uiState.value.copy(isLoading = false, error = e.message) } @@ -351,6 +368,7 @@ class SearchViewModel @Inject constructor( } } } + items.forEach { mediaRepository.cacheItem(it) } _uiState.value = _uiState.value.copy(isLoading = false, aiResults = if (sq.limit != null) items.take(sq.limit) else items) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e _uiState.value = _uiState.value.copy(isLoading = false, error = e.message) } From 7fd4c3674bd5f5e0530e9a457626e648eb1f226a Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 24 Aug 2026 17:39:16 +0530 Subject: [PATCH 09/12] fix(mobile): restore status bars after playback, fix details cold hydration, and keep unwatched series start label --- .../main/kotlin/com/arflix/tv/MainActivity.kt | 47 ++++++---- .../tv/data/repository/MediaRepository.kt | 92 +++++++++++++++---- .../arflix/tv/ui/components/AppBottomBar.kt | 9 +- .../tv/ui/screens/details/DetailsViewModel.kt | 52 ++++++++--- .../arflix/tv/ui/screens/home/HomeScreen.kt | 29 ++++-- .../tv/ui/screens/home/HomeViewModel.kt | 32 ++++++- .../tv/ui/screens/player/PlayerScreen.kt | 14 +-- .../tv/ui/screens/tv/live/LiveTvScreen.kt | 13 ++- .../kotlin/com/arflix/tv/util/DeviceType.kt | 10 ++ 9 files changed, 228 insertions(+), 70 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index bd6b83cfe..17108e9fe 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -8,7 +8,9 @@ import android.view.ViewTreeObserver import android.view.WindowManager import com.arflix.tv.R import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing @@ -27,6 +29,7 @@ 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.statusBarsPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn @@ -72,6 +75,7 @@ import com.arflix.tv.util.LocalAppLanguage import com.arflix.tv.util.LAST_APP_LANGUAGE_KEY import com.arflix.tv.util.detectDeviceType import com.arflix.tv.util.deviceHasTouchScreen +import com.arflix.tv.util.findActivity import com.arflix.tv.util.settingsDataStore import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.stringPreferencesKey @@ -239,30 +243,20 @@ class MainActivity : ComponentActivity() { DeviceType.PHONE -> ActivityInfo.SCREEN_ORIENTATION_FULL_USER } - // All devices use edge-to-edge (setDecorFitsSystemWindows=false). - // TV hides the bars; mobile keeps them visible and Compose handles - // insets via systemBarsPadding() in the root layout. - WindowCompat.setDecorFitsSystemWindows(window, false) if (initialDeviceType == DeviceType.TV) { + WindowCompat.setDecorFitsSystemWindows(window, false) WindowInsetsControllerCompat(window, window.decorView).apply { systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE hide(WindowInsetsCompat.Type.systemBars()) } } else { + enableEdgeToEdge( + statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), + navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT) + ) // Clear any FLAG_FULLSCREEN the Leanback theme may have set @Suppress("DEPRECATION") window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) - // Transparent bars — the dark app background shows through them. - // White (light) icons are used since the background is dark. - @Suppress("DEPRECATION") - window.statusBarColor = android.graphics.Color.TRANSPARENT - @Suppress("DEPRECATION") - window.navigationBarColor = android.graphics.Color.TRANSPARENT - WindowInsetsControllerCompat(window, window.decorView).apply { - show(WindowInsetsCompat.Type.systemBars()) - isAppearanceLightStatusBars = false // white icons on dark bg - isAppearanceLightNavigationBars = false // white icons on dark bg - } } lifecycleScope.launch(kotlinx.coroutines.Dispatchers.IO) { @@ -649,6 +643,23 @@ fun ArflixApp( val isPlayerRoute = iptvFullscreen || currentRoute?.contains("player") == true + val hostActivity = remember(context) { context.findActivity() } + LaunchedEffect(isPlayerRoute, isMobile) { + if (isMobile && !isPlayerRoute) { + val win = hostActivity?.window ?: (context as? ComponentActivity)?.window + if (win != null) { + @Suppress("DEPRECATION") + win.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN) + WindowInsetsControllerCompat(win, win.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + show(WindowInsetsCompat.Type.systemBars()) + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false + } + } + } + } + Column( modifier = Modifier .fillMaxSize() @@ -666,11 +677,11 @@ fun ArflixApp( ) } ) - // On mobile, push content between the status bar and navigation bar. + // On mobile, push content below the status bar (except player). // Applied AFTER background so the gradient fills behind the bars. - // systemBarsPadding() reads live WindowInsets, so it automatically + // statusBarsPadding() reads live WindowInsets, so it automatically // becomes 0 when the player hides the bars. - .then(if (isMobile && !isPlayerRoute) Modifier.systemBarsPadding() else Modifier) + .then(if (isMobile && !isPlayerRoute) Modifier.statusBarsPadding() else Modifier) ) { Box(modifier = Modifier.weight(1f)) { AppNavigation( diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index cffe100da..422d98f6a 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -320,7 +320,58 @@ class MediaRepository @Inject constructor( fun getCachedItem(mediaType: MediaType, mediaId: Int): MediaItem? { val cacheKey = detailsCacheKey(mediaType, mediaId) - return getFromCache(detailsCache, cacheKey) + val inMemory = getFromCache(detailsCache, cacheKey) + if (inMemory != null) return inMemory + return peekItemFromDiskCache(mediaType, mediaId) + } + + private fun peekItemFromDiskCache(mediaType: MediaType, mediaId: Int): MediaItem? { + return try { + val cacheFiles = mutableListOf() + context.cacheDir.listFiles { _, name -> name.startsWith("home_categories_cache_") && name.endsWith(".json") } + ?.let { cacheFiles.addAll(it) } + context.filesDir.listFiles { _, name -> name.startsWith("home_continue_watching_") && name.endsWith(".json") } + ?.let { cacheFiles.addAll(it) } + + for (file in cacheFiles) { + if (!file.exists() || file.length() > 12_000_000L) continue + val json = file.readText() + if (json.isBlank()) continue + if (!json.contains("\"id\":$mediaId") && !json.contains("\"id\": $mediaId")) continue + + val type = com.google.gson.reflect.TypeToken + .getParameterized(MutableList::class.java, Category::class.java) + .type + val categories: List? = runCatching { gson.fromJson>(json, type) }.getOrNull() + if (categories != null) { + for (cat in categories) { + for (item in cat.items) { + if (item.id == mediaId && item.mediaType == mediaType) { + cacheItem(item) + return item + } + } + } + } + + val cwType = com.google.gson.reflect.TypeToken + .getParameterized(MutableList::class.java, ContinueWatchingItem::class.java) + .type + val cwItems: List? = runCatching { gson.fromJson>(json, cwType) }.getOrNull() + if (cwItems != null) { + for (cw in cwItems) { + if (cw.id == mediaId && cw.mediaType == mediaType) { + val item = cw.toMediaItem() + cacheItem(item) + return item + } + } + } + } + null + } catch (_: Throwable) { + null + } } fun getCachedFullItem(mediaType: MediaType, mediaId: Int): MediaItem? { @@ -3201,29 +3252,32 @@ class MediaRepository @Inject constructor( /** Instant synchronous peek into the in-memory or persisted logo cache. */ fun peekCachedLogoUrl(mediaType: MediaType, mediaId: Int): String? { - val cacheKey = "${mediaType}_logo_$mediaId" - if (logoCache.containsKey(cacheKey)) { - val cached = getFromCache(logoCache, cacheKey) - if (!cached.isNullOrBlank()) return cached - } - val altKey = "${mediaType}_$mediaId" - if (logoCache.containsKey(altKey)) { - val cached = getFromCache(logoCache, altKey) - if (!cached.isNullOrBlank()) return cached + val keys = listOf( + "${mediaType}_logo_$mediaId", + "${mediaType}_$mediaId", + "${mediaType.name.lowercase()}_logo_$mediaId", + "${mediaType.name.lowercase()}_$mediaId", + "${mediaType.name.uppercase()}_logo_$mediaId", + "${mediaType.name.uppercase()}_$mediaId" + ) + for (k in keys) { + if (logoCache.containsKey(k)) { + val cached = getFromCache(logoCache, k) + if (!cached.isNullOrBlank()) return cached + } } try { val json = context.getSharedPreferences("logo_cache", Context.MODE_PRIVATE).getString("urls", null) if (!json.isNullOrBlank()) { val jsonObject = org.json.JSONObject(json) - val url = when { - jsonObject.has(altKey) -> jsonObject.optString(altKey) - jsonObject.has(cacheKey) -> jsonObject.optString(cacheKey) - jsonObject.has("${mediaType.name.lowercase()}_$mediaId") -> jsonObject.optString("${mediaType.name.lowercase()}_$mediaId") - else -> null - } - if (!url.isNullOrBlank()) { - logoCache[cacheKey] = CacheEntry(url, System.currentTimeMillis()) - return url + for (k in keys) { + if (jsonObject.has(k)) { + val url = jsonObject.optString(k) + if (!url.isNullOrBlank()) { + logoCache["${mediaType}_logo_$mediaId"] = CacheEntry(url, System.currentTimeMillis()) + return url + } + } } } } catch (_: Throwable) {} diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt index 6b471d015..077c3fbbe 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppBottomBar.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -141,7 +142,12 @@ fun AppBottomBar( ) val spec = appBottomBarSpec(mode) - Column(modifier = modifier.fillMaxWidth()) { + Column( + modifier = modifier + .fillMaxWidth() + .background(appBackgroundDark().copy(alpha = 0.95f)) + .navigationBarsPadding() + ) { Box( modifier = Modifier .fillMaxWidth() @@ -152,7 +158,6 @@ fun AppBottomBar( Row( modifier = Modifier .fillMaxWidth() - .background(appBackgroundDark().copy(alpha = 0.95f)) .padding(horizontal = 8.dp, vertical = spec.rowVerticalPaddingDp.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 5a0dc4dc4..07bbe648e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -577,11 +577,15 @@ class DetailsViewModel @Inject constructor( playTmdbSeason = canonicalTargetSeason, playTmdbEpisode = canonicalTargetEpisode, playLabel = displayTarget?.let { - context.getString( - R.string.continue_season_episode, - it.displaySeason, - it.displayEpisode - ) + if (hasExplicitEpisodeTarget || state.episodes.any { ep -> ep.isWatched }) { + context.getString( + R.string.continue_season_episode, + it.displaySeason, + it.displayEpisode + ) + } else { + context.getString(R.string.play_start_s1e1) + } } ?: state.playLabel ) } @@ -806,12 +810,10 @@ class DetailsViewModel @Inject constructor( nextUnwatchedEpisode?.episodeNumber ?: if (hasWatchedEpisodes) 1 else state.playEpisode } else state.playEpisode, playLabel = if (shouldUseEpisodeTarget) { - if (nextUnwatchedEpisode != null) { + if (hasWatchedEpisodes && nextUnwatchedEpisode != null) { context.getString(R.string.continue_season_episode, nextUnwatchedEpisode.seasonNumber, nextUnwatchedEpisode.episodeNumber) - } else if (hasWatchedEpisodes) { - context.getString(R.string.play_start_s1e1) } else { - state.playLabel + context.getString(R.string.play_start_s1e1) } } else state.playLabel ) @@ -912,7 +914,11 @@ class DetailsViewModel @Inject constructor( playTmdbSeason = playTarget?.season, playTmdbEpisode = playTarget?.episode, playLabel = displayTarget?.let { - context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + if (playTarget?.label == context.getString(R.string.play_start_s1e1)) { + context.getString(R.string.play_start_s1e1) + } else { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } } ?: playTarget?.label, playPositionMs = playTarget?.positionMs ) @@ -932,7 +938,11 @@ class DetailsViewModel @Inject constructor( playTmdbSeason = playTarget?.season, playTmdbEpisode = playTarget?.episode, playLabel = displayTarget?.let { - context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + if (playTarget?.label == context.getString(R.string.play_start_s1e1)) { + context.getString(R.string.play_start_s1e1) + } else { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } } ?: playTarget?.label, playPositionMs = playTarget?.positionMs ) @@ -1425,7 +1435,11 @@ class DetailsViewModel @Inject constructor( playTmdbSeason = playTarget?.season ?: latestState.playTmdbSeason, playTmdbEpisode = playTarget?.episode ?: latestState.playTmdbEpisode, playLabel = displayPlayTarget?.let { - context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + if (playTarget?.label == context.getString(R.string.play_start_s1e1)) { + context.getString(R.string.play_start_s1e1) + } else { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } } ?: playTarget?.label ?: latestState.playLabel, playPositionMs = playTarget?.positionMs ?: 0L ) @@ -1438,6 +1452,7 @@ class DetailsViewModel @Inject constructor( private suspend fun deriveNextUnwatchedPlayTarget(tmdbId: Int, watchedKeys: Set): PlayTarget? { return try { val tvDetails = tmdbApi.getTvDetails(tmdbId, Constants.TMDB_API_KEY) + val hasWatchedForShow = watchedKeys.any { it.startsWith("show_tmdb:$tmdbId:") } for (seasonNum in 1..tvDetails.numberOfSeasons) { val seasonDetails = try { tmdbApi.getTvSeason(tmdbId, seasonNum, Constants.TMDB_API_KEY) @@ -1450,10 +1465,15 @@ class DetailsViewModel @Inject constructor( !watchedKeys.contains(key) } if (firstUnwatched != null) { + val label = if (hasWatchedForShow) { + context.getString(R.string.continue_season_episode, seasonNum, firstUnwatched.episodeNumber) + } else { + context.getString(R.string.play_start_s1e1) + } return PlayTarget( season = seasonNum, episode = firstUnwatched.episodeNumber, - label = context.getString(R.string.continue_season_episode, seasonNum, firstUnwatched.episodeNumber) + label = label ) } } @@ -2152,7 +2172,11 @@ class DetailsViewModel @Inject constructor( playTmdbSeason = playTarget?.season ?: _uiState.value.playTmdbSeason, playTmdbEpisode = playTarget?.episode ?: _uiState.value.playTmdbEpisode, playLabel = displayPlayTarget?.let { - context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + if (playTarget?.label == context.getString(R.string.play_start_s1e1)) { + context.getString(R.string.play_start_s1e1) + } else { + context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) + } } ?: playTarget?.label ?: _uiState.value.playLabel, playPositionMs = playTarget?.positionMs ?: _uiState.value.playPositionMs, toastMessage = context.getString(R.string.details_season_marked_watched, season), 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 909caf6f0..8ec5d5fab 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 @@ -640,6 +640,23 @@ fun HomeScreen( val lifecycleOwner = LocalLifecycleOwner.current var suppressSelectUntilMs by remember { mutableLongStateOf(0L) } + val navigateToDetailsWithCache: (MediaType, Int, Int?, Int?) -> Unit = { mediaType, mediaId, initialSeason, initialEpisode -> + val matchingItem = uiState.categories.asSequence() + .flatMap { it.items.asSequence() } + .firstOrNull { it.id == mediaId && it.mediaType == mediaType } + ?: uiState.heroItem?.takeIf { it.id == mediaId && it.mediaType == mediaType } + if (matchingItem != null) { + viewModel.cacheItem(matchingItem) + } + val matchingLogo = cardLogoUrls["${mediaType}_$mediaId"] + ?: cardLogoUrls["${mediaType.name.lowercase()}_$mediaId"] + ?: uiState.heroLogoUrl?.takeIf { uiState.heroItem?.id == mediaId && uiState.heroItem?.mediaType == mediaType } + if (!matchingLogo.isNullOrBlank()) { + viewModel.cacheLogoUrl(mediaType, mediaId, matchingLogo) + } + onNavigateToDetails(mediaType, mediaId, initialSeason, initialEpisode) + } + LaunchedEffect(Unit) { // Prevent stale select key events from previous screen from reopening details. suppressSelectUntilMs = SystemClock.elapsedRealtime() + 150L @@ -1187,7 +1204,7 @@ fun HomeScreen( } else if (viewModel.isCollectionItem(item)) { onNavigateToCollection(item.status?.removePrefix("collection:").orEmpty()) } else { - onNavigateToDetails(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) + navigateToDetailsWithCache(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) } } }, @@ -1200,7 +1217,7 @@ fun HomeScreen( } else if (viewModel.isCollectionItem(item)) { onNavigateToCollection(item.status?.removePrefix("collection:").orEmpty()) } else { - onNavigateToDetails(item.mediaType, item.id, null, null) + navigateToDetailsWithCache(item.mediaType, item.id, null, null) } } }, @@ -1218,7 +1235,7 @@ fun HomeScreen( onMobileCategoryVisiblePosition = { categoryId, lastVisibleItemIndex -> viewModel.onMobileCategoryVisiblePosition(categoryId, lastVisibleItemIndex) }, - onNavigateToDetails = onNavigateToDetails, + onNavigateToDetails = navigateToDetailsWithCache, onNavigateToCollection = onNavigateToCollection, onNavigateToSearch = onNavigateToSearch, onNavigateToWatchlist = onNavigateToWatchlist, @@ -1249,7 +1266,7 @@ fun HomeScreen( contentStartPadding = contentStartPadding, isMobile = isMobile, showBudget = uiState.showBudget, - onNavigateToDetails = onNavigateToDetails, + onNavigateToDetails = navigateToDetailsWithCache, onNavigateToTv = { channelId, streamUrl -> onNavigateToTv(channelId, streamUrl) }, isIptvItem = { item -> viewModel.isIptvItem(item) }, getIptvChannelId = { item -> viewModel.getIptvChannelId(item) }, @@ -1310,7 +1327,7 @@ fun HomeScreen( } else if (viewModel.isIptvItem(item)) { onNavigateToTv(viewModel.getIptvChannelId(item), viewModel.getIptvStreamUrl(item.id)) } else { - onNavigateToDetails(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) + navigateToDetailsWithCache(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) } }, onViewDetails = { @@ -1319,7 +1336,7 @@ fun HomeScreen( } else if (viewModel.isIptvItem(item)) { onNavigateToTv(viewModel.getIptvChannelId(item), viewModel.getIptvStreamUrl(item.id)) } else { - onNavigateToDetails(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) + navigateToDetailsWithCache(item.mediaType, item.id, item.nextEpisode?.seasonNumber, item.nextEpisode?.episodeNumber) } }, onToggleWatchlist = { 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 57504a6f4..4394d2496 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 @@ -615,6 +615,14 @@ class HomeViewModel @Inject constructor( ) } + fun cacheItem(item: MediaItem) { + mediaRepository.cacheItem(item) + } + + fun cacheLogoUrl(mediaType: MediaType, mediaId: Int, logoUrl: String) { + mediaRepository.cacheLogoUrl(mediaType, mediaId, logoUrl) + } + private fun getCachedHeroDetailsSnapshot(item: MediaItem): HeroDetailsSnapshot? { val key = heroDetailsKey(item) return heroDetailsCache[key] @@ -1091,6 +1099,9 @@ class HomeViewModel @Inject constructor( val cleanItems = cat.items.filter { item -> val isValid = item.title.isNotBlank() && item.title != "Unknown" if (!isValid) hadBlankTitles = true + if (isValid) { + mediaRepository.cacheItem(item) + } isValid } cat.copy(items = cleanItems) @@ -1371,6 +1382,14 @@ class HomeViewModel @Inject constructor( logoCache[key] = value changed = true } + val parts = key.split("_") + if (parts.size >= 2) { + val mediaType = if (parts[0].equals("tv", ignoreCase = true)) MediaType.TV else MediaType.MOVIE + val mediaId = parts[1].toIntOrNull() + if (mediaId != null && value.isNotBlank()) { + mediaRepository.cacheLogoUrl(mediaType, mediaId, value) + } + } } if (changed) { while (logoCache.size > maxLogoCacheEntries) { @@ -1464,11 +1483,20 @@ class HomeViewModel @Inject constructor( synchronized(logoCacheLock) { while (keys.hasNext()) { val key = keys.next() - logoCache[key] = map.getString(key) + val url = map.getString(key) + logoCache[key] = url while (logoCache.size > maxLogoCacheEntries) { val eldestKey = logoCache.entries.iterator().next().key logoCache.remove(eldestKey) } + val parts = key.split("_") + if (parts.size >= 2) { + val mediaType = if (parts[0].equals("tv", ignoreCase = true)) MediaType.TV else MediaType.MOVIE + val mediaId = parts[1].toIntOrNull() + if (mediaId != null && !url.isNullOrBlank()) { + mediaRepository.cacheLogoUrl(mediaType, mediaId, url) + } + } } if (logoCache.isNotEmpty()) { logoCacheRevision += 1L @@ -1934,6 +1962,8 @@ class HomeViewModel @Inject constructor( usedPreloadedData = true + categories.forEach { cat -> cat.items.forEach { mediaRepository.cacheItem(it) } } + heroItem?.let { mediaRepository.cacheItem(it) } putCachedLogos(logoCache) // Filter out any existing continue_watching from preloaded data diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 915100073..7812d78a7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -7,6 +7,7 @@ import android.app.ActivityManager import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo +import com.arflix.tv.util.findActivity import android.media.AudioManager import android.net.Uri import android.os.Build @@ -340,8 +341,14 @@ fun PlayerScreen( } onDispose { if (window != null && deviceType != com.arflix.tv.util.DeviceType.TV) { + @Suppress("DEPRECATION") + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN) val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) + controller.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_DEFAULT controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + controller.isAppearanceLightStatusBars = false + controller.isAppearanceLightNavigationBars = false } } } @@ -6061,13 +6068,6 @@ private fun resolveFrameRateOffStrategy(): Int { return readMedia3FrameRateConst("VIDEO_CHANGE_FRAME_RATE_STRATEGY_OFF", fallback = 0) } -private tailrec fun Context.findActivity(): Activity? { - return when (this) { - is Activity -> this - is ContextWrapper -> baseContext.findActivity() - else -> null - } -} private fun readMedia3FrameRateConst(fieldName: String, fallback: Int): Int { return runCatching { C::class.java.getField(fieldName).getInt(null) }.getOrDefault(fallback) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt index 592f28d91..613659f9f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/live/LiveTvScreen.kt @@ -7,6 +7,7 @@ import android.app.ActivityManager import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo +import com.arflix.tv.util.findActivity import android.view.KeyEvent as AndroidKeyEvent import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility @@ -1616,11 +1617,17 @@ fun LiveTvScreen( onDispose { if (previousOrientation != null) { - activity.requestedOrientation = previousOrientation + activity?.requestedOrientation = previousOrientation } if (window != null) { - androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) - .show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + @Suppress("DEPRECATION") + window.clearFlags(android.view.WindowManager.LayoutParams.FLAG_FULLSCREEN) + val controller = androidx.core.view.WindowInsetsControllerCompat(window, window.decorView) + controller.systemBarsBehavior = + androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_DEFAULT + controller.show(androidx.core.view.WindowInsetsCompat.Type.systemBars()) + controller.isAppearanceLightStatusBars = false + controller.isAppearanceLightNavigationBars = false } } } diff --git a/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt b/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt index 494baf311..e6ec07d55 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt @@ -1,6 +1,8 @@ package com.arflix.tv.util +import android.app.Activity import android.content.Context +import android.content.ContextWrapper import android.content.pm.PackageManager import android.content.res.Configuration import androidx.compose.runtime.compositionLocalOf @@ -99,3 +101,11 @@ fun detectDeviceType(context: Context): DeviceType { return DeviceType.PHONE } + +tailrec fun Context.findActivity(): Activity? { + return when (this) { + is Activity -> this + is ContextWrapper -> baseContext.findActivity() + else -> null + } +} From 1f089897066e179f4d124b9815e208c43b2e5a60 Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Mon, 24 Aug 2026 18:13:10 +0530 Subject: [PATCH 10/12] fix(settings): display volume boost as 0 dB number badge and add local sync summary foundation --- .../tv/data/repository/TraktSyncService.kt | 84 +++++++-- .../tv/ui/screens/settings/SettingsScreen.kt | 5 +- .../ui/screens/settings/SettingsViewModel.kt | 166 ++++++++++++------ 3 files changed, 183 insertions(+), 72 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt index 5e090d94b..54549f4ed 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/TraktSyncService.kt @@ -80,6 +80,25 @@ class TraktSyncService @Inject constructor( private fun accessTokenKey() = profileManager.profileStringKey("trakt_access_token") private fun refreshTokenKey() = profileManager.profileStringKey("trakt_refresh_token") private fun expiresAtKey() = profileManager.profileLongKey("trakt_expires_at") + private fun lastSyncTimeKey() = profileManager.profileStringKey("trakt_last_sync_time") + private fun lastSyncMoviesKey() = profileManager.profileStringKey("trakt_last_sync_movies") + private fun lastSyncEpisodesKey() = profileManager.profileStringKey("trakt_last_sync_episodes") + + suspend fun saveLocalSyncSummary( + lastSyncAt: String, + moviesSynced: Int, + episodesSynced: Int + ) { + try { + context.traktDataStore.edit { prefs -> + prefs[lastSyncTimeKey()] = lastSyncAt + prefs[lastSyncMoviesKey()] = moviesSynced.toString() + prefs[lastSyncEpisodesKey()] = episodesSynced.toString() + } + } catch (e: Exception) { + if (e is kotlinx.coroutines.CancellationException) throw e + } + } // In-memory cache for current session (fallback if Supabase fails) private var cachedWatchedMovies: List? = null @@ -283,6 +302,13 @@ class TraktSyncService @Inject constructor( } } + val nowIso = Instant.now().toString() + saveLocalSyncSummary( + lastSyncAt = nowIso, + moviesSynced = totalMovies, + episodesSynced = totalEpisodes + ) + _syncProgress.value = SyncProgress( status = SyncStatus.COMPLETED, message = "Sync completed!", @@ -486,6 +512,15 @@ class TraktSyncService @Inject constructor( } + val currentSummary = getLastSyncSummary() + val newTotalMovies = (currentSummary?.moviesSynced ?: 0) + moviesUpdated + val newTotalEpisodes = (currentSummary?.episodesSynced ?: 0) + episodesUpdated + saveLocalSyncSummary( + lastSyncAt = Instant.now().toString(), + moviesSynced = newTotalMovies, + episodesSynced = newTotalEpisodes + ) + _syncProgress.value = SyncProgress( status = SyncStatus.COMPLETED, message = if (moviesUpdated == 0 && episodesUpdated == 0) "Already up to date" else "Sync completed!", @@ -1156,23 +1191,44 @@ class TraktSyncService @Inject constructor( */ suspend fun getLastSyncSummary(): TraktSyncSummary? = withContext(Dispatchers.IO) { try { - val userId = getUserId() ?: return@withContext null - if (getSupabaseAuth() == null) return@withContext null - - val syncStates = executeSupabaseCall("get sync state summary") { auth -> - supabaseApi.getSyncState( - auth, - userId = "eq.$userId", - profileId = "eq.${activeProfileId()}" + // 1. Check local DataStore first (works offline and without Supabase) + val prefs = context.traktDataStore.data.first() + val localSyncAt = prefs[lastSyncTimeKey()] + val localMovies = prefs[lastSyncMoviesKey()]?.toIntOrNull() + val localEpisodes = prefs[lastSyncEpisodesKey()]?.toIntOrNull() + + if (!localSyncAt.isNullOrBlank() && (localMovies != null || localEpisodes != null)) { + return@withContext TraktSyncSummary( + lastSyncAt = localSyncAt, + moviesSynced = localMovies ?: 0, + episodesSynced = localEpisodes ?: 0 ) } - syncStates.firstOrNull()?.let { state -> - TraktSyncSummary( - lastSyncAt = state.lastSyncAt, - moviesSynced = state.moviesSynced, - episodesSynced = state.episodesSynced - ) + + // 2. Fallback to Supabase if logged in + val userId = getUserId() + if (userId != null && getSupabaseAuth() != null) { + val syncStates = executeSupabaseCall("get sync state summary") { auth -> + supabaseApi.getSyncState( + auth, + userId = "eq.$userId", + profileId = "eq.${activeProfileId()}" + ) + } + syncStates.firstOrNull()?.let { state -> + val summary = TraktSyncSummary( + lastSyncAt = state.lastSyncAt, + moviesSynced = state.moviesSynced, + episodesSynced = state.episodesSynced + ) + state.lastSyncAt?.let { at -> + saveLocalSyncSummary(at, state.moviesSynced, state.episodesSynced) + } + return@withContext summary + } } + + null } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e 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 d1eb40926..13147bc62 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 @@ -4421,7 +4421,8 @@ private fun MobileSettingsSubPage( MobileSettingsRow( icon = Icons.Default.VolumeUp, title = stringResource(R.string.volume_boost), - value = if (uiState.volumeBoostDb > 0) "+${uiState.volumeBoostDb} dB" else "Off", + value = if (uiState.volumeBoostDb > 0) "+${uiState.volumeBoostDb} dB" else "0 dB", + isToggle = false, isFocused = false, showDivider = false, onClick = { viewModel.cycleVolumeBoost() } @@ -5662,7 +5663,7 @@ private fun TvGeneralSettingsRows( icon = Icons.Default.VolumeUp, title = stringResource(R.string.volume_boost), subtitle = stringResource(R.string.volume_boost_desc), - value = if (volumeBoostDb == 0) "Off" else "+${volumeBoostDb} dB", + value = if (volumeBoostDb == 0) "0 dB" else "+${volumeBoostDb} dB", isFocused = focusedIndex == localIndex, onClick = onVolumeBoostClick, modifier = 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 afffd1257..2c60231ff 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 @@ -281,7 +281,8 @@ class SettingsViewModel @Inject constructor( private val mdbListRepository: com.arflix.tv.data.repository.MdbListRepository, private val syncProviderStore: com.arflix.tv.data.repository.sync.SyncProviderStore, private val watchHistoryRepository: com.arflix.tv.data.repository.WatchHistoryRepository, - private val simklAuthManager: com.arflix.tv.data.repository.simkl.SimklAuthManager + private val simklAuthManager: com.arflix.tv.data.repository.simkl.SimklAuthManager, + private val simklSyncService: com.arflix.tv.data.repository.simkl.SimklSyncService ) : ViewModel() { private fun visibleCatalogs(catalogs: List): List { return catalogs.filter { config -> @@ -685,7 +686,7 @@ class SettingsViewModel @Inject constructor( ) refreshIntegrationUsernames(loadProfileId, isTrakt, isMdbList, isSimkl) - if (isTrakt) refreshSyncSummary(loadProfileId) + if (isTrakt || isMdbList || isSimkl) refreshSyncSummary(loadProfileId) } } @@ -755,18 +756,50 @@ class SettingsViewModel @Inject constructor( private fun refreshSyncSummary(profileId: String) { syncSummaryJob?.cancel() - syncSummaryJob = viewModelScope.launch { - val previousLastSyncTime = _uiState.value.lastSyncTime + syncSummaryJob = viewModelScope.launch(Dispatchers.IO) { val summary = traktSyncService.getLastSyncSummary() - if ( - profileManager.getProfileIdSync() != profileId || - _uiState.value.lastSyncTime != previousLastSyncTime - ) return@launch - _uiState.value = _uiState.value.copy( - lastSyncTime = formatSyncTime(summary?.lastSyncAt), - syncedMovies = summary?.moviesSynced ?: 0, - syncedEpisodes = summary?.episodesSynced ?: 0 - ) + var movies = summary?.moviesSynced ?: 0 + var episodes = summary?.episodesSynced ?: 0 + var lastSyncAt = summary?.lastSyncAt + + val isTrakt = _uiState.value.isTraktAuthenticated + val isMdbList = _uiState.value.isMdbListConnected + val isSimkl = _uiState.value.isSimklConnected + + // If summary has 0/null but a provider is connected, query provider caches directly + if (movies == 0 && episodes == 0 && (isTrakt || isMdbList || isSimkl)) { + if (isTrakt) { + val traktMovies = runCatching { traktRepository.getWatchedMovies() }.getOrDefault(emptySet()) + val traktEpisodes = runCatching { traktRepository.getWatchedEpisodes() }.getOrDefault(emptySet()) + movies += traktMovies.size + episodes += traktEpisodes.size + } + if (isMdbList) { + val mdbMovies = runCatching { mdbListRepository.getWatchedMovies() }.getOrDefault(emptySet()) + val mdbEpisodes = runCatching { mdbListRepository.getWatchedEpisodes() }.getOrDefault(emptySet()) + movies += mdbMovies.size + episodes += mdbEpisodes.size + } + if (isSimkl) { + val simklMovies = runCatching { simklSyncService.getWatchedMovies() }.getOrDefault(emptySet()) + val simklEpisodes = runCatching { simklSyncService.getWatchedEpisodes() }.getOrDefault(emptySet()) + movies += simklMovies.size + episodes += simklEpisodes.size + } + if (lastSyncAt == null && (movies > 0 || episodes > 0)) { + lastSyncAt = java.time.Instant.now().toString() + traktSyncService.saveLocalSyncSummary(lastSyncAt, movies, episodes) + } + } + + if (profileManager.getProfileIdSync() != profileId) return@launch + withContext(Dispatchers.Main) { + _uiState.value = _uiState.value.copy( + lastSyncTime = formatSyncTime(lastSyncAt), + syncedMovies = movies, + syncedEpisodes = episodes + ) + } } } @@ -948,62 +981,83 @@ class SettingsViewModel @Inject constructor( // ========== App Updates ========== - fun performFullSync(silent: Boolean = false) { - viewModelScope.launch { + fun syncAllTrackingProviders(silent: Boolean = false) { + viewModelScope.launch(Dispatchers.IO) { if (_uiState.value.isSyncing) return@launch - val result = traktSyncService.performFullSync() - when (result) { - is SyncResult.Success -> { - _uiState.value = _uiState.value.copy( - syncedMovies = result.moviesSynced, - syncedEpisodes = result.episodesSynced, - lastSyncTime = formatSyncTime(java.time.Instant.now().toString()), - toastMessage = "Synced ${result.moviesSynced} movies and ${result.episodesSynced} episodes", - toastType = ToastType.SUCCESS - ) - // Invalidate repository cache to pick up new data + withContext(Dispatchers.Main) { + _uiState.value = _uiState.value.copy(isSyncing = true) + } + try { + var totalMovies = 0 + var totalEpisodes = 0 + var syncedAny = false + + if (_uiState.value.isTraktAuthenticated) { + val result = traktSyncService.performFullSync() + if (result is SyncResult.Success) { + totalMovies += result.moviesSynced + totalEpisodes += result.episodesSynced + syncedAny = true + } + } + if (_uiState.value.isMdbListConnected) { + val mdbMovies = runCatching { mdbListRepository.getWatchedMovies() }.getOrDefault(emptySet()) + val mdbEpisodes = runCatching { mdbListRepository.getWatchedEpisodes() }.getOrDefault(emptySet()) + totalMovies += mdbMovies.size + totalEpisodes += mdbEpisodes.size + syncedAny = true + } + if (_uiState.value.isSimklConnected) { + runCatching { simklSyncService.syncIfNeeded(force = true) } + val simklMovies = runCatching { simklSyncService.getWatchedMovies() }.getOrDefault(emptySet()) + val simklEpisodes = runCatching { simklSyncService.getWatchedEpisodes() }.getOrDefault(emptySet()) + totalMovies += simklMovies.size + totalEpisodes += simklEpisodes.size + syncedAny = true + } + + val nowIso = java.time.Instant.now().toString() + if (syncedAny) { + traktSyncService.saveLocalSyncSummary(nowIso, totalMovies, totalEpisodes) + withContext(Dispatchers.Main) { + _uiState.value = _uiState.value.copy( + syncedMovies = totalMovies, + syncedEpisodes = totalEpisodes, + lastSyncTime = formatSyncTime(nowIso), + toastMessage = if (!silent) "Synced $totalMovies movies and $totalEpisodes episodes" else _uiState.value.toastMessage, + toastType = if (!silent) ToastType.SUCCESS else _uiState.value.toastType + ) + } traktRepository.invalidateWatchedCache() traktRepository.initializeWatchedCache() + } else if (!silent) { + withContext(Dispatchers.Main) { + _uiState.value = _uiState.value.copy( + toastMessage = "No tracking provider connected", + toastType = ToastType.ERROR + ) + } } - is SyncResult.Error -> { - if (!silent) { + } catch (e: Exception) { + if (e is CancellationException) throw e + if (!silent) { + withContext(Dispatchers.Main) { _uiState.value = _uiState.value.copy( - toastMessage = context.getString(R.string.sync_failed, result.message), + toastMessage = context.getString(R.string.sync_failed, e.message), toastType = ToastType.ERROR ) } } + } finally { + withContext(Dispatchers.Main) { + _uiState.value = _uiState.value.copy(isSyncing = false) + } } } } - fun performIncrementalSync() { - viewModelScope.launch { - val result = traktSyncService.performIncrementalSync() - when (result) { - is SyncResult.Success -> { - _uiState.value = _uiState.value.copy( - syncedMovies = _uiState.value.syncedMovies + result.moviesSynced, - syncedEpisodes = _uiState.value.syncedEpisodes + result.episodesSynced, - lastSyncTime = formatSyncTime(java.time.Instant.now().toString()), - toastMessage = if (result.moviesSynced == 0 && result.episodesSynced == 0) - "Already up to date" - else - "Synced ${result.moviesSynced} movies and ${result.episodesSynced} episodes", - toastType = ToastType.SUCCESS - ) - // Invalidate repository cache to pick up new data - traktRepository.invalidateWatchedCache() - traktRepository.initializeWatchedCache() - } - is SyncResult.Error -> { - _uiState.value = _uiState.value.copy( - toastMessage = context.getString(R.string.sync_failed, result.message), - toastType = ToastType.ERROR - ) - } - } - } + fun performFullSync(silent: Boolean = false) { + syncAllTrackingProviders(silent = silent) } fun setDefaultSubtitle(language: String) { From 0f0e5c63112c7243922fae1bbcad7f7670d3d4ad Mon Sep 17 00:00:00 2001 From: Himanth Reddy Date: Tue, 25 Aug 2026 23:04:19 +0530 Subject: [PATCH 11/12] feat(mobile): add Apple TV-style horizontal season sliding viewport, prefetch TV seasons, and polish mobile sports feed --- .../tv/data/repository/MediaRepository.kt | 78 ++-- .../tv/ui/screens/details/DetailsScreen.kt | 199 +++++++--- .../tv/ui/screens/details/DetailsViewModel.kt | 349 ++++++++++++------ .../tv/ui/screens/home/HomeViewModel.kt | 41 +- 4 files changed, 479 insertions(+), 188 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 422d98f6a..cdb4c4e34 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -36,11 +36,16 @@ import com.arflix.tv.data.model.SportsAddonCapabilities import com.arflix.tv.util.CatalogUrlParser import com.arflix.tv.util.Constants import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -107,6 +112,7 @@ class MediaRepository @Inject constructor( private val apiKey = Constants.TMDB_API_KEY private val gson = Gson() + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** TMDB content language (e.g. "en-US", "fr-FR", "nl-NL"). */ @Volatile @@ -155,6 +161,8 @@ class MediaRepository @Inject constructor( private val addonTitleToTmdbCache = ConcurrentHashMap?>>() private val homeServerLogoRefCache = ConcurrentHashMap?>>() private val collectionRefsCache = ConcurrentHashMap>>>() + private val _episodeRatingsUpdated = MutableSharedFlow>(extraBufferCapacity = 64) + val episodeRatingsUpdated = _episodeRatingsUpdated.asSharedFlow() private fun getFromCache(cache: Map>, key: String): T? { val entry = cache[key] ?: return null @@ -3005,7 +3013,9 @@ class MediaRepository @Inject constructor( } /** - * Get season episodes with Trakt watched status + * Get season episodes with Trakt watched status. + * Returns immediately upon fetching the TMDB season structure, hydrating missing + * episode IMDb ratings asynchronously in the background. */ suspend fun getSeasonEpisodes(tvId: Int, seasonNumber: Int): List { val cacheKey = "tv_${tvId}_season_$seasonNumber" @@ -3022,49 +3032,69 @@ class MediaRepository @Inject constructor( traktRepository.getWatchedEpisodesForShow(tvId) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e - emptySet() } } val hasShowWatchedData = watchedEpisodes.any { it.startsWith("show_tmdb:$tvId:") } - // Re-apply watched status on cached episodes so stale season cache doesn't hide badges. + // Fast-path: return cached episodes immediately if (cachedEpisodes != null) { - val episodeImdbRatings = if (cachedEpisodes.any { it.imdbRating.isBlank() }) { - getSeasonEpisodeImdbRatings( - tvId = tvId, - seasonNumber = seasonNumber, - episodeNumbers = cachedEpisodes.map { it.episodeNumber } - ) - } else { - emptyMap() - } - return cachedEpisodes.map { episode -> + val episodes = cachedEpisodes.map { episode -> val episodeKey = "show_tmdb:$tvId:${episode.seasonNumber}:${episode.episodeNumber}" episode.copy( - imdbRating = episode.imdbRating.ifBlank { - episodeImdbRatings[episode.seasonNumber to episode.episodeNumber].orEmpty() - }, isWatched = if (hasShowWatchedData) episodeKey in watchedEpisodes else episode.isWatched ) } + if (episodes.any { it.imdbRating.isBlank() }) { + repositoryScope.launch { + val ratings = getSeasonEpisodeImdbRatings( + tvId = tvId, + seasonNumber = seasonNumber, + episodeNumbers = episodes.map { it.episodeNumber } + ) + if (ratings.isNotEmpty()) { + val hydrated = episodes.map { ep -> + ep.copy(imdbRating = ep.imdbRating.ifBlank { ratings[ep.seasonNumber to ep.episodeNumber].orEmpty() }) + } + seasonEpisodesCache[cacheKey] = CacheEntry(hydrated, System.currentTimeMillis()) + _episodeRatingsUpdated.tryEmit(tvId to seasonNumber) + } + } + } + return episodes } val season = tmdbApi.getTvSeason(tvId, seasonNumber, apiKey, language = contentLanguage) - val episodeImdbRatings = getSeasonEpisodeImdbRatings( - tvId = tvId, - seasonNumber = seasonNumber, - episodeNumbers = season.episodes.map { it.episodeNumber } - ) - + val cinemetaRatings = getSeriesCinemetaEpisodeRatings(tvId) val episodes = season.episodes.map { episode -> val episodeKey = "show_tmdb:$tvId:$seasonNumber:${episode.episodeNumber}" + val rating = cinemetaRatings[seasonNumber to episode.episodeNumber].orEmpty() episode.toEpisode().copy( - imdbRating = episodeImdbRatings[seasonNumber to episode.episodeNumber].orEmpty(), + imdbRating = rating, isWatched = episodeKey in watchedEpisodes ) } seasonEpisodesCache[cacheKey] = CacheEntry(episodes, System.currentTimeMillis()) + + if (episodes.any { it.imdbRating.isBlank() }) { + repositoryScope.launch { + val missingNumbers = episodes.filter { it.imdbRating.isBlank() }.map { it.episodeNumber } + val ratings = getSeasonEpisodeImdbRatings( + tvId = tvId, + seasonNumber = seasonNumber, + episodeNumbers = missingNumbers + ) + if (ratings.isNotEmpty()) { + val currentCache = getFromCache(seasonEpisodesCache, cacheKey) ?: episodes + val hydrated = currentCache.map { ep -> + ep.copy(imdbRating = ep.imdbRating.ifBlank { ratings[ep.seasonNumber to ep.episodeNumber].orEmpty() }) + } + seasonEpisodesCache[cacheKey] = CacheEntry(hydrated, System.currentTimeMillis()) + _episodeRatingsUpdated.tryEmit(tvId to seasonNumber) + } + } + } + return episodes } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index b56fe3567..c4c7cdb5c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -16,7 +16,10 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Spring @@ -60,6 +63,8 @@ import androidx.compose.foundation.verticalScroll import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.runtime.mutableStateMapOf import com.arflix.tv.util.settingsDataStore import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first @@ -161,6 +166,7 @@ import com.arflix.tv.ui.components.resolveDetailsBackdropHeightDp import com.arflix.tv.ui.components.rememberCatalogueRowLayoutMode import com.arflix.tv.ui.components.SidebarItem import com.arflix.tv.ui.components.SkeletonDetailsPage +import com.arflix.tv.ui.components.SkeletonEpisodeCard import com.arflix.tv.ui.components.StreamSelector import com.arflix.tv.ui.components.TrailerPlayer import androidx.activity.compose.BackHandler @@ -446,7 +452,7 @@ fun DetailsScreen( // and cancels a superseded load, so overlapping requests can't display a stale season. Episode // focus is reset by the currentSeason-driven effect above once the new season's episodes arrive. LaunchedEffect(seasonIndex) { - if (uiState.totalSeasons > 1) { + if (uiState.totalSeasons > 1 && uiState.currentSeason != seasonIndex + 1) { delay(100) viewModel.loadSeason(seasonIndex + 1) } @@ -1016,6 +1022,7 @@ fun DetailsScreen( isMobile = isMobile, spoilerBlurEnabled = spoilerBlurEnabled, isLoading = uiState.isLoading, + isSeasonLoading = uiState.isSeasonLoading, onBack = onBack, onButtonClick = onButtonClickRemembered, onSeasonClick = onSeasonClickRemembered, @@ -1290,6 +1297,7 @@ private fun DetailsContent( showEpisodeRatings: Boolean = true, isMobile: Boolean = false, isLoading: Boolean = false, + isSeasonLoading: Boolean = false, // Persistent back callback used by the phone-layout back button overlay // (issue #43). No-op by default so tablet/TV callers don't need to pass it. onBack: () -> Unit = {}, @@ -1335,6 +1343,13 @@ private fun DetailsContent( } } + val cachedSeasonEpisodes = remember { mutableStateMapOf>() } + LaunchedEffect(currentSeason, episodes) { + if (episodes.isNotEmpty() && episodes.all { it.seasonNumber == currentSeason }) { + cachedSeasonEpisodes[currentSeason] = episodes + } + } + val tvSeriesLabel = stringResource(R.string.details_label_tv_series) val movieLabel = stringResource(R.string.movie) val genreText = genres.take(2).map(::formatGenreName).joinToString(" / ").ifBlank { @@ -1675,7 +1690,7 @@ private fun DetailsContent( ) // --- TV Show: Season selector & Episodes --- - if (item.mediaType == MediaType.TV && episodes.isNotEmpty()) { + if (item.mediaType == MediaType.TV && (episodes.isNotEmpty() || isSeasonLoading)) { if (totalSeasons > 1) { Spacer(modifier = Modifier.height(20.dp)) Text( @@ -1722,41 +1737,102 @@ private fun DetailsContent( } } - // Episodes LazyRow (outside the inner Column to allow independent horizontal scroll) - if (item.mediaType == MediaType.TV && episodes.isNotEmpty()) { - LazyRow( - modifier = Modifier.arvioDpadFocusGroup(), - contentPadding = PaddingValues(start = 16.dp, end = 16.dp), - horizontalArrangement = Arrangement.spacedBy(12.dp) + // Episodes & Ratings sliding viewport container (Apple TV-style horizontal content transition) + if (item.mediaType == MediaType.TV && (episodes.isNotEmpty() || isSeasonLoading)) { + Box( + modifier = Modifier + .fillMaxWidth() + .clipToBounds() ) { - standardItemsIndexed( - episodes, - key = { index, ep -> "mob_ep_${ep.seasonNumber}_${ep.episodeNumber}_$index" }, - contentType = { _, _ -> "episode" } - ) { index, episode -> - EpisodeCard( - episode = episode, - isFocused = false, - spoilerBlurEnabled = spoilerBlurEnabled, - onClick = { onEpisodeClick(index) } - ) + val appleEase = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f) + AnimatedContent( + targetState = currentSeason, + transitionSpec = { + val animDuration = 340 + val slideSpec = androidx.compose.animation.core.tween( + durationMillis = animDuration, + easing = appleEase + ) + val fadeSpec = androidx.compose.animation.core.tween( + durationMillis = animDuration, + easing = appleEase + ) + if (targetState > initialState) { + // Moving forward (e.g. S1 -> S2): + // Old content exits completely to the left, New content enters from the right + (slideInHorizontally(animationSpec = slideSpec) { fullWidth -> fullWidth } + + fadeIn(animationSpec = fadeSpec)) togetherWith + (slideOutHorizontally(animationSpec = slideSpec) { fullWidth -> -fullWidth } + + fadeOut(animationSpec = fadeSpec)) + } else { + // Moving backward (e.g. S2 -> S1): + // Old content exits completely to the right, New content enters from the left + (slideInHorizontally(animationSpec = slideSpec) { fullWidth -> -fullWidth } + + fadeIn(animationSpec = fadeSpec)) togetherWith + (slideOutHorizontally(animationSpec = slideSpec) { fullWidth -> fullWidth } + + fadeOut(animationSpec = fadeSpec)) + } + }, + label = "mobile_season_viewport_anim" + ) { season -> + val seasonEpisodes = cachedSeasonEpisodes[season] + ?: episodes.takeIf { it.isNotEmpty() && it.all { ep -> ep.seasonNumber == season } } + ?: emptyList() + val isCurrentSeasonLoading = (isSeasonLoading && season == currentSeason) || seasonEpisodes.isEmpty() + + Column(modifier = Modifier.fillMaxWidth()) { + if (isCurrentSeasonLoading || seasonEpisodes.isEmpty()) { + LazyRow( + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(4) { + SkeletonEpisodeCard() + } + } + } else { + LazyRow( + modifier = Modifier.arvioDpadFocusGroup(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + standardItemsIndexed( + seasonEpisodes, + key = { index, ep -> "mob_ep_${ep.seasonNumber}_${ep.episodeNumber}_$index" }, + contentType = { _, _ -> "episode" } + ) { index, episode -> + EpisodeCard( + episode = episode, + isFocused = false, + spoilerBlurEnabled = spoilerBlurEnabled, + onClick = { onEpisodeClick(index) } + ) + } + } + } + + val hasValidRating = remember(seasonEpisodes) { + seasonEpisodes.any { (it.imdbRating.toFloatOrNull() ?: 0f) > 0f } + } + AnimatedVisibility( + visible = showEpisodeRatings && hasValidRating, + enter = fadeIn(animationSpec = androidx.compose.animation.core.tween(250)), + exit = fadeOut(animationSpec = androidx.compose.animation.core.tween(150)) + ) { + DetailsEpisodeRatingsRail( + episodes = seasonEpisodes, + totalSeasons = totalSeasons, + currentSeason = season, + episodeIndex = episodeIndex, + isMobile = true, + isSeasonLoading = false + ) + } + } } } } - val hasAnyValidRating = remember(episodes) { - episodes.any { (it.imdbRating.toFloatOrNull() ?: 0f) > 0f } - } - if (item.mediaType == MediaType.TV && episodes.isNotEmpty() && showEpisodeRatings && hasAnyValidRating) { - DetailsEpisodeRatingsRail( - episodes = episodes, - totalSeasons = totalSeasons, - currentSeason = currentSeason, - episodeIndex = episodeIndex, - isMobile = true - ) - } - // Cast section if (cast.isNotEmpty()) { Column( @@ -2351,6 +2427,7 @@ private fun DetailsContent( usePosterCards = usePosterCards, showEpisodeRatings = showEpisodeRatings, spoilerBlurEnabled = spoilerBlurEnabled, + isSeasonLoading = isSeasonLoading, contentRowHeight = contentRowHeight, contentRowBottomPadding = contentRowBottomPadding, configuration = configuration, @@ -2390,6 +2467,7 @@ private fun DetailsTvRows( usePosterCards: Boolean, showEpisodeRatings: Boolean, spoilerBlurEnabled: Boolean, + isSeasonLoading: Boolean = false, contentRowHeight: Dp, contentRowBottomPadding: Dp, configuration: android.content.res.Configuration, @@ -2514,7 +2592,7 @@ private fun DetailsTvRows( verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(top = 6.dp) ) { - if (item.mediaType == MediaType.TV && episodes.isNotEmpty()) { + if (item.mediaType == MediaType.TV && (episodes.isNotEmpty() || isSeasonLoading)) { if (totalSeasons > 1) { item { DetailsSeasonRail( @@ -2540,6 +2618,7 @@ private fun DetailsTvRows( contentStartPadding = contentStartPadding, contentOuterStartPadding = contentOuterStartPadding, spoilerBlurEnabled = spoilerBlurEnabled, + isSeasonLoading = isSeasonLoading, onEpisodeClick = onEpisodeClick ) } @@ -2728,11 +2807,12 @@ private fun DetailsEpisodeRatingsRail( episodeIndex: Int, ratingsIndex: Int = 0, isMobile: Boolean, + isSeasonLoading: Boolean = false, focusSectionForUi: FocusSection? = null, contentStartPadding: Dp = 0.dp, contentOuterStartPadding: Dp = 0.dp ) { - if (episodes.isEmpty()) return + if (episodes.isEmpty() && !isSeasonLoading) return var previousRatingsIndex by remember { mutableIntStateOf(ratingsIndex) } var leftChevronBump by remember { mutableStateOf(false) } @@ -2958,6 +3038,7 @@ private fun DetailsEpisodeRail( contentStartPadding: Dp, contentOuterStartPadding: Dp, spoilerBlurEnabled: Boolean, + isSeasonLoading: Boolean = false, onEpisodeClick: (Int) -> Unit ) { val episodeCardWidth = if (configuration.screenWidthDp < 1400) 292.dp else 300.dp @@ -2998,21 +3079,27 @@ private fun DetailsEpisodeRail( ), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { - itemsIndexed( - episodes, - key = { index, ep -> "${ep.seasonNumber}_${ep.episodeNumber}_$index" } - ) { index, episode -> - val isFocused = currentFocusedSection == FocusSection.EPISODES && index == currentEpisodeIndex - val onClickForEpisode = remember(index) { - { currentOnEpisodeClick.value(index) } + if (isSeasonLoading) { + items(4) { + SkeletonEpisodeCard(modifier = Modifier.width(episodeCardWidth)) + } + } else { + itemsIndexed( + episodes, + key = { index, ep -> "${ep.seasonNumber}_${ep.episodeNumber}_$index" } + ) { index, episode -> + val isFocused = currentFocusedSection == FocusSection.EPISODES && index == currentEpisodeIndex + val onClickForEpisode = remember(index) { + { currentOnEpisodeClick.value(index) } + } + EpisodeCard( + episode = episode, + cardWidth = episodeCardWidth, + isFocused = isFocused && !episodeFixedFocus, + spoilerBlurEnabled = spoilerBlurEnabled, + onClick = onClickForEpisode + ) } - EpisodeCard( - episode = episode, - cardWidth = episodeCardWidth, - isFocused = isFocused && !episodeFixedFocus, - spoilerBlurEnabled = spoilerBlurEnabled, - onClick = onClickForEpisode - ) } } if (episodeFixedFocus) { @@ -4192,16 +4279,26 @@ private fun SeasonButton( onLongClick: (() -> Unit)? = null ) { val shape = RoundedCornerShape(8.dp) - val backgroundColor = when { + val targetBackgroundColor = when { isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.2f) + isSelected -> Color.White.copy(alpha = 0.22f) else -> Color.White.copy(alpha = 0.08f) } - val textColor = when { + val targetTextColor = when { isFocused -> Color.Black isSelected -> Color.White else -> Color.White.copy(alpha = 0.6f) } + val backgroundColor by animateColorAsState( + targetValue = targetBackgroundColor, + animationSpec = androidx.compose.animation.core.tween(150), + label = "season_btn_bg" + ) + val textColor by animateColorAsState( + targetValue = targetTextColor, + animationSpec = androidx.compose.animation.core.tween(150), + label = "season_btn_txt" + ) val isFullyWatched = totalCount > 0 && watchedCount >= totalCount diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index 07bbe648e..cc50655a7 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -39,6 +39,7 @@ import com.arflix.tv.util.settingsDataStore import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull @@ -62,6 +63,7 @@ data class DetailsUiState( val episodes: List = emptyList(), val totalSeasons: Int = 1, val currentSeason: Int = 1, + val isSeasonLoading: Boolean = false, val cast: List = emptyList(), val similar: List = emptyList(), val similarLogoUrls: Map = emptyMap(), @@ -244,8 +246,73 @@ class DetailsViewModel @Inject constructor( // Guards overlapping season loads: only the most recently requested season may apply its result. private var seasonLoadJob: kotlinx.coroutines.Job? = null private var seasonLoadRequestedSeason: Int = -1 - /** Set to true after loadDetails() child coroutines finish populating episodes/seasons. */ @Volatile private var initialLoadComplete = false + + init { + viewModelScope.launch { + mediaRepository.episodeRatingsUpdated.collect { (tvId, seasonNumber) -> + if (tvId == currentMediaId && currentMediaType == MediaType.TV) { + val structure = animeSeasonStructure + if (structure != null) { + val currentSeason = _uiState.value.currentSeason + val cachedAnime = peekAnimeDisplaySeason(tvId, currentSeason, structure) + if (cachedAnime != null) { + val watchedKeys = traktRepository.getWatchedEpisodesFromCache() + val decorated = if (watchedKeys.isNotEmpty()) { + cachedAnime.map { ep -> + val key = "show_tmdb:$tvId:${ep.tmdbSeasonNumber}:${ep.tmdbEpisodeNumber}" + if (watchedKeys.contains(key)) ep.copy(isWatched = true) else ep + } + } else { + cachedAnime + } + _uiState.value = _uiState.value.copy(episodes = decorated) + } + } else if (seasonNumber == _uiState.value.currentSeason) { + val cached = mediaRepository.peekCachedSeasonEpisodes(tvId, seasonNumber) + if (cached != null) { + val normalized = normalizeAnimeEpisodesForDisplay( + tmdbId = tvId, + displaySeason = seasonNumber, + item = _uiState.value.item, + canonicalEpisodes = cached + ) + val watchedKeys = traktRepository.getWatchedEpisodesFromCache() + val decorated = if (watchedKeys.isNotEmpty()) { + normalized.map { ep -> + val key = "show_tmdb:$tvId:${ep.tmdbSeasonNumber}:${ep.tmdbEpisodeNumber}" + if (watchedKeys.contains(key)) ep.copy(isWatched = true) else ep + } + } else { + normalized + } + _uiState.value = _uiState.value.copy(episodes = decorated) + } + } + } + } + } + } + + private fun peekAnimeDisplaySeason( + tmdbId: Int, + displaySeason: Int, + structure: AnimeSeasonStructure + ): List? { + val identities = structure.seasons[displaySeason] ?: return null + val canonicalSeasonsNeeded = identities.map { it.tmdbSeason }.distinct() + val cachedCanonicalBySeason = canonicalSeasonsNeeded.associateWith { season -> + mediaRepository.peekCachedSeasonEpisodes(tmdbId, season) ?: return null + } + return identities.mapNotNull { identity -> + val episodesForSeason = cachedCanonicalBySeason[identity.tmdbSeason] ?: return@mapNotNull null + episodesForSeason.find { it.episodeNumber == identity.tmdbEpisode }?.copy( + seasonNumber = identity.displaySeason, + episodeNumber = identity.displayEpisode, + identity = identity + ) + } + } private fun autoPlaySingleSourceKey() = profileManager.profileBooleanKey("auto_play_single_source") private fun autoPlayMinQualityKey() = profileManager.profileStringKey("auto_play_min_quality") private fun showBudgetKey() = profileManager.profileBooleanKey("show_budget_on_home") @@ -441,30 +508,14 @@ class DetailsViewModel @Inject constructor( _uiState.value = block(_uiState.value) } - if (mediaType == MediaType.TV) { - launch { - val firstEpisodes = runCatching { episodesDeferred?.await() }.getOrNull() - if (!firstEpisodes.isNullOrEmpty()) { - val episodeItem = runCatching { itemDeferred.await() }.getOrNull() ?: initialItem - val displayedEpisodes = normalizeAnimeEpisodesForDisplay( - tmdbId = mediaId, - displaySeason = seasonToLoad, - item = episodeItem, - canonicalEpisodes = firstEpisodes - ) - updateState { state -> - if (state.currentSeason == seasonToLoad && state.episodes == displayedEpisodes) { - state - } else { - state.copy( - episodes = displayedEpisodes, - currentSeason = seasonToLoad - ) - } - } - } + val isAnime = mediaType == MediaType.TV && ( + animeMapper.isAnimeContent(mediaId, initialItem?.genreIds.orEmpty(), initialItem?.originalLanguage) + ) + val animeStructureDeferred = if (isAnime) { + async(Dispatchers.IO) { + animeMapper.resolveAnimeSeasonStructure(mediaId) } - } + } else null val loadedItem = runCatching { itemDeferred.await() }.getOrNull() val item = loadedItem ?: initialItem @@ -478,14 +529,79 @@ class DetailsViewModel @Inject constructor( val mergedItem = mergeItem(item, initialItem) val hasTrustedTvDetails = mediaType != MediaType.TV || loadedItem != null || cachedFullItem != null - // Get total seasons for TV shows (stored in totalEpisodes field) - val totalSeasons = if (mediaType == MediaType.TV) { - if (hasTrustedTvDetails) { + val isActuallyAnime = mediaType == MediaType.TV && ( + isAnime || animeMapper.isAnimeContent(mediaId, mergedItem.genreIds, mergedItem.originalLanguage) + ) + val structure = if (isActuallyAnime) { + animeStructureDeferred?.await() ?: animeMapper.resolveAnimeSeasonStructure(mediaId) + } else null + + animeSeasonStructure = structure + + // Resolve TV show seasonal episodes directly without intermediate layout flash + val resolvedTotalSeasons: Int + val resolvedCurrentSeason: Int + val resolvedEpisodes: List + val displayTarget: EpisodeIdentity? + + if (structure != null) { + val canonicalTargetSeason = seasonToLoad + val canonicalTargetEpisode = initialEpisode ?: 1 + val target = structure.identityForTmdb(canonicalTargetSeason, canonicalTargetEpisode) + val displaySeason = target?.displaySeason ?: seasonToLoad.coerceIn(1, structure.seasonCount) + val episodes = loadAnimeDisplaySeason(mediaId, displaySeason, structure) + + resolvedTotalSeasons = structure.seasonCount + resolvedCurrentSeason = displaySeason + resolvedEpisodes = episodes + displayTarget = target + + // Pre-fetch all underlying TMDB seasons in background for 0ms season transitions + launch(Dispatchers.IO) { + val neededTmdbSeasons = structure.seasons.values.flatten().map { it.tmdbSeason }.distinct() + for (s in neededTmdbSeasons) { + if (isCurrentRequest() && mediaRepository.peekCachedSeasonEpisodes(mediaId, s) == null) { + runCatching { mediaRepository.getSeasonEpisodes(mediaId, s) } + } + } + } + } else if (mediaType == MediaType.TV) { + val tmdbSeasons = if (hasTrustedTvDetails) { mergedItem.totalEpisodes?.coerceAtLeast(1) ?: 1 } else { 1 } - } else 1 + val canonicalEpisodes = runCatching { episodesDeferred?.await() }.getOrNull() ?: emptyList() + val displayedEpisodes = normalizeAnimeEpisodesForDisplay( + tmdbId = mediaId, + displaySeason = seasonToLoad, + item = mergedItem, + canonicalEpisodes = canonicalEpisodes + ) + + resolvedTotalSeasons = tmdbSeasons + resolvedCurrentSeason = seasonToLoad + resolvedEpisodes = displayedEpisodes + displayTarget = null + + // Pre-fetch remaining seasons in background + if (tmdbSeasons > 1) { + launch(Dispatchers.IO) { + for (s in 1..tmdbSeasons) { + if (s != seasonToLoad && isCurrentRequest()) { + if (mediaRepository.peekCachedSeasonEpisodes(mediaId, s) == null) { + runCatching { mediaRepository.getSeasonEpisodes(mediaId, s) } + } + } + } + } + } + } else { + resolvedTotalSeasons = 1 + resolvedCurrentSeason = 1 + resolvedEpisodes = emptyList() + displayTarget = null + } // Map genre IDs to names val genreMap = if (mediaType == MediaType.TV) tvGenres else movieGenres @@ -517,15 +633,34 @@ class DetailsViewModel @Inject constructor( } val itemWithWatchedStatus = mergedItem.copy(isWatched = isWatched) + val initialSeasonIndex = (resolvedCurrentSeason - 1).coerceAtLeast(0) + val baseState = _uiState.value.copy( isLoading = false, item = itemWithWatchedStatus, - totalSeasons = totalSeasons, - currentSeason = seasonToLoad, + totalSeasons = resolvedTotalSeasons, + currentSeason = resolvedCurrentSeason, + episodes = resolvedEpisodes, + initialSeasonIndex = initialSeasonIndex, genres = genreNames, language = languageName, budget = visibleBudget, - showStatus = showStatus + showStatus = showStatus, + playSeason = displayTarget?.displaySeason ?: _uiState.value.playSeason, + playEpisode = displayTarget?.displayEpisode ?: _uiState.value.playEpisode, + playTmdbSeason = seasonToLoad, + playTmdbEpisode = initialEpisode ?: 1, + playLabel = displayTarget?.let { + if (hasExplicitEpisodeTarget || resolvedEpisodes.any { ep -> ep.isWatched }) { + context.getString( + R.string.continue_season_episode, + it.displaySeason, + it.displayEpisode + ) + } else { + context.getString(R.string.play_start_s1e1) + } + } ?: _uiState.value.playLabel ) _uiState.value = baseState @@ -538,73 +673,15 @@ class DetailsViewModel @Inject constructor( } } - // ARM/Kitsu may expose several anime seasons for a single TMDB season. Resolve this - // after the fast TMDB details path and only replace the UI when the mapping is - // complete; otherwise the existing TMDB structure remains untouched. - if ( - mediaType == MediaType.TV && - animeMapper.isAnimeContent(mediaId, mergedItem.genreIds, mergedItem.originalLanguage) - ) { + if (mediaType == MediaType.TV) { launch { - val structure = animeMapper.resolveAnimeSeasonStructure(mediaId) - ?: return@launch - if (!isCurrentRequest()) return@launch - val currentState = _uiState.value - val canonicalTargetSeason = currentState.playTmdbSeason - ?: currentState.playSeason - ?: seasonToLoad - val canonicalTargetEpisode = currentState.playTmdbEpisode - ?: currentState.playEpisode - ?: initialEpisode - ?: 1 - val displayTarget = structure.identityForTmdb( - canonicalTargetSeason, - canonicalTargetEpisode - ) - val requestedDisplaySeason = displayTarget?.displaySeason - ?: seasonToLoad.coerceIn(1, structure.seasonCount) - val animeEpisodes = loadAnimeDisplaySeason(mediaId, requestedDisplaySeason, structure) - if (animeEpisodes.isEmpty() || !isCurrentRequest()) return@launch - animeSeasonStructure = structure - updateState { state -> - state.copy( - totalSeasons = structure.seasonCount, - currentSeason = requestedDisplaySeason, - episodes = animeEpisodes, - initialSeasonIndex = requestedDisplaySeason - 1, - playSeason = displayTarget?.displaySeason ?: state.playSeason, - playEpisode = displayTarget?.displayEpisode ?: state.playEpisode, - playTmdbSeason = canonicalTargetSeason, - playTmdbEpisode = canonicalTargetEpisode, - playLabel = displayTarget?.let { - if (hasExplicitEpisodeTarget || state.episodes.any { ep -> ep.isWatched }) { - context.getString( - R.string.continue_season_episode, - it.displaySeason, - it.displayEpisode - ) - } else { - context.getString(R.string.play_start_s1e1) - } - } ?: state.playLabel - ) - } - val displayProgress = runCatching { fetchSeasonProgress(mediaId) }.getOrNull() - if (animeSeasonStructure === structure && isCurrentRequest()) { - updateState { state -> - state.copy( - seasonProgress = displayProgress?.progress ?: state.seasonProgress, - totalSeasons = structure.seasonCount - ) - } + val progress = runCatching { fetchSeasonProgress(mediaId) }.getOrNull() + if (isCurrentRequest() && progress != null) { + updateState { state -> state.copy(seasonProgress = progress.progress) } } } } - // Calculate initial season index (0-based) - val initialSeasonIndex = (seasonToLoad - 1).coerceAtLeast(0) - updateState { it.copy(initialSeasonIndex = initialSeasonIndex) } - launch { val externalIds = runCatching { externalIdsDeferred.await() }.getOrNull() val imdbId = externalIds?.imdbId @@ -940,7 +1017,7 @@ class DetailsViewModel @Inject constructor( playLabel = displayTarget?.let { if (playTarget?.label == context.getString(R.string.play_start_s1e1)) { context.getString(R.string.play_start_s1e1) - } else { + } else { context.getString(R.string.continue_season_episode, it.displaySeason, it.displayEpisode) } } ?: playTarget?.label, @@ -951,6 +1028,22 @@ class DetailsViewModel @Inject constructor( } if (mediaType == MediaType.TV) { + val totalSeasonsCount = baseState.totalSeasons + if (totalSeasonsCount > 1) { + launch(Dispatchers.IO) { + (1..totalSeasonsCount).filter { it != seasonToLoad }.forEach { sNum -> + runCatching { + val currentStructure = animeSeasonStructure + if (currentStructure != null) { + loadAnimeDisplaySeason(mediaId, sNum, currentStructure) + } else { + mediaRepository.getSeasonEpisodes(mediaId, sNum) + } + } + } + } + } + launch { val titleForPrefetch = baseState.item?.title.orEmpty().ifBlank { mergedItem.title } if (titleForPrefetch.isBlank()) { @@ -1004,23 +1097,57 @@ class DetailsViewModel @Inject constructor( fun loadSeason(seasonNumber: Int) { if (currentMediaType != MediaType.TV) return - // Don't reload if already on this season - if (_uiState.value.currentSeason == seasonNumber && _uiState.value.episodes.isNotEmpty()) return - // Ignore a duplicate request for a load that is already in flight (the click handler and the - // hover effect can both ask for the same season). + // Don't reload if already on this season with loaded episodes and not in loading state + if (_uiState.value.currentSeason == seasonNumber && _uiState.value.episodes.isNotEmpty() && !_uiState.value.isSeasonLoading) return + // Ignore a duplicate request for a load that is already in flight if (seasonLoadRequestedSeason == seasonNumber && seasonLoadJob?.isActive == true) return - // Cancel any in-flight season load so only the most recently requested season can win — - // otherwise an older, slower request could finish last and display the wrong season's - // episodes for the currently selected season. seasonLoadJob?.cancel() seasonLoadRequestedSeason = seasonNumber - seasonLoadJob = viewModelScope.launch { - // Keep current episodes visible while loading new ones - val currentEpisodes = _uiState.value.episodes + val structure = animeSeasonStructure + // Fast-path: Check in-memory cache synchronously for instant 0ms transition! + val cachedEpisodes = if (structure != null) { + peekAnimeDisplaySeason(currentMediaId, seasonNumber, structure) + } else { + mediaRepository.peekCachedSeasonEpisodes(currentMediaId, seasonNumber)?.let { canonical -> + normalizeAnimeEpisodesForDisplay( + tmdbId = currentMediaId, + displaySeason = seasonNumber, + item = _uiState.value.item, + canonicalEpisodes = canonical + ) + } + } + if (cachedEpisodes != null && cachedEpisodes.isNotEmpty()) { + val watchedKeys = traktRepository.getWatchedEpisodesFromCache() + val decorated = if (watchedKeys.isNotEmpty()) { + cachedEpisodes.map { ep -> + val key = "show_tmdb:$currentMediaId:${ep.tmdbSeasonNumber}:${ep.tmdbEpisodeNumber}" + if (watchedKeys.contains(key)) ep.copy(isWatched = true) else ep + } + } else { + val progress = _uiState.value.seasonProgress[seasonNumber] + val isFullyWatchedSeason = progress != null && progress.second > 0 && progress.first >= progress.second + if (isFullyWatchedSeason) cachedEpisodes.map { it.copy(isWatched = true) } else cachedEpisodes + } + _uiState.value = _uiState.value.copy( + currentSeason = seasonNumber, + episodes = decorated, + isSeasonLoading = false + ) + return + } + + // Cache miss: Optimistically update selected season pill immediately and show loading skeleton + _uiState.value = _uiState.value.copy( + currentSeason = seasonNumber, + episodes = emptyList(), + isSeasonLoading = true + ) + + seasonLoadJob = viewModelScope.launch { try { - val structure = animeSeasonStructure val episodes = if (structure != null) { loadAnimeDisplaySeason(currentMediaId, seasonNumber, structure) } else { @@ -1054,26 +1181,26 @@ class DetailsViewModel @Inject constructor( } } - // Re-check after the watched-status fetch (another suspension point) so a - // superseded request never overwrites the newer season's episodes. + // Re-check after the watched-status fetch so a superseded request never overwrites if (seasonLoadRequestedSeason != seasonNumber) return@launch _uiState.value = _uiState.value.copy( episodes = decoratedEpisodes, - currentSeason = seasonNumber + currentSeason = seasonNumber, + isSeasonLoading = false ) } else { - // If no episodes returned, keep current and show error _uiState.value = _uiState.value.copy( + isSeasonLoading = false, toastMessage = context.getString(R.string.details_no_episodes_season, seasonNumber), toastType = ToastType.ERROR ) } } catch (e: CancellationException) { - // Superseded by a newer season request — leave the newer load's state untouched. + // Superseded by a newer season request throw e } catch (e: Exception) { - // On error, keep showing current episodes _uiState.value = _uiState.value.copy( + isSeasonLoading = false, toastMessage = context.getString(R.string.details_failed_load_season, seasonNumber), toastType = ToastType.ERROR ) 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 4394d2496..b4d0fe2fc 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 @@ -196,7 +196,16 @@ class HomeViewModel @Inject constructor( catalogs.map { it.id }.toSet() }.distinctUntilChanged() ) { rows, visibleIds -> - rows.filter { it.id in visibleIds } + val filteredByCatalog = rows.filter { it.id in visibleIds } + if (!isTvDevice) { + filteredByCatalog.filter { row -> + row.items.any { item -> + !item.isPlaceholder && !SportsAddonCapabilities.isSportsLockedStatus(item.status) + } + } + } else { + filteredByCatalog + } }.let { flow -> val state = MutableStateFlow>(emptyList()) viewModelScope.launch { flow.collect { state.value = it } } @@ -226,7 +235,35 @@ class HomeViewModel @Inject constructor( fun withSportsHomeRows( categories: List, sportsRows: List - ): List = sportsRepository.mergeSportsRows(categories, sportsRows) + ): List { + if (!isTvDevice) { + val activeSportsRows = sportsRows.filter { row -> + row.items.any { item -> + !item.isPlaceholder && !SportsAddonCapabilities.isSportsLockedStatus(item.status) + } + } + val activeSportsById = activeSportsRows.associateBy { it.id } + // On mobile: If no sports addon is installed or a sports row is locked/empty, + // strictly strip it from the mobile feed (both from categories and sportsRows). + val filteredCategories = categories.mapNotNull { category -> + if (category.id == SportsAddonCapabilities.POPULAR_LIVE_TV_ROW_ID || + category.id == SportsAddonCapabilities.SPORTS_CATEGORY_ROW_ID) { + activeSportsById[category.id] + } else { + activeSportsById[category.id] ?: category + } + } + val existingIds = filteredCategories.map { it.id }.toSet() + val extraActiveSports = activeSportsRows.filter { it.id !in existingIds } + return if (extraActiveSports.isNotEmpty()) { + sportsRepository.mergeSportsRows(filteredCategories, extraActiveSports) + } else { + filteredCategories + } + } else { + return sportsRepository.mergeSportsRows(categories, sportsRows) + } + } fun openSportsHomeItem( item: MediaItem, From 4c5c47dcce4cf22875632ba73363aab3f21d318a Mon Sep 17 00:00:00 2001 From: Arvin Date: Wed, 26 Aug 2026 11:12:00 +0200 Subject: [PATCH 12/12] fix: harden progressive home runtime behavior --- .../tv/data/repository/MdbListRepository.kt | 36 ++++++++++ .../tv/data/repository/MediaRepository.kt | 29 ++------ .../tv/data/repository/SportsRepository.kt | 3 +- .../data/repository/simkl/SimklSyncService.kt | 11 +-- .../arflix/tv/ui/components/SkeletonLoader.kt | 1 - .../tv/ui/screens/details/DetailsViewModel.kt | 49 ++++++++----- .../tv/ui/screens/home/HomeViewModel.kt | 28 ++++---- .../ui/screens/settings/SettingsViewModel.kt | 71 ++++++++++++++----- 8 files changed, 151 insertions(+), 77 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt index 3e9f20df6..dd9a7b1af 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MdbListRepository.kt @@ -58,6 +58,11 @@ data class MdbExternalRating( val value: String ) +data class MdbWatchedSnapshot( + val movies: Set, + val episodes: Set +) + @Singleton class MdbListRepository @Inject constructor( private val api: MdbListApi, @@ -351,6 +356,37 @@ class MdbListRepository @Inject constructor( // ===== Watched reads ===== + suspend fun getWatchedSnapshot(): Result = withContext(Dispatchers.IO) { + val k = key() ?: return@withContext Result.failure(IllegalStateException("MDBList is not connected")) + try { + val movies = mutableSetOf() + val episodes = mutableSetOf() + var offset = 0 + val limit = 1000 + while (true) { + val response = api.getWatched(k, limit = limit, offset = offset) + response.movies?.forEach { row -> + row.movie?.ids?.tmdb?.let(movies::add) + } + response.episodes?.forEach { row -> + val episode = row.episode ?: return@forEach + val showTmdb = episode.show?.ids?.tmdb ?: return@forEach + val season = episode.season ?: return@forEach + val number = episode.number ?: return@forEach + episodes.add("show_tmdb:$showTmdb:$season:$number") + } + if (response.pagination?.hasMore != true) break + offset += limit + } + Result.success(MdbWatchedSnapshot(movies, episodes)) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e(TAG, "watched snapshot fetch failed", e) + Result.failure(e) + } + } + suspend fun getWatchedMovies(): Set = withContext(Dispatchers.IO) { val k = key() ?: return@withContext emptySet() try { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index cdb4c4e34..fa8170973 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -328,13 +328,15 @@ class MediaRepository @Inject constructor( fun getCachedItem(mediaType: MediaType, mediaId: Int): MediaItem? { val cacheKey = detailsCacheKey(mediaType, mediaId) - val inMemory = getFromCache(detailsCache, cacheKey) - if (inMemory != null) return inMemory - return peekItemFromDiskCache(mediaType, mediaId) + return getFromCache(detailsCache, cacheKey) } - private fun peekItemFromDiskCache(mediaType: MediaType, mediaId: Int): MediaItem? { - return try { + suspend fun getCachedItemFromDisk(mediaType: MediaType, mediaId: Int): MediaItem? = + withContext(Dispatchers.IO) { + peekItemFromDiskCache(mediaType, mediaId) + } + + private fun peekItemFromDiskCache(mediaType: MediaType, mediaId: Int): MediaItem? = try { val cacheFiles = mutableListOf() context.cacheDir.listFiles { _, name -> name.startsWith("home_categories_cache_") && name.endsWith(".json") } ?.let { cacheFiles.addAll(it) } @@ -380,7 +382,6 @@ class MediaRepository @Inject constructor( } catch (_: Throwable) { null } - } fun getCachedFullItem(mediaType: MediaType, mediaId: Int): MediaItem? { val cacheKey = detailsCacheKey(mediaType, mediaId) @@ -1812,22 +1813,6 @@ class MediaRepository @Inject constructor( ) } - suspend fun loadSingleBuiltinCategory(categoryId: String): Category? { - val pageResult = loadHomeCategoryPage(categoryId, 1) - if (pageResult.items.isEmpty()) return null - val title = when (categoryId) { - "trending_movies" -> context.getString(R.string.trending_movies) - "trending_tv" -> context.getString(R.string.trending_series) - "trending_anime" -> context.getString(R.string.trending_anime) - else -> categoryId - } - return Category( - id = categoryId, - title = title, - items = pageResult.items - ) - } - suspend fun loadCustomCatalog(catalog: CatalogConfig, maxItems: Int = 40): Category? = coroutineScope { if (catalog.kind == CatalogKind.COLLECTION) { val page = loadCollectionCatalogPage(catalog = catalog, offset = 0, limit = maxItems) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt index 306598a33..2eb0508b9 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/SportsRepository.kt @@ -392,8 +392,7 @@ class SportsRepository @Inject constructor( overview = overview, mediaType = MediaType.TV, badge = badge, - status = "${SportsAddonCapabilities.SPORTS_LOCKED_STATUS_PREFIX}$key", - isPlaceholder = true + status = "${SportsAddonCapabilities.SPORTS_LOCKED_STATUS_PREFIX}$key" ) private fun candidateCatalogs(addon: Addon, selectedSportId: String?): List { diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt index ea91b1e93..46e13e633 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/simkl/SimklSyncService.kt @@ -80,11 +80,11 @@ class SimklSyncService @Inject constructor( * Phase 2: Check /sync/activities first. If timestamp changed, fetch delta using /sync/all-items/?date_from=... * Throttles background checks to once every 15 minutes unless forced. */ - suspend fun syncIfNeeded(force: Boolean = false) = syncMutex.withLock { + suspend fun syncIfNeeded(force: Boolean = false): Boolean = syncMutex.withLock { val token = authManager.getAccessToken() if (token.isNullOrBlank()) { clearCachedState() - return@withLock + return@withLock false } val tokenScope = token.hashCode() if (activeTokenScope != tokenScope) { @@ -95,10 +95,10 @@ class SimklSyncService @Inject constructor( val now = System.currentTimeMillis() if (!force && hasInitialSnapshot && now - lastActivityCheckTime < SNAPSHOT_TTL_MS) { - return@withLock + return@withLock true } if (!force && now - lastSyncAttemptTime < FAILED_SYNC_BACKOFF_MS) { - return@withLock + return@withLock hasInitialSnapshot } lastSyncAttemptTime = now @@ -120,14 +120,17 @@ class SimklSyncService @Inject constructor( // Keep partial/previous data visible and retry after the short failure backoff. lastActivityCheckTime = 0L } + outcome.complete } else { AppLogger.d("SimklSyncService", "Simkl activities unchanged ($currentActivityDate). Skipping sync.") lastActivityCheckTime = now + true } } catch (e: CancellationException) { throw e } catch (e: Exception) { AppLogger.e("SimklSyncService", "Error during Simkl sync: ${e.message}") + false } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt index ee99da6e1..455b8f09f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/SkeletonLoader.kt @@ -551,4 +551,3 @@ fun SkeletonMobileHeroBanner( enum class SkeletonCardType { POSTER, MEDIA, EPISODE, CAST } - diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt index cc50655a7..7b69a9d84 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsViewModel.kt @@ -245,6 +245,7 @@ class DetailsViewModel @Inject constructor( private var lastStreamListPrewarmKey: String = "" // Guards overlapping season loads: only the most recently requested season may apply its result. private var seasonLoadJob: kotlinx.coroutines.Job? = null + private var seasonPrefetchJob: kotlinx.coroutines.Job? = null private var seasonLoadRequestedSeason: Int = -1 @Volatile private var initialLoadComplete = false @@ -367,6 +368,9 @@ class DetailsViewModel @Inject constructor( homeServerAppendJob?.cancel() streamListPrewarmJob?.cancel() focusedStreamPrewarmJob?.cancel() + seasonLoadJob?.cancel() + seasonPrefetchJob?.cancel() + seasonLoadRequestedSeason = -1 lastStreamListPrewarmKey = "" viewModelScope.launch { @@ -407,7 +411,9 @@ class DetailsViewModel @Inject constructor( it.id == mediaId && it.mediaType == mediaType } val cachedFullItem = mediaRepository.getCachedFullItem(mediaType, mediaId) - val cachedItem = cachedFullItem ?: mediaRepository.getCachedItem(mediaType, mediaId) + val cachedItem = cachedFullItem + ?: mediaRepository.getCachedItem(mediaType, mediaId) + ?: mediaRepository.getCachedItemFromDisk(mediaType, mediaId) val initialItem = cachedItem ?: previousItem val cachedLogoUrl = mediaRepository.peekCachedLogoUrl(mediaType, mediaId) ?: previousState.logoUrl?.takeIf { previousMatches } @@ -1028,21 +1034,7 @@ class DetailsViewModel @Inject constructor( } if (mediaType == MediaType.TV) { - val totalSeasonsCount = baseState.totalSeasons - if (totalSeasonsCount > 1) { - launch(Dispatchers.IO) { - (1..totalSeasonsCount).filter { it != seasonToLoad }.forEach { sNum -> - runCatching { - val currentStructure = animeSeasonStructure - if (currentStructure != null) { - loadAnimeDisplaySeason(mediaId, sNum, currentStructure) - } else { - mediaRepository.getSeasonEpisodes(mediaId, sNum) - } - } - } - } - } + prefetchAdjacentSeasons(mediaId, seasonToLoad, baseState.totalSeasons) launch { val titleForPrefetch = baseState.item?.title.orEmpty().ifBlank { mergedItem.title } @@ -1136,6 +1128,7 @@ class DetailsViewModel @Inject constructor( episodes = decorated, isSeasonLoading = false ) + prefetchAdjacentSeasons(currentMediaId, seasonNumber, _uiState.value.totalSeasons) return } @@ -1188,6 +1181,7 @@ class DetailsViewModel @Inject constructor( currentSeason = seasonNumber, isSeasonLoading = false ) + prefetchAdjacentSeasons(currentMediaId, seasonNumber, _uiState.value.totalSeasons) } else { _uiState.value = _uiState.value.copy( isSeasonLoading = false, @@ -1208,6 +1202,29 @@ class DetailsViewModel @Inject constructor( } } + private fun prefetchAdjacentSeasons(mediaId: Int, selectedSeason: Int, totalSeasons: Int) { + seasonPrefetchJob?.cancel() + val seasons = listOf(selectedSeason - 1, selectedSeason + 1) + .filter { it in 1..totalSeasons } + if (seasons.isEmpty()) return + + val structure = animeSeasonStructure + seasonPrefetchJob = viewModelScope.launch(Dispatchers.IO) { + seasons.forEach { season -> + try { + if (structure != null) { + loadAnimeDisplaySeason(mediaId, season, structure) + } else { + mediaRepository.getSeasonEpisodes(mediaId, season) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + } + } + } + } + private suspend fun loadAnimeDisplaySeason( tmdbId: Int, displaySeason: Int, 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 b4d0fe2fc..69b137865 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 @@ -218,6 +218,11 @@ class HomeViewModel @Inject constructor( /** Prefix used in MediaItem.status to identify IPTV items. */ const val IPTV_STATUS_PREFIX = "iptv:" private const val TOP_10_ITEM_LIMIT = 10 + private val BUILTIN_TMDB_CATEGORY_IDS = setOf( + "trending_movies", + "trending_tv", + "trending_anime" + ) private val HARD_CAPPED_TOP_10_CATALOG_IDS = setOf( "top10_movies_today", "top10_shows_today" @@ -3160,24 +3165,19 @@ class HomeViewModel @Inject constructor( } // 5. TMDB Built-in Categories (Trending Movies, Shows, Anime) - fast independent single-request fetches - val builtinTmdbIds = setOf("trending_movies", "trending_tv", "trending_anime") val tmdbConfigs = savedCatalogs.filter { - (it.id in builtinTmdbIds) || (it.isPreinstalled && it.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(it) && !isCollectionTileConfig(it)) + (it.id in BUILTIN_TMDB_CATEGORY_IDS) || (it.isPreinstalled && it.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(it) && !isCollectionTileConfig(it)) } tmdbConfigs.forEach { cfg -> viewModelScope.launch(networkDispatcher) { - val category = runCatching { - mediaRepository.loadSingleBuiltinCategory(cfg.id) + val page = runCatching { + mediaRepository.loadHomeCategoryPage(cfg.id, 1) }.getOrNull() - if (category != null && category.items.isNotEmpty()) { - val titled = if (cfg.title.isNotBlank() && cfg.title != category.title) { - category.copy(title = cfg.title) - } else { - category - } + if (page != null && page.items.isNotEmpty()) { + val category = Category(id = cfg.id, title = cfg.title, items = page.items) withContext(Dispatchers.Main.immediate) { if (requestId == loadHomeRequestId) { - updateMobileCategoryRow(cfg.id, titled.withTop10CapIfNeeded(), hasMore = true) + updateMobileCategoryRow(cfg.id, category.withTop10CapIfNeeded(), hasMore = page.hasMore) persistCategoriesCache(_uiState.value.categories) } } @@ -3187,7 +3187,7 @@ class HomeViewModel @Inject constructor( // 6. MDBList and Custom/Addon Catalogs - progressive fetch with higher concurrency val customConfigs = savedCatalogs.filter { cfg -> - cfg.id !in builtinTmdbIds && + cfg.id !in BUILTIN_TMDB_CATEGORY_IDS && (isCustomCatalogConfig(cfg) || (cfg.isPreinstalled && !cfg.sourceUrl.isNullOrBlank() && !isCollectionRailConfig(cfg) && !isCollectionTileConfig(cfg))) } val customSemaphore = Semaphore(if (isLowRamDevice) 3 else 6) @@ -3465,7 +3465,9 @@ class HomeViewModel @Inject constructor( val catalog = savedCatalogById[categoryId] val pageSize = getCategoryPageSize(categoryId) - val result = if (catalog?.isPreinstalled == true && catalog.sourceUrl.isNullOrBlank()) { + val result = if (categoryId in BUILTIN_TMDB_CATEGORY_IDS || + (catalog?.isPreinstalled == true && catalog.sourceUrl.isNullOrBlank()) + ) { // Pure TMDB preinstalled catalog (no MDBList source) val nextPage = (realItems.size / 20) + 1 mediaRepository.loadHomeCategoryPage(categoryId, nextPage) 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 2c60231ff..dd4b6eb82 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 @@ -991,29 +991,43 @@ class SettingsViewModel @Inject constructor( var totalMovies = 0 var totalEpisodes = 0 var syncedAny = false + val connectedProviders = mutableListOf() + val failures = mutableListOf() if (_uiState.value.isTraktAuthenticated) { - val result = traktSyncService.performFullSync() - if (result is SyncResult.Success) { - totalMovies += result.moviesSynced - totalEpisodes += result.episodesSynced - syncedAny = true + connectedProviders += "Trakt" + when (val result = traktSyncService.performFullSync()) { + is SyncResult.Success -> { + totalMovies += result.moviesSynced + totalEpisodes += result.episodesSynced + syncedAny = true + } + is SyncResult.Error -> failures += "Trakt: ${result.message}" } } if (_uiState.value.isMdbListConnected) { - val mdbMovies = runCatching { mdbListRepository.getWatchedMovies() }.getOrDefault(emptySet()) - val mdbEpisodes = runCatching { mdbListRepository.getWatchedEpisodes() }.getOrDefault(emptySet()) - totalMovies += mdbMovies.size - totalEpisodes += mdbEpisodes.size - syncedAny = true + connectedProviders += "MDBList" + mdbListRepository.getWatchedSnapshot() + .onSuccess { snapshot -> + totalMovies += snapshot.movies.size + totalEpisodes += snapshot.episodes.size + syncedAny = true + } + .onFailure { error -> + failures += "MDBList: ${error.message ?: "request failed"}" + } } if (_uiState.value.isSimklConnected) { - runCatching { simklSyncService.syncIfNeeded(force = true) } - val simklMovies = runCatching { simklSyncService.getWatchedMovies() }.getOrDefault(emptySet()) - val simklEpisodes = runCatching { simklSyncService.getWatchedEpisodes() }.getOrDefault(emptySet()) - totalMovies += simklMovies.size - totalEpisodes += simklEpisodes.size - syncedAny = true + connectedProviders += "Simkl" + if (simklSyncService.syncIfNeeded(force = true)) { + val simklMovies = simklSyncService.getWatchedMovies() + val simklEpisodes = simklSyncService.getWatchedEpisodes() + totalMovies += simklMovies.size + totalEpisodes += simklEpisodes.size + syncedAny = true + } else { + failures += "Simkl: request failed" + } } val nowIso = java.time.Instant.now().toString() @@ -1024,19 +1038,38 @@ class SettingsViewModel @Inject constructor( syncedMovies = totalMovies, syncedEpisodes = totalEpisodes, lastSyncTime = formatSyncTime(nowIso), - toastMessage = if (!silent) "Synced $totalMovies movies and $totalEpisodes episodes" else _uiState.value.toastMessage, - toastType = if (!silent) ToastType.SUCCESS else _uiState.value.toastType + toastMessage = if (!silent) { + if (failures.isEmpty()) { + "Synced $totalMovies movies and $totalEpisodes episodes" + } else { + "Synced $totalMovies movies and $totalEpisodes episodes; ${failures.joinToString("; ")}" + } + } else { + _uiState.value.toastMessage + }, + toastType = if (!silent) { + if (failures.isEmpty()) ToastType.SUCCESS else ToastType.ERROR + } else { + _uiState.value.toastType + } ) } traktRepository.invalidateWatchedCache() traktRepository.initializeWatchedCache() - } else if (!silent) { + } else if (!silent && connectedProviders.isEmpty()) { withContext(Dispatchers.Main) { _uiState.value = _uiState.value.copy( toastMessage = "No tracking provider connected", toastType = ToastType.ERROR ) } + } else if (!silent) { + withContext(Dispatchers.Main) { + _uiState.value = _uiState.value.copy( + toastMessage = context.getString(R.string.sync_failed, failures.joinToString("; ")), + toastType = ToastType.ERROR + ) + } } } catch (e: Exception) { if (e is CancellationException) throw e