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

View file

@ -19,7 +19,7 @@
android:allowBackup="true" android:allowBackup="true"
android:banner="@mipmap/banner" android:banner="@mipmap/banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="Nuvio" android:label="@string/app_name"
android:largeHeap="true" android:largeHeap="true"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
android:supportsRtl="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.screens.profile.ProfileSelectionScreen
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
import com.nuvio.tv.ui.theme.NuvioTheme import com.nuvio.tv.ui.theme.NuvioTheme
import com.nuvio.tv.updater.UpdateUiState
import com.nuvio.tv.updater.UpdateViewModel import com.nuvio.tv.updater.UpdateViewModel
import com.nuvio.tv.updater.ui.UpdatePromptDialog import com.nuvio.tv.updater.ui.UpdatePromptDialog
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.haze import dev.chrisbanes.haze.haze
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -175,6 +177,8 @@ class MainActivity : ComponentActivity() {
lateinit var appOnboardingDataStore: AppOnboardingDataStore lateinit var appOnboardingDataStore: AppOnboardingDataStore
private lateinit var jankStats: JankStats private lateinit var jankStats: JankStats
private var startupSyncJob: Job? = null
private var hasDispatchedStartupSync: Boolean = false
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
override fun attachBaseContext(newBase: Context) { 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 mainUiPrefs.modernSidebarBlurPref && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
val hideBuiltInHeadersForFloatingPill = modernSidebarEnabled && !sidebarCollapsed val hideBuiltInHeadersForFloatingPill = modernSidebarEnabled && !sidebarCollapsed
val updateViewModel: UpdateViewModel = hiltViewModel(this@MainActivity) var shouldInitUpdateViewModel by remember { mutableStateOf(false) }
val updateState by updateViewModel.uiState.collectAsState() 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 startDestination = if (layoutChosen) Screen.Home.route else Screen.LayoutSelection.route
val navController = rememberNavController() val navController = rememberNavController()
@ -443,11 +456,11 @@ class MainActivity : ComponentActivity() {
UpdatePromptDialog( UpdatePromptDialog(
state = updateState, state = updateState,
onDismiss = { updateViewModel.dismissDialog() }, onDismiss = { updateViewModel?.dismissDialog() },
onDownload = { updateViewModel.downloadUpdate() }, onDownload = { updateViewModel?.downloadUpdate() },
onInstall = { updateViewModel.installUpdateOrRequestPermission() }, onInstall = { updateViewModel?.installUpdateOrRequestPermission() },
onIgnore = { updateViewModel.ignoreThisVersion() }, onIgnore = { updateViewModel?.ignoreThisVersion() },
onOpenUnknownSources = { updateViewModel.openUnknownSourcesSettings() } onOpenUnknownSources = { updateViewModel?.openUnknownSourcesSettings() }
) )
} }
} }
@ -476,9 +489,22 @@ class MainActivity : ComponentActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
startupSyncService.requestSyncNow() if (hasDispatchedStartupSync || startupSyncJob?.isActive == true) {
lifecycleScope.launch { return
}
startupSyncJob = lifecycleScope.launch {
delay(3200)
startupSyncService.requestSyncNow()
delay(1200)
traktProgressService.refreshNow() 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 package com.nuvio.tv
import android.app.Application import android.app.Application
import android.content.ComponentCallbacks2
import android.content.res.Configuration
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.nuvio.tv.core.device.DeviceCapabilities
import com.nuvio.tv.core.sync.StartupSyncService import com.nuvio.tv.core.sync.StartupSyncService
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import javax.inject.Inject import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class NuvioApplication : Application(), ImageLoaderFactory { class NuvioApplication : Application(), ImageLoaderFactory, ComponentCallbacks2 {
@Inject lateinit var startupSyncService: StartupSyncService @Inject lateinit var startupSyncService: StartupSyncService
@Inject lateinit var deviceCapabilities: DeviceCapabilities
private var _imageLoader: ImageLoader? = null
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
} }
override fun newImageLoader(): ImageLoader { override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this) val caps = deviceCapabilities
val loader = ImageLoader.Builder(this)
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.25) .maxSizePercent(caps.memoryCachePercent)
.build() .build()
} }
.diskCache { .diskCache {
DiskCache.Builder() DiskCache.Builder()
.directory(cacheDir.resolve("image_cache")) .directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(200L * 1024 * 1024) .maxSizeBytes(caps.diskCacheSizeBytes)
.build() .build()
} }
.decoderDispatcher(Dispatchers.IO.limitedParallelism(2)) .decoderDispatcher(Dispatchers.IO.limitedParallelism(caps.decoderParallelism))
.fetcherDispatcher(Dispatchers.IO.limitedParallelism(4)) .fetcherDispatcher(Dispatchers.IO.limitedParallelism(caps.fetcherParallelism))
.bitmapFactoryMaxParallelism(2) .bitmapFactoryMaxParallelism(caps.decoderParallelism)
.allowRgb565(true) .allowRgb565(true)
.crossfade(false) .crossfade(false)
.build() .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.TmdbPersonCreditCast
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCrew import com.nuvio.tv.data.remote.api.TmdbPersonCreditCrew
import com.nuvio.tv.data.remote.api.TmdbRecommendationResult 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.ContentType
import com.nuvio.tv.domain.model.MetaCastMember import com.nuvio.tv.domain.model.MetaCastMember
import com.nuvio.tv.domain.model.MetaCompany import com.nuvio.tv.domain.model.MetaCompany
@ -28,7 +29,8 @@ private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
@Singleton @Singleton
class TmdbMetadataService @Inject constructor( class TmdbMetadataService @Inject constructor(
private val tmdbApi: TmdbApi private val tmdbApi: TmdbApi,
private val deviceCapabilities: DeviceCapabilities
) { ) {
// In-memory caches // In-memory caches
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>() 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 val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany( MetaCompany(
name = name, name = name,
logo = buildImageUrl(company.logoPath, size = "w300") logo = buildLogoUrl(company.logoPath)
) )
} }
val networks = details?.networks val networks = details?.networks
@ -129,11 +131,11 @@ class TmdbMetadataService @Inject constructor(
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
MetaCompany( MetaCompany(
name = name, name = name,
logo = buildImageUrl(network.logoPath, size = "w300") logo = buildLogoUrl(network.logoPath)
) )
} }
val poster = buildImageUrl(details?.posterPath, size = "w500") val poster = buildPosterUrl(details?.posterPath)
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280") val backdrop = buildBackdropUrl(details?.backdropPath)
val logoPath = images?.logos val logoPath = images?.logos
?.sortedWith( ?.sortedWith(
@ -146,7 +148,7 @@ class TmdbMetadataService @Inject constructor(
?.firstOrNull() ?.firstOrNull()
?.filePath ?.filePath
val logo = buildImageUrl(logoPath, size = "w500") val logo = buildLogoUrl(logoPath)
val castMembers = credits?.cast val castMembers = credits?.cast
.orEmpty() .orEmpty()
@ -155,7 +157,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = member.character?.takeIf { it.isNotBlank() }, character = member.character?.takeIf { it.isNotBlank() },
photo = buildImageUrl(member.profilePath, size = "w500"), photo = buildProfileUrl(member.profilePath),
tmdbId = member.id tmdbId = member.id
) )
} }
@ -169,7 +171,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = "Creator", character = "Creator",
photo = buildImageUrl(creator.profilePath, size = "w500"), photo = buildProfileUrl(creator.profilePath),
tmdbId = tmdbPersonId tmdbId = tmdbPersonId
) )
} }
@ -197,7 +199,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = "Director", character = "Director",
photo = buildImageUrl(member.profilePath, size = "w500"), photo = buildProfileUrl(member.profilePath),
tmdbId = tmdbPersonId tmdbId = tmdbPersonId
) )
} }
@ -220,7 +222,7 @@ class TmdbMetadataService @Inject constructor(
MetaCastMember( MetaCastMember(
name = name, name = name,
character = "Writer", character = "Writer",
photo = buildImageUrl(member.profilePath, size = "w500"), photo = buildProfileUrl(member.profilePath),
tmdbId = tmdbPersonId tmdbId = tmdbPersonId
) )
} }
@ -404,8 +406,8 @@ class TmdbMetadataService @Inject constructor(
) )
} }
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280") val backdrop = buildBackdropUrl(localizedBackdropPath ?: rec.backdropPath)
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780") val fallbackPoster = buildBackdropUrl(rec.posterPath)
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4) val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
MetaPreview( 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? { private fun buildImageUrl(path: String?, size: String): String? {
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
return "https://image.tmdb.org/t/p/$size$clean" return "https://image.tmdb.org/t/p/$size$clean"
@ -509,7 +526,7 @@ class TmdbMetadataService @Inject constructor(
birthday = person.birthday?.takeIf { it.isNotBlank() }, birthday = person.birthday?.takeIf { it.isNotBlank() },
deathday = person.deathday?.takeIf { it.isNotBlank() }, deathday = person.deathday?.takeIf { it.isNotBlank() },
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() }, placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
profilePhoto = buildImageUrl(person.profilePath, "w500"), profilePhoto = buildProfileUrl(person.profilePath),
knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() }, knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() },
movieCredits = movieCredits, movieCredits = movieCredits,
tvCredits = tvCredits tvCredits = tvCredits
@ -541,9 +558,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.MOVIE, type = ContentType.MOVIE,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -566,9 +583,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.MOVIE, type = ContentType.MOVIE,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -591,9 +608,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.SERIES, type = ContentType.SERIES,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -616,9 +633,9 @@ class TmdbMetadataService @Inject constructor(
id = "tmdb:${credit.id}", id = "tmdb:${credit.id}",
type = ContentType.SERIES, type = ContentType.SERIES,
name = title, name = title,
poster = buildImageUrl(credit.posterPath, "w500"), poster = buildPosterUrl(credit.posterPath),
posterShape = PosterShape.POSTER, posterShape = PosterShape.POSTER,
background = buildImageUrl(credit.backdropPath, "w1280"), background = buildBackdropUrl(credit.backdropPath),
logo = null, logo = null,
description = credit.overview?.takeIf { it.isNotBlank() }, description = credit.overview?.takeIf { it.isNotBlank() },
releaseInfo = year, releaseInfo = year,
@ -714,7 +731,7 @@ data class TmdbEpisodeEnrichment(
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment { private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
val title = name?.takeIf { it.isNotBlank() } val title = name?.takeIf { it.isNotBlank() }
val overview = overview?.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() } val airDate = airDate?.takeIf { it.isNotBlank() }
return TmdbEpisodeEnrichment( return TmdbEpisodeEnrichment(
title = title, title = title,

View file

@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -22,6 +23,7 @@ class DebugSettingsDataStore @Inject constructor(
private val accountTabEnabledKey = booleanPreferencesKey("account_tab_enabled") private val accountTabEnabledKey = booleanPreferencesKey("account_tab_enabled")
private val syncCodeFeaturesEnabledKey = booleanPreferencesKey("sync_code_features_enabled") private val syncCodeFeaturesEnabledKey = booleanPreferencesKey("sync_code_features_enabled")
private val deviceTierOverrideKey = stringPreferencesKey("device_tier_override")
val accountTabEnabled: Flow<Boolean> = dataStore.data.map { prefs -> val accountTabEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
prefs[accountTabEnabledKey] ?: false prefs[accountTabEnabledKey] ?: false
@ -31,6 +33,10 @@ class DebugSettingsDataStore @Inject constructor(
prefs[syncCodeFeaturesEnabledKey] ?: false prefs[syncCodeFeaturesEnabledKey] ?: false
} }
val deviceTierOverride: Flow<String> = dataStore.data.map { prefs ->
prefs[deviceTierOverrideKey] ?: "auto"
}
suspend fun setAccountTabEnabled(enabled: Boolean) { suspend fun setAccountTabEnabled(enabled: Boolean) {
dataStore.edit { prefs -> dataStore.edit { prefs ->
prefs[accountTabEnabledKey] = enabled prefs[accountTabEnabledKey] = enabled
@ -42,4 +48,10 @@ class DebugSettingsDataStore @Inject constructor(
prefs[syncCodeFeaturesEnabledKey] = enabled 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 package com.nuvio.tv.data.repository
import android.util.Log import android.util.Log
import com.nuvio.tv.core.image.TmdbImageUrlOptimizer
import com.nuvio.tv.core.network.NetworkResult 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.mapper.toDomain
import com.nuvio.tv.data.remote.api.AddonApi import com.nuvio.tv.data.remote.api.AddonApi
import com.nuvio.tv.domain.model.CatalogRow import com.nuvio.tv.domain.model.CatalogRow
import com.nuvio.tv.domain.model.ContentType import com.nuvio.tv.domain.model.ContentType
import com.nuvio.tv.domain.repository.CatalogRepository 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.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import java.net.URLEncoder import java.net.URLEncoder
import java.util.concurrent.ConcurrentHashMap import kotlin.math.max
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@Singleton @Singleton
class CatalogRepositoryImpl @Inject constructor( class CatalogRepositoryImpl @Inject constructor(
private val api: AddonApi private val api: AddonApi,
private val tmdbImageUrlOptimizer: TmdbImageUrlOptimizer
) : CatalogRepository { ) : CatalogRepository {
companion object { companion object {
private const val TAG = "CatalogRepository" 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( override fun getCatalog(
addonBaseUrl: String, addonBaseUrl: String,
@ -44,60 +99,271 @@ class CatalogRepositoryImpl @Inject constructor(
skip = skip, skip = skip,
extraArgs = extraArgs extraArgs = extraArgs
) )
val now = System.currentTimeMillis()
val cachedEntry = getCachedEntry(cacheKey, now)
// Emit cached data immediately if available // Serve cache immediately when fresh or within stale-while-revalidate.
val cached = catalogCache[cacheKey] val serveCachedImmediately = cachedEntry != null &&
if (cached != null) { now - cachedEntry.fetchedAtMs <= cachedEntry.policy.immediateServeWindowMs
emit(NetworkResult.Success(cached))
if (serveCachedImmediately) {
emit(NetworkResult.Success(cachedEntry.row))
} else { } else {
emit(NetworkResult.Loading) 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) val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs)
Log.d( Log.d(
TAG, TAG,
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip supportsSkip=$supportsSkip url=$url" "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) }) { when (result) {
is NetworkResult.Success -> { is CatalogFetchResult.Success -> {
val items = result.data.metas.map { it.toDomain() } // Only emit fresh data if it differs from what is currently displayed.
Log.d( if (cachedEntry == null || cachedEntry.row.items != result.row.items) {
TAG, emit(NetworkResult.Success(result.row))
"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))
} }
} }
is NetworkResult.Error -> { is CatalogFetchResult.Error -> {
Log.w( val canServeStaleOnError = cachedEntry != null &&
TAG, now - cachedEntry.fetchedAtMs <= cachedEntry.policy.errorServeWindowMs
"Catalog fetch failed addonId=$addonId type=$type catalogId=$catalogId code=${result.code} message=${result.message} url=$url"
) if (canServeStaleOnError) {
// Only emit error if we had no cached data // Keep stale content on-screen when network fails.
if (cached == null) { if (!serveCachedImmediately) {
emit(result) 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, upFocusRequester: FocusRequester? = null,
listState: LazyListState = rememberLazyListState(initialFirstVisibleItemIndex = initialScrollIndex) 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 currentOnItemFocused by rememberUpdatedState(onItemFocused)
val currentOnItemFocus by rememberUpdatedState(onItemFocus) val currentOnItemFocus by rememberUpdatedState(onItemFocus)
@ -185,8 +191,8 @@ fun CatalogRowSection(
) { ) {
itemsIndexed( itemsIndexed(
items = catalogRow.items, items = catalogRow.items,
key = { index, item -> key = { _, item ->
"${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}_${item.id}_$index" "${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}_${item.id}"
}, },
contentType = { _, _ -> "content_card" } contentType = { _, _ -> "content_card" }
) { index, item -> ) { index, item ->
@ -223,18 +229,15 @@ fun CatalogRowSection(
.width(posterCardStyle.width) .width(posterCardStyle.width)
.height(posterCardStyle.height) .height(posterCardStyle.height)
.then(directionalFocusModifier), .then(directionalFocusModifier),
shape = CardDefaults.shape(shape = seeAllCardShape), shape = seeAllShapeSpec,
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.BackgroundCard focusedContainerColor = NuvioColors.BackgroundCard
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = seeAllFocusedBorder
border = BorderStroke(posterCardStyle.focusedBorderWidth, NuvioColors.FocusRing),
shape = seeAllCardShape
)
), ),
scale = CardDefaults.scale(focusedScale = posterCardStyle.focusedScale) scale = seeAllScale
) { ) {
Box( Box(
modifier = Modifier.fillMaxSize(), 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.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.nuvio.tv.R import com.nuvio.tv.R
@ -61,6 +62,8 @@ import kotlinx.coroutines.delay
private const val BACKDROP_ASPECT_RATIO = 16f / 9f private const val BACKDROP_ASPECT_RATIO = 16f / 9f
private const val TRAILER_PREVIEW_REQUEST_FOCUS_DEBOUNCE_MS = 140L private const val TRAILER_PREVIEW_REQUEST_FOCUS_DEBOUNCE_MS = 140L
private val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""") 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) @OptIn(ExperimentalTvMaterial3Api::class)
@Composable @Composable
@ -82,6 +85,18 @@ fun ContentCard(
onClick: () -> Unit = {} onClick: () -> Unit = {}
) { ) {
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) } 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) { val baseCardWidth = when (item.posterShape) {
PosterShape.POSTER -> posterCardStyle.width PosterShape.POSTER -> posterCardStyle.width
PosterShape.LANDSCAPE -> 260.dp PosterShape.LANDSCAPE -> 260.dp
@ -99,11 +114,16 @@ fun ContentCard(
var interactionNonce by remember { mutableIntStateOf(0) } var interactionNonce by remember { mutableIntStateOf(0) }
var isBackdropExpanded by remember { mutableStateOf(false) } var isBackdropExpanded by remember { mutableStateOf(false) }
var trailerFirstFrameRendered by remember(trailerPreviewUrl) { mutableStateOf(false) } var trailerFirstFrameRendered by remember(trailerPreviewUrl) { mutableStateOf(false) }
val watchedIconEndPadding by animateDpAsState( val watchedIconEndPadding = if (isWatched) {
targetValue = if (isFocused) 18.dp else 8.dp, val padding by animateDpAsState(
animationSpec = tween(durationMillis = 180), targetValue = if (isFocused) 18.dp else 8.dp,
label = "contentCardWatchedIconEndPadding" animationSpec = WatchedIconAnimSpec,
) label = "contentCardWatchedIconEndPadding"
)
padding
} else {
8.dp
}
val needsFocusState = focusedPosterBackdropExpandEnabled || focusedPosterBackdropTrailerEnabled val needsFocusState = focusedPosterBackdropExpandEnabled || focusedPosterBackdropTrailerEnabled
val lastFocusedRef = remember { booleanArrayOf(false) } val lastFocusedRef = remember { booleanArrayOf(false) }
@ -275,21 +295,16 @@ fun ContentCard(
} }
false false
} }
.then( .then(focusRequesterModifier),
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier shape = cardShapeSpec,
),
shape = CardDefaults.shape(shape = cardShape),
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.BackgroundCard focusedContainerColor = NuvioColors.BackgroundCard
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = focusedBorder
border = BorderStroke(posterCardStyle.focusedBorderWidth, NuvioColors.FocusRing),
shape = cardShape
)
), ),
scale = CardDefaults.scale(focusedScale = posterCardStyle.focusedScale) scale = cardScale
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@ -325,7 +340,7 @@ fun ContentCard(
val trailerCoverAlpha = if (shouldPlayTrailerPreview) { val trailerCoverAlpha = if (shouldPlayTrailerPreview) {
val alpha by animateFloatAsState( val alpha by animateFloatAsState(
targetValue = if (!trailerFirstFrameRendered) 1f else 0f, targetValue = if (!trailerFirstFrameRendered) 1f else 0f,
animationSpec = tween(durationMillis = 250), animationSpec = TrailerCoverAlphaAnimSpec,
label = "trailerCoverAlpha" label = "trailerCoverAlpha"
) )
alpha alpha

View file

@ -66,6 +66,8 @@ import java.util.concurrent.TimeUnit
private val CwCardShape = RoundedCornerShape(12.dp) private val CwCardShape = RoundedCornerShape(12.dp)
private val CwClipShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp) private val CwClipShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
private val BadgeShape = RoundedCornerShape(4.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) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
@ -289,6 +291,13 @@ fun ContinueWatchingCard(
.build() .build()
} }
val focusRingColor = NuvioColors.FocusRing
val cwFocusedBorder = remember(focusRingColor) {
Border(
border = BorderStroke(2.dp, focusRingColor),
shape = CwCardShape
)
}
val bgColor = NuvioColors.Background val bgColor = NuvioColors.Background
val overlayBrush = remember(bgColor) { val overlayBrush = remember(bgColor) {
Brush.verticalGradient( Brush.verticalGradient(
@ -333,18 +342,15 @@ fun ContinueWatchingCard(
} }
false false
}, },
shape = CardDefaults.shape(shape = CwCardShape), shape = CwCardShapeSpec,
colors = CardDefaults.colors( colors = CardDefaults.colors(
containerColor = NuvioColors.BackgroundCard, containerColor = NuvioColors.BackgroundCard,
focusedContainerColor = NuvioColors.FocusBackground focusedContainerColor = NuvioColors.FocusBackground
), ),
border = CardDefaults.border( border = CardDefaults.border(
focusedBorder = Border( focusedBorder = cwFocusedBorder
border = BorderStroke(2.dp, NuvioColors.FocusRing),
shape = CwCardShape
)
), ),
scale = CardDefaults.scale(focusedScale = 1.02f) scale = CwCardScale
) { ) {
Column { Column {
// Thumbnail with progress overlay // 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.HeroCarousel
import com.nuvio.tv.ui.components.PosterCardStyle import com.nuvio.tv.ui.components.PosterCardStyle
/** Minimum interval between processed key repeat events to prevent HWUI overload. */ /** Default minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
private class FocusSnapshot( private class FocusSnapshot(
var rowIndex: Int, var rowIndex: Int,
@ -56,9 +56,12 @@ fun ClassicHomeContent(
) { ) {
// Nested prefetch: when LazyColumn prefetches a row ahead of scrolling, // Nested prefetch: when LazyColumn prefetches a row ahead of scrolling,
// pre-compose up to 2 ContentCards in its nested LazyRow across multiple frames. // pre-compose ContentCards in its nested LazyRow across multiple frames.
// This spreads the composition work and prevents frame spikes when a new row scrolls in. // On low-tier devices, prefetch is disabled (0) to avoid decoder contention.
val nestedPrefetchStrategy = remember { LazyListPrefetchStrategy(nestedPrefetchItemCount = 2) } val adaptiveNestedPrefetchCount = uiState.nestedPrefetchItemCount
val nestedPrefetchStrategy = remember(adaptiveNestedPrefetchCount) {
LazyListPrefetchStrategy(nestedPrefetchItemCount = adaptiveNestedPrefetchCount)
}
val columnListState = rememberLazyListState( val columnListState = rememberLazyListState(
initialFirstVisibleItemIndex = focusState.verticalScrollIndex, initialFirstVisibleItemIndex = focusState.verticalScrollIndex,
@ -137,6 +140,7 @@ fun ClassicHomeContent(
// Throttle D-pad key repeats to prevent HWUI overload when a key is held down. // Throttle D-pad key repeats to prevent HWUI overload when a key is held down.
var lastKeyRepeatTime by remember { mutableStateOf(0L) } var lastKeyRepeatTime by remember { mutableStateOf(0L) }
val keyRepeatThrottle = uiState.keyRepeatThrottleMs
LazyColumn( LazyColumn(
state = columnListState, state = columnListState,
@ -146,7 +150,7 @@ fun ClassicHomeContent(
val native = event.nativeKeyEvent val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) { if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime < KEY_REPEAT_THROTTLE_MS) { if (now - lastKeyRepeatTime < keyRepeatThrottle) {
return@onPreviewKeyEvent true // consume — too fast return@onPreviewKeyEvent true // consume — too fast
} }
lastKeyRepeatTime = now 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.components.PosterCardStyle
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
/** Minimum interval between processed key repeat events to prevent HWUI overload. */ /** Default minimum interval between processed key repeat events to prevent HWUI overload. */
private const val KEY_REPEAT_THROTTLE_MS = 80L private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable @Composable
@ -167,7 +167,7 @@ fun GridHomeContent(
val native = event.nativeKeyEvent val native = event.nativeKeyEvent
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) { if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastKeyRepeatTime[0] < KEY_REPEAT_THROTTLE_MS) { if (now - lastKeyRepeatTime[0] < uiState.keyRepeatThrottleMs) {
return@onPreviewKeyEvent true return@onPreviewKeyEvent true
} }
lastKeyRepeatTime[0] = now lastKeyRepeatTime[0] = now

View file

@ -73,6 +73,7 @@ fun HomeScreen(
LaunchedEffect(hasCatalogContent) { LaunchedEffect(hasCatalogContent) {
if (hasCatalogContent) { if (hasCatalogContent) {
hasEnteredCatalogContent = true hasEnteredCatalogContent = true
viewModel.onCatalogContentRendered()
} }
} }
@ -169,6 +170,15 @@ fun HomeScreen(
animationSpec = tween(320) 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) { when (uiState.homeLayout) {
HomeLayout.CLASSIC -> ClassicHomeRoute( HomeLayout.CLASSIC -> ClassicHomeRoute(
viewModel = viewModel, viewModel = viewModel,
@ -177,9 +187,7 @@ fun HomeScreen(
onNavigateToDetail = onNavigateToDetail, onNavigateToDetail = onNavigateToDetail,
onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingClick = onContinueWatchingClick,
onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll, onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll,
isCatalogItemWatched = { item -> isCatalogItemWatched = stableIsWatched,
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
},
onCatalogItemLongPress = { item, addonBaseUrl -> onCatalogItemLongPress = { item, addonBaseUrl ->
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl) posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
} }
@ -192,9 +200,7 @@ fun HomeScreen(
onNavigateToDetail = onNavigateToDetail, onNavigateToDetail = onNavigateToDetail,
onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingClick = onContinueWatchingClick,
onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll, onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll,
isCatalogItemWatched = { item -> isCatalogItemWatched = stableIsWatched,
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
},
onCatalogItemLongPress = { item, addonBaseUrl -> onCatalogItemLongPress = { item, addonBaseUrl ->
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl) posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
} }
@ -205,9 +211,7 @@ fun HomeScreen(
uiState = uiState, uiState = uiState,
onNavigateToDetail = onNavigateToDetail, onNavigateToDetail = onNavigateToDetail,
onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingClick = onContinueWatchingClick,
isCatalogItemWatched = { item -> isCatalogItemWatched = stableIsWatched,
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
},
onCatalogItemLongPress = { item, addonBaseUrl -> onCatalogItemLongPress = { item, addonBaseUrl ->
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl) posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
} }

View file

@ -36,7 +36,9 @@ data class HomeUiState(
val movieWatchedStatus: Map<String, Boolean> = emptyMap(), val movieWatchedStatus: Map<String, Boolean> = emptyMap(),
val posterLibraryPending: Set<String> = emptySet(), val posterLibraryPending: Set<String> = emptySet(),
val movieWatchedPending: 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 @Immutable

View file

@ -4,6 +4,8 @@ import android.util.Log
import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateMapOf
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.network.NetworkResult
import com.nuvio.tv.core.tmdb.TmdbMetadataService import com.nuvio.tv.core.tmdb.TmdbMetadataService
import com.nuvio.tv.core.tmdb.TmdbService import com.nuvio.tv.core.tmdb.TmdbService
@ -76,7 +78,8 @@ class HomeViewModel @Inject constructor(
private val traktSettingsDataStore: TraktSettingsDataStore, private val traktSettingsDataStore: TraktSettingsDataStore,
private val tmdbService: TmdbService, private val tmdbService: TmdbService,
private val tmdbMetadataService: TmdbMetadataService, private val tmdbMetadataService: TmdbMetadataService,
private val trailerService: TrailerService private val trailerService: TrailerService,
private val deviceCapabilities: DeviceCapabilities
) : ViewModel() { ) : ViewModel() {
companion object { companion object {
private const val TAG = "HomeViewModel" 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_RECENT_PROGRESS_ITEMS = 300
private const val MAX_NEXT_UP_LOOKUPS = 24 private const val MAX_NEXT_UP_LOOKUPS = 24
private const val MAX_NEXT_UP_CONCURRENCY = 4 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 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 catalogsMap = linkedMapOf<String, CatalogRow>()
private val catalogOrder = mutableListOf<String>() private val catalogOrder = mutableListOf<String>()
private val activeCatalogLoadJobs = mutableMapOf<String, Job>()
private var addonsCache: List<Addon> = emptyList() private var addonsCache: List<Addon> = emptyList()
private var homeCatalogOrderKeys: List<String> = emptyList() private var homeCatalogOrderKeys: List<String> = emptyList()
private var disabledHomeCatalogKeys: Set<String> = emptySet() private var disabledHomeCatalogKeys: Set<String> = emptySet()
private var currentHeroCatalogKeys: List<String> = emptyList() private var currentHeroCatalogKeys: List<String> = emptyList()
private var activeCatalogLoadSessionId: Long = 0L
private var catalogUpdateJob: Job? = null private var catalogUpdateJob: Job? = null
private var hasRenderedFirstCatalog = false private var hasDispatchedFirstCatalogBatch = false
private val catalogLoadSemaphore = Semaphore(MAX_CATALOG_LOAD_CONCURRENCY) @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 var pendingCatalogLoads = 0
private data class TruncatedRowCacheEntry( private data class TruncatedRowCacheEntry(
val sourceRow: CatalogRow, val sourceRow: CatalogRow,
val truncatedRow: CatalogRow val truncatedRow: CatalogRow
) )
private val truncatedRowCache = mutableMapOf<String, TruncatedRowCacheEntry>() private val truncatedRowCache = mutableMapOf<String, TruncatedRowCacheEntry>()
private var catalogVersion = 0L
private var lastProcessedCatalogVersion = -1L
private val trailerPreviewLoadingIds = mutableSetOf<String>() private val trailerPreviewLoadingIds = mutableSetOf<String>()
private val trailerPreviewNegativeCache = mutableSetOf<String>() private val trailerPreviewNegativeCache = mutableSetOf<String>()
private val trailerPreviewUrlsState = mutableStateMapOf<String, String>() private val trailerPreviewUrlsState = mutableStateMapOf<String, String>()
@ -132,6 +149,9 @@ class HomeViewModel @Inject constructor(
private var pendingExternalMetaPrefetchItemId: String? = null private var pendingExternalMetaPrefetchItemId: String? = null
private val posterLibraryObserverJobs = mutableMapOf<String, Job>() private val posterLibraryObserverJobs = mutableMapOf<String, Job>()
private val movieWatchedObserverJobs = 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 @Volatile
private var externalMetaPrefetchEnabled: Boolean = false private var externalMetaPrefetchEnabled: Boolean = false
@Volatile @Volatile
@ -243,6 +263,7 @@ class HomeViewModel @Inject constructor(
previousState.heroSectionEnabled != prefs.heroSectionEnabled || previousState.heroSectionEnabled != prefs.heroSectionEnabled ||
previousState.homeLayout != prefs.layout previousState.homeLayout != prefs.layout
currentHeroCatalogKeys = prefs.heroCatalogKeys currentHeroCatalogKeys = prefs.heroCatalogKeys
val isLowTier = deviceCapabilities.tier == DeviceTier.LOW
_uiState.update { _uiState.update {
it.copy( it.copy(
homeLayout = prefs.layout, homeLayout = prefs.layout,
@ -252,14 +273,16 @@ class HomeViewModel @Inject constructor(
catalogAddonNameEnabled = prefs.catalogAddonNameEnabled, catalogAddonNameEnabled = prefs.catalogAddonNameEnabled,
catalogTypeSuffixEnabled = prefs.catalogTypeSuffixEnabled, catalogTypeSuffixEnabled = prefs.catalogTypeSuffixEnabled,
modernLandscapePostersEnabled = prefs.modernLandscapePostersEnabled, modernLandscapePostersEnabled = prefs.modernLandscapePostersEnabled,
focusedPosterBackdropExpandEnabled = prefs.focusedBackdropExpandEnabled, focusedPosterBackdropExpandEnabled = if (isLowTier) false else prefs.focusedBackdropExpandEnabled,
focusedPosterBackdropExpandDelaySeconds = prefs.focusedBackdropExpandDelaySeconds, focusedPosterBackdropExpandDelaySeconds = prefs.focusedBackdropExpandDelaySeconds,
focusedPosterBackdropTrailerEnabled = prefs.focusedBackdropTrailerEnabled, focusedPosterBackdropTrailerEnabled = if (isLowTier) false else prefs.focusedBackdropTrailerEnabled,
focusedPosterBackdropTrailerMuted = prefs.focusedBackdropTrailerMuted, focusedPosterBackdropTrailerMuted = prefs.focusedBackdropTrailerMuted,
focusedPosterBackdropTrailerPlaybackTarget = prefs.focusedBackdropTrailerPlaybackTarget, focusedPosterBackdropTrailerPlaybackTarget = prefs.focusedBackdropTrailerPlaybackTarget,
posterCardWidthDp = prefs.posterCardWidthDp, posterCardWidthDp = prefs.posterCardWidthDp,
posterCardHeightDp = prefs.posterCardHeightDp, posterCardHeightDp = prefs.posterCardHeightDp,
posterCardCornerRadiusDp = prefs.posterCardCornerRadiusDp posterCardCornerRadiusDp = prefs.posterCardCornerRadiusDp,
keyRepeatThrottleMs = deviceCapabilities.keyRepeatThrottleMs,
nestedPrefetchItemCount = deviceCapabilities.nestedPrefetchItemCount
) )
} }
if (shouldRefreshCatalogPresentation) { if (shouldRefreshCatalogPresentation) {
@ -376,6 +399,7 @@ class HomeViewModel @Inject constructor(
fun onItemFocus(item: MetaPreview) { fun onItemFocus(item: MetaPreview) {
if (startupGracePeriodActive) return if (startupGracePeriodActive) return
if (!externalMetaPrefetchEnabled) return if (!externalMetaPrefetchEnabled) return
if (deviceCapabilities.tier == DeviceTier.LOW) return
if (item.id in prefetchedExternalMetaIds) return if (item.id in prefetchedExternalMetaIds) return
if (pendingExternalMetaPrefetchItemId == item.id) return if (pendingExternalMetaPrefetchItemId == item.id) return
@ -423,6 +447,7 @@ class HomeViewModel @Inject constructor(
val mutableItems = row.items.toMutableList() val mutableItems = row.items.toMutableList()
mutableItems[itemIndex] = merged mutableItems[itemIndex] = merged
catalogsMap[key] = row.copy(items = mutableItems) catalogsMap[key] = row.copy(items = mutableItems)
catalogVersion++
truncatedRowCache.remove(key) truncatedRowCache.remove(key)
} }
} }
@ -452,7 +477,9 @@ class HomeViewModel @Inject constructor(
private fun loadHomeCatalogOrderPreference() { private fun loadHomeCatalogOrderPreference() {
viewModelScope.launch { viewModelScope.launch {
layoutPreferenceDataStore.homeCatalogOrderKeys.collectLatest { keys -> layoutPreferenceDataStore.homeCatalogOrderKeys
.distinctUntilChanged()
.collectLatest { keys ->
homeCatalogOrderKeys = keys homeCatalogOrderKeys = keys
rebuildCatalogOrder(addonsCache) rebuildCatalogOrder(addonsCache)
scheduleUpdateCatalogRows() scheduleUpdateCatalogRows()
@ -462,7 +489,9 @@ class HomeViewModel @Inject constructor(
private fun loadDisabledHomeCatalogPreference() { private fun loadDisabledHomeCatalogPreference() {
viewModelScope.launch { viewModelScope.launch {
layoutPreferenceDataStore.disabledHomeCatalogKeys.collectLatest { keys -> layoutPreferenceDataStore.disabledHomeCatalogKeys
.distinctUntilChanged()
.collectLatest { keys ->
disabledHomeCatalogKeys = keys.toSet() disabledHomeCatalogKeys = keys.toSet()
rebuildCatalogOrder(addonsCache) rebuildCatalogOrder(addonsCache)
if (addonsCache.isNotEmpty()) { if (addonsCache.isNotEmpty()) {
@ -560,36 +589,39 @@ class HomeViewModel @Inject constructor(
showUnairedNextUp = showUnairedNextUp showUnairedNextUp = showUnairedNextUp
) )
}.collectLatest { snapshot -> }.collectLatest { snapshot ->
val items = snapshot.items val preparedState = withContext(Dispatchers.Default) {
val daysCap = snapshot.daysCap 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 dismissedNextUp = snapshot.dismissedNextUp
val showUnairedNextUp = snapshot.showUnairedNextUp val showUnairedNextUp = snapshot.showUnairedNextUp
val cutoffMs = if (daysCap == TraktSettingsDataStore.CONTINUE_WATCHING_DAYS_CAP_ALL) { Log.d("HomeViewModel", "allProgress emitted=${preparedState.allProgressCount} recentWindow=${recentItems.size}")
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", "inProgressOnly: ${inProgressOnly.size} items after filter+dedup") 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. // Then enrich Next Up and item details in background.
enrichContinueWatchingProgressively( enrichContinueWatchingProgressively(
allProgress = recentItems, allProgress = recentItems,
@ -621,6 +661,12 @@ class HomeViewModel @Inject constructor(
val showUnairedNextUp: Boolean val showUnairedNextUp: Boolean
) )
private data class ContinueWatchingPreparedState(
val allProgressCount: Int,
val recentItems: List<WatchProgress>,
val inProgressOnly: List<ContinueWatchingItem.InProgress>
)
private data class NextUpArtworkFallback( private data class NextUpArtworkFallback(
val thumbnail: String?, val thumbnail: String?,
val backdrop: String?, val backdrop: String?,
@ -692,34 +738,35 @@ class HomeViewModel @Inject constructor(
dismissedNextUp: Set<String>, dismissedNextUp: Set<String>,
showUnairedNextUp: Boolean showUnairedNextUp: Boolean
) = coroutineScope { ) = coroutineScope {
val inProgressIds = inProgressItems val latestCompletedBySeries = withContext(Dispatchers.Default) {
.map { it.progress.contentId } val inProgressIds = inProgressItems
.filter { it.isNotBlank() } .map { it.progress.contentId }
.toSet() .filter { it.isNotBlank() }
.toSet()
val latestCompletedBySeries = allProgress allProgress
.filter { progress -> .filter { progress ->
isSeriesType(progress.contentType) && isSeriesType(progress.contentType) &&
progress.season != null && progress.season != null &&
progress.episode != null && progress.episode != null &&
progress.season != 0 && progress.season != 0 &&
progress.isCompleted() && progress.isCompleted() &&
progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK
} }
.groupBy { it.contentId } .groupBy { it.contentId }
.mapNotNull { (_, items) -> .mapNotNull { (_, items) ->
items.maxWithOrNull( items.maxWithOrNull(
compareBy<WatchProgress>( compareBy<WatchProgress>(
{ it.lastWatched }, { it.lastWatched },
{ it.season ?: -1 }, { it.season ?: -1 },
{ it.episode ?: -1 } { it.episode ?: -1 }
)
) )
) }
} .filter { it.contentId !in inProgressIds }
.filter { it.contentId !in inProgressIds } .filter { progress -> nextUpDismissKey(progress.contentId) !in dismissedNextUp }
.filter { progress -> nextUpDismissKey(progress.contentId) !in dismissedNextUp } .sortedByDescending { it.lastWatched }
.sortedByDescending { it.lastWatched } .take(MAX_NEXT_UP_LOOKUPS)
.take(MAX_NEXT_UP_LOOKUPS) }
if (latestCompletedBySeries.isEmpty()) { if (latestCompletedBySeries.isEmpty()) {
return@coroutineScope return@coroutineScope
@ -741,7 +788,7 @@ class HomeViewModel @Inject constructor(
) ?: return@withPermit ) ?: return@withPermit
mergeMutex.withLock { mergeMutex.withLock {
nextUpByContent[progress.contentId] = nextUp nextUpByContent[progress.contentId] = nextUp
if (nextUpByContent.size - lastEmittedNextUpCount >= 2) { if (nextUpByContent.size - lastEmittedNextUpCount >= 4) {
val nextUpItems = nextUpByContent.values.toList() val nextUpItems = nextUpByContent.values.toList()
_uiState.update { _uiState.update {
val mergedItems = mergeContinueWatchingItems( val mergedItems = mergeContinueWatchingItems(
@ -802,7 +849,7 @@ class HomeViewModel @Inject constructor(
if (enrichedItem != item) { if (enrichedItem != item) {
enrichedByProgress[item.progress] = enrichedItem enrichedByProgress[item.progress] = enrichedItem
if (enrichedByProgress.size - lastAppliedCount >= 2) { if (enrichedByProgress.size - lastAppliedCount >= 4) {
applyInProgressEpisodeDetailEnrichment(enrichedByProgress) applyInProgressEpisodeDetailEnrichment(enrichedByProgress)
lastAppliedCount = enrichedByProgress.size lastAppliedCount = enrichedByProgress.size
} }
@ -1177,6 +1224,7 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
addonRepository.getInstalledAddons() addonRepository.getInstalledAddons()
.distinctUntilChanged() .distinctUntilChanged()
.debounce(200)
.collectLatest { addons -> .collectLatest { addons ->
addonsCache = addons addonsCache = addons
loadAllCatalogs(addons) loadAllCatalogs(addons)
@ -1185,13 +1233,19 @@ class HomeViewModel @Inject constructor(
} }
private suspend fun loadAllCatalogs(addons: List<Addon>) { private suspend fun loadAllCatalogs(addons: List<Addon>) {
val catalogLoadSessionId = startCatalogLoadSession()
_uiState.update { it.copy(isLoading = true, error = null, installedAddonsCount = addons.size) } _uiState.update { it.copy(isLoading = true, error = null, installedAddonsCount = addons.size) }
catalogOrder.clear() catalogOrder.clear()
catalogsMap.clear() catalogsMap.clear()
catalogVersion++
lastProcessedCatalogVersion = -1L
reconcilePosterObserversJob?.cancel()
hasBootstrappedPosterObservers = false
hasDispatchedFirstCatalogBatch = false
hasCatalogContentDrawn = false
reconcilePosterStatusObservers(emptyList()) reconcilePosterStatusObservers(emptyList())
_fullCatalogRows.value = emptyList() _fullCatalogRows.value = emptyList()
truncatedRowCache.clear() truncatedRowCache.clear()
hasRenderedFirstCatalog = false
trailerPreviewLoadingIds.clear() trailerPreviewLoadingIds.clear()
trailerPreviewNegativeCache.clear() trailerPreviewNegativeCache.clear()
trailerPreviewUrlsState.clear() trailerPreviewUrlsState.clear()
@ -1206,6 +1260,7 @@ class HomeViewModel @Inject constructor(
try { try {
if (addons.isEmpty()) { if (addons.isEmpty()) {
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
_uiState.update { it.copy(isLoading = false, error = "No addons installed") } _uiState.update { it.copy(isLoading = false, error = "No addons installed") }
return return
} }
@ -1213,6 +1268,7 @@ class HomeViewModel @Inject constructor(
rebuildCatalogOrder(addons) rebuildCatalogOrder(addons)
if (catalogOrder.isEmpty()) { if (catalogOrder.isEmpty()) {
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
_uiState.update { it.copy(isLoading = false, error = "No catalog addons installed") } _uiState.update { it.copy(isLoading = false, error = "No catalog addons installed") }
return return
} }
@ -1232,17 +1288,52 @@ class HomeViewModel @Inject constructor(
.map { catalog -> addon to catalog } .map { catalog -> addon to catalog }
} }
pendingCatalogLoads = catalogsToLoad.size 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) { } catch (e: Exception) {
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
_uiState.update { it.copy(isLoading = false, error = e.message) } _uiState.update { it.copy(isLoading = false, error = e.message) }
} }
} }
private fun loadCatalog(addon: Addon, catalog: CatalogDescriptor) { private fun startCatalogLoadSession(): Long {
viewModelScope.launch { 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 { catalogLoadSemaphore.withPermit {
if (sessionId != activeCatalogLoadSessionId) return@withPermit
val supportsSkip = catalog.extra.any { it.name == "skip" } val supportsSkip = catalog.extra.any { it.name == "skip" }
Log.d( Log.d(
TAG, TAG,
@ -1258,14 +1349,11 @@ class HomeViewModel @Inject constructor(
skip = 0, skip = 0,
supportsSkip = supportsSkip supportsSkip = supportsSkip
).collect { result -> ).collect { result ->
if (sessionId != activeCatalogLoadSessionId) return@collect
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val key = catalogKey(
addonId = addon.id,
type = catalog.apiType,
catalogId = catalog.id
)
catalogsMap[key] = result.data catalogsMap[key] = result.data
catalogVersion++
pendingCatalogLoads = (pendingCatalogLoads - 1).coerceAtLeast(0) pendingCatalogLoads = (pendingCatalogLoads - 1).coerceAtLeast(0)
Log.d( Log.d(
TAG, 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) { 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 // Mark loading via lightweight separate flow — avoids full state cascade
catalogsMap[key] = currentRow.copy(isLoading = true) catalogsMap[key] = currentRow.copy(isLoading = true)
catalogVersion++
_loadingCatalogs.update { it + key } _loadingCatalogs.update { it + key }
viewModelScope.launch { viewModelScope.launch {
@ -1318,11 +1413,13 @@ class HomeViewModel @Inject constructor(
is NetworkResult.Success -> { is NetworkResult.Success -> {
val mergedItems = currentRow.items + result.data.items val mergedItems = currentRow.items + result.data.items
catalogsMap[key] = result.data.copy(items = mergedItems) catalogsMap[key] = result.data.copy(items = mergedItems)
catalogVersion++
_loadingCatalogs.update { it - key } _loadingCatalogs.update { it - key }
scheduleUpdateCatalogRows() scheduleUpdateCatalogRows()
} }
is NetworkResult.Error -> { is NetworkResult.Error -> {
catalogsMap[key] = currentRow.copy(isLoading = false) catalogsMap[key] = currentRow.copy(isLoading = false)
catalogVersion++
_loadingCatalogs.update { it - key } _loadingCatalogs.update { it - key }
scheduleUpdateCatalogRows() scheduleUpdateCatalogRows()
} }
@ -1338,13 +1435,13 @@ class HomeViewModel @Inject constructor(
val debounceMs = when { val debounceMs = when {
// First render: use minimal debounce to show content ASAP while still // First render: use minimal debounce to show content ASAP while still
// batching near-simultaneous arrivals. // batching near-simultaneous arrivals.
!hasRenderedFirstCatalog && catalogsMap.isNotEmpty() -> { !hasDispatchedFirstCatalogBatch && catalogsMap.isNotEmpty() -> {
hasRenderedFirstCatalog = true hasDispatchedFirstCatalogBatch = true
50L 50L
} }
pendingCatalogLoads > 8 -> 200L pendingCatalogLoads > 8 -> 350L
pendingCatalogLoads > 3 -> 150L pendingCatalogLoads > 3 -> 250L
pendingCatalogLoads > 0 -> 100L pendingCatalogLoads > 0 -> 150L
else -> 50L else -> 50L
} }
delay(debounceMs) delay(debounceMs)
@ -1353,6 +1450,11 @@ class HomeViewModel @Inject constructor(
} }
private suspend fun updateCatalogRows() { 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 // Snapshot mutable state before entering background context
val orderedKeys = catalogOrder.toList() val orderedKeys = catalogOrder.toList()
val catalogSnapshot = catalogsMap.toMap() val catalogSnapshot = catalogsMap.toMap()
@ -1465,10 +1567,13 @@ class HomeViewModel @Inject constructor(
Triple(computedDisplayRows, computedHeroItems, computedGridItems) Triple(computedDisplayRows, computedHeroItems, computedGridItems)
} }
// Full (untruncated) rows for CatalogSeeAllScreen // Full (untruncated) rows for CatalogSeeAllScreen — only needed when
val fullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] } // user navigates to SeeAll, so defer during the initial bulk load.
_fullCatalogRows.update { rows -> if (pendingCatalogLoads <= 0) {
if (rows == fullRows) rows else fullRows 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 // 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 tmdbSettings = currentTmdbSettings
val shouldUseEnrichedHeroItems = tmdbSettings.enabled && val shouldUseEnrichedHeroItems = tmdbSettings.enabled &&
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails) (tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) { if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty() && pendingCatalogLoads <= 0) {
heroEnrichmentJob?.cancel() heroEnrichmentJob?.cancel()
heroEnrichmentJob = viewModelScope.launch { heroEnrichmentJob = viewModelScope.launch {
val enrichmentSignature = heroEnrichmentSignature(baseHeroItems, tmdbSettings) val enrichmentSignature = heroEnrichmentSignature(baseHeroItems, tmdbSettings)
@ -1529,10 +1636,72 @@ class HomeViewModel @Inject constructor(
lastHeroEnrichedItems = emptyList() 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>>() val desiredItemsByKey = linkedMapOf<String, Pair<String, String>>()
rows.asSequence() rows.asSequence()
.flatMap { row -> row.items.asSequence() } .flatMap { row -> row.items.asSequence() }
@ -1558,26 +1727,24 @@ class HomeViewModel @Inject constructor(
movieWatchedObserverJobs.remove(staleKey)?.cancel() 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) -> desiredItemsByKey.forEach { (statusKey, itemRef) ->
val itemId = itemRef.first val itemId = itemRef.first
val itemType = itemRef.second val itemType = itemRef.second
var launchedAny = false
if (statusKey !in posterLibraryObserverJobs) { if (statusKey !in posterLibraryObserverJobs) {
posterLibraryObserverJobs[statusKey] = viewModelScope.launch { posterLibraryObserverJobs[statusKey] = viewModelScope.launch {
libraryRepository.isInLibrary(itemId = itemId, itemType = itemType) libraryRepository.isInLibrary(itemId = itemId, itemType = itemType)
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { isInLibrary -> .collectLatest { isInLibrary ->
_uiState.update { state -> pendingLibraryStatusUpdates[statusKey] = isInLibrary
if (state.posterLibraryMembership[statusKey] == isInLibrary) { schedulePosterStatusFlush()
state
} else {
state.copy(
posterLibraryMembership = state.posterLibraryMembership + (statusKey to isInLibrary)
)
}
}
} }
} }
launchedAny = true
} }
if (itemType.equals("movie", ignoreCase = true)) { if (itemType.equals("movie", ignoreCase = true)) {
@ -1586,21 +1753,33 @@ class HomeViewModel @Inject constructor(
watchProgressRepository.isWatched(contentId = itemId) watchProgressRepository.isWatched(contentId = itemId)
.distinctUntilChanged() .distinctUntilChanged()
.collectLatest { watched -> .collectLatest { watched ->
_uiState.update { state -> pendingWatchedStatusUpdates[statusKey] = watched
if (state.movieWatchedStatus[statusKey] == watched) { schedulePosterStatusFlush()
state
} else {
state.copy(
movieWatchedStatus = state.movieWatchedStatus + (statusKey to watched)
)
}
}
} }
} }
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 -> _uiState.update { state ->
val trimmedLibraryMembership = val trimmedLibraryMembership =
state.posterLibraryMembership.filterKeys { it in desiredKeys } state.posterLibraryMembership.filterKeys { it in desiredKeys }
@ -1895,10 +2074,16 @@ class HomeViewModel @Inject constructor(
} }
override fun onCleared() { override fun onCleared() {
posterStatusFlushJob?.cancel()
reconcilePosterObserversJob?.cancel()
activeCatalogLoadJobs.values.forEach { it.cancel() }
activeCatalogLoadJobs.clear()
posterLibraryObserverJobs.values.forEach { it.cancel() } posterLibraryObserverJobs.values.forEach { it.cancel() }
movieWatchedObserverJobs.values.forEach { it.cancel() } movieWatchedObserverJobs.values.forEach { it.cancel() }
posterLibraryObserverJobs.clear() posterLibraryObserverJobs.clear()
movieWatchedObserverJobs.clear() movieWatchedObserverJobs.clear()
pendingLibraryStatusUpdates.clear()
pendingWatchedStatusUpdates.clear()
super.onCleared() super.onCleared()
} }
} }

View file

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

View file

@ -38,6 +38,8 @@ import coil.request.ImageRequest
import com.nuvio.tv.ui.components.TrailerPlayer import com.nuvio.tv.ui.components.TrailerPlayer
import com.nuvio.tv.ui.theme.NuvioColors import com.nuvio.tv.ui.theme.NuvioColors
private val HeroCrossfadeAnimSpec = tween<Float>(durationMillis = 200)
@Composable @Composable
internal fun ModernHeroMediaLayer( internal fun ModernHeroMediaLayer(
heroBackdrop: String?, heroBackdrop: String?,
@ -60,7 +62,7 @@ internal fun ModernHeroMediaLayer(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer { alpha = heroBackdropAlpha }, .graphicsLayer { alpha = heroBackdropAlpha },
animationSpec = tween(durationMillis = 350), animationSpec = HeroCrossfadeAnimSpec,
label = "modernHeroBackground" label = "modernHeroBackground"
) { imageUrl -> ) { imageUrl ->
val imageModel = remember(localContext, imageUrl, requestWidthPx, requestHeightPx) { 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.drawWithCache { .drawWithCache {
val horizontalGradient = Brush.horizontalGradient( val horizontalGradient = Brush.horizontalGradient(colorStops = horizontalGradientStops)
0.0f to bgColor.copy(alpha = 0.96f),
0.10f to bgColor.copy(alpha = 0.72f),
0.30f to Color.Transparent
)
val radialGradient = Brush.radialGradient( val radialGradient = Brush.radialGradient(
colorStops = arrayOf( colorStops = radialGradientStops,
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
),
center = Offset(0f, size.height / 2f), center = Offset(0f, size.height / 2f),
radius = size.height radius = size.height
) )
val verticalGradient = Brush.verticalGradient( val verticalGradient = Brush.verticalGradient(colorStops = verticalGradientStops)
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
)
onDrawBehind { onDrawBehind {
drawRect(brush = horizontalGradient, size = size) drawRect(brush = horizontalGradient, size = size)
drawRect(brush = radialGradient, size = size) drawRect(brush = radialGradient, size = size)
@ -292,10 +298,11 @@ internal fun HeroTitleBlock(
@Composable @Composable
private fun HeroMetaDivider(scale: Float) { private fun HeroMetaDivider(scale: Float) {
val dividerColor = remember { NuvioColors.TextTertiary.copy(alpha = 0.78f) }
Box( Box(
modifier = Modifier modifier = Modifier
.size((4.dp * scale).coerceAtLeast(2.dp)) .size((4.dp * scale).coerceAtLeast(2.dp))
.clip(RoundedCornerShape(percent = 50)) .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_TEXT_WIDTH_FRACTION = 0.42f
internal const val MODERN_HERO_BACKDROP_HEIGHT_FRACTION = 0.62f internal const val MODERN_HERO_BACKDROP_HEIGHT_FRACTION = 0.62f
internal const val MODERN_TRAILER_OVERSCAN_ZOOM = 1.35f 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_ROW_HEADER_FOCUS_INSET = 56.dp
internal val MODERN_LANDSCAPE_LOGO_GRADIENT = Brush.verticalGradient( internal val MODERN_LANDSCAPE_LOGO_GRADIENT = Brush.verticalGradient(
colorStops = arrayOf( colorStops = arrayOf(

View file

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

View file

@ -3,6 +3,8 @@ package com.nuvio.tv.ui.screens.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.nuvio.tv.core.auth.AuthManager 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 com.nuvio.tv.data.local.DebugSettingsDataStore
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -16,7 +18,8 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class DebugSettingsViewModel @Inject constructor( class DebugSettingsViewModel @Inject constructor(
private val dataStore: DebugSettingsDataStore, private val dataStore: DebugSettingsDataStore,
private val authManager: AuthManager private val authManager: AuthManager,
private val deviceCapabilities: DeviceCapabilities
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(DebugSettingsUiState()) private val _uiState = MutableStateFlow(DebugSettingsUiState())
@ -33,6 +36,23 @@ class DebugSettingsViewModel @Inject constructor(
_uiState.update { it.copy(syncCodeFeaturesEnabled = enabled) } _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) { fun onEvent(event: DebugSettingsEvent) {
@ -43,6 +63,9 @@ class DebugSettingsViewModel @Inject constructor(
is DebugSettingsEvent.ToggleSyncCodeFeatures -> { is DebugSettingsEvent.ToggleSyncCodeFeatures -> {
viewModelScope.launch { dataStore.setSyncCodeFeaturesEnabled(event.enabled) } viewModelScope.launch { dataStore.setSyncCodeFeaturesEnabled(event.enabled) }
} }
is DebugSettingsEvent.SetDeviceTierOverride -> {
viewModelScope.launch { dataStore.setDeviceTierOverride(event.value) }
}
is DebugSettingsEvent.SignIn -> { is DebugSettingsEvent.SignIn -> {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(signInLoading = true, signInResult = null) } _uiState.update { it.copy(signInLoading = true, signInResult = null) }
@ -63,11 +86,14 @@ data class DebugSettingsUiState(
val accountTabEnabled: Boolean = false, val accountTabEnabled: Boolean = false,
val syncCodeFeaturesEnabled: Boolean = false, val syncCodeFeaturesEnabled: Boolean = false,
val signInLoading: 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 { sealed class DebugSettingsEvent {
data class ToggleAccountTab(val enabled: Boolean) : DebugSettingsEvent() data class ToggleAccountTab(val enabled: Boolean) : DebugSettingsEvent()
data class ToggleSyncCodeFeatures(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() data class SignIn(val email: String, val password: String) : DebugSettingsEvent()
} }

View file

@ -6,10 +6,31 @@
com.nuvio.tv.domain.model.* com.nuvio.tv.domain.model.*
// UI state classes // 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.SearchUiState
com.nuvio.tv.ui.screens.search.DiscoverCatalog com.nuvio.tv.ui.screens.search.DiscoverCatalog
com.nuvio.tv.ui.components.PosterCardStyle 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 // Collections from kotlinx
kotlin.collections.List kotlin.collections.List
kotlin.collections.Map kotlin.collections.Map

View file

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

View file

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