feat: refactor movie watched status tracking to use a global set of watched IDs instead of individual observers.
This commit is contained in:
parent
6e049fb02d
commit
a7e99ca6b9
8 changed files with 140 additions and 52 deletions
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue