Compare commits
4 commits
dev
...
performanc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0f978a715 | ||
|
|
68cf2a597b | ||
|
|
244907a9dd | ||
|
|
c81e00c0e5 |
26 changed files with 1210 additions and 311 deletions
|
|
@ -63,9 +63,10 @@ android {
|
|||
buildTypes {
|
||||
debug {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isDebuggable = false
|
||||
isDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
|
||||
resValue("string", "app_name", "Nuvio Debug")
|
||||
buildConfigField("boolean", "IS_DEBUG_BUILD", "true")
|
||||
|
||||
// Dev environment (from local.dev.properties)
|
||||
|
|
@ -86,6 +87,7 @@ android {
|
|||
)
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
|
||||
resValue("string", "app_name", "Nuvio")
|
||||
buildConfigField("boolean", "IS_DEBUG_BUILD", "false")
|
||||
|
||||
// Production environment (from local.properties)
|
||||
|
|
@ -124,6 +126,12 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
variant.applicationId.set("com.nuviodebug.tv")
|
||||
}
|
||||
}
|
||||
|
||||
composeCompiler {
|
||||
// Enable Compose compiler metrics for performance analysis
|
||||
metricsDestination = layout.buildDirectory.dir("compose_metrics")
|
||||
|
|
@ -140,7 +148,7 @@ configurations.all {
|
|||
dependencies {
|
||||
val composeBom = platform("androidx.compose:compose-bom:2026.01.01")
|
||||
|
||||
// baselineProfile(project(":benchmark")) // TODO: create benchmark module later
|
||||
baselineProfile(project(":benchmark"))
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation("androidx.core:core-splashscreen:1.0.1")
|
||||
implementation(libs.androidx.appcompat)
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@
|
|||
android:allowBackup="true"
|
||||
android:banner="@mipmap/banner"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="Nuvio"
|
||||
android:label="@string/app_name"
|
||||
android:largeHeap="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:supportsRtl="true"
|
||||
|
|
|
|||
|
|
@ -116,12 +116,14 @@ import com.nuvio.tv.ui.screens.account.AuthQrSignInScreen
|
|||
import com.nuvio.tv.ui.screens.profile.ProfileSelectionScreen
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
import com.nuvio.tv.ui.theme.NuvioTheme
|
||||
import com.nuvio.tv.updater.UpdateUiState
|
||||
import com.nuvio.tv.updater.UpdateViewModel
|
||||
import com.nuvio.tv.updater.ui.UpdatePromptDialog
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.haze
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -175,6 +177,8 @@ class MainActivity : ComponentActivity() {
|
|||
lateinit var appOnboardingDataStore: AppOnboardingDataStore
|
||||
|
||||
private lateinit var jankStats: JankStats
|
||||
private var startupSyncJob: Job? = null
|
||||
private var hasDispatchedStartupSync: Boolean = false
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
override fun attachBaseContext(newBase: Context) {
|
||||
|
|
@ -330,8 +334,17 @@ class MainActivity : ComponentActivity() {
|
|||
mainUiPrefs.modernSidebarBlurPref && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S
|
||||
val hideBuiltInHeadersForFloatingPill = modernSidebarEnabled && !sidebarCollapsed
|
||||
|
||||
val updateViewModel: UpdateViewModel = hiltViewModel(this@MainActivity)
|
||||
val updateState by updateViewModel.uiState.collectAsState()
|
||||
var shouldInitUpdateViewModel by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
delay(4000)
|
||||
shouldInitUpdateViewModel = true
|
||||
}
|
||||
val updateViewModel: UpdateViewModel? = if (shouldInitUpdateViewModel) {
|
||||
hiltViewModel(this@MainActivity)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val updateState = updateViewModel?.uiState?.collectAsState()?.value ?: UpdateUiState()
|
||||
|
||||
val startDestination = if (layoutChosen) Screen.Home.route else Screen.LayoutSelection.route
|
||||
val navController = rememberNavController()
|
||||
|
|
@ -443,11 +456,11 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
UpdatePromptDialog(
|
||||
state = updateState,
|
||||
onDismiss = { updateViewModel.dismissDialog() },
|
||||
onDownload = { updateViewModel.downloadUpdate() },
|
||||
onInstall = { updateViewModel.installUpdateOrRequestPermission() },
|
||||
onIgnore = { updateViewModel.ignoreThisVersion() },
|
||||
onOpenUnknownSources = { updateViewModel.openUnknownSourcesSettings() }
|
||||
onDismiss = { updateViewModel?.dismissDialog() },
|
||||
onDownload = { updateViewModel?.downloadUpdate() },
|
||||
onInstall = { updateViewModel?.installUpdateOrRequestPermission() },
|
||||
onIgnore = { updateViewModel?.ignoreThisVersion() },
|
||||
onOpenUnknownSources = { updateViewModel?.openUnknownSourcesSettings() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -476,9 +489,22 @@ class MainActivity : ComponentActivity() {
|
|||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
startupSyncService.requestSyncNow()
|
||||
lifecycleScope.launch {
|
||||
if (hasDispatchedStartupSync || startupSyncJob?.isActive == true) {
|
||||
return
|
||||
}
|
||||
startupSyncJob = lifecycleScope.launch {
|
||||
delay(3200)
|
||||
startupSyncService.requestSyncNow()
|
||||
delay(1200)
|
||||
traktProgressService.refreshNow()
|
||||
hasDispatchedStartupSync = true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
if (!hasDispatchedStartupSync && startupSyncJob?.isActive == true) {
|
||||
startupSyncJob?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,68 @@
|
|||
package com.nuvio.tv
|
||||
|
||||
import android.app.Application
|
||||
import android.content.ComponentCallbacks2
|
||||
import android.content.res.Configuration
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.nuvio.tv.core.device.DeviceCapabilities
|
||||
import com.nuvio.tv.core.sync.StartupSyncService
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltAndroidApp
|
||||
class NuvioApplication : Application(), ImageLoaderFactory {
|
||||
class NuvioApplication : Application(), ImageLoaderFactory, ComponentCallbacks2 {
|
||||
|
||||
@Inject lateinit var startupSyncService: StartupSyncService
|
||||
@Inject lateinit var deviceCapabilities: DeviceCapabilities
|
||||
|
||||
private var _imageLoader: ImageLoader? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
val caps = deviceCapabilities
|
||||
val loader = ImageLoader.Builder(this)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.25)
|
||||
.maxSizePercent(caps.memoryCachePercent)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(cacheDir.resolve("image_cache"))
|
||||
.maxSizeBytes(200L * 1024 * 1024)
|
||||
.maxSizeBytes(caps.diskCacheSizeBytes)
|
||||
.build()
|
||||
}
|
||||
.decoderDispatcher(Dispatchers.IO.limitedParallelism(2))
|
||||
.fetcherDispatcher(Dispatchers.IO.limitedParallelism(4))
|
||||
.bitmapFactoryMaxParallelism(2)
|
||||
.decoderDispatcher(Dispatchers.IO.limitedParallelism(caps.decoderParallelism))
|
||||
.fetcherDispatcher(Dispatchers.IO.limitedParallelism(caps.fetcherParallelism))
|
||||
.bitmapFactoryMaxParallelism(caps.decoderParallelism)
|
||||
.allowRgb565(true)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
_imageLoader = loader
|
||||
return loader
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onTrimMemory(level: Int) {
|
||||
super.onTrimMemory(level)
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW) {
|
||||
_imageLoader?.memoryCache?.clear()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLowMemory() {
|
||||
super.onLowMemory()
|
||||
_imageLoader?.memoryCache?.clear()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
app/src/main/java/com/nuvio/tv/core/device/DeviceCapabilities.kt
Normal file
140
app/src/main/java/com/nuvio/tv/core/device/DeviceCapabilities.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import com.nuvio.tv.data.remote.api.TmdbImage
|
|||
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCast
|
||||
import com.nuvio.tv.data.remote.api.TmdbPersonCreditCrew
|
||||
import com.nuvio.tv.data.remote.api.TmdbRecommendationResult
|
||||
import com.nuvio.tv.core.device.DeviceCapabilities
|
||||
import com.nuvio.tv.domain.model.ContentType
|
||||
import com.nuvio.tv.domain.model.MetaCastMember
|
||||
import com.nuvio.tv.domain.model.MetaCompany
|
||||
|
|
@ -28,7 +29,8 @@ private const val TMDB_API_KEY = "439c478a771f35c05022f9feabcca01c"
|
|||
|
||||
@Singleton
|
||||
class TmdbMetadataService @Inject constructor(
|
||||
private val tmdbApi: TmdbApi
|
||||
private val tmdbApi: TmdbApi,
|
||||
private val deviceCapabilities: DeviceCapabilities
|
||||
) {
|
||||
// In-memory caches
|
||||
private val enrichmentCache = ConcurrentHashMap<String, TmdbEnrichment>()
|
||||
|
|
@ -120,7 +122,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
val name = company.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
MetaCompany(
|
||||
name = name,
|
||||
logo = buildImageUrl(company.logoPath, size = "w300")
|
||||
logo = buildLogoUrl(company.logoPath)
|
||||
)
|
||||
}
|
||||
val networks = details?.networks
|
||||
|
|
@ -129,11 +131,11 @@ class TmdbMetadataService @Inject constructor(
|
|||
val name = network.name?.trim()?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
|
||||
MetaCompany(
|
||||
name = name,
|
||||
logo = buildImageUrl(network.logoPath, size = "w300")
|
||||
logo = buildLogoUrl(network.logoPath)
|
||||
)
|
||||
}
|
||||
val poster = buildImageUrl(details?.posterPath, size = "w500")
|
||||
val backdrop = buildImageUrl(details?.backdropPath, size = "w1280")
|
||||
val poster = buildPosterUrl(details?.posterPath)
|
||||
val backdrop = buildBackdropUrl(details?.backdropPath)
|
||||
|
||||
val logoPath = images?.logos
|
||||
?.sortedWith(
|
||||
|
|
@ -146,7 +148,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
?.firstOrNull()
|
||||
?.filePath
|
||||
|
||||
val logo = buildImageUrl(logoPath, size = "w500")
|
||||
val logo = buildLogoUrl(logoPath)
|
||||
|
||||
val castMembers = credits?.cast
|
||||
.orEmpty()
|
||||
|
|
@ -155,7 +157,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
MetaCastMember(
|
||||
name = name,
|
||||
character = member.character?.takeIf { it.isNotBlank() },
|
||||
photo = buildImageUrl(member.profilePath, size = "w500"),
|
||||
photo = buildProfileUrl(member.profilePath),
|
||||
tmdbId = member.id
|
||||
)
|
||||
}
|
||||
|
|
@ -169,7 +171,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
MetaCastMember(
|
||||
name = name,
|
||||
character = "Creator",
|
||||
photo = buildImageUrl(creator.profilePath, size = "w500"),
|
||||
photo = buildProfileUrl(creator.profilePath),
|
||||
tmdbId = tmdbPersonId
|
||||
)
|
||||
}
|
||||
|
|
@ -197,7 +199,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
MetaCastMember(
|
||||
name = name,
|
||||
character = "Director",
|
||||
photo = buildImageUrl(member.profilePath, size = "w500"),
|
||||
photo = buildProfileUrl(member.profilePath),
|
||||
tmdbId = tmdbPersonId
|
||||
)
|
||||
}
|
||||
|
|
@ -220,7 +222,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
MetaCastMember(
|
||||
name = name,
|
||||
character = "Writer",
|
||||
photo = buildImageUrl(member.profilePath, size = "w500"),
|
||||
photo = buildProfileUrl(member.profilePath),
|
||||
tmdbId = tmdbPersonId
|
||||
)
|
||||
}
|
||||
|
|
@ -404,8 +406,8 @@ class TmdbMetadataService @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
val backdrop = buildImageUrl(localizedBackdropPath ?: rec.backdropPath, size = "w1280")
|
||||
val fallbackPoster = buildImageUrl(rec.posterPath, size = "w780")
|
||||
val backdrop = buildBackdropUrl(localizedBackdropPath ?: rec.backdropPath)
|
||||
val fallbackPoster = buildBackdropUrl(rec.posterPath)
|
||||
val releaseInfo = (rec.releaseDate ?: rec.firstAirDate)?.take(4)
|
||||
|
||||
MetaPreview(
|
||||
|
|
@ -433,6 +435,21 @@ class TmdbMetadataService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun buildPosterUrl(path: String?): String? =
|
||||
buildImageUrl(path, deviceCapabilities.tmdbPosterSize)
|
||||
|
||||
private fun buildBackdropUrl(path: String?): String? =
|
||||
buildImageUrl(path, deviceCapabilities.tmdbBackdropSize)
|
||||
|
||||
private fun buildProfileUrl(path: String?): String? =
|
||||
buildImageUrl(path, deviceCapabilities.tmdbProfileSize)
|
||||
|
||||
private fun buildLogoUrl(path: String?): String? =
|
||||
buildImageUrl(path, deviceCapabilities.tmdbLogoSize)
|
||||
|
||||
private fun buildStillUrl(path: String?): String? =
|
||||
buildImageUrl(path, deviceCapabilities.tmdbStillSize)
|
||||
|
||||
private fun buildImageUrl(path: String?, size: String): String? {
|
||||
val clean = path?.trim()?.takeIf { it.isNotBlank() } ?: return null
|
||||
return "https://image.tmdb.org/t/p/$size$clean"
|
||||
|
|
@ -509,7 +526,7 @@ class TmdbMetadataService @Inject constructor(
|
|||
birthday = person.birthday?.takeIf { it.isNotBlank() },
|
||||
deathday = person.deathday?.takeIf { it.isNotBlank() },
|
||||
placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() },
|
||||
profilePhoto = buildImageUrl(person.profilePath, "w500"),
|
||||
profilePhoto = buildProfileUrl(person.profilePath),
|
||||
knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() },
|
||||
movieCredits = movieCredits,
|
||||
tvCredits = tvCredits
|
||||
|
|
@ -541,9 +558,9 @@ class TmdbMetadataService @Inject constructor(
|
|||
id = "tmdb:${credit.id}",
|
||||
type = ContentType.MOVIE,
|
||||
name = title,
|
||||
poster = buildImageUrl(credit.posterPath, "w500"),
|
||||
poster = buildPosterUrl(credit.posterPath),
|
||||
posterShape = PosterShape.POSTER,
|
||||
background = buildImageUrl(credit.backdropPath, "w1280"),
|
||||
background = buildBackdropUrl(credit.backdropPath),
|
||||
logo = null,
|
||||
description = credit.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = year,
|
||||
|
|
@ -566,9 +583,9 @@ class TmdbMetadataService @Inject constructor(
|
|||
id = "tmdb:${credit.id}",
|
||||
type = ContentType.MOVIE,
|
||||
name = title,
|
||||
poster = buildImageUrl(credit.posterPath, "w500"),
|
||||
poster = buildPosterUrl(credit.posterPath),
|
||||
posterShape = PosterShape.POSTER,
|
||||
background = buildImageUrl(credit.backdropPath, "w1280"),
|
||||
background = buildBackdropUrl(credit.backdropPath),
|
||||
logo = null,
|
||||
description = credit.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = year,
|
||||
|
|
@ -591,9 +608,9 @@ class TmdbMetadataService @Inject constructor(
|
|||
id = "tmdb:${credit.id}",
|
||||
type = ContentType.SERIES,
|
||||
name = title,
|
||||
poster = buildImageUrl(credit.posterPath, "w500"),
|
||||
poster = buildPosterUrl(credit.posterPath),
|
||||
posterShape = PosterShape.POSTER,
|
||||
background = buildImageUrl(credit.backdropPath, "w1280"),
|
||||
background = buildBackdropUrl(credit.backdropPath),
|
||||
logo = null,
|
||||
description = credit.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = year,
|
||||
|
|
@ -616,9 +633,9 @@ class TmdbMetadataService @Inject constructor(
|
|||
id = "tmdb:${credit.id}",
|
||||
type = ContentType.SERIES,
|
||||
name = title,
|
||||
poster = buildImageUrl(credit.posterPath, "w500"),
|
||||
poster = buildPosterUrl(credit.posterPath),
|
||||
posterShape = PosterShape.POSTER,
|
||||
background = buildImageUrl(credit.backdropPath, "w1280"),
|
||||
background = buildBackdropUrl(credit.backdropPath),
|
||||
logo = null,
|
||||
description = credit.overview?.takeIf { it.isNotBlank() },
|
||||
releaseInfo = year,
|
||||
|
|
@ -714,7 +731,7 @@ data class TmdbEpisodeEnrichment(
|
|||
private fun TmdbEpisode.toEnrichment(): TmdbEpisodeEnrichment {
|
||||
val title = name?.takeIf { it.isNotBlank() }
|
||||
val overview = overview?.takeIf { it.isNotBlank() }
|
||||
val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w500$it" }
|
||||
val thumbnail = stillPath?.takeIf { it.isNotBlank() }?.let { "https://image.tmdb.org/t/p/w300$it" }
|
||||
val airDate = airDate?.takeIf { it.isNotBlank() }
|
||||
return TmdbEpisodeEnrichment(
|
||||
title = title,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import androidx.datastore.core.DataStore
|
|||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -22,6 +23,7 @@ class DebugSettingsDataStore @Inject constructor(
|
|||
|
||||
private val accountTabEnabledKey = booleanPreferencesKey("account_tab_enabled")
|
||||
private val syncCodeFeaturesEnabledKey = booleanPreferencesKey("sync_code_features_enabled")
|
||||
private val deviceTierOverrideKey = stringPreferencesKey("device_tier_override")
|
||||
|
||||
val accountTabEnabled: Flow<Boolean> = dataStore.data.map { prefs ->
|
||||
prefs[accountTabEnabledKey] ?: false
|
||||
|
|
@ -31,6 +33,10 @@ class DebugSettingsDataStore @Inject constructor(
|
|||
prefs[syncCodeFeaturesEnabledKey] ?: false
|
||||
}
|
||||
|
||||
val deviceTierOverride: Flow<String> = dataStore.data.map { prefs ->
|
||||
prefs[deviceTierOverrideKey] ?: "auto"
|
||||
}
|
||||
|
||||
suspend fun setAccountTabEnabled(enabled: Boolean) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[accountTabEnabledKey] = enabled
|
||||
|
|
@ -42,4 +48,10 @@ class DebugSettingsDataStore @Inject constructor(
|
|||
prefs[syncCodeFeaturesEnabledKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setDeviceTierOverride(value: String) {
|
||||
dataStore.edit { prefs ->
|
||||
prefs[deviceTierOverrideKey] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,84 @@
|
|||
package com.nuvio.tv.data.repository
|
||||
|
||||
import android.util.Log
|
||||
import com.nuvio.tv.core.image.TmdbImageUrlOptimizer
|
||||
import com.nuvio.tv.core.network.NetworkResult
|
||||
import com.nuvio.tv.core.network.safeApiCall
|
||||
import com.nuvio.tv.data.mapper.toDomain
|
||||
import com.nuvio.tv.data.remote.api.AddonApi
|
||||
import com.nuvio.tv.domain.model.CatalogRow
|
||||
import com.nuvio.tv.domain.model.ContentType
|
||||
import com.nuvio.tv.domain.repository.CatalogRepository
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import java.net.URLEncoder
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.math.max
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class CatalogRepositoryImpl @Inject constructor(
|
||||
private val api: AddonApi
|
||||
private val api: AddonApi,
|
||||
private val tmdbImageUrlOptimizer: TmdbImageUrlOptimizer
|
||||
) : CatalogRepository {
|
||||
companion object {
|
||||
private const val TAG = "CatalogRepository"
|
||||
private const val MAX_CACHE_ENTRIES = 256
|
||||
|
||||
// Fallbacks when addon responses don't provide Cache-Control directives.
|
||||
private const val DEFAULT_MAX_AGE_SECONDS = 120L
|
||||
private const val DEFAULT_STALE_REVALIDATE_SECONDS = 300L
|
||||
private const val DEFAULT_STALE_IF_ERROR_SECONDS = 900L
|
||||
|
||||
private const val MAX_MAX_AGE_SECONDS = 24 * 60 * 60L
|
||||
private const val MAX_STALE_WINDOW_SECONDS = 24 * 60 * 60L
|
||||
}
|
||||
|
||||
private val catalogCache = ConcurrentHashMap<String, CatalogRow>()
|
||||
private data class CachePolicy(
|
||||
val maxAgeMs: Long,
|
||||
val staleRevalidateMs: Long,
|
||||
val staleIfErrorMs: Long,
|
||||
val cacheable: Boolean
|
||||
) {
|
||||
val immediateServeWindowMs: Long
|
||||
get() = maxAgeMs + staleRevalidateMs
|
||||
|
||||
val errorServeWindowMs: Long
|
||||
get() = maxAgeMs + staleIfErrorMs
|
||||
|
||||
val hardExpiryWindowMs: Long
|
||||
get() = max(immediateServeWindowMs, errorServeWindowMs)
|
||||
}
|
||||
|
||||
private data class CachedCatalogEntry(
|
||||
val row: CatalogRow,
|
||||
val fetchedAtMs: Long,
|
||||
val policy: CachePolicy
|
||||
)
|
||||
|
||||
private sealed interface CatalogFetchResult {
|
||||
data class Success(val row: CatalogRow) : CatalogFetchResult
|
||||
|
||||
data class Error(
|
||||
val message: String,
|
||||
val code: Int? = null
|
||||
) : CatalogFetchResult
|
||||
}
|
||||
|
||||
private val cacheLock = Any()
|
||||
private val catalogCache =
|
||||
object : LinkedHashMap<String, CachedCatalogEntry>(MAX_CACHE_ENTRIES, 0.75f, true) {
|
||||
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, CachedCatalogEntry>?): Boolean {
|
||||
return size > MAX_CACHE_ENTRIES
|
||||
}
|
||||
}
|
||||
private val inFlightMutex = Mutex()
|
||||
private val inFlightRequests = mutableMapOf<String, Deferred<CatalogFetchResult>>()
|
||||
|
||||
override fun getCatalog(
|
||||
addonBaseUrl: String,
|
||||
|
|
@ -44,60 +99,271 @@ class CatalogRepositoryImpl @Inject constructor(
|
|||
skip = skip,
|
||||
extraArgs = extraArgs
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
val cachedEntry = getCachedEntry(cacheKey, now)
|
||||
|
||||
// Emit cached data immediately if available
|
||||
val cached = catalogCache[cacheKey]
|
||||
if (cached != null) {
|
||||
emit(NetworkResult.Success(cached))
|
||||
// Serve cache immediately when fresh or within stale-while-revalidate.
|
||||
val serveCachedImmediately = cachedEntry != null &&
|
||||
now - cachedEntry.fetchedAtMs <= cachedEntry.policy.immediateServeWindowMs
|
||||
|
||||
if (serveCachedImmediately) {
|
||||
emit(NetworkResult.Success(cachedEntry.row))
|
||||
} else {
|
||||
emit(NetworkResult.Loading)
|
||||
}
|
||||
|
||||
// Fresh cache: no network call needed.
|
||||
if (cachedEntry != null && now - cachedEntry.fetchedAtMs <= cachedEntry.policy.maxAgeMs) {
|
||||
return@flow
|
||||
}
|
||||
|
||||
val url = buildCatalogUrl(addonBaseUrl, type, catalogId, skip, extraArgs)
|
||||
Log.d(
|
||||
TAG,
|
||||
"Fetching catalog addonId=$addonId addonName=$addonName type=$type catalogId=$catalogId skip=$skip supportsSkip=$supportsSkip url=$url"
|
||||
)
|
||||
val result = fetchCatalogShared(
|
||||
cacheKey = cacheKey,
|
||||
url = url,
|
||||
addonId = addonId,
|
||||
addonName = addonName,
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
catalogId = catalogId,
|
||||
catalogName = catalogName,
|
||||
type = type,
|
||||
skip = skip,
|
||||
supportsSkip = supportsSkip
|
||||
)
|
||||
|
||||
when (val result = safeApiCall { api.getCatalog(url) }) {
|
||||
is NetworkResult.Success -> {
|
||||
val items = result.data.metas.map { it.toDomain() }
|
||||
Log.d(
|
||||
TAG,
|
||||
"Catalog fetch success addonId=$addonId type=$type catalogId=$catalogId items=${items.size}"
|
||||
)
|
||||
|
||||
val catalogRow = CatalogRow(
|
||||
addonId = addonId,
|
||||
addonName = addonName,
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
catalogId = catalogId,
|
||||
catalogName = catalogName,
|
||||
type = ContentType.fromString(type),
|
||||
rawType = type,
|
||||
items = items,
|
||||
isLoading = false,
|
||||
hasMore = supportsSkip && items.isNotEmpty(),
|
||||
currentPage = skip / 100,
|
||||
supportsSkip = supportsSkip
|
||||
)
|
||||
catalogCache[cacheKey] = catalogRow
|
||||
// Only emit fresh data if it differs from cache
|
||||
if (cached == null || cached.items != catalogRow.items) {
|
||||
emit(NetworkResult.Success(catalogRow))
|
||||
when (result) {
|
||||
is CatalogFetchResult.Success -> {
|
||||
// Only emit fresh data if it differs from what is currently displayed.
|
||||
if (cachedEntry == null || cachedEntry.row.items != result.row.items) {
|
||||
emit(NetworkResult.Success(result.row))
|
||||
}
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Catalog fetch failed addonId=$addonId type=$type catalogId=$catalogId code=${result.code} message=${result.message} url=$url"
|
||||
)
|
||||
// Only emit error if we had no cached data
|
||||
if (cached == null) {
|
||||
emit(result)
|
||||
is CatalogFetchResult.Error -> {
|
||||
val canServeStaleOnError = cachedEntry != null &&
|
||||
now - cachedEntry.fetchedAtMs <= cachedEntry.policy.errorServeWindowMs
|
||||
|
||||
if (canServeStaleOnError) {
|
||||
// Keep stale content on-screen when network fails.
|
||||
if (!serveCachedImmediately) {
|
||||
emit(NetworkResult.Success(cachedEntry.row))
|
||||
}
|
||||
} else {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Catalog fetch failed addonId=$addonId type=$type catalogId=$catalogId code=${result.code} message=${result.message} url=$url"
|
||||
)
|
||||
emit(NetworkResult.Error(result.message, result.code))
|
||||
}
|
||||
}
|
||||
NetworkResult.Loading -> { /* Already emitted */ }
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
private suspend fun fetchCatalogShared(
|
||||
cacheKey: String,
|
||||
url: String,
|
||||
addonId: String,
|
||||
addonName: String,
|
||||
addonBaseUrl: String,
|
||||
catalogId: String,
|
||||
catalogName: String,
|
||||
type: String,
|
||||
skip: Int,
|
||||
supportsSkip: Boolean
|
||||
): CatalogFetchResult {
|
||||
val existing = inFlightMutex.withLock { inFlightRequests[cacheKey] }
|
||||
if (existing != null) {
|
||||
return existing.await()
|
||||
}
|
||||
|
||||
val deferred = CompletableDeferred<CatalogFetchResult>()
|
||||
val winner = inFlightMutex.withLock {
|
||||
val current = inFlightRequests[cacheKey]
|
||||
if (current != null) {
|
||||
current
|
||||
} else {
|
||||
inFlightRequests[cacheKey] = deferred
|
||||
deferred
|
||||
}
|
||||
}
|
||||
|
||||
if (winner !== deferred) {
|
||||
return winner.await()
|
||||
}
|
||||
|
||||
try {
|
||||
val result = fetchCatalogFromNetwork(
|
||||
cacheKey = cacheKey,
|
||||
url = url,
|
||||
addonId = addonId,
|
||||
addonName = addonName,
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
catalogId = catalogId,
|
||||
catalogName = catalogName,
|
||||
type = type,
|
||||
skip = skip,
|
||||
supportsSkip = supportsSkip
|
||||
)
|
||||
deferred.complete(result)
|
||||
return result
|
||||
} catch (e: Exception) {
|
||||
val error = CatalogFetchResult.Error(e.message ?: "Unknown error occurred")
|
||||
deferred.complete(error)
|
||||
return error
|
||||
} finally {
|
||||
inFlightMutex.withLock {
|
||||
if (inFlightRequests[cacheKey] === deferred) {
|
||||
inFlightRequests.remove(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchCatalogFromNetwork(
|
||||
cacheKey: String,
|
||||
url: String,
|
||||
addonId: String,
|
||||
addonName: String,
|
||||
addonBaseUrl: String,
|
||||
catalogId: String,
|
||||
catalogName: String,
|
||||
type: String,
|
||||
skip: Int,
|
||||
supportsSkip: Boolean
|
||||
): CatalogFetchResult {
|
||||
val response = try {
|
||||
api.getCatalog(url)
|
||||
} catch (e: Exception) {
|
||||
return CatalogFetchResult.Error(e.message ?: "Unknown error occurred")
|
||||
}
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
return CatalogFetchResult.Error(
|
||||
message = response.message(),
|
||||
code = response.code()
|
||||
)
|
||||
}
|
||||
|
||||
val body = response.body()
|
||||
?: return CatalogFetchResult.Error("Empty response body")
|
||||
|
||||
val items = body.metas.map { dto ->
|
||||
val domain = dto.toDomain()
|
||||
domain.copy(
|
||||
poster = tmdbImageUrlOptimizer.optimizePosterUrl(domain.poster) ?: domain.poster,
|
||||
background = tmdbImageUrlOptimizer.optimizeBackdropUrl(domain.background) ?: domain.background
|
||||
)
|
||||
}
|
||||
Log.d(
|
||||
TAG,
|
||||
"Catalog fetch success addonId=$addonId type=$type catalogId=$catalogId items=${items.size}"
|
||||
)
|
||||
|
||||
val catalogRow = CatalogRow(
|
||||
addonId = addonId,
|
||||
addonName = addonName,
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
catalogId = catalogId,
|
||||
catalogName = catalogName,
|
||||
type = ContentType.fromString(type),
|
||||
rawType = type,
|
||||
items = items,
|
||||
isLoading = false,
|
||||
hasMore = supportsSkip && items.isNotEmpty(),
|
||||
currentPage = skip / 100,
|
||||
supportsSkip = supportsSkip
|
||||
)
|
||||
val now = System.currentTimeMillis()
|
||||
val policy = parseCachePolicy(response.headers()["Cache-Control"])
|
||||
if (policy.cacheable) {
|
||||
putCachedEntry(
|
||||
cacheKey = cacheKey,
|
||||
entry = CachedCatalogEntry(
|
||||
row = catalogRow,
|
||||
fetchedAtMs = now,
|
||||
policy = policy
|
||||
)
|
||||
)
|
||||
} else {
|
||||
removeCachedEntry(cacheKey)
|
||||
}
|
||||
|
||||
return CatalogFetchResult.Success(row = catalogRow)
|
||||
}
|
||||
|
||||
private fun parseCachePolicy(cacheControlHeader: String?): CachePolicy {
|
||||
var noStore = false
|
||||
var maxAgeSeconds: Long? = null
|
||||
var staleRevalidateSeconds: Long? = null
|
||||
var staleIfErrorSeconds: Long? = null
|
||||
|
||||
cacheControlHeader
|
||||
?.split(',')
|
||||
?.map { it.trim().lowercase() }
|
||||
?.forEach { directive ->
|
||||
when {
|
||||
directive == "no-store" -> noStore = true
|
||||
directive == "no-cache" -> maxAgeSeconds = 0L
|
||||
directive.startsWith("max-age=") -> {
|
||||
maxAgeSeconds = directive.substringAfter('=').toLongOrNull()
|
||||
}
|
||||
directive.startsWith("stale-while-revalidate=") -> {
|
||||
staleRevalidateSeconds = directive.substringAfter('=').toLongOrNull()
|
||||
}
|
||||
directive.startsWith("stale-if-error=") -> {
|
||||
staleIfErrorSeconds = directive.substringAfter('=').toLongOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noStore) {
|
||||
return CachePolicy(
|
||||
maxAgeMs = 0L,
|
||||
staleRevalidateMs = 0L,
|
||||
staleIfErrorMs = 0L,
|
||||
cacheable = false
|
||||
)
|
||||
}
|
||||
|
||||
val maxAgeMs = (maxAgeSeconds ?: DEFAULT_MAX_AGE_SECONDS)
|
||||
.coerceIn(0L, MAX_MAX_AGE_SECONDS) * 1000L
|
||||
val staleRevalidateMs = (staleRevalidateSeconds ?: DEFAULT_STALE_REVALIDATE_SECONDS)
|
||||
.coerceIn(0L, MAX_STALE_WINDOW_SECONDS) * 1000L
|
||||
val staleIfErrorMs = (staleIfErrorSeconds ?: DEFAULT_STALE_IF_ERROR_SECONDS)
|
||||
.coerceIn(0L, MAX_STALE_WINDOW_SECONDS) * 1000L
|
||||
|
||||
return CachePolicy(
|
||||
maxAgeMs = maxAgeMs,
|
||||
staleRevalidateMs = staleRevalidateMs,
|
||||
staleIfErrorMs = staleIfErrorMs,
|
||||
cacheable = true
|
||||
)
|
||||
}
|
||||
|
||||
private fun getCachedEntry(cacheKey: String, nowMs: Long): CachedCatalogEntry? {
|
||||
synchronized(cacheLock) {
|
||||
val entry = catalogCache[cacheKey] ?: return null
|
||||
val ageMs = nowMs - entry.fetchedAtMs
|
||||
if (ageMs > entry.policy.hardExpiryWindowMs) {
|
||||
catalogCache.remove(cacheKey)
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
}
|
||||
|
||||
private fun putCachedEntry(cacheKey: String, entry: CachedCatalogEntry) {
|
||||
synchronized(cacheLock) {
|
||||
catalogCache[cacheKey] = entry
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeCachedEntry(cacheKey: String) {
|
||||
synchronized(cacheLock) {
|
||||
catalogCache.remove(cacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -80,7 +80,13 @@ fun CatalogRowSection(
|
|||
upFocusRequester: FocusRequester? = null,
|
||||
listState: LazyListState = rememberLazyListState(initialFirstVisibleItemIndex = initialScrollIndex)
|
||||
) {
|
||||
val seeAllCardShape = RoundedCornerShape(posterCardStyle.cornerRadius)
|
||||
val seeAllCardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) }
|
||||
val seeAllFocusRingColor = NuvioColors.FocusRing
|
||||
val seeAllFocusedBorder = remember(posterCardStyle.focusedBorderWidth, seeAllFocusRingColor, seeAllCardShape) {
|
||||
Border(border = BorderStroke(posterCardStyle.focusedBorderWidth, seeAllFocusRingColor), shape = seeAllCardShape)
|
||||
}
|
||||
val seeAllShapeSpec = remember(seeAllCardShape) { CardDefaults.shape(shape = seeAllCardShape) }
|
||||
val seeAllScale = remember(posterCardStyle.focusedScale) { CardDefaults.scale(focusedScale = posterCardStyle.focusedScale) }
|
||||
val currentOnItemFocused by rememberUpdatedState(onItemFocused)
|
||||
val currentOnItemFocus by rememberUpdatedState(onItemFocus)
|
||||
|
||||
|
|
@ -185,8 +191,8 @@ fun CatalogRowSection(
|
|||
) {
|
||||
itemsIndexed(
|
||||
items = catalogRow.items,
|
||||
key = { index, item ->
|
||||
"${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}_${item.id}_$index"
|
||||
key = { _, item ->
|
||||
"${catalogRow.addonId}_${catalogRow.apiType}_${catalogRow.catalogId}_${item.id}"
|
||||
},
|
||||
contentType = { _, _ -> "content_card" }
|
||||
) { index, item ->
|
||||
|
|
@ -223,18 +229,15 @@ fun CatalogRowSection(
|
|||
.width(posterCardStyle.width)
|
||||
.height(posterCardStyle.height)
|
||||
.then(directionalFocusModifier),
|
||||
shape = CardDefaults.shape(shape = seeAllCardShape),
|
||||
shape = seeAllShapeSpec,
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = NuvioColors.BackgroundCard,
|
||||
focusedContainerColor = NuvioColors.BackgroundCard
|
||||
),
|
||||
border = CardDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(posterCardStyle.focusedBorderWidth, NuvioColors.FocusRing),
|
||||
shape = seeAllCardShape
|
||||
)
|
||||
focusedBorder = seeAllFocusedBorder
|
||||
),
|
||||
scale = CardDefaults.scale(focusedScale = posterCardStyle.focusedScale)
|
||||
scale = seeAllScale
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import androidx.compose.ui.platform.LocalDensity
|
|||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.zIndex
|
||||
import com.nuvio.tv.R
|
||||
|
|
@ -61,6 +62,8 @@ import kotlinx.coroutines.delay
|
|||
private const val BACKDROP_ASPECT_RATIO = 16f / 9f
|
||||
private const val TRAILER_PREVIEW_REQUEST_FOCUS_DEBOUNCE_MS = 140L
|
||||
private val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""")
|
||||
private val WatchedIconAnimSpec = tween<Dp>(durationMillis = 180)
|
||||
private val TrailerCoverAlphaAnimSpec = tween<Float>(durationMillis = 250)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -82,6 +85,18 @@ fun ContentCard(
|
|||
onClick: () -> Unit = {}
|
||||
) {
|
||||
val cardShape = remember(posterCardStyle.cornerRadius) { RoundedCornerShape(posterCardStyle.cornerRadius) }
|
||||
val focusRingColor = NuvioColors.FocusRing
|
||||
val focusedBorder = remember(posterCardStyle.focusedBorderWidth, focusRingColor, cardShape) {
|
||||
Border(
|
||||
border = BorderStroke(posterCardStyle.focusedBorderWidth, focusRingColor),
|
||||
shape = cardShape
|
||||
)
|
||||
}
|
||||
val cardShapeSpec = remember(cardShape) { CardDefaults.shape(shape = cardShape) }
|
||||
val cardScale = remember(posterCardStyle.focusedScale) { CardDefaults.scale(focusedScale = posterCardStyle.focusedScale) }
|
||||
val focusRequesterModifier = remember(focusRequester) {
|
||||
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier
|
||||
}
|
||||
val baseCardWidth = when (item.posterShape) {
|
||||
PosterShape.POSTER -> posterCardStyle.width
|
||||
PosterShape.LANDSCAPE -> 260.dp
|
||||
|
|
@ -99,11 +114,16 @@ fun ContentCard(
|
|||
var interactionNonce by remember { mutableIntStateOf(0) }
|
||||
var isBackdropExpanded by remember { mutableStateOf(false) }
|
||||
var trailerFirstFrameRendered by remember(trailerPreviewUrl) { mutableStateOf(false) }
|
||||
val watchedIconEndPadding by animateDpAsState(
|
||||
targetValue = if (isFocused) 18.dp else 8.dp,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "contentCardWatchedIconEndPadding"
|
||||
)
|
||||
val watchedIconEndPadding = if (isWatched) {
|
||||
val padding by animateDpAsState(
|
||||
targetValue = if (isFocused) 18.dp else 8.dp,
|
||||
animationSpec = WatchedIconAnimSpec,
|
||||
label = "contentCardWatchedIconEndPadding"
|
||||
)
|
||||
padding
|
||||
} else {
|
||||
8.dp
|
||||
}
|
||||
|
||||
val needsFocusState = focusedPosterBackdropExpandEnabled || focusedPosterBackdropTrailerEnabled
|
||||
val lastFocusedRef = remember { booleanArrayOf(false) }
|
||||
|
|
@ -275,21 +295,16 @@ fun ContentCard(
|
|||
}
|
||||
false
|
||||
}
|
||||
.then(
|
||||
if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier
|
||||
),
|
||||
shape = CardDefaults.shape(shape = cardShape),
|
||||
.then(focusRequesterModifier),
|
||||
shape = cardShapeSpec,
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = NuvioColors.BackgroundCard,
|
||||
focusedContainerColor = NuvioColors.BackgroundCard
|
||||
),
|
||||
border = CardDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(posterCardStyle.focusedBorderWidth, NuvioColors.FocusRing),
|
||||
shape = cardShape
|
||||
)
|
||||
focusedBorder = focusedBorder
|
||||
),
|
||||
scale = CardDefaults.scale(focusedScale = posterCardStyle.focusedScale)
|
||||
scale = cardScale
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
|
|
@ -325,7 +340,7 @@ fun ContentCard(
|
|||
val trailerCoverAlpha = if (shouldPlayTrailerPreview) {
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (!trailerFirstFrameRendered) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 250),
|
||||
animationSpec = TrailerCoverAlphaAnimSpec,
|
||||
label = "trailerCoverAlpha"
|
||||
)
|
||||
alpha
|
||||
|
|
|
|||
|
|
@ -66,6 +66,8 @@ import java.util.concurrent.TimeUnit
|
|||
private val CwCardShape = RoundedCornerShape(12.dp)
|
||||
private val CwClipShape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp)
|
||||
private val BadgeShape = RoundedCornerShape(4.dp)
|
||||
private val CwCardShapeSpec = CardDefaults.shape(shape = CwCardShape)
|
||||
private val CwCardScale = CardDefaults.scale(focusedScale = 1.02f)
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
|
|
@ -289,6 +291,13 @@ fun ContinueWatchingCard(
|
|||
.build()
|
||||
}
|
||||
|
||||
val focusRingColor = NuvioColors.FocusRing
|
||||
val cwFocusedBorder = remember(focusRingColor) {
|
||||
Border(
|
||||
border = BorderStroke(2.dp, focusRingColor),
|
||||
shape = CwCardShape
|
||||
)
|
||||
}
|
||||
val bgColor = NuvioColors.Background
|
||||
val overlayBrush = remember(bgColor) {
|
||||
Brush.verticalGradient(
|
||||
|
|
@ -333,18 +342,15 @@ fun ContinueWatchingCard(
|
|||
}
|
||||
false
|
||||
},
|
||||
shape = CardDefaults.shape(shape = CwCardShape),
|
||||
shape = CwCardShapeSpec,
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = NuvioColors.BackgroundCard,
|
||||
focusedContainerColor = NuvioColors.FocusBackground
|
||||
),
|
||||
border = CardDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.FocusRing),
|
||||
shape = CwCardShape
|
||||
)
|
||||
focusedBorder = cwFocusedBorder
|
||||
),
|
||||
scale = CardDefaults.scale(focusedScale = 1.02f)
|
||||
scale = CwCardScale
|
||||
) {
|
||||
Column {
|
||||
// Thumbnail with progress overlay
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ import com.nuvio.tv.ui.components.ContinueWatchingSection
|
|||
import com.nuvio.tv.ui.components.HeroCarousel
|
||||
import com.nuvio.tv.ui.components.PosterCardStyle
|
||||
|
||||
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
|
||||
private const val KEY_REPEAT_THROTTLE_MS = 80L
|
||||
/** Default minimum interval between processed key repeat events to prevent HWUI overload. */
|
||||
private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
|
||||
|
||||
private class FocusSnapshot(
|
||||
var rowIndex: Int,
|
||||
|
|
@ -56,9 +56,12 @@ fun ClassicHomeContent(
|
|||
) {
|
||||
|
||||
// Nested prefetch: when LazyColumn prefetches a row ahead of scrolling,
|
||||
// pre-compose up to 2 ContentCards in its nested LazyRow across multiple frames.
|
||||
// This spreads the composition work and prevents frame spikes when a new row scrolls in.
|
||||
val nestedPrefetchStrategy = remember { LazyListPrefetchStrategy(nestedPrefetchItemCount = 2) }
|
||||
// pre-compose ContentCards in its nested LazyRow across multiple frames.
|
||||
// On low-tier devices, prefetch is disabled (0) to avoid decoder contention.
|
||||
val adaptiveNestedPrefetchCount = uiState.nestedPrefetchItemCount
|
||||
val nestedPrefetchStrategy = remember(adaptiveNestedPrefetchCount) {
|
||||
LazyListPrefetchStrategy(nestedPrefetchItemCount = adaptiveNestedPrefetchCount)
|
||||
}
|
||||
|
||||
val columnListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = focusState.verticalScrollIndex,
|
||||
|
|
@ -137,6 +140,7 @@ fun ClassicHomeContent(
|
|||
|
||||
// Throttle D-pad key repeats to prevent HWUI overload when a key is held down.
|
||||
var lastKeyRepeatTime by remember { mutableStateOf(0L) }
|
||||
val keyRepeatThrottle = uiState.keyRepeatThrottleMs
|
||||
|
||||
LazyColumn(
|
||||
state = columnListState,
|
||||
|
|
@ -146,7 +150,7 @@ fun ClassicHomeContent(
|
|||
val native = event.nativeKeyEvent
|
||||
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastKeyRepeatTime < KEY_REPEAT_THROTTLE_MS) {
|
||||
if (now - lastKeyRepeatTime < keyRepeatThrottle) {
|
||||
return@onPreviewKeyEvent true // consume — too fast
|
||||
}
|
||||
lastKeyRepeatTime = now
|
||||
|
|
|
|||
|
|
@ -57,8 +57,8 @@ import com.nuvio.tv.ui.components.PosterCardDefaults
|
|||
import com.nuvio.tv.ui.components.PosterCardStyle
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
|
||||
/** Minimum interval between processed key repeat events to prevent HWUI overload. */
|
||||
private const val KEY_REPEAT_THROTTLE_MS = 80L
|
||||
/** Default minimum interval between processed key repeat events to prevent HWUI overload. */
|
||||
private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
|
||||
|
||||
@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
|
|
@ -167,7 +167,7 @@ fun GridHomeContent(
|
|||
val native = event.nativeKeyEvent
|
||||
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastKeyRepeatTime[0] < KEY_REPEAT_THROTTLE_MS) {
|
||||
if (now - lastKeyRepeatTime[0] < uiState.keyRepeatThrottleMs) {
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
lastKeyRepeatTime[0] = now
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ fun HomeScreen(
|
|||
LaunchedEffect(hasCatalogContent) {
|
||||
if (hasCatalogContent) {
|
||||
hasEnteredCatalogContent = true
|
||||
viewModel.onCatalogContentRendered()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,6 +170,15 @@ fun HomeScreen(
|
|||
animationSpec = tween(320)
|
||||
)
|
||||
) {
|
||||
// Stabilize lambda identity — only recompute when the
|
||||
// watched status map itself changes, not on every uiState update.
|
||||
val watchedStatus = uiState.movieWatchedStatus
|
||||
val stableIsWatched = remember(watchedStatus) {
|
||||
{ item: MetaPreview ->
|
||||
watchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
|
||||
}
|
||||
}
|
||||
|
||||
when (uiState.homeLayout) {
|
||||
HomeLayout.CLASSIC -> ClassicHomeRoute(
|
||||
viewModel = viewModel,
|
||||
|
|
@ -177,9 +187,7 @@ fun HomeScreen(
|
|||
onNavigateToDetail = onNavigateToDetail,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll,
|
||||
isCatalogItemWatched = { item ->
|
||||
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
|
||||
},
|
||||
isCatalogItemWatched = stableIsWatched,
|
||||
onCatalogItemLongPress = { item, addonBaseUrl ->
|
||||
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
|
||||
}
|
||||
|
|
@ -192,9 +200,7 @@ fun HomeScreen(
|
|||
onNavigateToDetail = onNavigateToDetail,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
onNavigateToCatalogSeeAll = onNavigateToCatalogSeeAll,
|
||||
isCatalogItemWatched = { item ->
|
||||
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
|
||||
},
|
||||
isCatalogItemWatched = stableIsWatched,
|
||||
onCatalogItemLongPress = { item, addonBaseUrl ->
|
||||
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
|
||||
}
|
||||
|
|
@ -205,9 +211,7 @@ fun HomeScreen(
|
|||
uiState = uiState,
|
||||
onNavigateToDetail = onNavigateToDetail,
|
||||
onContinueWatchingClick = onContinueWatchingClick,
|
||||
isCatalogItemWatched = { item ->
|
||||
uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true
|
||||
},
|
||||
isCatalogItemWatched = stableIsWatched,
|
||||
onCatalogItemLongPress = { item, addonBaseUrl ->
|
||||
posterOptionsTarget = HomePosterOptionsTarget(item, addonBaseUrl)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,9 @@ data class HomeUiState(
|
|||
val movieWatchedStatus: Map<String, Boolean> = emptyMap(),
|
||||
val posterLibraryPending: Set<String> = emptySet(),
|
||||
val movieWatchedPending: Set<String> = emptySet(),
|
||||
val gridItems: List<GridItem> = emptyList()
|
||||
val gridItems: List<GridItem> = emptyList(),
|
||||
val keyRepeatThrottleMs: Long = 80L,
|
||||
val nestedPrefetchItemCount: Int = 2
|
||||
)
|
||||
|
||||
@Immutable
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import android.util.Log
|
|||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nuvio.tv.core.device.DeviceCapabilities
|
||||
import com.nuvio.tv.core.device.DeviceTier
|
||||
import com.nuvio.tv.core.network.NetworkResult
|
||||
import com.nuvio.tv.core.tmdb.TmdbMetadataService
|
||||
import com.nuvio.tv.core.tmdb.TmdbService
|
||||
|
|
@ -76,7 +78,8 @@ class HomeViewModel @Inject constructor(
|
|||
private val traktSettingsDataStore: TraktSettingsDataStore,
|
||||
private val tmdbService: TmdbService,
|
||||
private val tmdbMetadataService: TmdbMetadataService,
|
||||
private val trailerService: TrailerService
|
||||
private val trailerService: TrailerService,
|
||||
private val deviceCapabilities: DeviceCapabilities
|
||||
) : ViewModel() {
|
||||
companion object {
|
||||
private const val TAG = "HomeViewModel"
|
||||
|
|
@ -84,7 +87,7 @@ class HomeViewModel @Inject constructor(
|
|||
private const val MAX_RECENT_PROGRESS_ITEMS = 300
|
||||
private const val MAX_NEXT_UP_LOOKUPS = 24
|
||||
private const val MAX_NEXT_UP_CONCURRENCY = 4
|
||||
private const val MAX_CATALOG_LOAD_CONCURRENCY = 4
|
||||
private const val CATALOG_WAVE_DELAY_MS = 120L
|
||||
private const val EXTERNAL_META_PREFETCH_FOCUS_DEBOUNCE_MS = 220L
|
||||
}
|
||||
|
||||
|
|
@ -104,19 +107,33 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
private val catalogsMap = linkedMapOf<String, CatalogRow>()
|
||||
private val catalogOrder = mutableListOf<String>()
|
||||
private val activeCatalogLoadJobs = mutableMapOf<String, Job>()
|
||||
private var addonsCache: List<Addon> = emptyList()
|
||||
private var homeCatalogOrderKeys: List<String> = emptyList()
|
||||
private var disabledHomeCatalogKeys: Set<String> = emptySet()
|
||||
private var currentHeroCatalogKeys: List<String> = emptyList()
|
||||
private var activeCatalogLoadSessionId: Long = 0L
|
||||
private var catalogUpdateJob: Job? = null
|
||||
private var hasRenderedFirstCatalog = false
|
||||
private val catalogLoadSemaphore = Semaphore(MAX_CATALOG_LOAD_CONCURRENCY)
|
||||
private var hasDispatchedFirstCatalogBatch = false
|
||||
@Volatile
|
||||
private var hasCatalogContentDrawn = false
|
||||
private var reconcilePosterObserversJob: Job? = null
|
||||
private var hasBootstrappedPosterObservers = false
|
||||
private val catalogLoadSemaphore = Semaphore(
|
||||
when (deviceCapabilities.tier) {
|
||||
DeviceTier.LOW -> 2
|
||||
DeviceTier.MEDIUM -> 3
|
||||
DeviceTier.HIGH -> 4
|
||||
}
|
||||
)
|
||||
private var pendingCatalogLoads = 0
|
||||
private data class TruncatedRowCacheEntry(
|
||||
val sourceRow: CatalogRow,
|
||||
val truncatedRow: CatalogRow
|
||||
)
|
||||
private val truncatedRowCache = mutableMapOf<String, TruncatedRowCacheEntry>()
|
||||
private var catalogVersion = 0L
|
||||
private var lastProcessedCatalogVersion = -1L
|
||||
private val trailerPreviewLoadingIds = mutableSetOf<String>()
|
||||
private val trailerPreviewNegativeCache = mutableSetOf<String>()
|
||||
private val trailerPreviewUrlsState = mutableStateMapOf<String, String>()
|
||||
|
|
@ -132,6 +149,9 @@ class HomeViewModel @Inject constructor(
|
|||
private var pendingExternalMetaPrefetchItemId: String? = null
|
||||
private val posterLibraryObserverJobs = mutableMapOf<String, Job>()
|
||||
private val movieWatchedObserverJobs = mutableMapOf<String, Job>()
|
||||
private val pendingLibraryStatusUpdates = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
|
||||
private val pendingWatchedStatusUpdates = java.util.concurrent.ConcurrentHashMap<String, Boolean>()
|
||||
private var posterStatusFlushJob: Job? = null
|
||||
@Volatile
|
||||
private var externalMetaPrefetchEnabled: Boolean = false
|
||||
@Volatile
|
||||
|
|
@ -243,6 +263,7 @@ class HomeViewModel @Inject constructor(
|
|||
previousState.heroSectionEnabled != prefs.heroSectionEnabled ||
|
||||
previousState.homeLayout != prefs.layout
|
||||
currentHeroCatalogKeys = prefs.heroCatalogKeys
|
||||
val isLowTier = deviceCapabilities.tier == DeviceTier.LOW
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
homeLayout = prefs.layout,
|
||||
|
|
@ -252,14 +273,16 @@ class HomeViewModel @Inject constructor(
|
|||
catalogAddonNameEnabled = prefs.catalogAddonNameEnabled,
|
||||
catalogTypeSuffixEnabled = prefs.catalogTypeSuffixEnabled,
|
||||
modernLandscapePostersEnabled = prefs.modernLandscapePostersEnabled,
|
||||
focusedPosterBackdropExpandEnabled = prefs.focusedBackdropExpandEnabled,
|
||||
focusedPosterBackdropExpandEnabled = if (isLowTier) false else prefs.focusedBackdropExpandEnabled,
|
||||
focusedPosterBackdropExpandDelaySeconds = prefs.focusedBackdropExpandDelaySeconds,
|
||||
focusedPosterBackdropTrailerEnabled = prefs.focusedBackdropTrailerEnabled,
|
||||
focusedPosterBackdropTrailerEnabled = if (isLowTier) false else prefs.focusedBackdropTrailerEnabled,
|
||||
focusedPosterBackdropTrailerMuted = prefs.focusedBackdropTrailerMuted,
|
||||
focusedPosterBackdropTrailerPlaybackTarget = prefs.focusedBackdropTrailerPlaybackTarget,
|
||||
posterCardWidthDp = prefs.posterCardWidthDp,
|
||||
posterCardHeightDp = prefs.posterCardHeightDp,
|
||||
posterCardCornerRadiusDp = prefs.posterCardCornerRadiusDp
|
||||
posterCardCornerRadiusDp = prefs.posterCardCornerRadiusDp,
|
||||
keyRepeatThrottleMs = deviceCapabilities.keyRepeatThrottleMs,
|
||||
nestedPrefetchItemCount = deviceCapabilities.nestedPrefetchItemCount
|
||||
)
|
||||
}
|
||||
if (shouldRefreshCatalogPresentation) {
|
||||
|
|
@ -376,6 +399,7 @@ class HomeViewModel @Inject constructor(
|
|||
fun onItemFocus(item: MetaPreview) {
|
||||
if (startupGracePeriodActive) return
|
||||
if (!externalMetaPrefetchEnabled) return
|
||||
if (deviceCapabilities.tier == DeviceTier.LOW) return
|
||||
if (item.id in prefetchedExternalMetaIds) return
|
||||
if (pendingExternalMetaPrefetchItemId == item.id) return
|
||||
|
||||
|
|
@ -423,6 +447,7 @@ class HomeViewModel @Inject constructor(
|
|||
val mutableItems = row.items.toMutableList()
|
||||
mutableItems[itemIndex] = merged
|
||||
catalogsMap[key] = row.copy(items = mutableItems)
|
||||
catalogVersion++
|
||||
truncatedRowCache.remove(key)
|
||||
}
|
||||
}
|
||||
|
|
@ -452,7 +477,9 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
private fun loadHomeCatalogOrderPreference() {
|
||||
viewModelScope.launch {
|
||||
layoutPreferenceDataStore.homeCatalogOrderKeys.collectLatest { keys ->
|
||||
layoutPreferenceDataStore.homeCatalogOrderKeys
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { keys ->
|
||||
homeCatalogOrderKeys = keys
|
||||
rebuildCatalogOrder(addonsCache)
|
||||
scheduleUpdateCatalogRows()
|
||||
|
|
@ -462,7 +489,9 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
private fun loadDisabledHomeCatalogPreference() {
|
||||
viewModelScope.launch {
|
||||
layoutPreferenceDataStore.disabledHomeCatalogKeys.collectLatest { keys ->
|
||||
layoutPreferenceDataStore.disabledHomeCatalogKeys
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { keys ->
|
||||
disabledHomeCatalogKeys = keys.toSet()
|
||||
rebuildCatalogOrder(addonsCache)
|
||||
if (addonsCache.isNotEmpty()) {
|
||||
|
|
@ -560,36 +589,39 @@ class HomeViewModel @Inject constructor(
|
|||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
}.collectLatest { snapshot ->
|
||||
val items = snapshot.items
|
||||
val daysCap = snapshot.daysCap
|
||||
val preparedState = withContext(Dispatchers.Default) {
|
||||
val items = snapshot.items
|
||||
val daysCap = snapshot.daysCap
|
||||
val cutoffMs = if (daysCap == TraktSettingsDataStore.CONTINUE_WATCHING_DAYS_CAP_ALL) {
|
||||
null
|
||||
} else {
|
||||
val windowMs = daysCap.toLong() * 24L * 60L * 60L * 1000L
|
||||
System.currentTimeMillis() - windowMs
|
||||
}
|
||||
val recentItems = items
|
||||
.asSequence()
|
||||
.filter { progress -> cutoffMs == null || progress.lastWatched >= cutoffMs }
|
||||
.sortedByDescending { it.lastWatched }
|
||||
.take(MAX_RECENT_PROGRESS_ITEMS)
|
||||
.toList()
|
||||
|
||||
val inProgressOnly = deduplicateInProgress(
|
||||
recentItems.filter { shouldTreatAsInProgressForContinueWatching(it) }
|
||||
).map { progress ->
|
||||
ContinueWatchingItem.InProgress(progress = progress)
|
||||
}
|
||||
|
||||
ContinueWatchingPreparedState(
|
||||
allProgressCount = items.size,
|
||||
recentItems = recentItems,
|
||||
inProgressOnly = inProgressOnly
|
||||
)
|
||||
}
|
||||
val recentItems = preparedState.recentItems
|
||||
val inProgressOnly = preparedState.inProgressOnly
|
||||
val dismissedNextUp = snapshot.dismissedNextUp
|
||||
val showUnairedNextUp = snapshot.showUnairedNextUp
|
||||
val cutoffMs = if (daysCap == TraktSettingsDataStore.CONTINUE_WATCHING_DAYS_CAP_ALL) {
|
||||
null
|
||||
} else {
|
||||
val windowMs = daysCap.toLong() * 24L * 60L * 60L * 1000L
|
||||
System.currentTimeMillis() - windowMs
|
||||
}
|
||||
val recentItems = items
|
||||
.asSequence()
|
||||
.filter { progress -> cutoffMs == null || progress.lastWatched >= cutoffMs }
|
||||
.sortedByDescending { it.lastWatched }
|
||||
.take(MAX_RECENT_PROGRESS_ITEMS)
|
||||
.toList()
|
||||
|
||||
Log.d("HomeViewModel", "allProgress emitted=${items.size} recentWindow=${recentItems.size}")
|
||||
|
||||
val inProgressOnly = buildList {
|
||||
deduplicateInProgress(
|
||||
recentItems.filter { shouldTreatAsInProgressForContinueWatching(it) }
|
||||
).forEach { progress ->
|
||||
add(
|
||||
ContinueWatchingItem.InProgress(
|
||||
progress = progress
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
Log.d("HomeViewModel", "allProgress emitted=${preparedState.allProgressCount} recentWindow=${recentItems.size}")
|
||||
|
||||
Log.d("HomeViewModel", "inProgressOnly: ${inProgressOnly.size} items after filter+dedup")
|
||||
|
||||
|
|
@ -602,6 +634,14 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
// Wait for first catalog to render before enriching, so enrichment
|
||||
// state updates don't compete with the initial catalog paint.
|
||||
if (!hasCatalogContentDrawn) {
|
||||
withTimeoutOrNull(5000L) {
|
||||
while (!hasCatalogContentDrawn) { delay(100) }
|
||||
}
|
||||
}
|
||||
|
||||
// Then enrich Next Up and item details in background.
|
||||
enrichContinueWatchingProgressively(
|
||||
allProgress = recentItems,
|
||||
|
|
@ -621,6 +661,12 @@ class HomeViewModel @Inject constructor(
|
|||
val showUnairedNextUp: Boolean
|
||||
)
|
||||
|
||||
private data class ContinueWatchingPreparedState(
|
||||
val allProgressCount: Int,
|
||||
val recentItems: List<WatchProgress>,
|
||||
val inProgressOnly: List<ContinueWatchingItem.InProgress>
|
||||
)
|
||||
|
||||
private data class NextUpArtworkFallback(
|
||||
val thumbnail: String?,
|
||||
val backdrop: String?,
|
||||
|
|
@ -692,34 +738,35 @@ class HomeViewModel @Inject constructor(
|
|||
dismissedNextUp: Set<String>,
|
||||
showUnairedNextUp: Boolean
|
||||
) = coroutineScope {
|
||||
val inProgressIds = inProgressItems
|
||||
.map { it.progress.contentId }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
|
||||
val latestCompletedBySeries = allProgress
|
||||
.filter { progress ->
|
||||
isSeriesType(progress.contentType) &&
|
||||
progress.season != null &&
|
||||
progress.episode != null &&
|
||||
progress.season != 0 &&
|
||||
progress.isCompleted() &&
|
||||
progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK
|
||||
}
|
||||
.groupBy { it.contentId }
|
||||
.mapNotNull { (_, items) ->
|
||||
items.maxWithOrNull(
|
||||
compareBy<WatchProgress>(
|
||||
{ it.lastWatched },
|
||||
{ it.season ?: -1 },
|
||||
{ it.episode ?: -1 }
|
||||
val latestCompletedBySeries = withContext(Dispatchers.Default) {
|
||||
val inProgressIds = inProgressItems
|
||||
.map { it.progress.contentId }
|
||||
.filter { it.isNotBlank() }
|
||||
.toSet()
|
||||
allProgress
|
||||
.filter { progress ->
|
||||
isSeriesType(progress.contentType) &&
|
||||
progress.season != null &&
|
||||
progress.episode != null &&
|
||||
progress.season != 0 &&
|
||||
progress.isCompleted() &&
|
||||
progress.source != WatchProgress.SOURCE_TRAKT_PLAYBACK
|
||||
}
|
||||
.groupBy { it.contentId }
|
||||
.mapNotNull { (_, items) ->
|
||||
items.maxWithOrNull(
|
||||
compareBy<WatchProgress>(
|
||||
{ it.lastWatched },
|
||||
{ it.season ?: -1 },
|
||||
{ it.episode ?: -1 }
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
.filter { it.contentId !in inProgressIds }
|
||||
.filter { progress -> nextUpDismissKey(progress.contentId) !in dismissedNextUp }
|
||||
.sortedByDescending { it.lastWatched }
|
||||
.take(MAX_NEXT_UP_LOOKUPS)
|
||||
}
|
||||
.filter { it.contentId !in inProgressIds }
|
||||
.filter { progress -> nextUpDismissKey(progress.contentId) !in dismissedNextUp }
|
||||
.sortedByDescending { it.lastWatched }
|
||||
.take(MAX_NEXT_UP_LOOKUPS)
|
||||
}
|
||||
|
||||
if (latestCompletedBySeries.isEmpty()) {
|
||||
return@coroutineScope
|
||||
|
|
@ -741,7 +788,7 @@ class HomeViewModel @Inject constructor(
|
|||
) ?: return@withPermit
|
||||
mergeMutex.withLock {
|
||||
nextUpByContent[progress.contentId] = nextUp
|
||||
if (nextUpByContent.size - lastEmittedNextUpCount >= 2) {
|
||||
if (nextUpByContent.size - lastEmittedNextUpCount >= 4) {
|
||||
val nextUpItems = nextUpByContent.values.toList()
|
||||
_uiState.update {
|
||||
val mergedItems = mergeContinueWatchingItems(
|
||||
|
|
@ -802,7 +849,7 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
if (enrichedItem != item) {
|
||||
enrichedByProgress[item.progress] = enrichedItem
|
||||
if (enrichedByProgress.size - lastAppliedCount >= 2) {
|
||||
if (enrichedByProgress.size - lastAppliedCount >= 4) {
|
||||
applyInProgressEpisodeDetailEnrichment(enrichedByProgress)
|
||||
lastAppliedCount = enrichedByProgress.size
|
||||
}
|
||||
|
|
@ -1177,6 +1224,7 @@ class HomeViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
addonRepository.getInstalledAddons()
|
||||
.distinctUntilChanged()
|
||||
.debounce(200)
|
||||
.collectLatest { addons ->
|
||||
addonsCache = addons
|
||||
loadAllCatalogs(addons)
|
||||
|
|
@ -1185,13 +1233,19 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun loadAllCatalogs(addons: List<Addon>) {
|
||||
val catalogLoadSessionId = startCatalogLoadSession()
|
||||
_uiState.update { it.copy(isLoading = true, error = null, installedAddonsCount = addons.size) }
|
||||
catalogOrder.clear()
|
||||
catalogsMap.clear()
|
||||
catalogVersion++
|
||||
lastProcessedCatalogVersion = -1L
|
||||
reconcilePosterObserversJob?.cancel()
|
||||
hasBootstrappedPosterObservers = false
|
||||
hasDispatchedFirstCatalogBatch = false
|
||||
hasCatalogContentDrawn = false
|
||||
reconcilePosterStatusObservers(emptyList())
|
||||
_fullCatalogRows.value = emptyList()
|
||||
truncatedRowCache.clear()
|
||||
hasRenderedFirstCatalog = false
|
||||
trailerPreviewLoadingIds.clear()
|
||||
trailerPreviewNegativeCache.clear()
|
||||
trailerPreviewUrlsState.clear()
|
||||
|
|
@ -1206,6 +1260,7 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
try {
|
||||
if (addons.isEmpty()) {
|
||||
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
|
||||
_uiState.update { it.copy(isLoading = false, error = "No addons installed") }
|
||||
return
|
||||
}
|
||||
|
|
@ -1213,6 +1268,7 @@ class HomeViewModel @Inject constructor(
|
|||
rebuildCatalogOrder(addons)
|
||||
|
||||
if (catalogOrder.isEmpty()) {
|
||||
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
|
||||
_uiState.update { it.copy(isLoading = false, error = "No catalog addons installed") }
|
||||
return
|
||||
}
|
||||
|
|
@ -1232,17 +1288,52 @@ class HomeViewModel @Inject constructor(
|
|||
.map { catalog -> addon to catalog }
|
||||
}
|
||||
pendingCatalogLoads = catalogsToLoad.size
|
||||
catalogsToLoad.forEach { (addon, catalog) ->
|
||||
loadCatalog(addon, catalog)
|
||||
|
||||
// Stagger catalog dispatching in waves so the UI thread gets breathing
|
||||
// room between batches to render + start Coil image loads.
|
||||
val firstWaveSize = if (deviceCapabilities.tier == DeviceTier.LOW) 1 else 2
|
||||
val subsequentWaveSize = if (deviceCapabilities.tier == DeviceTier.LOW) 2 else 3
|
||||
|
||||
val firstWave = catalogsToLoad.take(firstWaveSize)
|
||||
val remaining = catalogsToLoad.drop(firstWaveSize)
|
||||
|
||||
firstWave.forEach { (addon, catalog) -> loadCatalog(addon, catalog, catalogLoadSessionId) }
|
||||
|
||||
val waveDelayMs = if (deviceCapabilities.tier == DeviceTier.LOW) {
|
||||
CATALOG_WAVE_DELAY_MS * 2 // Extra breathing room on constrained devices
|
||||
} else {
|
||||
CATALOG_WAVE_DELAY_MS
|
||||
}
|
||||
remaining.chunked(subsequentWaveSize).forEach { wave ->
|
||||
delay(waveDelayMs)
|
||||
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
|
||||
wave.forEach { (addon, catalog) -> loadCatalog(addon, catalog, catalogLoadSessionId) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (catalogLoadSessionId != activeCatalogLoadSessionId) return
|
||||
_uiState.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCatalog(addon: Addon, catalog: CatalogDescriptor) {
|
||||
viewModelScope.launch {
|
||||
private fun startCatalogLoadSession(): Long {
|
||||
activeCatalogLoadSessionId += 1
|
||||
activeCatalogLoadJobs.values.forEach { it.cancel() }
|
||||
activeCatalogLoadJobs.clear()
|
||||
_loadingCatalogs.value = emptySet()
|
||||
pendingCatalogLoads = 0
|
||||
return activeCatalogLoadSessionId
|
||||
}
|
||||
|
||||
private fun loadCatalog(addon: Addon, catalog: CatalogDescriptor, sessionId: Long) {
|
||||
val key = catalogKey(
|
||||
addonId = addon.id,
|
||||
type = catalog.apiType,
|
||||
catalogId = catalog.id
|
||||
)
|
||||
activeCatalogLoadJobs.remove(key)?.cancel()
|
||||
val job = viewModelScope.launch {
|
||||
catalogLoadSemaphore.withPermit {
|
||||
if (sessionId != activeCatalogLoadSessionId) return@withPermit
|
||||
val supportsSkip = catalog.extra.any { it.name == "skip" }
|
||||
Log.d(
|
||||
TAG,
|
||||
|
|
@ -1258,14 +1349,11 @@ class HomeViewModel @Inject constructor(
|
|||
skip = 0,
|
||||
supportsSkip = supportsSkip
|
||||
).collect { result ->
|
||||
if (sessionId != activeCatalogLoadSessionId) return@collect
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
val key = catalogKey(
|
||||
addonId = addon.id,
|
||||
type = catalog.apiType,
|
||||
catalogId = catalog.id
|
||||
)
|
||||
catalogsMap[key] = result.data
|
||||
catalogVersion++
|
||||
pendingCatalogLoads = (pendingCatalogLoads - 1).coerceAtLeast(0)
|
||||
Log.d(
|
||||
TAG,
|
||||
|
|
@ -1286,6 +1374,12 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
activeCatalogLoadJobs[key] = job
|
||||
job.invokeOnCompletion {
|
||||
if (activeCatalogLoadJobs[key] == job) {
|
||||
activeCatalogLoadJobs.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadMoreCatalogItems(catalogId: String, addonId: String, type: String) {
|
||||
|
|
@ -1297,6 +1391,7 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
// Mark loading via lightweight separate flow — avoids full state cascade
|
||||
catalogsMap[key] = currentRow.copy(isLoading = true)
|
||||
catalogVersion++
|
||||
_loadingCatalogs.update { it + key }
|
||||
|
||||
viewModelScope.launch {
|
||||
|
|
@ -1318,11 +1413,13 @@ class HomeViewModel @Inject constructor(
|
|||
is NetworkResult.Success -> {
|
||||
val mergedItems = currentRow.items + result.data.items
|
||||
catalogsMap[key] = result.data.copy(items = mergedItems)
|
||||
catalogVersion++
|
||||
_loadingCatalogs.update { it - key }
|
||||
scheduleUpdateCatalogRows()
|
||||
}
|
||||
is NetworkResult.Error -> {
|
||||
catalogsMap[key] = currentRow.copy(isLoading = false)
|
||||
catalogVersion++
|
||||
_loadingCatalogs.update { it - key }
|
||||
scheduleUpdateCatalogRows()
|
||||
}
|
||||
|
|
@ -1338,13 +1435,13 @@ class HomeViewModel @Inject constructor(
|
|||
val debounceMs = when {
|
||||
// First render: use minimal debounce to show content ASAP while still
|
||||
// batching near-simultaneous arrivals.
|
||||
!hasRenderedFirstCatalog && catalogsMap.isNotEmpty() -> {
|
||||
hasRenderedFirstCatalog = true
|
||||
!hasDispatchedFirstCatalogBatch && catalogsMap.isNotEmpty() -> {
|
||||
hasDispatchedFirstCatalogBatch = true
|
||||
50L
|
||||
}
|
||||
pendingCatalogLoads > 8 -> 200L
|
||||
pendingCatalogLoads > 3 -> 150L
|
||||
pendingCatalogLoads > 0 -> 100L
|
||||
pendingCatalogLoads > 8 -> 350L
|
||||
pendingCatalogLoads > 3 -> 250L
|
||||
pendingCatalogLoads > 0 -> 150L
|
||||
else -> 50L
|
||||
}
|
||||
delay(debounceMs)
|
||||
|
|
@ -1353,6 +1450,11 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun updateCatalogRows() {
|
||||
// Skip expensive recomputation when catalog data hasn't changed.
|
||||
val currentVersion = catalogVersion
|
||||
if (currentVersion == lastProcessedCatalogVersion && !_uiState.value.isLoading) return
|
||||
lastProcessedCatalogVersion = currentVersion
|
||||
|
||||
// Snapshot mutable state before entering background context
|
||||
val orderedKeys = catalogOrder.toList()
|
||||
val catalogSnapshot = catalogsMap.toMap()
|
||||
|
|
@ -1465,10 +1567,13 @@ class HomeViewModel @Inject constructor(
|
|||
Triple(computedDisplayRows, computedHeroItems, computedGridItems)
|
||||
}
|
||||
|
||||
// Full (untruncated) rows for CatalogSeeAllScreen
|
||||
val fullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] }
|
||||
_fullCatalogRows.update { rows ->
|
||||
if (rows == fullRows) rows else fullRows
|
||||
// Full (untruncated) rows for CatalogSeeAllScreen — only needed when
|
||||
// user navigates to SeeAll, so defer during the initial bulk load.
|
||||
if (pendingCatalogLoads <= 0) {
|
||||
val fullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] }
|
||||
_fullCatalogRows.update { rows ->
|
||||
if (rows == fullRows) rows else fullRows
|
||||
}
|
||||
}
|
||||
|
||||
// Emit un-enriched hero items immediately so the UI renders without waiting for TMDB
|
||||
|
|
@ -1488,12 +1593,14 @@ class HomeViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
// Launch hero enrichment in the background — updates heroItems when done
|
||||
// Launch hero enrichment in the background — updates heroItems when done.
|
||||
// Defer during the initial bulk load to avoid 28 TMDB API calls competing
|
||||
// with catalog network requests and triggering extra recompositions.
|
||||
val tmdbSettings = currentTmdbSettings
|
||||
val shouldUseEnrichedHeroItems = tmdbSettings.enabled &&
|
||||
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
|
||||
|
||||
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) {
|
||||
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty() && pendingCatalogLoads <= 0) {
|
||||
heroEnrichmentJob?.cancel()
|
||||
heroEnrichmentJob = viewModelScope.launch {
|
||||
val enrichmentSignature = heroEnrichmentSignature(baseHeroItems, tmdbSettings)
|
||||
|
|
@ -1529,10 +1636,72 @@ class HomeViewModel @Inject constructor(
|
|||
lastHeroEnrichedItems = emptyList()
|
||||
}
|
||||
|
||||
reconcilePosterStatusObservers(displayRows)
|
||||
// Defer poster-status observers during the initial bulk load to avoid
|
||||
// launching hundreds of coroutines that compete with catalog loading + rendering.
|
||||
if (pendingCatalogLoads <= 0) {
|
||||
scheduleReconcilePosterStatusObservers(displayRows)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reconcilePosterStatusObservers(rows: List<CatalogRow>) {
|
||||
private fun scheduleReconcilePosterStatusObservers(rows: List<CatalogRow>) {
|
||||
reconcilePosterObserversJob?.cancel()
|
||||
val rowsSnapshot = rows.toList()
|
||||
reconcilePosterObserversJob = viewModelScope.launch {
|
||||
if (!hasBootstrappedPosterObservers) {
|
||||
withTimeoutOrNull(4000L) {
|
||||
while (!hasCatalogContentDrawn) {
|
||||
delay(50)
|
||||
}
|
||||
}
|
||||
}
|
||||
delay(if (hasBootstrappedPosterObservers) 120L else 450L)
|
||||
reconcilePosterStatusObservers(rowsSnapshot)
|
||||
hasBootstrappedPosterObservers = true
|
||||
}
|
||||
}
|
||||
|
||||
fun onCatalogContentRendered() {
|
||||
hasCatalogContentDrawn = true
|
||||
}
|
||||
|
||||
private fun schedulePosterStatusFlush() {
|
||||
if (posterStatusFlushJob?.isActive == true) return
|
||||
posterStatusFlushJob = viewModelScope.launch {
|
||||
delay(200)
|
||||
flushPosterStatusUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushPosterStatusUpdates() {
|
||||
val libraryBatch = HashMap(pendingLibraryStatusUpdates)
|
||||
val watchedBatch = HashMap(pendingWatchedStatusUpdates)
|
||||
if (libraryBatch.isEmpty() && watchedBatch.isEmpty()) return
|
||||
pendingLibraryStatusUpdates.keys.removeAll(libraryBatch.keys)
|
||||
pendingWatchedStatusUpdates.keys.removeAll(watchedBatch.keys)
|
||||
|
||||
_uiState.update { state ->
|
||||
val newLibrary = if (libraryBatch.isEmpty()) {
|
||||
state.posterLibraryMembership
|
||||
} else {
|
||||
state.posterLibraryMembership + libraryBatch
|
||||
}
|
||||
val newWatched = if (watchedBatch.isEmpty()) {
|
||||
state.movieWatchedStatus
|
||||
} else {
|
||||
state.movieWatchedStatus + watchedBatch
|
||||
}
|
||||
if (newLibrary == state.posterLibraryMembership && newWatched == state.movieWatchedStatus) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
posterLibraryMembership = newLibrary,
|
||||
movieWatchedStatus = newWatched
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reconcilePosterStatusObservers(rows: List<CatalogRow>) {
|
||||
val desiredItemsByKey = linkedMapOf<String, Pair<String, String>>()
|
||||
rows.asSequence()
|
||||
.flatMap { row -> row.items.asSequence() }
|
||||
|
|
@ -1558,26 +1727,24 @@ class HomeViewModel @Inject constructor(
|
|||
movieWatchedObserverJobs.remove(staleKey)?.cancel()
|
||||
}
|
||||
|
||||
// Launch observers in chunks to avoid a burst of 300+ coroutines
|
||||
// all emitting initial values simultaneously.
|
||||
var launchedInChunk = 0
|
||||
desiredItemsByKey.forEach { (statusKey, itemRef) ->
|
||||
val itemId = itemRef.first
|
||||
val itemType = itemRef.second
|
||||
var launchedAny = false
|
||||
|
||||
if (statusKey !in posterLibraryObserverJobs) {
|
||||
posterLibraryObserverJobs[statusKey] = viewModelScope.launch {
|
||||
libraryRepository.isInLibrary(itemId = itemId, itemType = itemType)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { isInLibrary ->
|
||||
_uiState.update { state ->
|
||||
if (state.posterLibraryMembership[statusKey] == isInLibrary) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
posterLibraryMembership = state.posterLibraryMembership + (statusKey to isInLibrary)
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingLibraryStatusUpdates[statusKey] = isInLibrary
|
||||
schedulePosterStatusFlush()
|
||||
}
|
||||
}
|
||||
launchedAny = true
|
||||
}
|
||||
|
||||
if (itemType.equals("movie", ignoreCase = true)) {
|
||||
|
|
@ -1586,21 +1753,33 @@ class HomeViewModel @Inject constructor(
|
|||
watchProgressRepository.isWatched(contentId = itemId)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { watched ->
|
||||
_uiState.update { state ->
|
||||
if (state.movieWatchedStatus[statusKey] == watched) {
|
||||
state
|
||||
} else {
|
||||
state.copy(
|
||||
movieWatchedStatus = state.movieWatchedStatus + (statusKey to watched)
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingWatchedStatusUpdates[statusKey] = watched
|
||||
schedulePosterStatusFlush()
|
||||
}
|
||||
}
|
||||
launchedAny = true
|
||||
}
|
||||
}
|
||||
|
||||
if (launchedAny) {
|
||||
launchedInChunk++
|
||||
if (launchedInChunk >= 30) {
|
||||
launchedInChunk = 0
|
||||
// Yield to let pending coroutines emit their first values
|
||||
// and batch into a single flush before launching the next chunk.
|
||||
kotlinx.coroutines.yield()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any buffered updates before trimming stale entries.
|
||||
posterStatusFlushJob?.cancel()
|
||||
flushPosterStatusUpdates()
|
||||
|
||||
// Remove stale entries from pending buffers.
|
||||
pendingLibraryStatusUpdates.keys.retainAll(desiredKeys)
|
||||
pendingWatchedStatusUpdates.keys.retainAll(desiredMovieKeys)
|
||||
|
||||
_uiState.update { state ->
|
||||
val trimmedLibraryMembership =
|
||||
state.posterLibraryMembership.filterKeys { it in desiredKeys }
|
||||
|
|
@ -1895,10 +2074,16 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
override fun onCleared() {
|
||||
posterStatusFlushJob?.cancel()
|
||||
reconcilePosterObserversJob?.cancel()
|
||||
activeCatalogLoadJobs.values.forEach { it.cancel() }
|
||||
activeCatalogLoadJobs.clear()
|
||||
posterLibraryObserverJobs.values.forEach { it.cancel() }
|
||||
movieWatchedObserverJobs.values.forEach { it.cancel() }
|
||||
posterLibraryObserverJobs.clear()
|
||||
movieWatchedObserverJobs.clear()
|
||||
pendingLibraryStatusUpdates.clear()
|
||||
pendingWatchedStatusUpdates.clear()
|
||||
super.onCleared()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,8 @@ import kotlinx.coroutines.delay
|
|||
import android.view.KeyEvent as AndroidKeyEvent
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
private const val KEY_REPEAT_THROTTLE_MS = 80L
|
||||
private const val KEY_REPEAT_THROTTLE_MS_DEFAULT = 80L
|
||||
private val HeroTransitionAnimSpec = tween<Float>(durationMillis = 480)
|
||||
|
||||
@Composable
|
||||
fun ModernHomeContent(
|
||||
|
|
@ -439,7 +440,9 @@ fun ModernHomeContent(
|
|||
}
|
||||
}
|
||||
|
||||
val activeRow by remember(carouselRows, rowByKey, activeRowKey) {
|
||||
// Use carouselRows/rowByKey as remember keys (plain collections, not snapshot-observable).
|
||||
// activeRowKey is snapshot-observable via mutableStateOf — derivedStateOf tracks it automatically.
|
||||
val activeRow by remember(carouselRows, rowByKey) {
|
||||
derivedStateOf {
|
||||
val activeKey = activeRowKey
|
||||
if (activeKey == null) {
|
||||
|
|
@ -449,7 +452,8 @@ fun ModernHomeContent(
|
|||
}
|
||||
}
|
||||
}
|
||||
val clampedActiveItemIndex by remember(activeRow, activeItemIndex) {
|
||||
// activeRow and activeItemIndex are both snapshot-observable — no remember keys needed.
|
||||
val clampedActiveItemIndex by remember {
|
||||
derivedStateOf {
|
||||
activeRow?.let { row ->
|
||||
activeItemIndex.coerceIn(0, (row.items.size - 1).coerceAtLeast(0))
|
||||
|
|
@ -466,7 +470,7 @@ fun ModernHomeContent(
|
|||
focusedItemByRow[row.key] = clampedIndex
|
||||
}
|
||||
|
||||
val activeHeroItemKey by remember(activeRow, clampedActiveItemIndex) {
|
||||
val activeHeroItemKey by remember {
|
||||
derivedStateOf {
|
||||
val row = activeRow ?: return@derivedStateOf null
|
||||
row.items.getOrNull(clampedActiveItemIndex)?.key ?: row.items.firstOrNull()?.key
|
||||
|
|
@ -532,19 +536,21 @@ fun ModernHomeContent(
|
|||
) {
|
||||
val rowHorizontalPadding = 52.dp
|
||||
|
||||
val resolvedHero by remember(heroItem, activeRow, clampedActiveItemIndex) {
|
||||
val resolvedHero by remember {
|
||||
derivedStateOf {
|
||||
heroItem
|
||||
?: activeRow?.items?.getOrNull(clampedActiveItemIndex)?.heroPreview
|
||||
?: activeRow?.items?.firstOrNull()?.heroPreview
|
||||
}
|
||||
}
|
||||
val activeRowFallbackBackdrop = remember(activeRow?.key, activeRow?.items) {
|
||||
activeRow?.items?.firstNotNullOfOrNull { item ->
|
||||
item.heroPreview.backdrop?.takeIf { it.isNotBlank() }
|
||||
val activeRowFallbackBackdrop by remember {
|
||||
derivedStateOf {
|
||||
activeRow?.items?.firstNotNullOfOrNull { item ->
|
||||
item.heroPreview.backdrop?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
}
|
||||
}
|
||||
val heroBackdrop by remember(heroItem, resolvedHero, activeRowFallbackBackdrop) {
|
||||
val heroBackdrop by remember {
|
||||
derivedStateOf {
|
||||
firstNonBlank(
|
||||
resolvedHero?.backdrop,
|
||||
|
|
@ -554,23 +560,21 @@ fun ModernHomeContent(
|
|||
)
|
||||
}
|
||||
}
|
||||
val expandedFocusedSelection by remember(focusedCatalogSelection, expandedCatalogFocusKey) {
|
||||
val expandedFocusedSelection by remember {
|
||||
derivedStateOf {
|
||||
focusedCatalogSelection?.takeIf { it.focusKey == expandedCatalogFocusKey }
|
||||
}
|
||||
}
|
||||
val heroTrailerUrl by remember(expandedFocusedSelection, trailerPreviewUrls) {
|
||||
// trailerPreviewUrls is a plain Map parameter — keep as remember key.
|
||||
val heroTrailerUrl by remember(trailerPreviewUrls) {
|
||||
derivedStateOf {
|
||||
expandedFocusedSelection?.payload?.itemId?.let { trailerPreviewUrls[it] }
|
||||
}
|
||||
}
|
||||
val expandedCatalogTrailerUrl = heroTrailerUrl
|
||||
val shouldPlayHeroTrailer by remember(
|
||||
effectiveAutoplayEnabled,
|
||||
trailerPlaybackTarget,
|
||||
heroTrailerUrl,
|
||||
isVerticalRowsScrolling
|
||||
) {
|
||||
// effectiveAutoplayEnabled and trailerPlaybackTarget are plain vals — keep as keys.
|
||||
// heroTrailerUrl and isVerticalRowsScrolling are snapshot-observable — tracked automatically.
|
||||
val shouldPlayHeroTrailer by remember(effectiveAutoplayEnabled, trailerPlaybackTarget) {
|
||||
derivedStateOf {
|
||||
effectiveAutoplayEnabled &&
|
||||
!isVerticalRowsScrolling &&
|
||||
|
|
@ -586,7 +590,7 @@ fun ModernHomeContent(
|
|||
}
|
||||
val heroTransitionProgress by animateFloatAsState(
|
||||
targetValue = if (shouldPlayHeroTrailer && heroTrailerFirstFrameRendered) 1f else 0f,
|
||||
animationSpec = tween(durationMillis = 480),
|
||||
animationSpec = HeroTransitionAnimSpec,
|
||||
label = "heroBackdropTrailerCrossfadeProgress"
|
||||
)
|
||||
val heroBackdropAlpha = 1f - heroTransitionProgress
|
||||
|
|
@ -664,7 +668,7 @@ fun ModernHomeContent(
|
|||
val native = event.nativeKeyEvent
|
||||
if (native.action == AndroidKeyEvent.ACTION_DOWN && native.repeatCount > 0) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastKeyRepeatTime < KEY_REPEAT_THROTTLE_MS) {
|
||||
if (now - lastKeyRepeatTime < uiState.keyRepeatThrottleMs) {
|
||||
return@onPreviewKeyEvent true
|
||||
}
|
||||
lastKeyRepeatTime = now
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import coil.request.ImageRequest
|
|||
import com.nuvio.tv.ui.components.TrailerPlayer
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
|
||||
private val HeroCrossfadeAnimSpec = tween<Float>(durationMillis = 200)
|
||||
|
||||
@Composable
|
||||
internal fun ModernHeroMediaLayer(
|
||||
heroBackdrop: String?,
|
||||
|
|
@ -60,7 +62,7 @@ internal fun ModernHeroMediaLayer(
|
|||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer { alpha = heroBackdropAlpha },
|
||||
animationSpec = tween(durationMillis = 350),
|
||||
animationSpec = HeroCrossfadeAnimSpec,
|
||||
label = "modernHeroBackground"
|
||||
) { imageUrl ->
|
||||
val imageModel = remember(localContext, imageUrl, requestWidthPx, requestHeightPx) {
|
||||
|
|
@ -94,31 +96,35 @@ internal fun ModernHeroMediaLayer(
|
|||
)
|
||||
}
|
||||
|
||||
// Pre-compute color variants outside drawWithCache to avoid per-frame allocations.
|
||||
val bg096 = remember(bgColor) { bgColor.copy(alpha = 0.96f) }
|
||||
val bg072 = remember(bgColor) { bgColor.copy(alpha = 0.72f) }
|
||||
val bg078 = remember(bgColor) { bgColor.copy(alpha = 0.78f) }
|
||||
val bg052 = remember(bgColor) { bgColor.copy(alpha = 0.52f) }
|
||||
val bg016 = remember(bgColor) { bgColor.copy(alpha = 0.16f) }
|
||||
val bg098 = remember(bgColor) { bgColor.copy(alpha = 0.98f) }
|
||||
|
||||
val horizontalGradientStops = remember(bg096, bg072) {
|
||||
arrayOf(0.0f to bg096, 0.10f to bg072, 0.30f to Color.Transparent)
|
||||
}
|
||||
val radialGradientStops = remember(bg078, bg052, bg016) {
|
||||
arrayOf(0.0f to bg078, 0.55f to bg052, 0.80f to bg016, 1.0f to Color.Transparent)
|
||||
}
|
||||
val verticalGradientStops = remember(bg072, bg098, bgColor) {
|
||||
arrayOf(0.78f to Color.Transparent, 0.90f to bg072, 0.96f to bg098, 1.0f to bgColor)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithCache {
|
||||
val horizontalGradient = Brush.horizontalGradient(
|
||||
0.0f to bgColor.copy(alpha = 0.96f),
|
||||
0.10f to bgColor.copy(alpha = 0.72f),
|
||||
0.30f to Color.Transparent
|
||||
)
|
||||
val horizontalGradient = Brush.horizontalGradient(colorStops = horizontalGradientStops)
|
||||
val radialGradient = Brush.radialGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to bgColor.copy(alpha = 0.78f),
|
||||
0.55f to bgColor.copy(alpha = 0.52f),
|
||||
0.80f to bgColor.copy(alpha = 0.16f),
|
||||
1.0f to Color.Transparent
|
||||
),
|
||||
colorStops = radialGradientStops,
|
||||
center = Offset(0f, size.height / 2f),
|
||||
radius = size.height
|
||||
)
|
||||
val verticalGradient = Brush.verticalGradient(
|
||||
0.78f to Color.Transparent,
|
||||
0.90f to bgColor.copy(alpha = 0.72f),
|
||||
0.96f to bgColor.copy(alpha = 0.98f),
|
||||
1.0f to bgColor
|
||||
)
|
||||
val verticalGradient = Brush.verticalGradient(colorStops = verticalGradientStops)
|
||||
onDrawBehind {
|
||||
drawRect(brush = horizontalGradient, size = size)
|
||||
drawRect(brush = radialGradient, size = size)
|
||||
|
|
@ -292,10 +298,11 @@ internal fun HeroTitleBlock(
|
|||
|
||||
@Composable
|
||||
private fun HeroMetaDivider(scale: Float) {
|
||||
val dividerColor = remember { NuvioColors.TextTertiary.copy(alpha = 0.78f) }
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size((4.dp * scale).coerceAtLeast(2.dp))
|
||||
.clip(RoundedCornerShape(percent = 50))
|
||||
.background(NuvioColors.TextTertiary.copy(alpha = 0.78f))
|
||||
.background(dividerColor)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ internal val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""")
|
|||
internal const val MODERN_HERO_TEXT_WIDTH_FRACTION = 0.42f
|
||||
internal const val MODERN_HERO_BACKDROP_HEIGHT_FRACTION = 0.62f
|
||||
internal const val MODERN_TRAILER_OVERSCAN_ZOOM = 1.35f
|
||||
internal const val MODERN_HERO_FOCUS_DEBOUNCE_MS = 90L
|
||||
internal const val MODERN_HERO_FOCUS_DEBOUNCE_MS = 180L
|
||||
internal val MODERN_ROW_HEADER_FOCUS_INSET = 56.dp
|
||||
internal val MODERN_LANDSCAPE_LOGO_GRADIENT = Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -79,6 +78,8 @@ import com.nuvio.tv.ui.theme.NuvioColors
|
|||
import kotlin.math.abs
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
||||
private val CardWatchedIconAnimSpec = tween<Dp>(durationMillis = 180)
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
private fun ModernContinueWatchingRowItem(
|
||||
|
|
@ -229,10 +230,15 @@ internal fun ModernRowSection(
|
|||
val rowListStates = uiCaches.rowListStates
|
||||
val loadMoreRequestedTotals = uiCaches.loadMoreRequestedTotals
|
||||
|
||||
val titleMedium = MaterialTheme.typography.titleMedium
|
||||
val rowTitleStyle = remember(titleMedium) {
|
||||
titleMedium.copy(fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = row.title,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
style = rowTitleStyle,
|
||||
color = NuvioColors.TextPrimary,
|
||||
modifier = Modifier.padding(start = 52.dp, bottom = rowTitleBottom)
|
||||
)
|
||||
|
|
@ -242,9 +248,6 @@ internal fun ModernRowSection(
|
|||
firstVisibleItemIndex = focusStateCatalogRowScrollStates[row.key] ?: 0
|
||||
)
|
||||
}
|
||||
val isRowScrolling by remember(rowListState) {
|
||||
derivedStateOf { rowListState.isScrollInProgress }
|
||||
}
|
||||
val currentRowState = rememberUpdatedState(row)
|
||||
val loadMoreCatalogId = row.catalogId
|
||||
val loadMoreAddonId = row.addonId
|
||||
|
|
@ -375,9 +378,12 @@ internal fun ModernRowSection(
|
|||
} else {
|
||||
fallbackIndex
|
||||
}
|
||||
val visibleIndices = rowListState.layoutInfo.visibleItemsInfo.map { it.index }.toSet()
|
||||
val safeIndex = if (restoreIndex in visibleIndices) restoreIndex else
|
||||
visibleIndices.minByOrNull { kotlin.math.abs(it - restoreIndex) } ?: fallbackIndex
|
||||
val visibleItems = rowListState.layoutInfo.visibleItemsInfo
|
||||
val safeIndex = if (visibleItems.any { it.index == restoreIndex }) {
|
||||
restoreIndex
|
||||
} else {
|
||||
visibleItems.minByOrNull { kotlin.math.abs(it.index - restoreIndex) }?.index ?: fallbackIndex
|
||||
}
|
||||
val itemKey = row.items.getOrNull(safeIndex)?.key ?: row.items.first().key
|
||||
itemFocusRequesters[row.key]?.get(itemKey) ?: FocusRequester.Default
|
||||
},
|
||||
|
|
@ -424,8 +430,8 @@ internal fun ModernRowSection(
|
|||
modernCatalogCardWidth = modernCatalogCardWidth,
|
||||
modernCatalogCardHeight = modernCatalogCardHeight,
|
||||
focusedPosterBackdropTrailerMuted = focusedPosterBackdropTrailerMuted,
|
||||
effectiveExpandEnabled = effectiveExpandEnabled && !isRowScrolling,
|
||||
effectiveAutoplayEnabled = effectiveAutoplayEnabled && !isRowScrolling,
|
||||
effectiveExpandEnabled = effectiveExpandEnabled,
|
||||
effectiveAutoplayEnabled = effectiveAutoplayEnabled,
|
||||
trailerPlaybackTarget = trailerPlaybackTarget,
|
||||
expandedCatalogFocusKey = expandedCatalogFocusKey,
|
||||
expandedTrailerPreviewUrl = expandedTrailerPreviewUrl,
|
||||
|
|
@ -473,7 +479,7 @@ private fun ModernCarouselCard(
|
|||
onBackdropInteraction: () -> Unit,
|
||||
onTrailerEnded: () -> Unit
|
||||
) {
|
||||
val cardShape = RoundedCornerShape(cardCornerRadius)
|
||||
val cardShape = remember(cardCornerRadius) { RoundedCornerShape(cardCornerRadius) }
|
||||
val context = LocalContext.current
|
||||
val density = LocalDensity.current
|
||||
val expandedCardWidth = cardHeight * (16f / 9f)
|
||||
|
|
@ -538,11 +544,16 @@ private fun ModernCarouselCard(
|
|||
val hasLandscapeLogo = useLandscapePosters && !item.heroPreview.logo.isNullOrBlank()
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
var longPressTriggered by remember { mutableStateOf(false) }
|
||||
val watchedIconEndPadding by animateDpAsState(
|
||||
targetValue = if (isFocused) 16.dp else 8.dp,
|
||||
animationSpec = tween(durationMillis = 180),
|
||||
label = "modernCardWatchedIconEndPadding"
|
||||
)
|
||||
val watchedIconEndPadding = if (isWatched) {
|
||||
val padding by animateDpAsState(
|
||||
targetValue = if (isFocused) 16.dp else 8.dp,
|
||||
animationSpec = CardWatchedIconAnimSpec,
|
||||
label = "modernCardWatchedIconEndPadding"
|
||||
)
|
||||
padding
|
||||
} else {
|
||||
8.dp
|
||||
}
|
||||
val backgroundCardColor = NuvioColors.BackgroundCard
|
||||
val focusRingColor = NuvioColors.FocusRing
|
||||
val titleMedium = MaterialTheme.typography.titleMedium
|
||||
|
|
@ -552,6 +563,8 @@ private fun ModernCarouselCard(
|
|||
shape = cardShape
|
||||
)
|
||||
}
|
||||
val cardShapeSpec = remember(cardShape) { CardDefaults.shape(shape = cardShape) }
|
||||
val cardScale = remember { CardDefaults.scale(focusedScale = 1f) }
|
||||
val titleStyle = remember(titleMedium) {
|
||||
titleMedium.copy(fontWeight = FontWeight.Medium)
|
||||
}
|
||||
|
|
@ -604,7 +617,7 @@ private fun ModernCarouselCard(
|
|||
}
|
||||
false
|
||||
},
|
||||
shape = CardDefaults.shape(shape = cardShape),
|
||||
shape = cardShapeSpec,
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = backgroundCardColor,
|
||||
focusedContainerColor = backgroundCardColor
|
||||
|
|
@ -612,20 +625,22 @@ private fun ModernCarouselCard(
|
|||
border = CardDefaults.border(
|
||||
focusedBorder = focusedBorder
|
||||
),
|
||||
scale = CardDefaults.scale(focusedScale = 1f)
|
||||
scale = cardScale
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val mediaLayerModifier = if (hasLandscapeLogo) {
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithCache {
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = MODERN_LANDSCAPE_LOGO_GRADIENT, size = size)
|
||||
val mediaLayerModifier = remember(hasLandscapeLogo) {
|
||||
if (hasLandscapeLogo) {
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithCache {
|
||||
onDrawWithContent {
|
||||
drawContent()
|
||||
drawRect(brush = MODERN_LANDSCAPE_LOGO_GRADIENT, size = size)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
} else {
|
||||
Modifier.fillMaxSize()
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = mediaLayerModifier) {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,25 @@ fun DebugSettingsContent(
|
|||
)
|
||||
}
|
||||
|
||||
// ── Performance ──
|
||||
item(key = "debug_performance_header") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Performance",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = NuvioColors.TextTertiary,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "debug_device_tier") {
|
||||
DebugDeviceTierSelector(
|
||||
currentOverride = uiState.deviceTierOverride,
|
||||
detectedTier = uiState.detectedDeviceTier,
|
||||
onSelect = { viewModel.onEvent(DebugSettingsEvent.SetDeviceTierOverride(it)) }
|
||||
)
|
||||
}
|
||||
|
||||
// ── Feature Toggles ──
|
||||
item(key = "debug_feature_toggles_header") {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
|
@ -291,6 +310,66 @@ private fun DebugDialogButton(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugDeviceTierSelector(
|
||||
currentOverride: String,
|
||||
detectedTier: String,
|
||||
onSelect: (String) -> Unit
|
||||
) {
|
||||
val options = listOf("auto", "low", "medium", "high")
|
||||
|
||||
Card(
|
||||
onClick = {
|
||||
val currentIndex = options.indexOf(currentOverride)
|
||||
val nextIndex = (currentIndex + 1) % options.size
|
||||
onSelect(options[nextIndex])
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.colors(
|
||||
containerColor = NuvioColors.BackgroundCard,
|
||||
focusedContainerColor = NuvioColors.FocusBackground
|
||||
),
|
||||
border = CardDefaults.border(
|
||||
focusedBorder = Border(
|
||||
border = BorderStroke(2.dp, NuvioColors.FocusRing),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
),
|
||||
shape = CardDefaults.shape(RoundedCornerShape(12.dp)),
|
||||
scale = CardDefaults.scale(focusedScale = 1.02f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Device Performance Tier",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = NuvioColors.TextPrimary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Detected: $detectedTier. Click to cycle override.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = NuvioColors.TextSecondary
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Text(
|
||||
text = currentOverride.uppercase(),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = NuvioColors.Secondary
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DebugSignInCard(
|
||||
isLoading: Boolean,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package com.nuvio.tv.ui.screens.settings
|
|||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nuvio.tv.core.auth.AuthManager
|
||||
import com.nuvio.tv.core.device.DeviceCapabilities
|
||||
import com.nuvio.tv.core.device.DeviceTier
|
||||
import com.nuvio.tv.data.local.DebugSettingsDataStore
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -16,7 +18,8 @@ import javax.inject.Inject
|
|||
@HiltViewModel
|
||||
class DebugSettingsViewModel @Inject constructor(
|
||||
private val dataStore: DebugSettingsDataStore,
|
||||
private val authManager: AuthManager
|
||||
private val authManager: AuthManager,
|
||||
private val deviceCapabilities: DeviceCapabilities
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DebugSettingsUiState())
|
||||
|
|
@ -33,6 +36,23 @@ class DebugSettingsViewModel @Inject constructor(
|
|||
_uiState.update { it.copy(syncCodeFeaturesEnabled = enabled) }
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
dataStore.deviceTierOverride.collectLatest { override ->
|
||||
val tier = when (override) {
|
||||
"low" -> DeviceTier.LOW
|
||||
"medium" -> DeviceTier.MEDIUM
|
||||
"high" -> DeviceTier.HIGH
|
||||
else -> null
|
||||
}
|
||||
deviceCapabilities.setTierOverride(tier)
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
deviceTierOverride = override,
|
||||
detectedDeviceTier = deviceCapabilities.tier.name
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: DebugSettingsEvent) {
|
||||
|
|
@ -43,6 +63,9 @@ class DebugSettingsViewModel @Inject constructor(
|
|||
is DebugSettingsEvent.ToggleSyncCodeFeatures -> {
|
||||
viewModelScope.launch { dataStore.setSyncCodeFeaturesEnabled(event.enabled) }
|
||||
}
|
||||
is DebugSettingsEvent.SetDeviceTierOverride -> {
|
||||
viewModelScope.launch { dataStore.setDeviceTierOverride(event.value) }
|
||||
}
|
||||
is DebugSettingsEvent.SignIn -> {
|
||||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(signInLoading = true, signInResult = null) }
|
||||
|
|
@ -63,11 +86,14 @@ data class DebugSettingsUiState(
|
|||
val accountTabEnabled: Boolean = false,
|
||||
val syncCodeFeaturesEnabled: Boolean = false,
|
||||
val signInLoading: Boolean = false,
|
||||
val signInResult: String? = null
|
||||
val signInResult: String? = null,
|
||||
val deviceTierOverride: String = "auto",
|
||||
val detectedDeviceTier: String = "HIGH"
|
||||
)
|
||||
|
||||
sealed class DebugSettingsEvent {
|
||||
data class ToggleAccountTab(val enabled: Boolean) : DebugSettingsEvent()
|
||||
data class ToggleSyncCodeFeatures(val enabled: Boolean) : DebugSettingsEvent()
|
||||
data class SetDeviceTierOverride(val value: String) : DebugSettingsEvent()
|
||||
data class SignIn(val email: String, val password: String) : DebugSettingsEvent()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,31 @@
|
|||
com.nuvio.tv.domain.model.*
|
||||
|
||||
// UI state classes
|
||||
com.nuvio.tv.ui.screens.home.HomeUiState
|
||||
com.nuvio.tv.ui.screens.home.HomeScreenFocusState
|
||||
com.nuvio.tv.ui.screens.home.ContinueWatchingItem
|
||||
com.nuvio.tv.ui.screens.home.ContinueWatchingItem.InProgress
|
||||
com.nuvio.tv.ui.screens.home.ContinueWatchingItem.NextUp
|
||||
com.nuvio.tv.ui.screens.home.NextUpInfo
|
||||
com.nuvio.tv.ui.screens.home.GridItem
|
||||
com.nuvio.tv.ui.screens.home.GridItem.Hero
|
||||
com.nuvio.tv.ui.screens.home.GridItem.SectionDivider
|
||||
com.nuvio.tv.ui.screens.home.GridItem.Content
|
||||
com.nuvio.tv.ui.screens.home.GridItem.SeeAll
|
||||
com.nuvio.tv.ui.screens.search.SearchUiState
|
||||
com.nuvio.tv.ui.screens.search.DiscoverCatalog
|
||||
com.nuvio.tv.ui.components.PosterCardStyle
|
||||
|
||||
// Modern home screen types
|
||||
com.nuvio.tv.ui.screens.home.HeroPreview
|
||||
com.nuvio.tv.ui.screens.home.HeroCarouselRow
|
||||
com.nuvio.tv.ui.screens.home.ModernCarouselItem
|
||||
com.nuvio.tv.ui.screens.home.ModernPayload
|
||||
com.nuvio.tv.ui.screens.home.ModernPayload.Catalog
|
||||
com.nuvio.tv.ui.screens.home.ModernPayload.ContinueWatching
|
||||
com.nuvio.tv.ui.screens.home.FocusedCatalogSelection
|
||||
com.nuvio.tv.ui.screens.home.CarouselRowLookups
|
||||
|
||||
// Collections from kotlinx
|
||||
kotlin.collections.List
|
||||
kotlin.collections.Map
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. For more details, visit
|
||||
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
|
||||
|
|
|
|||
|
|
@ -21,5 +21,5 @@ dependencyResolutionManagement {
|
|||
|
||||
rootProject.name = "My Application"
|
||||
include(":app")
|
||||
// include(":benchmark") // TODO: create when ready
|
||||
include(":benchmark")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue