Compare commits

...

4 commits

Author SHA1 Message Date
tapframe
d0f978a715 catalog loading performance and cache improvement 2026-02-27 07:10:05 +05:30
tapframe
68cf2a597b improve catalog loading session 2026-02-27 06:53:45 +05:30
CrissZollo
244907a9dd
Inital load and performance updates 2026-02-26 22:09:59 +01:00
CrissZollo
c81e00c0e5
Increased the ram limit for building the app 2026-02-26 18:44:18 +01:00
26 changed files with 1210 additions and 311 deletions

View file

@ -63,9 +63,10 @@ android {
buildTypes {
debug {
signingConfig = signingConfigs.getByName("release")
isDebuggable = false
isDebuggable = true
isMinifyEnabled = false
resValue("string", "app_name", "Nuvio Debug")
buildConfigField("boolean", "IS_DEBUG_BUILD", "true")
// Dev environment (from local.dev.properties)
@ -86,6 +87,7 @@ android {
)
signingConfig = signingConfigs.getByName("release")
resValue("string", "app_name", "Nuvio")
buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
// Production environment (from local.properties)
@ -124,6 +126,12 @@ android {
}
}
androidComponents {
onVariants(selector().withBuildType("debug")) { variant ->
variant.applicationId.set("com.nuviodebug.tv")
}
}
composeCompiler {
// Enable Compose compiler metrics for performance analysis
metricsDestination = layout.buildDirectory.dir("compose_metrics")
@ -140,7 +148,7 @@ configurations.all {
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
// baselineProfile(project(":benchmark")) // TODO: create benchmark module later
baselineProfile(project(":benchmark"))
implementation(libs.androidx.core.ktx)
implementation("androidx.core:core-splashscreen:1.0.1")
implementation(libs.androidx.appcompat)

View file

@ -19,7 +19,7 @@
android:allowBackup="true"
android:banner="@mipmap/banner"
android:icon="@mipmap/ic_launcher"
android:label="Nuvio"
android:label="@string/app_name"
android:largeHeap="true"
android:usesCleartextTraffic="true"
android:supportsRtl="true"

View file

@ -116,12 +116,14 @@ import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen
import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen
import com.nuvio.tv.ui.theme.NuvioColors
import com.nuvio.tv.ui.theme.NuvioTheme
import com.nuvio.tv.updater.UpdateUiState
import com.nuvio.tv.updater.UpdateViewModel
import com.nuvio.tv.updater.ui.UpdatePromptDialog
import dagger.hilt.android.AndroidEntryPoint
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
@ -175,6 +177,8 @@ class MainActivity : ComponentActivity() {
lateinit var appOnboardingDataStore: AppOnboardingDataStore
private lateinit var jankStats: JankStats
private var startupSyncJob: Job? = null
private var hasDispatchedStartupSync: Boolean = false
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
override fun attachBaseContext(newBase: Context) {
@ -330,8 +334,17 @@ class MainActivity : ComponentActivity() {
mainUiPrefs.modernSidebarBlurPref && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
val hideBuiltInHeadersForFloatingPill = modernSidebarEnabled && !sidebarCollapsed
val updateViewModel: UpdateViewModel = hiltViewModel(this@MainActivity)
val updateState by updateViewModel.uiState.collectAsState()
var shouldInitUpdateViewModel by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(4000)
shouldInitUpdateViewModel = true
}
val updateViewModel: UpdateViewModel? = if (shouldInitUpdateViewModel) {
hiltViewModel(this@MainActivity)
} else {
null
}
val updateState = updateViewModel?.uiState?.collectAsState()?.value ?: UpdateUiState()
val startDestination = if (layoutChosen) Screen.Home.route else Screen.LayoutSelection.route
val navController = rememberNavController()
@ -443,11 +456,11 @@ class MainActivity : ComponentActivity() {
UpdatePromptDialog(
state = updateState,
onDismiss = { updateViewModel.dismissDialog() },
onDownload = { updateViewModel.downloadUpdate() },
onInstall = { updateViewModel.installUpdateOrRequestPermission() },
onIgnore = { updateViewModel.ignoreThisVersion() },
onOpenUnknownSources = { updateViewModel.openUnknownSourcesSettings() }
onDismiss = { updateViewModel?.dismissDialog() },
onDownload = { updateViewModel?.downloadUpdate() },
onInstall = { updateViewModel?.installUpdateOrRequestPermission() },
onIgnore = { updateViewModel?.ignoreThisVersion() },
onOpenUnknownSources = { updateViewModel?.openUnknownSourcesSettings() }
)
}
}
@ -476,9 +489,22 @@ class MainActivity : ComponentActivity() {
override fun onStart() {
super.onStart()
startupSyncService.requestSyncNow()
lifecycleScope.launch {
if (hasDispatchedStartupSync || startupSyncJob?.isActive == true) {
return
}
startupSyncJob = lifecycleScope.launch {
delay(3200)
startupSyncService.requestSyncNow()
delay(1200)
traktProgressService.refreshNow()
hasDispatchedStartupSync = true
}
}
override fun onStop() {
super.onStop()
if (!hasDispatchedStartupSync && startupSyncJob?.isActive == true) {
startupSyncJob?.cancel()
}
}
}

View file

@ -1,42 +1,68 @@
package com.nuvio.tv
import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import coil.memory.MemoryCache
import com.nuvio.tv.core.device.DeviceCapabilities
import com.nuvio.tv.core.sync.StartupSyncService
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers
import javax.inject.Inject
@HiltAndroidApp
class NuvioApplication : Application(), ImageLoaderFactory {
class NuvioApplication : Application(), ImageLoaderFactory, ComponentCallbacks2 {
@Inject lateinit var startupSyncService: StartupSyncService
@Inject lateinit var deviceCapabilities: DeviceCapabilities
private var _imageLoader: ImageLoader? = null
override fun onCreate() {
super.onCreate()
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
val caps = deviceCapabilities
val loader = ImageLoader.Builder(this)
.memoryCache {
MemoryCache.Builder(this)
.maxSizePercent(0.25)
.maxSizePercent(caps.memoryCachePercent)
.build()
}
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(200L * 1024 * 1024)
.maxSizeBytes(caps.diskCacheSizeBytes)
.build()
}
.decoderDispatcher(Dispatchers.IO.limitedParallelism(2))
.fetcherDispatcher(Dispatchers.IO.limitedParallelism(4))
.bitmapFactoryMaxParallelism(2)
.decoderDispatcher(Dispatchers.IO.limitedParallelism(caps.decoderParallelism))
.fetcherDispatcher(Dispatchers.IO.limitedParallelism(caps.fetcherParallelism))
.bitmapFactoryMaxParallelism(caps.decoderParallelism)
.allowRgb565(true)
.crossfade(false)
.build()
_imageLoader = loader
return loader
}
@Suppress("DEPRECATION")
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
_imageLoader?.memoryCache?.clear()
}
}
override fun onLowMemory() {
super.onLowMemory()
_imageLoader?.memoryCache?.clear()
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
}
}

View file

@ -0,0 +1,140 @@
package com.nuvio.tv.core.device
import android.app.ActivityManager
import android.content.Context
import android.util.Log
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import javax.inject.Inject
import javax.inject.Singleton
enum class DeviceTier { LOW, MEDIUM, HIGH }
@Singleton
class DeviceCapabilities @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "DeviceCapabilities"
private const val LOW_HEAP_THRESHOLD_MB = 256
private const val HIGH_HEAP_THRESHOLD_MB = 384
}
private val _tierOverride = MutableStateFlow<DeviceTier?>(null)
val tierOverride: StateFlow<DeviceTier?> = _tierOverride.asStateFlow()
private val detectedTier: DeviceTier = detectTier()
val tier: DeviceTier
get() = _tierOverride.value ?: detectedTier
fun setTierOverride(override: DeviceTier?) {
val previous = _tierOverride.value
_tierOverride.value = override
val effective = override ?: detectedTier
Log.i(TAG, "Tier override changed: ${previous ?: "auto"} -> ${override ?: "auto"} (effective=$effective, detected=$detectedTier)" +
" | poster=${tmdbPosterSize}, backdrop=${tmdbBackdropSize}, decoders=$decoderParallelism, fetchers=$fetcherParallelism" +
" | memCache=${(memoryCachePercent * 100).toInt()}%, diskCache=${diskCacheSizeBytes / 1024 / 1024}MB" +
" | keyThrottle=${keyRepeatThrottleMs}ms, prefetch=$nestedPrefetchItemCount")
}
// -- Coil image loader config --
val memoryCachePercent: Double
get() = when (tier) {
DeviceTier.LOW -> 0.15
DeviceTier.MEDIUM -> 0.20
DeviceTier.HIGH -> 0.25
}
val diskCacheSizeBytes: Long
get() = when (tier) {
DeviceTier.LOW -> 75L * 1024 * 1024
DeviceTier.MEDIUM -> 150L * 1024 * 1024
DeviceTier.HIGH -> 200L * 1024 * 1024
}
val decoderParallelism: Int
get() = when (tier) {
DeviceTier.LOW -> 1
DeviceTier.MEDIUM -> 2
DeviceTier.HIGH -> 2
}
val fetcherParallelism: Int
get() = when (tier) {
DeviceTier.LOW -> 2
DeviceTier.MEDIUM -> 3
DeviceTier.HIGH -> 4
}
// -- TMDB image sizes --
val tmdbPosterSize: String
get() = when (tier) {
DeviceTier.LOW -> "w185"
DeviceTier.MEDIUM -> "w342"
DeviceTier.HIGH -> "w500"
}
val tmdbBackdropSize: String
get() = when (tier) {
DeviceTier.LOW -> "w780"
DeviceTier.MEDIUM -> "w780"
DeviceTier.HIGH -> "w1280"
}
val tmdbProfileSize: String
get() = when (tier) {
DeviceTier.LOW -> "w185"
DeviceTier.MEDIUM -> "w342"
DeviceTier.HIGH -> "w500"
}
val tmdbLogoSize: String
get() = when (tier) {
DeviceTier.LOW -> "w185"
DeviceTier.MEDIUM -> "w300"
DeviceTier.HIGH -> "w500"
}
val tmdbStillSize: String
get() = when (tier) {
DeviceTier.LOW -> "w300"
DeviceTier.MEDIUM -> "w300"
DeviceTier.HIGH -> "w500"
}
// -- UI performance --
val keyRepeatThrottleMs: Long
get() = when (tier) {
DeviceTier.LOW -> 140L
DeviceTier.MEDIUM -> 80L
DeviceTier.HIGH -> 80L
}
val nestedPrefetchItemCount: Int
get() = when (tier) {
DeviceTier.LOW -> 0
DeviceTier.MEDIUM -> 2
DeviceTier.HIGH -> 2
}
private fun detectTier(): DeviceTier {
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val isLowRam = activityManager.isLowRamDevice
val maxHeapMb = (Runtime.getRuntime().maxMemory() / (1024 * 1024)).toInt()
val tier = when {
isLowRam || maxHeapMb < LOW_HEAP_THRESHOLD_MB -> DeviceTier.LOW
maxHeapMb < HIGH_HEAP_THRESHOLD_MB -> DeviceTier.MEDIUM
else -> DeviceTier.HIGH
}
Log.i(TAG, "Device tier: $tier (lowRam=$isLowRam, maxHeap=${maxHeapMb}MB)")
return tier
}
}

View file

@ -0,0 +1,33 @@
package com.nuvio.tv.core.image
import com.nuvio.tv.core.device.DeviceCapabilities
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class TmdbImageUrlOptimizer @Inject constructor(
private val deviceCapabilities: DeviceCapabilities
) {
companion object {
private val TMDB_IMAGE_URL_REGEX =
Regex("""https?://image\.tmdb\.org/t/p/(w\d+|original)(/[^?#\s]+)""")
}
fun optimizePosterUrl(url: String?): String? {
url ?: return null
return replaceSize(url, deviceCapabilities.tmdbPosterSize)
}
fun optimizeBackdropUrl(url: String?): String? {
url ?: return null
return replaceSize(url, deviceCapabilities.tmdbBackdropSize)
}
private fun replaceSize(url: String, targetSize: String): String {
val match = TMDB_IMAGE_URL_REGEX.find(url) ?: return url
val currentSize = match.groupValues[1]
if (currentSize == targetSize) return url
val path = match.groupValues[2]
return "https://image.tmdb.org/t/p/$targetSize$path"
}
}

View file

@ -7,6 +7,7 @@ import com.nuvio.tv.data.remote.api.TmdbImage
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCrew
import com.nuvio.tv.data.remote.api.TmdbRecommendationResult
import com.nuvio.tv.core.device.DeviceCapabilities
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.model.MetaCastMember
import com.nuvio.tv.domain.model.MetaCompany
@ -28,7 +29,8 @@ private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
@Singleton
class TmdbMetadataService @Inject constructor(
private val tmdbApi: TmdbApi
private val tmdbApi: TmdbApi,
private val deviceCapabilities: DeviceCapabilities
) {
// In-memory caches
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>()
@ -120,7 +122,7 @@ class TmdbMetadataService @Inject constructor(
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(company.logoPath, size = "w300")
logo = buildLogoUrl(company.logoPath)
)
}
val networks = details?.networks
@ -129,11 +131,11 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany(
name = name,
logo = buildImageUrl(network.logoPath, size = "w300")
logo = buildLogoUrl(network.logoPath)
)
}
val poster = buildImageUrl(details?.posterPath, size = "w500")
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280")
val poster = buildPosterUrl(details?.posterPath)
val backdrop = buildBackdropUrl(details?.backdropPath)
val logoPath = images?.logos
?.sortedWith(
@ -146,7 +148,7 @@ class TmdbMetadataService @Inject constructor(
?.firstOrNull()
?.filePath
val logo = buildImageUrl(logoPath, size = "w500")
val logo = buildLogoUrl(logoPath)
val castMembers = credits?.cast
.orEmpty()
@ -155,7 +157,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember(
name = name,
character = member.character?.takeIf { it.isNotBlank() },
photo = buildImageUrl(member.profilePath, size = "w500"),
photo = buildProfileUrl(member.profilePath),
tmdbId = member.id
)
}
@ -169,7 +171,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember(
name = name,
character = "Creator",
photo = buildImageUrl(creator.profilePath, size = "w500"),
photo = buildProfileUrl(creator.profilePath),
tmdbId = tmdbPersonId
)
}
@ -197,7 +199,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember(
name = name,
character = "Director",
photo = buildImageUrl(member.profilePath, size = "w500"),
photo = buildProfileUrl(member.profilePath),
tmdbId = tmdbPersonId
)
}
@ -220,7 +222,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember(
name = name,
character = "Writer",
photo = buildImageUrl(member.profilePath, size = "w500"),
photo = buildProfileUrl(member.profilePath),
tmdbId = tmdbPersonId
)
}
@ -404,8 +406,8 @@ class TmdbMetadataService @Inject constructor(
)
}
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280")
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780")
val backdrop = buildBackdropUrl(localizedBackdropPath ?: rec.backdropPath)
val fallbackPoster = buildBackdropUrl(rec.posterPath)
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
MetaPreview(
@ -433,6 +435,21 @@ class TmdbMetadataService @Inject constructor(
}
}
private fun buildPosterUrl(path: String?): String? =
buildImageUrl(path, deviceCapabilities.tmdbPosterSize)
private fun buildBackdropUrl(path: String?): String? =
buildImageUrl(path, deviceCapabilities.tmdbBackdropSize)
private fun buildProfileUrl(path: String?): String? =
buildImageUrl(path, deviceCapabilities.tmdbProfileSize)
private fun buildLogoUrl(path: String?): String? =
buildImageUrl(path, deviceCapabilities.tmdbLogoSize)
private fun buildStillUrl(path: String?): String? =
buildImageUrl(path, deviceCapabilities.tmdbStillSize)
private fun buildImageUrl(path: String?, size: String): String? {
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
return "https://image.tmdb.org/t/p/$size$clean"
@ -509,7 +526,7 @@ class TmdbMetadataService @Inject constructor(
birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() },
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
profilePhoto = buildImageUrl(person.profilePath, "w500"),
profilePhoto = buildProfileUrl(person.profilePath),
knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() },
movieCredits = movieCredits,
tvCredits = tvCredits
@ -541,9 +558,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}",
type = ContentType.MOVIE,
name = title,
poster = buildImageUrl(credit.posterPath, "w500"),
poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"),
background = buildBackdropUrl(credit.backdropPath),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year,
@ -566,9 +583,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}",
type = ContentType.MOVIE,
name = title,
poster = buildImageUrl(credit.posterPath, "w500"),
poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"),
background = buildBackdropUrl(credit.backdropPath),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year,
@ -591,9 +608,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}",
type = ContentType.SERIES,
name = title,
poster = buildImageUrl(credit.posterPath, "w500"),
poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"),
background = buildBackdropUrl(credit.backdropPath),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year,
@ -616,9 +633,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}",
type = ContentType.SERIES,
name = title,
poster = buildImageUrl(credit.posterPath, "w500"),
poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"),
background = buildBackdropUrl(credit.backdropPath),
logo = null,
description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year,
@ -714,7 +731,7 @@ data class TmdbEpisodeEnrichment(
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.takeIf { it.isNotBlank() }
val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w500$it" }
val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w300$it" }
val airDate = airDate?.takeIf { it.isNotBlank() }
return TmdbEpisodeEnrichment(
title = title,

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
@ -22,6 +23,7 @@ class DebugSettingsDataStore @Inject constructor(
private val accountTabEnabledKey = booleanPreferencesKey("account_tab_enabled")
private val syncCodeFeaturesEnabledKey = booleanPreferencesKey("sync_code_features_enabled")
private val deviceTierOverrideKey = stringPreferencesKey("device_tier_override")
val accountTabEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[accountTabEnabledKey] ?: false
@ -31,6 +33,10 @@ class DebugSettingsDataStore @Inject constructor(
prefs[syncCodeFeaturesEnabledKey] ?: false
}
val deviceTierOverride: Flow<String> = dataStore.data.map { prefs ->
prefs[deviceTierOverrideKey] ?: "auto"
}
suspend fun setAccountTabEnabled(enabled: Boolean) {
dataStore.edit { prefs ->
prefs[accountTabEnabledKey] = enabled
@ -42,4 +48,10 @@ class DebugSettingsDataStore @Inject constructor(
prefs[syncCodeFeaturesEnabledKey] = enabled
}
}
suspend fun setDeviceTierOverride(value: String) {
dataStore.edit { prefs ->
prefs[deviceTierOverrideKey] = value
}
}
}

View file

@ -1,29 +1,84 @@
package com.nuvio.tv.data.repository
import android.util.Log
import com.nuvio.tv.core.image.TmdbImageUrlOptimizer
import com.nuvio.tv.core.network.NetworkResult
import com.nuvio.tv.core.network.safeApiCall
import com.nuvio.tv.data.mapper.toDomain
import com.nuvio.tv.data.remote.api.AddonApi
import com.nuvio.tv.domain.model.CatalogRow
import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.repository.CatalogRepository
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.net.URLEncoder
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.max
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class CatalogRepositoryImpl @Inject constructor(
private val api: AddonApi
private val api: AddonApi,
private val tmdbImageUrlOptimizer: TmdbImageUrlOptimizer
) : CatalogRepository {
companion object {
private const val TAG = "CatalogRepository"
private const val MAX_CACHE_ENTRIES = 256
// Fallbacks when addon responses don't provide Cache-Control directives.
private const val DEFAULT_MAX_AGE_SECONDS = 120L
private const val DEFAULT_STALE_REVALIDATE_SECONDS = 300L
private const val DEFAULT_STALE_IF_ERROR_SECONDS = 900L
private const val MAX_MAX_AGE_SECONDS = 24 * 60 * 60L
private const val MAX_STALE_WINDOW_SECONDS = 24 * 60 * 60L
}
private val catalogCache = ConcurrentHashMap<String, CatalogRow>()
private data class CachePolicy(
val maxAgeMs: Long,
val staleRevalidateMs: Long,
val staleIfErrorMs: Long,
val cacheable: Boolean
) {
val immediateServeWindowMs: Long
get() = maxAgeMs + staleRevalidateMs
val errorServeWindowMs: Long
get() = maxAgeMs + staleIfErrorMs
val hardExpiryWindowMs: Long
get() = max(immediateServeWindowMs, errorServeWindowMs)
}
private data class CachedCatalogEntry(
val row: CatalogRow,
val fetchedAtMs: Long,
val policy: CachePolicy
)
private sealed interface CatalogFetchResult {
data class Success(val row: CatalogRow) : CatalogFetchResult
data class Error(
val message: String,
val code: Int? = null
) : CatalogFetchResult
}
private val cacheLock = Any()
private val catalogCache =
object : LinkedHashMap<String, CachedCatalogEntry>(MAX_CACHE_ENTRIES, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CachedCatalogEntry>?): Boolean {
return size > MAX_CACHE_ENTRIES
}
}
private val inFlightMutex = Mutex()
private val inFlightRequests = mutableMapOf<String, Deferred<CatalogFetchResult>>()
override fun getCatalog(
addonBaseUrl: String,
@ -44,60 +99,271 @@ class CatalogRepositoryImpl @Inject constructor(
skip = skip,
extraArgs = extraArgs
)
val now = System.currentTimeMillis()
val cachedEntry = getCachedEntry(cacheKey, now)
// Emit cached data immediately if available
val cached = catalogCache[cacheKey]
if (cached != null) {
emit(NetworkResult.Success(cached))
// Serve cache immediately when fresh or within stale-while-revalidate.
val serveCachedImmediately = cachedEntry != null &&
now - cachedEntry.fetchedAtMs <= cachedEntry.policy.immediateServeWindowMs
if (serveCachedImmediately) {
emit(NetworkResult.Success(cachedEntry.row))
} else {
emit(NetworkResult.Loading)
}
// Fresh cache: no network call needed.
if (cachedEntry != null && now - cachedEntry.fetchedAtMs <= cachedEntry.policy.maxAgeMs) {
return@flow
}
val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs)
Log.d(
TAG,
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip supportsSkip=$supportsSkip url=$url"
)
val result = fetchCatalogShared(
cacheKey = cacheKey,
url = url,
addonId = addonId,
addonName = addonName,
addonBaseUrl = addonBaseUrl,
catalogId = catalogId,
catalogName = catalogName,
type = type,
skip = skip,
supportsSkip = supportsSkip
)
when (val result = safeApiCall { api.getCatalog(url) }) {
is NetworkResult.Success -> {
val items = result.data.metas.map { it.toDomain() }
Log.d(
TAG,
"Catalog fetch success addonId=$addonId type=$type catalogId=$catalogId items=${items.size}"
)
val catalogRow = CatalogRow(
addonId = addonId,
addonName = addonName,
addonBaseUrl = addonBaseUrl,
catalogId = catalogId,
catalogName = catalogName,
type = ContentType.fromString(type),
rawType = type,
items = items,
isLoading = false,
hasMore = supportsSkip && items.isNotEmpty(),
currentPage = skip / 100,
supportsSkip = supportsSkip
)
catalogCache[cacheKey] = catalogRow
// Only emit fresh data if it differs from cache
if (cached == null || cached.items != catalogRow.items) {
emit(NetworkResult.Success(catalogRow))
when (result) {
is CatalogFetchResult.Success -> {
// Only emit fresh data if it differs from what is currently displayed.
if (cachedEntry == null || cachedEntry.row.items != result.row.items) {
emit(NetworkResult.Success(result.row))
}
}
is NetworkResult.Error -> {
Log.w(
TAG,
"Catalog fetch failed addonId=$addonId type=$type catalogId=$catalogId code=${result.code} message=${result.message} url=$url"
)
// Only emit error if we had no cached data
if (cached == null) {
emit(result)
is CatalogFetchResult.Error -> {
val canServeStaleOnError = cachedEntry != null &&
now - cachedEntry.fetchedAtMs <= cachedEntry.policy.errorServeWindowMs
if (canServeStaleOnError) {
// Keep stale content on-screen when network fails.
if (!serveCachedImmediately) {
emit(NetworkResult.Success(cachedEntry.row))
}
} else {
Log.w(
TAG,
"Catalog fetch failed addonId=$addonId type=$type catalogId=$catalogId code=${result.code} message=${result.message} url=$url"
)
emit(NetworkResult.Error(result.message, result.code))
}
}
NetworkResult.Loading -> { /* Already emitted */ }
}
}.flowOn(Dispatchers.IO)
private suspend fun fetchCatalogShared(
cacheKey: String,
url: String,
addonId: String,
addonName: String,
addonBaseUrl: String,
catalogId: String,
catalogName: String,
type: String,
skip: Int,
supportsSkip: Boolean
): CatalogFetchResult {
val existing = inFlightMutex.withLock { inFlightRequests[cacheKey] }
if (existing != null) {
return existing.await()
}
val deferred = CompletableDeferred<CatalogFetchResult>()
val winner = inFlightMutex.withLock {
val current = inFlightRequests[cacheKey]
if (current != null) {
current
} else {
inFlightRequests[cacheKey] = deferred
deferred
}
}
if (winner !== deferred) {
return winner.await()
}
try {
val result = fetchCatalogFromNetwork(
cacheKey = cacheKey,
url = url,
addonId = addonId,
addonName = addonName,
addonBaseUrl = addonBaseUrl,
catalogId = catalogId,
catalogName = catalogName,
type = type,
skip = skip,
supportsSkip = supportsSkip
)
deferred.complete(result)
return result
} catch (e: Exception) {
val error = CatalogFetchResult.Error(e.message ?: "Unknown error occurred")
deferred.complete(error)
return error
} finally {
inFlightMutex.withLock {
if (inFlightRequests[cacheKey] === deferred) {
inFlightRequests.remove(cacheKey)
}
}
}
}
private suspend fun fetchCatalogFromNetwork(
cacheKey: String,
url: String,
addonId: String,
addonName: String,
addonBaseUrl: String,
catalogId: String,
catalogName: String,
type: String,
skip: Int,
supportsSkip: Boolean
): CatalogFetchResult {
val response = try {
api.getCatalog(url)
} catch (e: Exception) {
return CatalogFetchResult.Error(e.message ?: "Unknown error occurred")
}
if (!response.isSuccessful) {
return CatalogFetchResult.Error(
message = response.message(),
code = response.code()
)
}
val body = response.body()
?: return CatalogFetchResult.Error("Empty response body")
val items = body.metas.map { dto ->
val domain = dto.toDomain()
domain.copy(
poster = tmdbImageUrlOptimizer.optimizePosterUrl(domain.poster) ?: domain.poster,
background = tmdbImageUrlOptimizer.optimizeBackdropUrl(domain.background) ?: domain.background
)
}
Log.d(
TAG,
"Catalog fetch success addonId=$addonId type=$type catalogId=$catalogId items=${items.size}"
)
val catalogRow = CatalogRow(
addonId = addonId,
addonName = addonName,
addonBaseUrl = addonBaseUrl,
catalogId = catalogId,
catalogName = catalogName,
type = ContentType.fromString(type),
rawType = type,
items = items,
isLoading = false,
hasMore = supportsSkip && items.isNotEmpty(),
currentPage = skip / 100,
supportsSkip = supportsSkip
)
val now = System.currentTimeMillis()
val policy = parseCachePolicy(response.headers()["Cache-Control"])
if (policy.cacheable) {
putCachedEntry(
cacheKey = cacheKey,
entry = CachedCatalogEntry(
row = catalogRow,
fetchedAtMs = now,
policy = policy
)
)
} else {
removeCachedEntry(cacheKey)
}
return CatalogFetchResult.Success(row = catalogRow)
}
private fun parseCachePolicy(cacheControlHeader: String?): CachePolicy {
var noStore = false
var maxAgeSeconds: Long? = null
var staleRevalidateSeconds: Long? = null
var staleIfErrorSeconds: Long? = null
cacheControlHeader
?.split(',')
?.map { it.trim().lowercase() }
?.forEach { directive ->
when {
directive == "no-store" -> noStore = true
directive == "no-cache" -> maxAgeSeconds = 0L
directive.startsWith("max-age=") -> {
maxAgeSeconds = directive.substringAfter('=').toLongOrNull()
}
directive.startsWith("stale-while-revalidate=") -> {
staleRevalidateSeconds = directive.substringAfter('=').toLongOrNull()
}
directive.startsWith("stale-if-error=") -> {
staleIfErrorSeconds = directive.substringAfter('=').toLongOrNull()
}
}
}
if (noStore) {
return CachePolicy(
maxAgeMs = 0L,
staleRevalidateMs = 0L,
staleIfErrorMs = 0L,
cacheable = false
)
}
val maxAgeMs = (maxAgeSeconds ?: DEFAULT_MAX_AGE_SECONDS)
.coerceIn(0L, MAX_MAX_AGE_SECONDS) * 1000L
val staleRevalidateMs = (staleRevalidateSeconds ?: DEFAULT_STALE_REVALIDATE_SECONDS)
.coerceIn(0L, MAX_STALE_WINDOW_SECONDS) * 1000L
val staleIfErrorMs = (staleIfErrorSeconds ?: DEFAULT_STALE_IF_ERROR_SECONDS)
.coerceIn(0L, MAX_STALE_WINDOW_SECONDS) * 1000L
return CachePolicy(
maxAgeMs = maxAgeMs,
staleRevalidateMs = staleRevalidateMs,
staleIfErrorMs = staleIfErrorMs,
cacheable = true
)
}
private fun getCachedEntry(cacheKey: String, nowMs: Long): CachedCatalogEntry? {
synchronized(cacheLock) {
val entry = catalogCache[cacheKey] ?: return null
val ageMs = nowMs - entry.fetchedAtMs
if (ageMs > entry.policy.hardExpiryWindowMs) {
catalogCache.remove(cacheKey)
return null
}
return entry
}
}
private fun putCachedEntry(cacheKey: String, entry: CachedCatalogEntry) {
synchronized(cacheLock) {
catalogCache[cacheKey] = entry
}
}
private fun removeCachedEntry(cacheKey: String) {
synchronized(cacheLock) {
catalogCache.remove(cacheKey)
}
}

View file

@ -80,7 +80,13 @@ fun CatalogRowSection(
upFocusRequester: FocusRequester? = null,
listState: LazyListState = rememberLazyListState(initialFirstVisibleItemIndex = initialScrollIndex)
) {
val seeAllCardShape = RoundedCornerShape(posterCardStyle.cornerRadius)
val seeAllCardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) }
val seeAllFocusRingColor = NuvioColors.FocusRing
val seeAllFocusedBorder = remember(posterCardStyle.focusedBorderWidth, seeAllFocusRingColor, seeAllCardShape) {
Border(border = BorderStroke(posterCardStyle.focusedBorderWidth, seeAllFocusRingColor), shape = seeAllCardShape)
}
val seeAllShapeSpec = remember(seeAllCardShape) { CardDefaults.shape(shape = seeAllCardShape) }
val seeAllScale = remember(posterCardStyle.focusedScale) { CardDefaults.scale(focusedScale = posterCardStyle.focusedScale) }
val currentOnItemFocused by rememberUpdatedState(onItemFocused)
val currentOnItemFocus by rememberUpdatedState(onItemFocus)
@ -185,8 +191,8 @@ fun CatalogRowSection(
) {
itemsIndexed(
items = catalogRow.items,
key = { index, item ->
"${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}_${item.id}_$index"
key = { _, item ->
"${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}_${item.id}"
},
contentType = { _, _ -> "content_card" }
) { index, item ->
@ -223,18 +229,15 @@ fun CatalogRowSection(
.width(posterCardStyle.width)
.height(posterCardStyle.height)
.then(directionalFocusModifier),
shape = CardDefaults.shape(shape = seeAllCardShape),
shape = seeAllShapeSpec,
colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.BackgroundCard
),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(posterCardStyle.focusedBorderWidth, NuvioColors.FocusRing),
shape = seeAllCardShape
)
focusedBorder = seeAllFocusedBorder
),
scale = CardDefaults.scale(focusedScale = posterCardStyle.focusedScale)
scale = seeAllScale
) {
Box(
modifier = Modifier.fillMaxSize(),

View file

@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import com.nuvio.tv.R
@ -61,6 +62,8 @@ import kotlinx.coroutines.delay
private const val BACKDROP_ASPECT_RATIO = 16f / 9f
private const val TRAILER_PREVIEW_REQUEST_FOCUS_DEBOUNCE_MS = 140L
private val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""")
private val WatchedIconAnimSpec = tween<Dp>(durationMillis = 180)
private val TrailerCoverAlphaAnimSpec = tween<Float>(durationMillis = 250)
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
@ -82,6 +85,18 @@ fun ContentCard(
onClick: () -> Unit = {}
) {
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) }
val focusRingColor = NuvioColors.FocusRing
val focusedBorder = remember(posterCardStyle.focusedBorderWidth, focusRingColor, cardShape) {
Border(
border = BorderStroke(posterCardStyle.focusedBorderWidth, focusRingColor),
shape = cardShape
)
}
val cardShapeSpec = remember(cardShape) { CardDefaults.shape(shape = cardShape) }
val cardScale = remember(posterCardStyle.focusedScale) { CardDefaults.scale(focusedScale = posterCardStyle.focusedScale) }
val focusRequesterModifier = remember(focusRequester) {
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier
}
val baseCardWidth = when (item.posterShape) {
PosterShape.POSTER -> posterCardStyle.width
PosterShape.LANDSCAPE -> 260.dp
@ -99,11 +114,16 @@ fun ContentCard(
var interactionNonce by remember { mutableIntStateOf(0) }
var isBackdropExpanded by remember { mutableStateOf(false) }
var trailerFirstFrameRendered by remember(trailerPreviewUrl) { mutableStateOf(false) }
val watchedIconEndPadding by animateDpAsState(
targetValue = if (isFocused) 18.dp else 8.dp,
animationSpec = tween(durationMillis = 180),
label = "contentCardWatchedIconEndPadding"
)
val watchedIconEndPadding = if (isWatched) {
val padding by animateDpAsState(
targetValue = if (isFocused) 18.dp else 8.dp,
animationSpec = WatchedIconAnimSpec,
label = "contentCardWatchedIconEndPadding"
)
padding
} else {
8.dp
}
val needsFocusState = focusedPosterBackdropExpandEnabled || focusedPosterBackdropTrailerEnabled
val lastFocusedRef = remember { booleanArrayOf(false) }
@ -275,21 +295,16 @@ fun ContentCard(
}
false
}
.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier
),
shape = CardDefaults.shape(shape = cardShape),
.then(focusRequesterModifier),
shape = cardShapeSpec,
colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.BackgroundCard
),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(posterCardStyle.focusedBorderWidth, NuvioColors.FocusRing),
shape = cardShape
)
focusedBorder = focusedBorder
),
scale = CardDefaults.scale(focusedScale = posterCardStyle.focusedScale)
scale = cardScale
) {
Box(
modifier = Modifier
@ -325,7 +340,7 @@ fun ContentCard(
val trailerCoverAlpha = if (shouldPlayTrailerPreview) {
val alpha by animateFloatAsState(
targetValue = if (!trailerFirstFrameRendered) 1f else 0f,
animationSpec = tween(durationMillis = 250),
animationSpec = TrailerCoverAlphaAnimSpec,
label = "trailerCoverAlpha"
)
alpha

View file

@ -66,6 +66,8 @@ import java.util.concurrent.TimeUnit
private val CwCardShape = RoundedCornerShape(12.dp)
private val CwClipShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
private val BadgeShape = RoundedCornerShape(4.dp)
private val CwCardShapeSpec = CardDefaults.shape(shape = CwCardShape)
private val CwCardScale = CardDefaults.scale(focusedScale = 1.02f)
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable
@ -289,6 +291,13 @@ fun ContinueWatchingCard(
.build()
}
val focusRingColor = NuvioColors.FocusRing
val cwFocusedBorder = remember(focusRingColor) {
Border(
border = BorderStroke(2.dp, focusRingColor),
shape = CwCardShape
)
}
val bgColor = NuvioColors.Background
val overlayBrush = remember(bgColor) {
Brush.verticalGradient(
@ -333,18 +342,15 @@ fun ContinueWatchingCard(
}
false
},
shape = CardDefaults.shape(shape = CwCardShape),
shape = CwCardShapeSpec,
colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.FocusBackground
),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = CwCardShape
)
focusedBorder = cwFocusedBorder
),
scale = CardDefaults.scale(focusedScale = 1.02f)
scale = CwCardScale
) {
Column {
// Thumbnail with progress overlay

View file

@ -29,8 +29,8 @@ import com.nuvio.tv.ui.components.ContinueWatchingSection
import com.nuvio.tv.ui.components.HeroCarousel
import com.nuvio.tv.ui.components.PosterCardStyle
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L
/** Default minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
private class FocusSnapshot(
var rowIndex: Int,
@ -56,9 +56,12 @@ fun ClassicHomeContent(
) {
// Nested prefetch: when LazyColumn prefetches a row ahead of scrolling,
// pre-compose up to 2 ContentCards in its nested LazyRow across multiple frames.
// This spreads the composition work and prevents frame spikes when a new row scrolls in.
val nestedPrefetchStrategy = remember { LazyListPrefetchStrategy(nestedPrefetchItemCount = 2) }
// pre-compose ContentCards in its nested LazyRow across multiple frames.
// On low-tier devices, prefetch is disabled (0) to avoid decoder contention.
val adaptiveNestedPrefetchCount = uiState.nestedPrefetchItemCount
val nestedPrefetchStrategy = remember(adaptiveNestedPrefetchCount) {
LazyListPrefetchStrategy(nestedPrefetchItemCount = adaptiveNestedPrefetchCount)
}
val columnListState = rememberLazyListState(
initialFirstVisibleItemIndex = focusState.verticalScrollIndex,
@ -137,6 +140,7 @@ fun ClassicHomeContent(
// Throttle D-pad key repeats to prevent HWUI overload when a key is held down.
var lastKeyRepeatTime by remember { mutableStateOf(0L) }
val keyRepeatThrottle = uiState.keyRepeatThrottleMs
LazyColumn(
state = columnListState,
@ -146,7 +150,7 @@ fun ClassicHomeContent(
val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime < KEY_REPEAT_THROTTLE_MS) {
if (now - lastKeyRepeatTime < keyRepeatThrottle) {
return@onPreviewKeyEvent true // consume — too fast
}
lastKeyRepeatTime = now

View file

@ -57,8 +57,8 @@ import com.nuvio.tv.ui.components.PosterCardDefaults
import com.nuvio.tv.ui.components.PosterCardStyle
import com.nuvio.tv.ui.theme.NuvioColors
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L
/** Default minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
@ -167,7 +167,7 @@ fun GridHomeContent(
val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime[0] < KEY_REPEAT_THROTTLE_MS) {
if (now - lastKeyRepeatTime[0] < uiState.keyRepeatThrottleMs) {
return@onPreviewKeyEvent true
}
lastKeyRepeatTime[0] = now

View file

@ -73,6 +73,7 @@ fun HomeScreen(
LaunchedEffect(hasCatalogContent) {
if (hasCatalogContent) {
hasEnteredCatalogContent = true
viewModel.onCatalogContentRendered()
}
}
@ -169,6 +170,15 @@ fun HomeScreen(
animationSpec = tween(320)
)
) {
// Stabilize lambda identity — only recompute when the
// watched status map itself changes, not on every uiState update.
val watchedStatus = uiState.movieWatchedStatus
val stableIsWatched = remember(watchedStatus) {
{ item: MetaPreview ->
watchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
}
}
when (uiState.homeLayout) {
HomeLayout.CLASSIC -> ClassicHomeRoute(
viewModel = viewModel,
@ -177,9 +187,7 @@ fun HomeScreen(
onNavigateToDetail = onNavigateToDetail,
onContinueWatchingClick = onContinueWatchingClick,
onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll,
isCatalogItemWatched = { item ->
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
},
isCatalogItemWatched = stableIsWatched,
onCatalogItemLongPress = { item, addonBaseUrl ->
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
}
@ -192,9 +200,7 @@ fun HomeScreen(
onNavigateToDetail = onNavigateToDetail,
onContinueWatchingClick = onContinueWatchingClick,
onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll,
isCatalogItemWatched = { item ->
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
},
isCatalogItemWatched = stableIsWatched,
onCatalogItemLongPress = { item, addonBaseUrl ->
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
}
@ -205,9 +211,7 @@ fun HomeScreen(
uiState = uiState,
onNavigateToDetail = onNavigateToDetail,
onContinueWatchingClick = onContinueWatchingClick,
isCatalogItemWatched = { item ->
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
},
isCatalogItemWatched = stableIsWatched,
onCatalogItemLongPress = { item, addonBaseUrl ->
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
}

View file

@ -36,7 +36,9 @@ data class HomeUiState(
val movieWatchedStatus: Map<String, Boolean> = emptyMap(),
val posterLibraryPending: Set<String> = emptySet(),
val movieWatchedPending: Set<String> = emptySet(),
val gridItems: List<GridItem> = emptyList()
val gridItems: List<GridItem> = emptyList(),
val keyRepeatThrottleMs: Long = 80L,
val nestedPrefetchItemCount: Int = 2
)
@Immutable

View file

@ -4,6 +4,8 @@ import android.util.Log
import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nuvio.tv.core.device.DeviceCapabilities
import com.nuvio.tv.core.device.DeviceTier
import com.nuvio.tv.core.network.NetworkResult
import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.core.tmdb.TmdbService
@ -76,7 +78,8 @@ class HomeViewModel @Inject constructor(
private val traktSettingsDataStore: TraktSettingsDataStore,
private val tmdbService: TmdbService,
private val tmdbMetadataService: TmdbMetadataService,
private val trailerService: TrailerService
private val trailerService: TrailerService,
private val deviceCapabilities: DeviceCapabilities
) : ViewModel() {
companion object {
private const val TAG = "HomeViewModel"
@ -84,7 +87,7 @@ class HomeViewModel @Inject constructor(
private const val MAX_RECENT_PROGRESS_ITEMS = 300
private const val MAX_NEXT_UP_LOOKUPS = 24
private const val MAX_NEXT_UP_CONCURRENCY = 4
private const val MAX_CATALOG_LOAD_CONCURRENCY = 4
private const val CATALOG_WAVE_DELAY_MS = 120L
private const val EXTERNAL_META_PREFETCH_FOCUS_DEBOUNCE_MS = 220L
}
@ -104,19 +107,33 @@ class HomeViewModel @Inject constructor(
private val catalogsMap = linkedMapOf<String, CatalogRow>()
private val catalogOrder = mutableListOf<String>()
private val activeCatalogLoadJobs = mutableMapOf<String, Job>()
private var addonsCache: List<Addon> = emptyList()
private var homeCatalogOrderKeys: List<String> = emptyList()
private var disabledHomeCatalogKeys: Set<String> = emptySet()
private var currentHeroCatalogKeys: List<String> = emptyList()
private var activeCatalogLoadSessionId: Long = 0L
private var catalogUpdateJob: Job? = null
private var hasRenderedFirstCatalog = false
private val catalogLoadSemaphore = Semaphore(MAX_CATALOG_LOAD_CONCURRENCY)
private var hasDispatchedFirstCatalogBatch = false
@Volatile
private var hasCatalogContentDrawn = false
private var reconcilePosterObserversJob: Job? = null
private var hasBootstrappedPosterObservers = false
private val catalogLoadSemaphore = Semaphore(
when (deviceCapabilities.tier) {
DeviceTier.LOW -> 2
DeviceTier.MEDIUM -> 3
DeviceTier.HIGH -> 4
}
)
private var pendingCatalogLoads = 0
private data class TruncatedRowCacheEntry(
val sourceRow: CatalogRow,
val truncatedRow: CatalogRow
)
private val truncatedRowCache = mutableMapOf<String, TruncatedRowCacheEntry>()
private var catalogVersion = 0L
private var lastProcessedCatalogVersion = -1L
private val trailerPreviewLoadingIds = mutableSetOf<String>()
private val trailerPreviewNegativeCache = mutableSetOf<String>()
private val trailerPreviewUrlsState = mutableStateMapOf<String, String>()
@ -132,6 +149,9 @@ class HomeViewModel @Inject constructor(
private var pendingExternalMetaPrefetchItemId: String? = null
private val posterLibraryObserverJobs = mutableMapOf<String, Job>()
private val movieWatchedObserverJobs = mutableMapOf<String, Job>()
private val pendingLibraryStatusUpdates = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
private val pendingWatchedStatusUpdates = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
private var posterStatusFlushJob: Job? = null
@Volatile
private var externalMetaPrefetchEnabled: Boolean = false
@Volatile
@ -243,6 +263,7 @@ class HomeViewModel @Inject constructor(
previousState.heroSectionEnabled != prefs.heroSectionEnabled ||
previousState.homeLayout != prefs.layout
currentHeroCatalogKeys = prefs.heroCatalogKeys
val isLowTier = deviceCapabilities.tier == DeviceTier.LOW
_uiState.update {
it.copy(
homeLayout = prefs.layout,
@ -252,14 +273,16 @@ class HomeViewModel @Inject constructor(
catalogAddonNameEnabled = prefs.catalogAddonNameEnabled,
catalogTypeSuffixEnabled = prefs.catalogTypeSuffixEnabled,
modernLandscapePostersEnabled = prefs.modernLandscapePostersEnabled,
focusedPosterBackdropExpandEnabled = prefs.focusedBackdropExpandEnabled,
focusedPosterBackdropExpandEnabled = if (isLowTier) false else prefs.focusedBackdropExpandEnabled,
focusedPosterBackdropExpandDelaySeconds = prefs.focusedBackdropExpandDelaySeconds,
focusedPosterBackdropTrailerEnabled = prefs.focusedBackdropTrailerEnabled,
focusedPosterBackdropTrailerEnabled = if (isLowTier) false else prefs.focusedBackdropTrailerEnabled,
focusedPosterBackdropTrailerMuted = prefs.focusedBackdropTrailerMuted,
focusedPosterBackdropTrailerPlaybackTarget = prefs.focusedBackdropTrailerPlaybackTarget,
posterCardWidthDp = prefs.posterCardWidthDp,
posterCardHeightDp = prefs.posterCardHeightDp,
posterCardCornerRadiusDp = prefs.posterCardCornerRadiusDp
posterCardCornerRadiusDp = prefs.posterCardCornerRadiusDp,
keyRepeatThrottleMs = deviceCapabilities.keyRepeatThrottleMs,
nestedPrefetchItemCount = deviceCapabilities.nestedPrefetchItemCount
)
}
if (shouldRefreshCatalogPresentation) {
@ -376,6 +399,7 @@ class HomeViewModel @Inject constructor(
fun onItemFocus(item: MetaPreview) {
if (startupGracePeriodActive) return
if (!externalMetaPrefetchEnabled) return
if (deviceCapabilities.tier == DeviceTier.LOW) return
if (item.id in prefetchedExternalMetaIds) return
if (pendingExternalMetaPrefetchItemId == item.id) return
@ -423,6 +447,7 @@ class HomeViewModel @Inject constructor(
val mutableItems = row.items.toMutableList()
mutableItems[itemIndex] = merged
catalogsMap[key] = row.copy(items = mutableItems)
catalogVersion++
truncatedRowCache.remove(key)
}
}
@ -452,7 +477,9 @@ class HomeViewModel @Inject constructor(
private fun loadHomeCatalogOrderPreference() {
viewModelScope.launch {
layoutPreferenceDataStore.homeCatalogOrderKeys.collectLatest { keys ->
layoutPreferenceDataStore.homeCatalogOrderKeys
.distinctUntilChanged()
.collectLatest { keys ->
homeCatalogOrderKeys = keys
rebuildCatalogOrder(addonsCache)
scheduleUpdateCatalogRows()
@ -462,7 +489,9 @@ class HomeViewModel @Inject constructor(
private fun loadDisabledHomeCatalogPreference() {
viewModelScope.launch {
layoutPreferenceDataStore.disabledHomeCatalogKeys.collectLatest { keys ->
layoutPreferenceDataStore.disabledHomeCatalogKeys
.distinctUntilChanged()
.collectLatest { keys ->
disabledHomeCatalogKeys = keys.toSet()
rebuildCatalogOrder(addonsCache)
if (addonsCache.isNotEmpty()) {
@ -560,36 +589,39 @@ class HomeViewModel @Inject constructor(
showUnairedNextUp = showUnairedNextUp
)
}.collectLatest { snapshot ->
val items = snapshot.items
val daysCap = snapshot.daysCap
val preparedState = withContext(Dispatchers.Default) {
val items = snapshot.items
val daysCap = snapshot.daysCap
val cutoffMs = if (daysCap == TraktSettingsDataStore.CONTINUE_WATCHING_DAYS_CAP_ALL) {
null
} else {
val windowMs = daysCap.toLong() * 24L * 60L * 60L * 1000L
System.currentTimeMillis() - windowMs
}
val recentItems = items
.asSequence()
.filter { progress -> cutoffMs == null || progress.lastWatched >= cutoffMs }
.sortedByDescending { it.lastWatched }
.take(MAX_RECENT_PROGRESS_ITEMS)
.toList()
val inProgressOnly = deduplicateInProgress(
recentItems.filter { shouldTreatAsInProgressForContinueWatching(it) }
).map { progress ->
ContinueWatchingItem.InProgress(progress = progress)
}
ContinueWatchingPreparedState(
allProgressCount = items.size,
recentItems = recentItems,
inProgressOnly = inProgressOnly
)
}
val recentItems = preparedState.recentItems
val inProgressOnly = preparedState.inProgressOnly
val dismissedNextUp = snapshot.dismissedNextUp
val showUnairedNextUp = snapshot.showUnairedNextUp
val cutoffMs = if (daysCap == TraktSettingsDataStore.CONTINUE_WATCHING_DAYS_CAP_ALL) {
null
} else {
val windowMs = daysCap.toLong() * 24L * 60L * 60L * 1000L
System.currentTimeMillis() - windowMs
}
val recentItems = items
.asSequence()
.filter { progress -> cutoffMs == null || progress.lastWatched >= cutoffMs }
.sortedByDescending { it.lastWatched }
.take(MAX_RECENT_PROGRESS_ITEMS)
.toList()
Log.d("HomeViewModel", "allProgress emitted=${items.size} recentWindow=${recentItems.size}")
val inProgressOnly = buildList {
deduplicateInProgress(
recentItems.filter { shouldTreatAsInProgressForContinueWatching(it) }
).forEach { progress ->
add(
ContinueWatchingItem.InProgress(
progress = progress
)
)
}
}
Log.d("HomeViewModel", "allProgress emitted=${preparedState.allProgressCount} recentWindow=${recentItems.size}")
Log.d("HomeViewModel", "inProgressOnly: ${inProgressOnly.size} items after filter+dedup")
@ -602,6 +634,14 @@ class HomeViewModel @Inject constructor(
}
}
// Wait for first catalog to render before enriching, so enrichment
// state updates don't compete with the initial catalog paint.
if (!hasCatalogContentDrawn) {
withTimeoutOrNull(5000L) {
while (!hasCatalogContentDrawn) { delay(100) }
}
}
// Then enrich Next Up and item details in background.
enrichContinueWatchingProgressively(
allProgress = recentItems,
@ -621,6 +661,12 @@ class HomeViewModel @Inject constructor(
val showUnairedNextUp: Boolean
)
private data class ContinueWatchingPreparedState(
val allProgressCount: Int,
val recentItems: List<WatchProgress>,
val inProgressOnly: List<ContinueWatchingItem.InProgress>
)
private data class NextUpArtworkFallback(
val thumbnail: String?,
val backdrop: String?,
@ -692,34 +738,35 @@ class HomeViewModel @Inject constructor(
dismissedNextUp: Set<String>,
showUnairedNextUp: Boolean
) = coroutineScope {
val inProgressIds = inProgressItems
.map { it.progress.contentId }
.filter { it.isNotBlank() }
.toSet()
val latestCompletedBySeries = allProgress
.filter { progress ->
isSeriesType(progress.contentType) &&
progress.season != null &&
progress.episode != null &&
progress.season != 0 &&
progress.isCompleted() &&
progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK
}
.groupBy { it.contentId }
.mapNotNull { (_, items) ->
items.maxWithOrNull(
compareBy<WatchProgress>(
{ it.lastWatched },
{ it.season ?: -1 },
{ it.episode ?: -1 }
val latestCompletedBySeries = withContext(Dispatchers.Default) {
val inProgressIds = inProgressItems
.map { it.progress.contentId }
.filter { it.isNotBlank() }
.toSet()
allProgress
.filter { progress ->
isSeriesType(progress.contentType) &&
progress.season != null &&
progress.episode != null &&
progress.season != 0 &&
progress.isCompleted() &&
progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK
}
.groupBy { it.contentId }
.mapNotNull { (_, items) ->
items.maxWithOrNull(
compareBy<WatchProgress>(
{ it.lastWatched },
{ it.season ?: -1 },
{ it.episode ?: -1 }
)
)
)
}
.filter { it.contentId !in inProgressIds }
.filter { progress -> nextUpDismissKey(progress.contentId) !in dismissedNextUp }
.sortedByDescending { it.lastWatched }
.take(MAX_NEXT_UP_LOOKUPS)
}
.filter { it.contentId !in inProgressIds }
.filter { progress -> nextUpDismissKey(progress.contentId) !in dismissedNextUp }
.sortedByDescending { it.lastWatched }
.take(MAX_NEXT_UP_LOOKUPS)
}
if (latestCompletedBySeries.isEmpty()) {
return@coroutineScope
@ -741,7 +788,7 @@ class HomeViewModel @Inject constructor(
) ?: return@withPermit
mergeMutex.withLock {
nextUpByContent[progress.contentId] = nextUp
if (nextUpByContent.size - lastEmittedNextUpCount >= 2) {
if (nextUpByContent.size - lastEmittedNextUpCount >= 4) {
val nextUpItems = nextUpByContent.values.toList()
_uiState.update {
val mergedItems = mergeContinueWatchingItems(
@ -802,7 +849,7 @@ class HomeViewModel @Inject constructor(
if (enrichedItem != item) {
enrichedByProgress[item.progress] = enrichedItem
if (enrichedByProgress.size - lastAppliedCount >= 2) {
if (enrichedByProgress.size - lastAppliedCount >= 4) {
applyInProgressEpisodeDetailEnrichment(enrichedByProgress)
lastAppliedCount = enrichedByProgress.size
}
@ -1177,6 +1224,7 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
addonRepository.getInstalledAddons()
.distinctUntilChanged()
.debounce(200)
.collectLatest { addons ->
addonsCache = addons
loadAllCatalogs(addons)
@ -1185,13 +1233,19 @@ class HomeViewModel @Inject constructor(
}
private suspend fun loadAllCatalogs(addons: List<Addon>) {
val catalogLoadSessionId = startCatalogLoadSession()
_uiState.update { it.copy(isLoading = true, error = null, installedAddonsCount = addons.size) }
catalogOrder.clear()
catalogsMap.clear()
catalogVersion++
lastProcessedCatalogVersion = -1L
reconcilePosterObserversJob?.cancel()
hasBootstrappedPosterObservers = false
hasDispatchedFirstCatalogBatch = false
hasCatalogContentDrawn = false
reconcilePosterStatusObservers(emptyList())
_fullCatalogRows.value = emptyList()
truncatedRowCache.clear()
hasRenderedFirstCatalog = false
trailerPreviewLoadingIds.clear()
trailerPreviewNegativeCache.clear()
trailerPreviewUrlsState.clear()
@ -1206,6 +1260,7 @@ class HomeViewModel @Inject constructor(
try {
if (addons.isEmpty()) {
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
_uiState.update { it.copy(isLoading = false, error = "No addons installed") }
return
}
@ -1213,6 +1268,7 @@ class HomeViewModel @Inject constructor(
rebuildCatalogOrder(addons)
if (catalogOrder.isEmpty()) {
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
_uiState.update { it.copy(isLoading = false, error = "No catalog addons installed") }
return
}
@ -1232,17 +1288,52 @@ class HomeViewModel @Inject constructor(
.map { catalog -> addon to catalog }
}
pendingCatalogLoads = catalogsToLoad.size
catalogsToLoad.forEach { (addon, catalog) ->
loadCatalog(addon, catalog)
// Stagger catalog dispatching in waves so the UI thread gets breathing
// room between batches to render + start Coil image loads.
val firstWaveSize = if (deviceCapabilities.tier == DeviceTier.LOW) 1 else 2
val subsequentWaveSize = if (deviceCapabilities.tier == DeviceTier.LOW) 2 else 3
val firstWave = catalogsToLoad.take(firstWaveSize)
val remaining = catalogsToLoad.drop(firstWaveSize)
firstWave.forEach { (addon, catalog) -> loadCatalog(addon, catalog, catalogLoadSessionId) }
val waveDelayMs = if (deviceCapabilities.tier == DeviceTier.LOW) {
CATALOG_WAVE_DELAY_MS * 2 // Extra breathing room on constrained devices
} else {
CATALOG_WAVE_DELAY_MS
}
remaining.chunked(subsequentWaveSize).forEach { wave ->
delay(waveDelayMs)
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
wave.forEach { (addon, catalog) -> loadCatalog(addon, catalog, catalogLoadSessionId) }
}
} catch (e: Exception) {
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
_uiState.update { it.copy(isLoading = false, error = e.message) }
}
}
private fun loadCatalog(addon: Addon, catalog: CatalogDescriptor) {
viewModelScope.launch {
private fun startCatalogLoadSession(): Long {
activeCatalogLoadSessionId += 1
activeCatalogLoadJobs.values.forEach { it.cancel() }
activeCatalogLoadJobs.clear()
_loadingCatalogs.value = emptySet()
pendingCatalogLoads = 0
return activeCatalogLoadSessionId
}
private fun loadCatalog(addon: Addon, catalog: CatalogDescriptor, sessionId: Long) {
val key = catalogKey(
addonId = addon.id,
type = catalog.apiType,
catalogId = catalog.id
)
activeCatalogLoadJobs.remove(key)?.cancel()
val job = viewModelScope.launch {
catalogLoadSemaphore.withPermit {
if (sessionId != activeCatalogLoadSessionId) return@withPermit
val supportsSkip = catalog.extra.any { it.name == "skip" }
Log.d(
TAG,
@ -1258,14 +1349,11 @@ class HomeViewModel @Inject constructor(
skip = 0,
supportsSkip = supportsSkip
).collect { result ->
if (sessionId != activeCatalogLoadSessionId) return@collect
when (result) {
is NetworkResult.Success -> {
val key = catalogKey(
addonId = addon.id,
type = catalog.apiType,
catalogId = catalog.id
)
catalogsMap[key] = result.data
catalogVersion++
pendingCatalogLoads = (pendingCatalogLoads - 1).coerceAtLeast(0)
Log.d(
TAG,
@ -1286,6 +1374,12 @@ class HomeViewModel @Inject constructor(
}
}
}
activeCatalogLoadJobs[key] = job
job.invokeOnCompletion {
if (activeCatalogLoadJobs[key] == job) {
activeCatalogLoadJobs.remove(key)
}
}
}
private fun loadMoreCatalogItems(catalogId: String, addonId: String, type: String) {
@ -1297,6 +1391,7 @@ class HomeViewModel @Inject constructor(
// Mark loading via lightweight separate flow — avoids full state cascade
catalogsMap[key] = currentRow.copy(isLoading = true)
catalogVersion++
_loadingCatalogs.update { it + key }
viewModelScope.launch {
@ -1318,11 +1413,13 @@ class HomeViewModel @Inject constructor(
is NetworkResult.Success -> {
val mergedItems = currentRow.items + result.data.items
catalogsMap[key] = result.data.copy(items = mergedItems)
catalogVersion++
_loadingCatalogs.update { it - key }
scheduleUpdateCatalogRows()
}
is NetworkResult.Error -> {
catalogsMap[key] = currentRow.copy(isLoading = false)
catalogVersion++
_loadingCatalogs.update { it - key }
scheduleUpdateCatalogRows()
}
@ -1338,13 +1435,13 @@ class HomeViewModel @Inject constructor(
val debounceMs = when {
// First render: use minimal debounce to show content ASAP while still
// batching near-simultaneous arrivals.
!hasRenderedFirstCatalog && catalogsMap.isNotEmpty() -> {
hasRenderedFirstCatalog = true
!hasDispatchedFirstCatalogBatch && catalogsMap.isNotEmpty() -> {
hasDispatchedFirstCatalogBatch = true
50L
}
pendingCatalogLoads > 8 -> 200L
pendingCatalogLoads > 3 -> 150L
pendingCatalogLoads > 0 -> 100L
pendingCatalogLoads > 8 -> 350L
pendingCatalogLoads > 3 -> 250L
pendingCatalogLoads > 0 -> 150L
else -> 50L
}
delay(debounceMs)
@ -1353,6 +1450,11 @@ class HomeViewModel @Inject constructor(
}
private suspend fun updateCatalogRows() {
// Skip expensive recomputation when catalog data hasn't changed.
val currentVersion = catalogVersion
if (currentVersion == lastProcessedCatalogVersion && !_uiState.value.isLoading) return
lastProcessedCatalogVersion = currentVersion
// Snapshot mutable state before entering background context
val orderedKeys = catalogOrder.toList()
val catalogSnapshot = catalogsMap.toMap()
@ -1465,10 +1567,13 @@ class HomeViewModel @Inject constructor(
Triple(computedDisplayRows, computedHeroItems, computedGridItems)
}
// Full (untruncated) rows for CatalogSeeAllScreen
val fullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] }
_fullCatalogRows.update { rows ->
if (rows == fullRows) rows else fullRows
// Full (untruncated) rows for CatalogSeeAllScreen — only needed when
// user navigates to SeeAll, so defer during the initial bulk load.
if (pendingCatalogLoads <= 0) {
val fullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] }
_fullCatalogRows.update { rows ->
if (rows == fullRows) rows else fullRows
}
}
// Emit un-enriched hero items immediately so the UI renders without waiting for TMDB
@ -1488,12 +1593,14 @@ class HomeViewModel @Inject constructor(
)
}
// Launch hero enrichment in the background — updates heroItems when done
// Launch hero enrichment in the background — updates heroItems when done.
// Defer during the initial bulk load to avoid 28 TMDB API calls competing
// with catalog network requests and triggering extra recompositions.
val tmdbSettings = currentTmdbSettings
val shouldUseEnrichedHeroItems = tmdbSettings.enabled &&
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) {
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty() && pendingCatalogLoads <= 0) {
heroEnrichmentJob?.cancel()
heroEnrichmentJob = viewModelScope.launch {
val enrichmentSignature = heroEnrichmentSignature(baseHeroItems, tmdbSettings)
@ -1529,10 +1636,72 @@ class HomeViewModel @Inject constructor(
lastHeroEnrichedItems = emptyList()
}
reconcilePosterStatusObservers(displayRows)
// Defer poster-status observers during the initial bulk load to avoid
// launching hundreds of coroutines that compete with catalog loading + rendering.
if (pendingCatalogLoads <= 0) {
scheduleReconcilePosterStatusObservers(displayRows)
}
}
private fun reconcilePosterStatusObservers(rows: List<CatalogRow>) {
private fun scheduleReconcilePosterStatusObservers(rows: List<CatalogRow>) {
reconcilePosterObserversJob?.cancel()
val rowsSnapshot = rows.toList()
reconcilePosterObserversJob = viewModelScope.launch {
if (!hasBootstrappedPosterObservers) {
withTimeoutOrNull(4000L) {
while (!hasCatalogContentDrawn) {
delay(50)
}
}
}
delay(if (hasBootstrappedPosterObservers) 120L else 450L)
reconcilePosterStatusObservers(rowsSnapshot)
hasBootstrappedPosterObservers = true
}
}
fun onCatalogContentRendered() {
hasCatalogContentDrawn = true
}
private fun schedulePosterStatusFlush() {
if (posterStatusFlushJob?.isActive == true) return
posterStatusFlushJob = viewModelScope.launch {
delay(200)
flushPosterStatusUpdates()
}
}
private fun flushPosterStatusUpdates() {
val libraryBatch = HashMap(pendingLibraryStatusUpdates)
val watchedBatch = HashMap(pendingWatchedStatusUpdates)
if (libraryBatch.isEmpty() && watchedBatch.isEmpty()) return
pendingLibraryStatusUpdates.keys.removeAll(libraryBatch.keys)
pendingWatchedStatusUpdates.keys.removeAll(watchedBatch.keys)
_uiState.update { state ->
val newLibrary = if (libraryBatch.isEmpty()) {
state.posterLibraryMembership
} else {
state.posterLibraryMembership + libraryBatch
}
val newWatched = if (watchedBatch.isEmpty()) {
state.movieWatchedStatus
} else {
state.movieWatchedStatus + watchedBatch
}
if (newLibrary == state.posterLibraryMembership && newWatched == state.movieWatchedStatus) {
state
} else {
state.copy(
posterLibraryMembership = newLibrary,
movieWatchedStatus = newWatched
)
}
}
}
private suspend fun reconcilePosterStatusObservers(rows: List<CatalogRow>) {
val desiredItemsByKey = linkedMapOf<String, Pair<String, String>>()
rows.asSequence()
.flatMap { row -> row.items.asSequence() }
@ -1558,26 +1727,24 @@ class HomeViewModel @Inject constructor(
movieWatchedObserverJobs.remove(staleKey)?.cancel()
}
// Launch observers in chunks to avoid a burst of 300+ coroutines
// all emitting initial values simultaneously.
var launchedInChunk = 0
desiredItemsByKey.forEach { (statusKey, itemRef) ->
val itemId = itemRef.first
val itemType = itemRef.second
var launchedAny = false
if (statusKey !in posterLibraryObserverJobs) {
posterLibraryObserverJobs[statusKey] = viewModelScope.launch {
libraryRepository.isInLibrary(itemId = itemId, itemType = itemType)
.distinctUntilChanged()
.collectLatest { isInLibrary ->
_uiState.update { state ->
if (state.posterLibraryMembership[statusKey] == isInLibrary) {
state
} else {
state.copy(
posterLibraryMembership = state.posterLibraryMembership + (statusKey to isInLibrary)
)
}
}
pendingLibraryStatusUpdates[statusKey] = isInLibrary
schedulePosterStatusFlush()
}
}
launchedAny = true
}
if (itemType.equals("movie", ignoreCase = true)) {
@ -1586,21 +1753,33 @@ class HomeViewModel @Inject constructor(
watchProgressRepository.isWatched(contentId = itemId)
.distinctUntilChanged()
.collectLatest { watched ->
_uiState.update { state ->
if (state.movieWatchedStatus[statusKey] == watched) {
state
} else {
state.copy(
movieWatchedStatus = state.movieWatchedStatus + (statusKey to watched)
)
}
}
pendingWatchedStatusUpdates[statusKey] = watched
schedulePosterStatusFlush()
}
}
launchedAny = true
}
}
if (launchedAny) {
launchedInChunk++
if (launchedInChunk >= 30) {
launchedInChunk = 0
// Yield to let pending coroutines emit their first values
// and batch into a single flush before launching the next chunk.
kotlinx.coroutines.yield()
}
}
}
// Flush any buffered updates before trimming stale entries.
posterStatusFlushJob?.cancel()
flushPosterStatusUpdates()
// Remove stale entries from pending buffers.
pendingLibraryStatusUpdates.keys.retainAll(desiredKeys)
pendingWatchedStatusUpdates.keys.retainAll(desiredMovieKeys)
_uiState.update { state ->
val trimmedLibraryMembership =
state.posterLibraryMembership.filterKeys { it in desiredKeys }
@ -1895,10 +2074,16 @@ class HomeViewModel @Inject constructor(
}
override fun onCleared() {
posterStatusFlushJob?.cancel()
reconcilePosterObserversJob?.cancel()
activeCatalogLoadJobs.values.forEach { it.cancel() }
activeCatalogLoadJobs.clear()
posterLibraryObserverJobs.values.forEach { it.cancel() }
movieWatchedObserverJobs.values.forEach { it.cancel() }
posterLibraryObserverJobs.clear()
movieWatchedObserverJobs.clear()
pendingLibraryStatusUpdates.clear()
pendingWatchedStatusUpdates.clear()
super.onCleared()
}
}

View file

@ -97,7 +97,8 @@ import kotlinx.coroutines.delay
import android.view.KeyEvent as AndroidKeyEvent
import kotlinx.coroutines.flow.distinctUntilChanged
private const val KEY_REPEAT_THROTTLE_MS = 80L
private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
private val HeroTransitionAnimSpec = tween<Float>(durationMillis = 480)
@Composable
fun ModernHomeContent(
@ -439,7 +440,9 @@ fun ModernHomeContent(
}
}
val activeRow by remember(carouselRows, rowByKey, activeRowKey) {
// Use carouselRows/rowByKey as remember keys (plain collections, not snapshot-observable).
// activeRowKey is snapshot-observable via mutableStateOf — derivedStateOf tracks it automatically.
val activeRow by remember(carouselRows, rowByKey) {
derivedStateOf {
val activeKey = activeRowKey
if (activeKey == null) {
@ -449,7 +452,8 @@ fun ModernHomeContent(
}
}
}
val clampedActiveItemIndex by remember(activeRow, activeItemIndex) {
// activeRow and activeItemIndex are both snapshot-observable — no remember keys needed.
val clampedActiveItemIndex by remember {
derivedStateOf {
activeRow?.let { row ->
activeItemIndex.coerceIn(0, (row.items.size - 1).coerceAtLeast(0))
@ -466,7 +470,7 @@ fun ModernHomeContent(
focusedItemByRow[row.key] = clampedIndex
}
val activeHeroItemKey by remember(activeRow, clampedActiveItemIndex) {
val activeHeroItemKey by remember {
derivedStateOf {
val row = activeRow ?: return@derivedStateOf null
row.items.getOrNull(clampedActiveItemIndex)?.key ?: row.items.firstOrNull()?.key
@ -532,19 +536,21 @@ fun ModernHomeContent(
) {
val rowHorizontalPadding = 52.dp
val resolvedHero by remember(heroItem, activeRow, clampedActiveItemIndex) {
val resolvedHero by remember {
derivedStateOf {
heroItem
?: activeRow?.items?.getOrNull(clampedActiveItemIndex)?.heroPreview
?: activeRow?.items?.firstOrNull()?.heroPreview
}
}
val activeRowFallbackBackdrop = remember(activeRow?.key, activeRow?.items) {
activeRow?.items?.firstNotNullOfOrNull { item ->
item.heroPreview.backdrop?.takeIf { it.isNotBlank() }
val activeRowFallbackBackdrop by remember {
derivedStateOf {
activeRow?.items?.firstNotNullOfOrNull { item ->
item.heroPreview.backdrop?.takeIf { it.isNotBlank() }
}
}
}
val heroBackdrop by remember(heroItem, resolvedHero, activeRowFallbackBackdrop) {
val heroBackdrop by remember {
derivedStateOf {
firstNonBlank(
resolvedHero?.backdrop,
@ -554,23 +560,21 @@ fun ModernHomeContent(
)
}
}
val expandedFocusedSelection by remember(focusedCatalogSelection, expandedCatalogFocusKey) {
val expandedFocusedSelection by remember {
derivedStateOf {
focusedCatalogSelection?.takeIf { it.focusKey == expandedCatalogFocusKey }
}
}
val heroTrailerUrl by remember(expandedFocusedSelection, trailerPreviewUrls) {
// trailerPreviewUrls is a plain Map parameter — keep as remember key.
val heroTrailerUrl by remember(trailerPreviewUrls) {
derivedStateOf {
expandedFocusedSelection?.payload?.itemId?.let { trailerPreviewUrls[it] }
}
}
val expandedCatalogTrailerUrl = heroTrailerUrl
val shouldPlayHeroTrailer by remember(
effectiveAutoplayEnabled,
trailerPlaybackTarget,
heroTrailerUrl,
isVerticalRowsScrolling
) {
// effectiveAutoplayEnabled and trailerPlaybackTarget are plain vals — keep as keys.
// heroTrailerUrl and isVerticalRowsScrolling are snapshot-observable — tracked automatically.
val shouldPlayHeroTrailer by remember(effectiveAutoplayEnabled, trailerPlaybackTarget) {
derivedStateOf {
effectiveAutoplayEnabled &&
!isVerticalRowsScrolling &&
@ -586,7 +590,7 @@ fun ModernHomeContent(
}
val heroTransitionProgress by animateFloatAsState(
targetValue = if (shouldPlayHeroTrailer && heroTrailerFirstFrameRendered) 1f else 0f,
animationSpec = tween(durationMillis = 480),
animationSpec = HeroTransitionAnimSpec,
label = "heroBackdropTrailerCrossfadeProgress"
)
val heroBackdropAlpha = 1f - heroTransitionProgress
@ -664,7 +668,7 @@ fun ModernHomeContent(
val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime < KEY_REPEAT_THROTTLE_MS) {
if (now - lastKeyRepeatTime < uiState.keyRepeatThrottleMs) {
return@onPreviewKeyEvent true
}
lastKeyRepeatTime = now

View file

@ -38,6 +38,8 @@ import coil.request.ImageRequest
import com.nuvio.tv.ui.components.TrailerPlayer
import com.nuvio.tv.ui.theme.NuvioColors
private val HeroCrossfadeAnimSpec = tween<Float>(durationMillis = 200)
@Composable
internal fun ModernHeroMediaLayer(
heroBackdrop: String?,
@ -60,7 +62,7 @@ internal fun ModernHeroMediaLayer(
modifier = Modifier
.fillMaxSize()
.graphicsLayer { alpha = heroBackdropAlpha },
animationSpec = tween(durationMillis = 350),
animationSpec = HeroCrossfadeAnimSpec,
label = "modernHeroBackground"
) { imageUrl ->
val imageModel = remember(localContext, imageUrl, requestWidthPx, requestHeightPx) {
@ -94,31 +96,35 @@ internal fun ModernHeroMediaLayer(
)
}
// Pre-compute color variants outside drawWithCache to avoid per-frame allocations.
val bg096 = remember(bgColor) { bgColor.copy(alpha = 0.96f) }
val bg072 = remember(bgColor) { bgColor.copy(alpha = 0.72f) }
val bg078 = remember(bgColor) { bgColor.copy(alpha = 0.78f) }
val bg052 = remember(bgColor) { bgColor.copy(alpha = 0.52f) }
val bg016 = remember(bgColor) { bgColor.copy(alpha = 0.16f) }
val bg098 = remember(bgColor) { bgColor.copy(alpha = 0.98f) }
val horizontalGradientStops = remember(bg096, bg072) {
arrayOf(0.0f to bg096, 0.10f to bg072, 0.30f to Color.Transparent)
}
val radialGradientStops = remember(bg078, bg052, bg016) {
arrayOf(0.0f to bg078, 0.55f to bg052, 0.80f to bg016, 1.0f to Color.Transparent)
}
val verticalGradientStops = remember(bg072, bg098, bgColor) {
arrayOf(0.78f to Color.Transparent, 0.90f to bg072, 0.96f to bg098, 1.0f to bgColor)
}
Box(
modifier = Modifier
.fillMaxSize()
.drawWithCache {
val horizontalGradient = Brush.horizontalGradient(
0.0f to bgColor.copy(alpha = 0.96f),
0.10f to bgColor.copy(alpha = 0.72f),
0.30f to Color.Transparent
)
val horizontalGradient = Brush.horizontalGradient(colorStops = horizontalGradientStops)
val radialGradient = Brush.radialGradient(
colorStops = arrayOf(
0.0f to bgColor.copy(alpha = 0.78f),
0.55f to bgColor.copy(alpha = 0.52f),
0.80f to bgColor.copy(alpha = 0.16f),
1.0f to Color.Transparent
),
colorStops = radialGradientStops,
center = Offset(0f, size.height / 2f),
radius = size.height
)
val verticalGradient = Brush.verticalGradient(
0.78f to Color.Transparent,
0.90f to bgColor.copy(alpha = 0.72f),
0.96f to bgColor.copy(alpha = 0.98f),
1.0f to bgColor
)
val verticalGradient = Brush.verticalGradient(colorStops = verticalGradientStops)
onDrawBehind {
drawRect(brush = horizontalGradient, size = size)
drawRect(brush = radialGradient, size = size)
@ -292,10 +298,11 @@ internal fun HeroTitleBlock(
@Composable
private fun HeroMetaDivider(scale: Float) {
val dividerColor = remember { NuvioColors.TextTertiary.copy(alpha = 0.78f) }
Box(
modifier = Modifier
.size((4.dp * scale).coerceAtLeast(2.dp))
.clip(RoundedCornerShape(percent = 50))
.background(NuvioColors.TextTertiary.copy(alpha = 0.78f))
.background(dividerColor)
)
}

View file

@ -14,7 +14,7 @@ internal val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""")
internal const val MODERN_HERO_TEXT_WIDTH_FRACTION = 0.42f
internal const val MODERN_HERO_BACKDROP_HEIGHT_FRACTION = 0.62f
internal const val MODERN_TRAILER_OVERSCAN_ZOOM = 1.35f
internal const val MODERN_HERO_FOCUS_DEBOUNCE_MS = 90L
internal const val MODERN_HERO_FOCUS_DEBOUNCE_MS = 180L
internal val MODERN_ROW_HEADER_FOCUS_INSET = 56.dp
internal val MODERN_LANDSCAPE_LOGO_GRADIENT = Brush.verticalGradient(
colorStops = arrayOf(

View file

@ -27,7 +27,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -79,6 +78,8 @@ import com.nuvio.tv.ui.theme.NuvioColors
import kotlin.math.abs
import kotlinx.coroutines.flow.distinctUntilChanged
private val CardWatchedIconAnimSpec = tween<Dp>(durationMillis = 180)
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun ModernContinueWatchingRowItem(
@ -229,10 +230,15 @@ internal fun ModernRowSection(
val rowListStates = uiCaches.rowListStates
val loadMoreRequestedTotals = uiCaches.loadMoreRequestedTotals
val titleMedium = MaterialTheme.typography.titleMedium
val rowTitleStyle = remember(titleMedium) {
titleMedium.copy(fontWeight = FontWeight.SemiBold)
}
Column {
Text(
text = row.title,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
style = rowTitleStyle,
color = NuvioColors.TextPrimary,
modifier = Modifier.padding(start = 52.dp, bottom = rowTitleBottom)
)
@ -242,9 +248,6 @@ internal fun ModernRowSection(
firstVisibleItemIndex = focusStateCatalogRowScrollStates[row.key] ?: 0
)
}
val isRowScrolling by remember(rowListState) {
derivedStateOf { rowListState.isScrollInProgress }
}
val currentRowState = rememberUpdatedState(row)
val loadMoreCatalogId = row.catalogId
val loadMoreAddonId = row.addonId
@ -375,9 +378,12 @@ internal fun ModernRowSection(
} else {
fallbackIndex
}
val visibleIndices = rowListState.layoutInfo.visibleItemsInfo.map { it.index }.toSet()
val safeIndex = if (restoreIndex in visibleIndices) restoreIndex else
visibleIndices.minByOrNull { kotlin.math.abs(it - restoreIndex) } ?: fallbackIndex
val visibleItems = rowListState.layoutInfo.visibleItemsInfo
val safeIndex = if (visibleItems.any { it.index == restoreIndex }) {
restoreIndex
} else {
visibleItems.minByOrNull { kotlin.math.abs(it.index - restoreIndex) }?.index ?: fallbackIndex
}
val itemKey = row.items.getOrNull(safeIndex)?.key ?: row.items.first().key
itemFocusRequesters[row.key]?.get(itemKey) ?: FocusRequester.Default
},
@ -424,8 +430,8 @@ internal fun ModernRowSection(
modernCatalogCardWidth = modernCatalogCardWidth,
modernCatalogCardHeight = modernCatalogCardHeight,
focusedPosterBackdropTrailerMuted = focusedPosterBackdropTrailerMuted,
effectiveExpandEnabled = effectiveExpandEnabled && !isRowScrolling,
effectiveAutoplayEnabled = effectiveAutoplayEnabled && !isRowScrolling,
effectiveExpandEnabled = effectiveExpandEnabled,
effectiveAutoplayEnabled = effectiveAutoplayEnabled,
trailerPlaybackTarget = trailerPlaybackTarget,
expandedCatalogFocusKey = expandedCatalogFocusKey,
expandedTrailerPreviewUrl = expandedTrailerPreviewUrl,
@ -473,7 +479,7 @@ private fun ModernCarouselCard(
onBackdropInteraction: () -> Unit,
onTrailerEnded: () -> Unit
) {
val cardShape = RoundedCornerShape(cardCornerRadius)
val cardShape = remember(cardCornerRadius) { RoundedCornerShape(cardCornerRadius) }
val context = LocalContext.current
val density = LocalDensity.current
val expandedCardWidth = cardHeight * (16f / 9f)
@ -538,11 +544,16 @@ private fun ModernCarouselCard(
val hasLandscapeLogo = useLandscapePosters && !item.heroPreview.logo.isNullOrBlank()
var isFocused by remember { mutableStateOf(false) }
var longPressTriggered by remember { mutableStateOf(false) }
val watchedIconEndPadding by animateDpAsState(
targetValue = if (isFocused) 16.dp else 8.dp,
animationSpec = tween(durationMillis = 180),
label = "modernCardWatchedIconEndPadding"
)
val watchedIconEndPadding = if (isWatched) {
val padding by animateDpAsState(
targetValue = if (isFocused) 16.dp else 8.dp,
animationSpec = CardWatchedIconAnimSpec,
label = "modernCardWatchedIconEndPadding"
)
padding
} else {
8.dp
}
val backgroundCardColor = NuvioColors.BackgroundCard
val focusRingColor = NuvioColors.FocusRing
val titleMedium = MaterialTheme.typography.titleMedium
@ -552,6 +563,8 @@ private fun ModernCarouselCard(
shape = cardShape
)
}
val cardShapeSpec = remember(cardShape) { CardDefaults.shape(shape = cardShape) }
val cardScale = remember { CardDefaults.scale(focusedScale = 1f) }
val titleStyle = remember(titleMedium) {
titleMedium.copy(fontWeight = FontWeight.Medium)
}
@ -604,7 +617,7 @@ private fun ModernCarouselCard(
}
false
},
shape = CardDefaults.shape(shape = cardShape),
shape = cardShapeSpec,
colors = CardDefaults.colors(
containerColor = backgroundCardColor,
focusedContainerColor = backgroundCardColor
@ -612,20 +625,22 @@ private fun ModernCarouselCard(
border = CardDefaults.border(
focusedBorder = focusedBorder
),
scale = CardDefaults.scale(focusedScale = 1f)
scale = cardScale
) {
Box(modifier = Modifier.fillMaxSize()) {
val mediaLayerModifier = if (hasLandscapeLogo) {
Modifier
.fillMaxSize()
.drawWithCache {
onDrawWithContent {
drawContent()
drawRect(brush = MODERN_LANDSCAPE_LOGO_GRADIENT, size = size)
val mediaLayerModifier = remember(hasLandscapeLogo) {
if (hasLandscapeLogo) {
Modifier
.fillMaxSize()
.drawWithCache {
onDrawWithContent {
drawContent()
drawRect(brush = MODERN_LANDSCAPE_LOGO_GRADIENT, size = size)
}
}
}
} else {
Modifier.fillMaxSize()
} else {
Modifier.fillMaxSize()
}
}
Box(modifier = mediaLayerModifier) {

View file

@ -87,6 +87,25 @@ fun DebugSettingsContent(
)
}
// ── Performance ──
item(key = "debug_performance_header") {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Performance",
style = MaterialTheme.typography.titleSmall,
color = NuvioColors.TextTertiary,
modifier = Modifier.padding(bottom = 4.dp)
)
}
item(key = "debug_device_tier") {
DebugDeviceTierSelector(
currentOverride = uiState.deviceTierOverride,
detectedTier = uiState.detectedDeviceTier,
onSelect = { viewModel.onEvent(DebugSettingsEvent.SetDeviceTierOverride(it)) }
)
}
// ── Feature Toggles ──
item(key = "debug_feature_toggles_header") {
Spacer(modifier = Modifier.height(8.dp))
@ -291,6 +310,66 @@ private fun DebugDialogButton(
}
}
@Composable
private fun DebugDeviceTierSelector(
currentOverride: String,
detectedTier: String,
onSelect: (String) -> Unit
) {
val options = listOf("auto", "low", "medium", "high")
Card(
onClick = {
val currentIndex = options.indexOf(currentOverride)
val nextIndex = (currentIndex + 1) % options.size
onSelect(options[nextIndex])
},
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.FocusBackground
),
border = CardDefaults.border(
focusedBorder = Border(
border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = RoundedCornerShape(12.dp)
)
),
shape = CardDefaults.shape(RoundedCornerShape(12.dp)),
scale = CardDefaults.scale(focusedScale = 1.02f)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Device Performance Tier",
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.TextPrimary
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Detected: $detectedTier. Click to cycle override.",
style = MaterialTheme.typography.bodySmall,
color = NuvioColors.TextSecondary
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = currentOverride.uppercase(),
style = MaterialTheme.typography.titleMedium,
color = NuvioColors.Secondary
)
}
}
}
@Composable
private fun DebugSignInCard(
isLoading: Boolean,

View file

@ -3,6 +3,8 @@ package com.nuvio.tv.ui.screens.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.nuvio.tv.core.auth.AuthManager
import com.nuvio.tv.core.device.DeviceCapabilities
import com.nuvio.tv.core.device.DeviceTier
import com.nuvio.tv.data.local.DebugSettingsDataStore
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@ -16,7 +18,8 @@ import javax.inject.Inject
@HiltViewModel
class DebugSettingsViewModel @Inject constructor(
private val dataStore: DebugSettingsDataStore,
private val authManager: AuthManager
private val authManager: AuthManager,
private val deviceCapabilities: DeviceCapabilities
) : ViewModel() {
private val _uiState = MutableStateFlow(DebugSettingsUiState())
@ -33,6 +36,23 @@ class DebugSettingsViewModel @Inject constructor(
_uiState.update { it.copy(syncCodeFeaturesEnabled = enabled) }
}
}
viewModelScope.launch {
dataStore.deviceTierOverride.collectLatest { override ->
val tier = when (override) {
"low" -> DeviceTier.LOW
"medium" -> DeviceTier.MEDIUM
"high" -> DeviceTier.HIGH
else -> null
}
deviceCapabilities.setTierOverride(tier)
_uiState.update {
it.copy(
deviceTierOverride = override,
detectedDeviceTier = deviceCapabilities.tier.name
)
}
}
}
}
fun onEvent(event: DebugSettingsEvent) {
@ -43,6 +63,9 @@ class DebugSettingsViewModel @Inject constructor(
is DebugSettingsEvent.ToggleSyncCodeFeatures -> {
viewModelScope.launch { dataStore.setSyncCodeFeaturesEnabled(event.enabled) }
}
is DebugSettingsEvent.SetDeviceTierOverride -> {
viewModelScope.launch { dataStore.setDeviceTierOverride(event.value) }
}
is DebugSettingsEvent.SignIn -> {
viewModelScope.launch {
_uiState.update { it.copy(signInLoading = true, signInResult = null) }
@ -63,11 +86,14 @@ data class DebugSettingsUiState(
val accountTabEnabled: Boolean = false,
val syncCodeFeaturesEnabled: Boolean = false,
val signInLoading: Boolean = false,
val signInResult: String? = null
val signInResult: String? = null,
val deviceTierOverride: String = "auto",
val detectedDeviceTier: String = "HIGH"
)
sealed class DebugSettingsEvent {
data class ToggleAccountTab(val enabled: Boolean) : DebugSettingsEvent()
data class ToggleSyncCodeFeatures(val enabled: Boolean) : DebugSettingsEvent()
data class SetDeviceTierOverride(val value: String) : DebugSettingsEvent()
data class SignIn(val email: String, val password: String) : DebugSettingsEvent()
}

View file

@ -6,10 +6,31 @@
com.nuvio.tv.domain.model.*
// UI state classes
com.nuvio.tv.ui.screens.home.HomeUiState
com.nuvio.tv.ui.screens.home.HomeScreenFocusState
com.nuvio.tv.ui.screens.home.ContinueWatchingItem
com.nuvio.tv.ui.screens.home.ContinueWatchingItem.InProgress
com.nuvio.tv.ui.screens.home.ContinueWatchingItem.NextUp
com.nuvio.tv.ui.screens.home.NextUpInfo
com.nuvio.tv.ui.screens.home.GridItem
com.nuvio.tv.ui.screens.home.GridItem.Hero
com.nuvio.tv.ui.screens.home.GridItem.SectionDivider
com.nuvio.tv.ui.screens.home.GridItem.Content
com.nuvio.tv.ui.screens.home.GridItem.SeeAll
com.nuvio.tv.ui.screens.search.SearchUiState
com.nuvio.tv.ui.screens.search.DiscoverCatalog
com.nuvio.tv.ui.components.PosterCardStyle
// Modern home screen types
com.nuvio.tv.ui.screens.home.HeroPreview
com.nuvio.tv.ui.screens.home.HeroCarouselRow
com.nuvio.tv.ui.screens.home.ModernCarouselItem
com.nuvio.tv.ui.screens.home.ModernPayload
com.nuvio.tv.ui.screens.home.ModernPayload.Catalog
com.nuvio.tv.ui.screens.home.ModernPayload.ContinueWatching
com.nuvio.tv.ui.screens.home.FocusedCatalogSelection
com.nuvio.tv.ui.screens.home.CarouselRowLookups
// Collections from kotlinx
kotlin.collections.List
kotlin.collections.Map

View file

@ -6,7 +6,7 @@
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects

View file

@ -21,5 +21,5 @@ dependencyResolutionManagement {
rootProject.name = "My Application"
include(":app")
// include(":benchmark") // TODO: create when ready
include(":benchmark")