From bd474133fcb3688aa18449519acd1713cf04579e Mon Sep 17 00:00:00 2001 From: DynamycSound Date: Sun, 7 Jun 2026 23:54:21 +0000 Subject: [PATCH] fix(android): stop home hero carousel reshuffling while loading The home hero pool is rebuilt every time a catalog batch finishes loading, then shuffled with a one-shot Random seeded on the request key. Re-permuting the growing pool on each intermediate publish made the carousel visibly reshuffle/reorder several times on app open before settling. Order the hero candidates by a stable per-item hash instead, so the relative order of already-loaded items stays fixed while later items slot into place. The carousel now settles instead of reshuffling, and ordering remains deterministic per catalog set and stable across app opens. Fixes #1133 --- .../nuvio/app/features/home/HomeRepository.kt | 24 ++++++++- .../app/features/home/HomeHeroOrderingTest.kt | 54 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeHeroOrderingTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 54b1e30e7..4d96dec97 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -197,14 +197,14 @@ object HomeRepository { } val catalogHeroItems = if (snapshot.heroEnabled) { - val heroRandom = Random((requestKey?.hashCode() ?: 0).absoluteValue + 1) + val heroOrderSeed = requestKey?.hashCode() ?: 0 currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } .mapNotNull { definition -> cachedSections[definition.key] } .map { section -> section.withReleaseFilter() } .flatMap { section -> section.items } .distinctBy { item -> "${item.type}:${item.id}" } - .shuffled(heroRandom) + .orderedForHero(heroOrderSeed) { item -> "${item.type}:${item.id}" } .take(HOME_HERO_ITEM_LIMIT) } else { emptyList() @@ -433,6 +433,26 @@ private const val HOME_CATALOG_FETCH_BATCH_SIZE = 4 private const val HOME_CATALOG_PREVIEW_FETCH_LIMIT = 18 private const val HOME_CATALOG_PUBLISH_INTERVAL = 2 +internal fun List.orderedForHero(seed: Int, keySelector: (T) -> String): List = + sortedWith( + compareBy( + { item -> stableHeroOrderRank(seed, keySelector(item)) }, + { item -> keySelector(item) }, + ), + ) + +internal fun stableHeroOrderRank(seed: Int, itemKey: String): Int { + var hash = seed + for (index in itemKey.indices) { + hash = hash * 31 + itemKey[index].code + } + // Avalanche mix so items from the same catalog (similar ids) don't cluster together. + hash = hash xor (hash ushr 16) + hash *= 0x7feb352d + hash = hash xor (hash ushr 15) + return hash +} + private fun prioritizeDefinitions( definitions: List, snapshot: HomeCatalogSettingsSnapshot, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeHeroOrderingTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeHeroOrderingTest.kt new file mode 100644 index 000000000..060dcb9ef --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeHeroOrderingTest.kt @@ -0,0 +1,54 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class HomeHeroOrderingTest { + + @Test + fun `ordering is deterministic for a given seed`() { + val items = listOf("series:a", "movie:b", "series:c", "movie:d", "series:e") + + assertEquals( + items.orderedForHero(seed = 42) { it }, + items.orderedForHero(seed = 42) { it }, + ) + } + + @Test + fun `ordering does not depend on the input order`() { + val seed = 7 + val ascending = listOf("a", "b", "c", "d", "e").orderedForHero(seed) { it } + val descending = listOf("e", "d", "c", "b", "a").orderedForHero(seed) { it } + + assertEquals(ascending, descending) + } + + @Test + fun `adding more items keeps the relative order of already-loaded items`() { + // Regression guard for the hero carousel reshuffle bug: the hero pool is rebuilt as more + // catalog batches load, so the ordering must keep already-shown items in place instead of + // re-permuting the whole carousel. The previous Random.shuffled() implementation failed + // this because shuffling lists of different sizes produces unrelated orders. + val seed = 123 + val firstBatch = listOf("s1", "s2", "s3", "s4") + val grownPool = firstBatch + listOf("s5", "s6", "s7", "s8", "s9") + + val firstBatchOrder = firstBatch.orderedForHero(seed) { it } + val grownOrderFilteredToFirstBatch = grownPool + .orderedForHero(seed) { it } + .filter { it in firstBatch } + + assertEquals(firstBatchOrder, grownOrderFilteredToFirstBatch) + } + + @Test + fun `ordering is shuffled rather than sorted alphabetically`() { + val items = (1..20).map { "series:tt$it" } + val ordered = items.orderedForHero(seed = 99) { it } + + assertTrue(ordered != items.sortedBy { it }, "hero order should not be a plain sort") + assertEquals(items.toSet(), ordered.toSet(), "ordering must not drop or add items") + } +}