Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 67 additions & 10 deletions app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,40 @@ internal fun compactHomeCategoriesForCache(
category.copy(items = items).takeIf { items.isNotEmpty() }
}

internal fun orderCategoriesBySavedCatalogs(
categories: List<Category>,
savedCatalogs: List<CatalogConfig>
): List<Category> {
if (categories.size <= 1 || savedCatalogs.isEmpty()) return categories

val orderMap = HashMap<String, Int>(savedCatalogs.size)
var orderIdx = 0
for (cfg in savedCatalogs) {
if (cfg.kind == CatalogKind.COLLECTION) continue
val catId = if (cfg.kind == CatalogKind.COLLECTION_RAIL) {
val group = cfg.collectionGroup ?: continue
"collection_row_${group.name.lowercase(Locale.US)}"
} else {
cfg.id
}
if (!orderMap.containsKey(catId)) {
orderMap[catId] = orderIdx++
}
}

return categories.sortedWith { a, b ->
when {
a.id == "continue_watching" -> -1
b.id == "continue_watching" -> 1
else -> {
val idxA = orderMap[a.id] ?: Int.MAX_VALUE
val idxB = orderMap[b.id] ?: Int.MAX_VALUE
idxA.compareTo(idxB)
}
}
}
}

enum class ToastType {
SUCCESS, ERROR, INFO
}
Expand Down Expand Up @@ -1265,6 +1299,8 @@ class HomeViewModel @Inject constructor(
private val heroDetailsCache = ConcurrentHashMap<String, HeroDetailsSnapshot>()
private val heroDetailsFetchInFlight = Collections.synchronizedSet(mutableSetOf<String>())
private val heroDetailsPrefetchSemaphore = Semaphore(if (isLowRamDevice) 1 else 2)
@Volatile
private var currentSavedCatalogs: List<CatalogConfig> = emptyList()
private val savedCatalogById = ConcurrentHashMap<String, CatalogConfig>()
private val categoryPaginationStates = ConcurrentHashMap<String, CategoryPaginationState>()
private val preloadedRequests: MutableSet<String> = run {
Expand Down Expand Up @@ -1386,6 +1422,7 @@ class HomeViewModel @Inject constructor(
lastResolvedBaseCategories = emptyList()
dismissedContinueWatchingAt.clear()
categoryPaginationStates.clear()
currentSavedCatalogs = emptyList()
savedCatalogById.clear()
collectionCatalogByMediaId.clear()
iptvChannelMap.clear()
Expand Down Expand Up @@ -2308,7 +2345,11 @@ class HomeViewModel @Inject constructor(
applyContentLanguageFromPrefs()

try {
if (_uiState.value.categories.isEmpty()) {
val existingCw = _uiState.value.categories.firstOrNull { it.id == "continue_watching" && it.items.isNotEmpty() }
val hasRealBaseCategories = _uiState.value.categories.any {
it.id != "continue_watching" && !it.id.startsWith("collection_row_") && it.items.any { item -> !item.isPlaceholder }
}
if (!hasRealBaseCategories) {
// Build the early skeleton from the default catalog list minus any
// preinstalled catalogs the user has explicitly hidden for the active
// profile. Without this filter, deleted catalogs flash back into view
Expand All @@ -2328,14 +2369,19 @@ class HomeViewModel @Inject constructor(
cachedContinueWatching = emptyList(),
hasRemoteContinueWatching = earlyHasRemote
)
val initialSkeleton = if (existingCw != null) {
listOf(existingCw) + earlySkeleton.filterNot { it.id == "continue_watching" }
} else {
earlySkeleton
}
if (requestId != loadHomeRequestId) return@loadHome
if (earlySkeleton.isNotEmpty()) {
if (initialSkeleton.isNotEmpty()) {
_uiState.value = _uiState.value.copy(
isLoading = true,
isInitialLoad = false,
categories = earlySkeleton,
heroItem = earlySkeleton.firstOrNull()?.items?.firstOrNull { !it.isPlaceholder },
heroLogoUrl = null,
categories = initialSkeleton,
heroItem = initialSkeleton.firstOrNull()?.items?.firstOrNull { !it.isPlaceholder } ?: _uiState.value.heroItem,
heroLogoUrl = _uiState.value.heroLogoUrl,
error = null
)
}
Expand Down Expand Up @@ -2375,6 +2421,7 @@ class HomeViewModel @Inject constructor(
}
}
}
currentSavedCatalogs = savedCatalogs
savedCatalogById.clear()
savedCatalogs.forEach { savedCatalogById[it.id] = it }
categoryPaginationStates.clear()
Expand Down Expand Up @@ -3009,13 +3056,14 @@ class HomeViewModel @Inject constructor(
} else {
currentCategories.add(newCategory)
}
val orderedCategories = orderCategoriesBySavedCatalogs(currentCategories, currentSavedCatalogs)
categoryPaginationStates[categoryId] = CategoryPaginationState(
loadedCount = newCategory.items.size,
hasMore = hasMore
)
val currentHero = _uiState.value.heroItem
val newHero = if (currentHero == null || !isEligibleHeroItem(currentHero)) {
chooseInitialHero(currentCategories)
chooseInitialHero(orderedCategories)
} else {
currentHero
}
Expand All @@ -3024,7 +3072,7 @@ class HomeViewModel @Inject constructor(
_uiState.value = _uiState.value.copy(
isLoading = false,
isInitialLoad = false,
categories = currentCategories,
categories = orderedCategories,
heroItem = newHero,
heroLogoUrl = heroLogo ?: _uiState.value.heroLogoUrl,
categoryHasMoreMap = categoryPaginationStates.mapValues { it.value.hasMore },
Expand Down Expand Up @@ -3101,12 +3149,21 @@ class HomeViewModel @Inject constructor(
cachedContinueWatching = cachedContinueWatching,
hasRemoteContinueWatching = hasRemoteContinueWatching
)
if (_uiState.value.categories.isEmpty()) {
val skeletonHero = chooseInitialHero(skeletonCategories)
val existingCw = _uiState.value.categories.firstOrNull { it.id == "continue_watching" && it.items.isNotEmpty() }
val hasRealBaseCategories = _uiState.value.categories.any {
it.id != "continue_watching" && !it.id.startsWith("collection_row_") && it.items.any { item -> !item.isPlaceholder }
}
if (!hasRealBaseCategories) {
val initialSkeleton = if (existingCw != null) {
listOf(existingCw) + skeletonCategories.filterNot { it.id == "continue_watching" }
} else {
skeletonCategories
}
val skeletonHero = chooseInitialHero(initialSkeleton)
_uiState.value = _uiState.value.copy(
isLoading = true,
isInitialLoad = false,
categories = skeletonCategories,
categories = initialSkeleton,
heroItem = skeletonHero,
heroLogoUrl = null,
error = null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.arflix.tv.ui.screens.home

import com.arflix.tv.data.model.CatalogConfig
import com.arflix.tv.data.model.CatalogKind
import com.arflix.tv.data.model.CatalogSourceType
import com.arflix.tv.data.model.Category
import com.arflix.tv.data.model.CollectionGroupKind
import com.arflix.tv.data.model.MediaItem
import com.arflix.tv.data.model.MediaType
import com.google.common.truth.Truth.assertThat
import org.junit.Test

class HomeMobileCategoryOrderingTest {

@Test
fun `orderCategoriesBySavedCatalogs prioritizes continue watching and enforces savedCatalogs order`() {
val savedCatalogs = listOf(
CatalogConfig(id = "trending_movies", title = "Trending in Movies", sourceType = CatalogSourceType.MDBLIST, isPreinstalled = true),
CatalogConfig(id = "trending_tv", title = "Trending in Shows", sourceType = CatalogSourceType.MDBLIST, isPreinstalled = true),
CatalogConfig(id = "trending_anime", title = "Trending in Anime", sourceType = CatalogSourceType.MDBLIST, isPreinstalled = true),
CatalogConfig(id = "collection_rail_service", title = "Services", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true, kind = CatalogKind.COLLECTION_RAIL, collectionGroup = CollectionGroupKind.SERVICE),
CatalogConfig(id = "collection_rail_genre", title = "Genres", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true, kind = CatalogKind.COLLECTION_RAIL, collectionGroup = CollectionGroupKind.GENRE),
CatalogConfig(id = "collection_rail_franchise", title = "Franchises", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true, kind = CatalogKind.COLLECTION_RAIL, collectionGroup = CollectionGroupKind.FRANCHISE),
CatalogConfig(id = "sports", title = "Sports", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true)
)

// Simulate categories finishing loading out of order:
// In-memory collection rails finish first, then Continue Watching arrives, then TMDB Trending finishes
val outOfOrderCategories = listOf(
Category(id = "collection_row_service", title = "Services", items = listOf(MediaItem(2, "Netflix", mediaType = MediaType.MOVIE))),
Category(id = "collection_row_genre", title = "Genres", items = listOf(MediaItem(3, "Action", mediaType = MediaType.MOVIE))),
Category(id = "collection_row_franchise", title = "Franchises", items = listOf(MediaItem(4, "Marvel", mediaType = MediaType.MOVIE))),
Category(id = "trending_movies", title = "Trending in Movies", items = listOf(MediaItem(5, "Top Movie", mediaType = MediaType.MOVIE))),
Category(id = "continue_watching", title = "Continue Watching", items = listOf(MediaItem(1, "CW Movie", mediaType = MediaType.MOVIE))),
Category(id = "trending_tv", title = "Trending in Shows", items = listOf(MediaItem(6, "Top Show", mediaType = MediaType.TV)))
)

val ordered = orderCategoriesBySavedCatalogs(outOfOrderCategories, savedCatalogs)

assertThat(ordered.map { it.id }).containsExactly(
"continue_watching",
"trending_movies",
"trending_tv",
"collection_row_service",
"collection_row_genre",
"collection_row_franchise"
).inOrder()
}

@Test
fun `orderCategoriesBySavedCatalogs ignores collection tile configs in catalog list`() {
val savedCatalogs = listOf(
CatalogConfig(id = "trending_movies", title = "Trending in Movies", sourceType = CatalogSourceType.MDBLIST, isPreinstalled = true),
CatalogConfig(id = "collection_service_netflix", title = "Netflix", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true, kind = CatalogKind.COLLECTION, collectionGroup = CollectionGroupKind.SERVICE),
CatalogConfig(id = "collection_rail_service", title = "Services", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true, kind = CatalogKind.COLLECTION_RAIL, collectionGroup = CollectionGroupKind.SERVICE),
CatalogConfig(id = "sports", title = "Sports", sourceType = CatalogSourceType.PREINSTALLED, isPreinstalled = true)
)

val categories = listOf(
Category(id = "sports", title = "Sports", items = listOf(MediaItem(1, "Live Match", mediaType = MediaType.MOVIE))),
Category(id = "collection_row_service", title = "Services", items = listOf(MediaItem(2, "Netflix", mediaType = MediaType.MOVIE))),
Category(id = "trending_movies", title = "Trending in Movies", items = listOf(MediaItem(3, "Top Movie", mediaType = MediaType.MOVIE)))
)

val ordered = orderCategoriesBySavedCatalogs(categories, savedCatalogs)

assertThat(ordered.map { it.id }).containsExactly(
"trending_movies",
"collection_row_service",
"sports"
).inOrder()
}
}
Loading