diff --git a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt index 28304451..a147b197 100644 --- a/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt +++ b/app/src/main/java/com/nuvio/tv/data/repository/WatchProgressRepositoryImpl.kt @@ -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 diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt index 5b98837f..cc1bf24d 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeScreen.kt @@ -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(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 diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt index 99597632..16ee59ce 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt @@ -14,6 +14,8 @@ import com.nuvio.tv.domain.model.WatchProgress data class HomeUiState( val catalogRows: List = emptyList(), val continueWatchingItems: List = emptyList(), + val continueWatchingResolved: Boolean = false, + val continueWatchingLastRemoteSyncAtMs: Long? = null, val isLoading: Boolean = true, val error: String? = null, val selectedItemId: String? = null, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt index 2764d710..40191187 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModel.kt @@ -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, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt index 5560cc4b..da7aebe0 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelContinueWatching.kt @@ -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(