feat: refactor movie watched status tracking to use a global set of watched IDs instead of individual observers.

This commit is contained in:
tapframe 2026-03-06 07:37:59 +05:30
parent 6e049fb02d
commit a7e99ca6b9
8 changed files with 140 additions and 52 deletions

View file

@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.stringSetPreferencesKey
import com.nuvio.tv.core.profile.ProfileManager
import com.google.gson.Gson
import com.nuvio.tv.domain.model.WatchedItem
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
@ -14,6 +15,7 @@ import javax.inject.Inject
import javax.inject.Singleton
@Singleton
@OptIn(ExperimentalCoroutinesApi::class)
class WatchedItemsPreferences @Inject constructor(
private val factory: ProfileDataStoreFactory,
private val profileManager: ProfileManager
@ -38,6 +40,8 @@ class WatchedItemsPreferences @Inject constructor(
}
}
fun observeAllItems(): Flow<List<WatchedItem>> = allItems
fun isWatched(contentId: String, season: Int? = null, episode: Int? = null): Flow<Boolean> {
return allItems.map { items ->
items.any { item ->

View file

@ -46,6 +46,27 @@ internal fun normalizeContentId(ids: TraktIdsDto?, fallback: String? = null): St
return fallback?.takeIf { it.isNotBlank() } ?: ""
}
internal fun movieLookupKeys(contentId: String?): Set<String> {
if (contentId.isNullOrBlank()) return emptySet()
val raw = contentId.trim()
if (raw.isBlank()) return emptySet()
val canonical = normalizeContentId(toTraktIds(parseContentIds(raw)))
return buildSet {
add(raw)
canonical.takeIf { it.isNotBlank() }?.let(::add)
}
}
internal fun movieLookupKeys(ids: TraktIdsDto?): Set<String> {
if (ids == null) return emptySet()
return buildSet {
ids.imdb?.takeIf { it.isNotBlank() }?.let(::add)
ids.tmdb?.let { add("tmdb:$it") }
ids.trakt?.let { add("trakt:$it") }
ids.slug?.takeIf { it.isNotBlank() }?.let(::add)
}
}
internal fun toTraktPathId(contentId: String): String {
val parsed = parseContentIds(contentId)
return when {

View file

@ -348,6 +348,25 @@ class TraktProgressService @Inject constructor(
.distinctUntilChanged()
}
fun observeWatchedMovieIds(): Flow<Set<String>> {
return combine(watchedMoviesState, optimisticProgress) { watchedMovies, optimistic ->
val resolved = watchedMovies.toMutableSet()
optimistic.values.forEach { entry ->
val progress = entry.progress
if (!progress.contentType.equals("movie", ignoreCase = true)) return@forEach
val keys = movieLookupKeys(progress.contentId)
if (keys.isEmpty()) return@forEach
when {
progress.isCompleted() -> resolved.addAll(keys)
progress.isInProgress() -> resolved.removeAll(keys)
}
}
resolved.toSet()
}
.onStart { getWatchedMoviesSnapshot(forceRefresh = false) }
.distinctUntilChanged()
}
suspend fun markAsWatched(
progress: WatchProgress,
title: String?,
@ -707,7 +726,7 @@ class TraktProgressService @Inject constructor(
val watchedMovies = response.body().orEmpty()
.flatMap { item ->
watchedMovieLookupKeys(item.movie?.ids)
movieLookupKeys(item.movie?.ids)
}
.toSet()
@ -746,16 +765,6 @@ class TraktProgressService @Inject constructor(
return if (canonical.isNotBlank()) canonical else contentId.trim()
}
private fun watchedMovieLookupKeys(ids: TraktIdsDto?): List<String> {
if (ids == null) return emptyList()
return buildList {
ids.imdb?.takeIf { it.isNotBlank() }?.let { add(it) }
ids.tmdb?.let { add("tmdb:$it") }
ids.trakt?.let { add("trakt:$it") }
ids.slug?.takeIf { it.isNotBlank() }?.let { add(it) }
}
}
private suspend fun fetchAllProgressSnapshot(force: Boolean = false): List<WatchProgress> {
val recentCompletedEpisodes = fetchRecentEpisodeHistorySnapshot()
val playbackStartAt = recentWatchWindowMs()?.let { windowMs ->

View file

@ -342,6 +342,45 @@ class WatchProgressRepositoryImpl @Inject constructor(
}
}
override fun observeWatchedMovieIds(): Flow<Set<String>> {
return traktAuthDataStore.isEffectivelyAuthenticated
.distinctUntilChanged()
.flatMapLatest { isAuthenticated ->
if (isAuthenticated) {
traktProgressService.observeWatchedMovieIds()
} else {
combine(
watchProgressPreferences.allRawProgress,
watchedItemsPreferences.observeAllItems()
) { progressEntries, watchedItems ->
val resolved = buildSet {
progressEntries.asSequence()
.filter { it.contentType.equals("movie", ignoreCase = true) && it.isCompleted() }
.flatMap { movieLookupKeys(it.contentId).asSequence() }
.forEach(::add)
watchedItems.asSequence()
.filter { it.contentType.equals("movie", ignoreCase = true) }
.flatMap { movieLookupKeys(it.contentId).asSequence() }
.forEach(::add)
}.toMutableSet()
progressEntries.asSequence()
.filter { entry ->
entry.contentType.equals("movie", ignoreCase = true) &&
!entry.isCompleted() &&
(entry.position > 0L || entry.progressPercent?.let { it > 0f } == true)
}
.forEach { entry ->
resolved.removeAll(movieLookupKeys(entry.contentId))
}
resolved.toSet()
}.distinctUntilChanged()
}
}
}
override suspend fun saveProgress(progress: WatchProgress, syncRemote: Boolean) {
if (traktAuthDataStore.isEffectivelyAuthenticated.first()) {
traktProgressService.applyOptimisticProgress(progress)

View file

@ -38,6 +38,11 @@ interface WatchProgressRepository {
* For series episodes pass both [season] and [episode].
*/
fun isWatched(contentId: String, season: Int? = null, episode: Int? = null): Flow<Boolean>
/**
* Returns the current watched movie identity set for cheap bulk membership checks.
*/
fun observeWatchedMovieIds(): Flow<Set<String>>
/**
* Save or update watch progress

View file

@ -48,6 +48,7 @@ import com.nuvio.tv.ui.components.PosterCardDefaults
import com.nuvio.tv.ui.components.PosterCardStyle
import com.nuvio.tv.ui.screens.home.HomeEvent
import com.nuvio.tv.ui.screens.home.HomeViewModel
import com.nuvio.tv.ui.screens.home.homeItemStatusKey
import com.nuvio.tv.ui.theme.NuvioColors
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlin.math.roundToInt
@ -192,6 +193,7 @@ fun CatalogSeeAllScreen(
item = item,
posterCardStyle = posterCardStyle,
showLabel = uiState.posterLabelsEnabled,
isWatched = uiState.movieWatchedStatus[homeItemStatusKey(item.id, item.apiType)] == true,
focusRequester = if (index == focusedItemIndex) restoreFocusRequester else null,
onFocused = {
focusedItemIndex = index

View file

@ -117,7 +117,7 @@ class HomeViewModel @Inject constructor(
internal var externalMetaPrefetchJob: Job? = null
internal var pendingExternalMetaPrefetchItemId: String? = null
internal val posterLibraryObserverJobs = mutableMapOf<String, Job>()
internal val movieWatchedObserverJobs = mutableMapOf<String, Job>()
internal var watchedMovieIds: Set<String> = emptySet()
internal var activePosterListPickerInput: LibraryEntryInput? = null
@Volatile
internal var externalMetaPrefetchEnabled: Boolean = false
@ -134,6 +134,7 @@ class HomeViewModel @Inject constructor(
loadHomeCatalogOrderPreference()
loadDisabledHomeCatalogPreference()
observeLibraryState()
observeWatchedMovieIds()
observeTmdbSettings()
loadContinueWatching()
observeInstalledAddons()
@ -231,6 +232,8 @@ class HomeViewModel @Inject constructor(
private suspend fun updateCatalogRows() = updateCatalogRowsPipeline()
private fun observeWatchedMovieIds() = observeWatchedMovieIdsPipeline()
internal var posterStatusReconcileJob: Job? = null
private fun schedulePosterStatusReconcile(rows: List<CatalogRow>) =
@ -239,6 +242,9 @@ class HomeViewModel @Inject constructor(
private fun reconcilePosterStatusObservers(rows: List<CatalogRow>) =
reconcilePosterStatusObserversPipeline(rows)
private fun syncMovieWatchedStatusForRows(rows: List<CatalogRow>) =
syncMovieWatchedStatusForRowsPipeline(rows)
private fun navigateToDetail(itemId: String, itemType: String) {
_uiState.update { it.copy(selectedItemId = itemId) }
}
@ -308,9 +314,7 @@ class HomeViewModel @Inject constructor(
posterStatusReconcileJob?.cancel()
cancelInFlightCatalogLoads()
posterLibraryObserverJobs.values.forEach { it.cancel() }
movieWatchedObserverJobs.values.forEach { it.cancel() }
posterLibraryObserverJobs.clear()
movieWatchedObserverJobs.clear()
super.onCleared()
}
}

View file

@ -3,6 +3,7 @@ package com.nuvio.tv.ui.screens.home
import android.util.Log
import androidx.lifecycle.viewModelScope
import com.nuvio.tv.core.network.NetworkResult
import com.nuvio.tv.data.repository.movieLookupKeys
import com.nuvio.tv.domain.model.Addon
import com.nuvio.tv.domain.model.CatalogDescriptor
import com.nuvio.tv.domain.model.CatalogRow
@ -75,6 +76,17 @@ internal fun HomeViewModel.observeInstalledAddonsPipeline() {
}
}
internal fun HomeViewModel.observeWatchedMovieIdsPipeline() {
viewModelScope.launch {
watchProgressRepository.observeWatchedMovieIds()
.distinctUntilChanged()
.collectLatest { watchedIds ->
watchedMovieIds = watchedIds
syncMovieWatchedStatusForRowsPipeline(_fullCatalogRows.value)
}
}
}
internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
addons: List<Addon>,
forceReload: Boolean = false
@ -99,6 +111,7 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
posterStatusReconcileJob?.cancel()
reconcilePosterStatusObserversPipeline(emptyList())
_fullCatalogRows.value = emptyList()
syncMovieWatchedStatusForRowsPipeline(emptyList())
truncatedRowCache.clear()
hasRenderedFirstCatalog = false
trailerPreviewLoadingIds.clear()
@ -441,6 +454,7 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() {
_fullCatalogRows.update { rows ->
if (rows == fullRowsFiltered) rows else fullRowsFiltered
}
syncMovieWatchedStatusForRowsPipeline(fullRowsFiltered)
val nextGridItems = if (currentLayout == HomeLayout.GRID) {
replaceGridHeroItemsPipeline(baseGridItems, baseHeroItems)
@ -539,20 +553,12 @@ internal fun HomeViewModel.reconcilePosterStatusObserversPipeline(rows: List<Cat
}
}
val desiredKeys = desiredItemsByKey.keys
val desiredMovieKeys = desiredItemsByKey
.filterValues { (_, itemType) -> itemType.equals("movie", ignoreCase = true) }
.keys
posterLibraryObserverJobs.keys
.filterNot { it in desiredKeys }
.forEach { staleKey ->
posterLibraryObserverJobs.remove(staleKey)?.cancel()
}
movieWatchedObserverJobs.keys
.filterNot { it in desiredMovieKeys }
.forEach { staleKey ->
movieWatchedObserverJobs.remove(staleKey)?.cancel()
}
desiredItemsByKey.forEach { (statusKey, itemRef) ->
val itemId = itemRef.first
@ -575,51 +581,49 @@ internal fun HomeViewModel.reconcilePosterStatusObserversPipeline(rows: List<Cat
}
}
}
if (itemType.equals("movie", ignoreCase = true)) {
if (statusKey !in movieWatchedObserverJobs) {
movieWatchedObserverJobs[statusKey] = viewModelScope.launch {
watchProgressRepository.isWatched(contentId = itemId)
.distinctUntilChanged()
.collectLatest { watched ->
_uiState.update { state ->
if (state.movieWatchedStatus[statusKey] == watched) {
state
} else {
state.copy(
movieWatchedStatus = state.movieWatchedStatus + (statusKey to watched)
)
}
}
}
}
}
}
}
_uiState.update { state ->
val trimmedLibraryMembership =
state.posterLibraryMembership.filterKeys { it in desiredKeys }
val trimmedMovieWatchedStatus =
state.movieWatchedStatus.filterKeys { it in desiredMovieKeys }
val trimmedLibraryPending =
state.posterLibraryPending.filterTo(linkedSetOf()) { it in desiredKeys }
val trimmedMovieWatchedPending =
state.movieWatchedPending.filterTo(linkedSetOf()) { it in desiredMovieKeys }
if (
trimmedLibraryMembership == state.posterLibraryMembership &&
trimmedMovieWatchedStatus == state.movieWatchedStatus &&
trimmedLibraryPending == state.posterLibraryPending &&
trimmedMovieWatchedPending == state.movieWatchedPending
trimmedLibraryPending == state.posterLibraryPending
) {
state
} else {
state.copy(
posterLibraryMembership = trimmedLibraryMembership,
movieWatchedStatus = trimmedMovieWatchedStatus,
posterLibraryPending = trimmedLibraryPending,
movieWatchedPending = trimmedMovieWatchedPending
posterLibraryPending = trimmedLibraryPending
)
}
}
}
internal fun HomeViewModel.syncMovieWatchedStatusForRowsPipeline(rows: List<CatalogRow>) {
val watchedStatus = linkedMapOf<String, Boolean>()
rows.asSequence()
.flatMap { row -> row.items.asSequence() }
.filter { item -> item.apiType.equals("movie", ignoreCase = true) }
.forEach { item ->
val statusKey = homeItemStatusKey(item.id, item.apiType)
if (statusKey !in watchedStatus) {
watchedStatus[statusKey] = movieLookupKeys(item.id).any { it in watchedMovieIds }
}
}
val watchedKeys = watchedStatus.keys
_uiState.update { state ->
val trimmedPending = state.movieWatchedPending.filterTo(linkedSetOf()) { it in watchedKeys }
if (state.movieWatchedStatus == watchedStatus && state.movieWatchedPending == trimmedPending) {
state
} else {
state.copy(
movieWatchedStatus = watchedStatus,
movieWatchedPending = trimmedPending
)
}
}