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
This commit is contained in:
DynamycSound 2026-06-07 23:54:21 +00:00
parent 09cb8f8277
commit bd474133fc
No known key found for this signature in database
2 changed files with 76 additions and 2 deletions

View file

@ -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 <T> List<T>.orderedForHero(seed: Int, keySelector: (T) -> String): List<T> =
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<HomeCatalogDefinition>,
snapshot: HomeCatalogSettingsSnapshot,

View file

@ -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")
}
}