perf: decouple home startup from continue watching sync

This commit is contained in:
HALİL İBRAHİM ÖZDEMİR 2026-03-26 00:30:23 +03:00
parent 0c2bb40707
commit 6c2acafec1
5 changed files with 101 additions and 25 deletions

View file

@ -74,6 +74,7 @@ class WatchProgressRepositoryImpl @Inject constructor(
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var syncJob: Job? = null
private var watchedItemsSyncJob: Job? = null
private var lastRemoteRefreshAtMs: Long = 0L
var isSyncingFromRemote = false
var hasCompletedInitialPull = false
var hasCompletedInitialWatchedItemsPull = false
@ -98,6 +99,44 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
}
suspend fun refreshRemoteProgressIfStale(
maxAgeMs: Long,
force: Boolean = false
): Long? {
if (isSyncingFromRemote) return null
val now = System.currentTimeMillis()
val useTraktProgress = shouldUseTraktProgress()
if (!force && hasCompletedInitialPull && lastRemoteRefreshAtMs > 0L) {
val ageMs = now - lastRemoteRefreshAtMs
if (ageMs in 0 until maxAgeMs) {
return lastRemoteRefreshAtMs
}
}
if (useTraktProgress) {
traktProgressService.refreshNow()
lastRemoteRefreshAtMs = now
return lastRemoteRefreshAtMs
}
if (!authManager.isAuthenticated) return lastRemoteRefreshAtMs.takeIf { it > 0L }
isSyncingFromRemote = true
return try {
val remoteEntries = watchProgressSyncService.pullFromRemote().getOrElse { throw it }
watchProgressPreferences.mergeRemoteEntries(remoteEntries.toMap())
hasCompletedInitialPull = true
lastRemoteRefreshAtMs = System.currentTimeMillis()
lastRemoteRefreshAtMs
} catch (error: Exception) {
Log.w(TAG, "refreshRemoteProgressIfStale failed", error)
lastRemoteRefreshAtMs.takeIf { it > 0L }
} finally {
isSyncingFromRemote = false
}
}
private fun triggerWatchedItemsSync() {
if (isSyncingFromRemote) return
if (!hasCompletedInitialWatchedItemsPull) return

View file

@ -31,6 +31,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Button
import androidx.tv.material3.ButtonDefaults
@ -50,7 +53,6 @@ import androidx.compose.ui.res.stringResource
import com.nuvio.tv.R
import com.nuvio.tv.data.local.StartupAuthNotice
import com.nuvio.tv.ui.theme.NuvioColors
import kotlinx.coroutines.delay
import kotlin.math.roundToInt
private data class HomePosterOptionsTarget(
@ -58,8 +60,6 @@ private data class HomePosterOptionsTarget(
val addonBaseUrl: String
)
private const val HOME_STARTUP_CW_GATE_TIMEOUT_MS = 5_000L
@OptIn(ExperimentalTvMaterial3Api::class)
@Composable
fun HomeScreen(
@ -86,11 +86,10 @@ fun HomeScreen(
val effectiveAutoplayEnabled by viewModel.effectiveAutoplayEnabled.collectAsStateWithLifecycle(
initialValue = false
)
val lifecycleOwner = LocalLifecycleOwner.current
val hasCatalogContent = uiState.catalogRows.any { it.items.isNotEmpty() }
var hasEnteredCatalogContent by rememberSaveable { mutableStateOf(false) }
var showHomeContentWithAnimation by rememberSaveable { mutableStateOf(false) }
var hasReleasedStartupCwGate by rememberSaveable { mutableStateOf(false) }
var startupCwGateTimedOut by rememberSaveable { mutableStateOf(false) }
var hasShownInitialHomeContent by rememberSaveable { mutableStateOf(false) }
var posterOptionsTarget by remember { mutableStateOf<HomePosterOptionsTarget?>(null) }
@ -112,15 +111,18 @@ fun HomeScreen(
}
LaunchedEffect(Unit) {
delay(HOME_STARTUP_CW_GATE_TIMEOUT_MS)
startupCwGateTimedOut = true
viewModel.refreshContinueWatchingIfStale()
}
LaunchedEffect(uiState.continueWatchingItems.isNotEmpty(), startupCwGateTimedOut) {
if (!hasReleasedStartupCwGate &&
(uiState.continueWatchingItems.isNotEmpty() || startupCwGateTimedOut)
) {
hasReleasedStartupCwGate = true
androidx.compose.runtime.DisposableEffect(lifecycleOwner, viewModel) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME) {
viewModel.refreshContinueWatchingIfStale()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
@ -195,9 +197,7 @@ fun HomeScreen(
}
else -> {
val shouldShowLoadingGate =
!hasReleasedStartupCwGate ||
(!hasEnteredCatalogContent && !hasCatalogContent)
val shouldShowLoadingGate = !hasEnteredCatalogContent && !hasCatalogContent
LaunchedEffect(shouldShowLoadingGate) {
if (shouldShowLoadingGate) {
showHomeContentWithAnimation = false

View file

@ -14,6 +14,8 @@ import com.nuvio.tv.domain.model.WatchProgress
data class HomeUiState(
val catalogRows: List<CatalogRow> = emptyList(),
val continueWatchingItems: List<ContinueWatchingItem> = emptyList(),
val continueWatchingResolved: Boolean = false,
val continueWatchingLastRemoteSyncAtMs: Long? = null,
val isLoading: Boolean = true,
val error: String? = null,
val selectedItemId: String? = null,

View file

@ -15,6 +15,7 @@ import com.nuvio.tv.data.local.StartupAuthNotice
import com.nuvio.tv.data.local.TmdbSettingsDataStore
import com.nuvio.tv.data.local.TraktSettingsDataStore
import com.nuvio.tv.data.local.WatchedItemsPreferences
import com.nuvio.tv.data.repository.WatchProgressRepositoryImpl
import com.nuvio.tv.data.trailer.TrailerService
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.CatalogDescriptor
@ -27,7 +28,6 @@ import com.nuvio.tv.domain.repository.AddonRepository
import com.nuvio.tv.domain.repository.CatalogRepository
import com.nuvio.tv.domain.repository.LibraryRepository
import com.nuvio.tv.domain.repository.MetaRepository
import com.nuvio.tv.domain.repository.WatchProgressRepository
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Job
@ -49,7 +49,7 @@ class HomeViewModel @Inject constructor(
@ApplicationContext internal val appContext: Context,
internal val addonRepository: AddonRepository,
internal val catalogRepository: CatalogRepository,
internal val watchProgressRepository: WatchProgressRepository,
internal val watchProgressRepository: WatchProgressRepositoryImpl,
internal val libraryRepository: LibraryRepository,
internal val metaRepository: MetaRepository,
internal val layoutPreferenceDataStore: LayoutPreferenceDataStore,
@ -74,6 +74,7 @@ class HomeViewModel @Inject constructor(
internal const val EXTERNAL_META_PREFETCH_FOCUS_DEBOUNCE_MS = 220L
internal const val EXTERNAL_META_PREFETCH_ADJACENT_DEBOUNCE_MS = 120L
internal const val MAX_POSTER_STATUS_OBSERVERS = 24
internal const val CONTINUE_WATCHING_REMOTE_REFRESH_STALE_MS = 60_000L
}
internal val _uiState = MutableStateFlow(HomeUiState())
@ -273,6 +274,24 @@ class HomeViewModel @Inject constructor(
loadContinueWatchingPipeline()
}
fun refreshContinueWatchingIfStale(force: Boolean = false) {
viewModelScope.launch {
val refreshedAt = watchProgressRepository.refreshRemoteProgressIfStale(
maxAgeMs = CONTINUE_WATCHING_REMOTE_REFRESH_STALE_MS,
force = force
)
if (refreshedAt != null) {
_uiState.update { state ->
if (state.continueWatchingLastRemoteSyncAtMs == refreshedAt) {
state
} else {
state.copy(continueWatchingLastRemoteSyncAtMs = refreshedAt)
}
}
}
}
}
private fun removeContinueWatching(
contentId: String,
season: Int? = null,

View file

@ -200,10 +200,14 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
if (inProgressOnly.isNotEmpty()) {
val initialItems = inProgressOnly.map { it as ContinueWatchingItem }
_uiState.update { state ->
if (state.continueWatchingItems == initialItems) {
val nextState = state.copy(
continueWatchingItems = initialItems,
continueWatchingResolved = true
)
if (state == nextState) {
state
} else {
state.copy(continueWatchingItems = initialItems)
nextState
}
}
debug.recordInitialRendered(
@ -233,10 +237,14 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
nextUpItems = partialNextUpItems
)
_uiState.update { state ->
if (state.continueWatchingItems == partialItems) {
val nextState = state.copy(
continueWatchingItems = partialItems,
continueWatchingResolved = true
)
if (state == nextState) {
state
} else {
state.copy(continueWatchingItems = partialItems)
nextState
}
}
debug.recordPartialRendered(
@ -259,10 +267,14 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
)
_uiState.update { state ->
if (state.continueWatchingItems == normalItems) {
val nextState = state.copy(
continueWatchingItems = normalItems,
continueWatchingResolved = true
)
if (state == nextState) {
state
} else {
state.copy(continueWatchingItems = normalItems)
nextState
}
}
debug.recordLightweightRendered(
@ -578,10 +590,14 @@ private suspend fun HomeViewModel.enrichVisibleContinueWatchingItems(
if (enrichedItems == finalItems) return@coroutineScope false
_uiState.update { state ->
if (state.continueWatchingItems == enrichedItems) {
val nextState = state.copy(
continueWatchingItems = enrichedItems,
continueWatchingResolved = true
)
if (state == nextState) {
state
} else {
state.copy(continueWatchingItems = enrichedItems)
nextState
}
}
persistLocalContinueWatchingMetadata(