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