mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-27 23:12:24 +00:00
fix: stabilize home startup and watched badges
This commit is contained in:
parent
b9caec19e7
commit
c17900ce0b
13 changed files with 173 additions and 241 deletions
|
|
@ -210,7 +210,6 @@ import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession
|
|||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.watchprogress.nextUpDismissKey
|
||||
import com.nuvio.app.features.watchprogress.toContinueWatchingItem
|
||||
import com.nuvio.app.features.watching.application.SeriesWatchedReconciliationService
|
||||
import com.nuvio.app.features.watching.application.WatchingActions
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
|
@ -692,9 +691,6 @@ private fun MainAppContent(
|
|||
remember {
|
||||
ProfileSettingsSync.startObserving()
|
||||
}
|
||||
remember {
|
||||
SeriesWatchedReconciliationService.startObserving()
|
||||
}
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ fun CatalogScreen(
|
|||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
|
||||
val initialScrollPosition = remember(
|
||||
target,
|
||||
homeCatalogSettingsUiState.hideUnreleasedContent,
|
||||
|
|
@ -203,6 +204,7 @@ fun CatalogScreen(
|
|||
isWatched = WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
item = item,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
),
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
onLongClick = onPosterLongClick?.let { { it(item) } },
|
||||
|
|
|
|||
|
|
@ -162,6 +162,7 @@ fun MetaDetailsScreen(
|
|||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by remember {
|
||||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
|
|
@ -360,10 +361,11 @@ fun MetaDetailsScreen(
|
|||
) {
|
||||
LibraryRepository.isSaved(meta.id, meta.type)
|
||||
}
|
||||
val isWatched = remember(watchedUiState.watchedKeys, metaPreview) {
|
||||
val isWatched = remember(watchedUiState.watchedKeys, fullyWatchedSeriesKeys, metaPreview) {
|
||||
WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
item = metaPreview,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
)
|
||||
}
|
||||
val openLibraryListPicker = remember(meta) {
|
||||
|
|
@ -411,6 +413,22 @@ fun MetaDetailsScreen(
|
|||
WatchProgressRepository.refreshEpisodeProgress(meta.id)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
meta.id,
|
||||
meta.type,
|
||||
todayIsoDate,
|
||||
watchedUiState.isLoaded,
|
||||
watchProgressUiState.hasLoadedRemoteProgress,
|
||||
watchedUiState.watchedKeys,
|
||||
watchProgressUiState.entries,
|
||||
) {
|
||||
if (watchedUiState.isLoaded && watchProgressUiState.hasLoadedRemoteProgress) {
|
||||
WatchingActions.reconcileSeriesWatchedState(
|
||||
meta = meta,
|
||||
todayIsoDate = todayIsoDate,
|
||||
)
|
||||
}
|
||||
}
|
||||
val movieProgress = progressByVideoId[meta.id]
|
||||
?.takeUnless { it.isCompleted }
|
||||
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ private fun buildHomeCatalogDescriptorSignature(
|
|||
buildString {
|
||||
append(addon.displayTitle)
|
||||
append('|')
|
||||
append(addon.enabled)
|
||||
append('|')
|
||||
append(addon.isRefreshing)
|
||||
append('|')
|
||||
append(addon.errorMessage.orEmpty())
|
||||
append('|')
|
||||
append(addon.manifestUrl)
|
||||
append('|')
|
||||
append(manifest.id)
|
||||
append('|')
|
||||
append(manifest.name)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ object HomeRepository {
|
|||
|
||||
private var activeJob: Job? = null
|
||||
private var activeRequestKey: String? = null
|
||||
private var lastRequestKey: String? = null
|
||||
private var completedRequestKey: String? = null
|
||||
private var currentDefinitions: List<HomeCatalogDefinition> = emptyList()
|
||||
private var cachedSections: Map<String, HomeCatalogSection> = emptyMap()
|
||||
private var cachedCollectionHeroItems: List<MetaPreview> = emptyList()
|
||||
|
|
@ -49,25 +49,28 @@ object HomeRepository {
|
|||
currentDefinitions = requests
|
||||
val requestCacheKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::cacheKey)
|
||||
cachedSections = cachedSections.filterKeys(requestCacheKeys::contains)
|
||||
val requestKey = requests.joinToString(separator = "|") { request ->
|
||||
request.cacheKey
|
||||
}
|
||||
val requestKey = requests.joinToString(separator = "|", transform = HomeCatalogDefinition::cacheKey)
|
||||
|
||||
if (!force && activeRequestKey == requestKey && _uiState.value.isLoading) return
|
||||
|
||||
if (!force && requestKey == lastRequestKey && requestCacheKeys.all(cachedSections::containsKey)) {
|
||||
if (
|
||||
!force &&
|
||||
requestKey == completedRequestKey &&
|
||||
requestCacheKeys.all(cachedSections::containsKey) &&
|
||||
requestCacheKeys.any(::hasRenderableCachedSection)
|
||||
) {
|
||||
if (_uiState.value.sections.isEmpty() || _uiState.value.heroItems.isEmpty()) {
|
||||
applyCurrentSettings()
|
||||
}
|
||||
return
|
||||
}
|
||||
lastRequestKey = requestKey
|
||||
activeRequestKey = requestKey
|
||||
|
||||
if (requests.isEmpty()) {
|
||||
activeJob?.cancel()
|
||||
activeJob = null
|
||||
activeRequestKey = null
|
||||
completedRequestKey = requestKey
|
||||
cachedSections = emptyMap()
|
||||
lastErrorMessage = null
|
||||
publishCurrentState(
|
||||
|
|
@ -138,6 +141,10 @@ object HomeRepository {
|
|||
|
||||
cachedSections = loadedSections.toMap()
|
||||
lastErrorMessage = firstErrorMessage
|
||||
if (cachedSections.values.any { section -> section.items.isNotEmpty() }) {
|
||||
completedRequestKey = requestKey
|
||||
}
|
||||
activeRequestKey = null
|
||||
publishCurrentState(
|
||||
isLoading = false,
|
||||
requestKey = requestKey,
|
||||
|
|
@ -153,12 +160,12 @@ object HomeRepository {
|
|||
fun applyCurrentSettings() {
|
||||
publishCurrentState(
|
||||
isLoading = _uiState.value.isLoading,
|
||||
requestKey = activeRequestKey ?: lastRequestKey,
|
||||
requestKey = activeRequestKey ?: completedRequestKey,
|
||||
)
|
||||
ensureCollectionHeroFallback(
|
||||
addons = AddonRepository.uiState.value.addons.enabledAddons(),
|
||||
force = false,
|
||||
requestKey = activeRequestKey ?: lastRequestKey,
|
||||
requestKey = activeRequestKey ?: completedRequestKey,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -166,7 +173,7 @@ object HomeRepository {
|
|||
activeJob?.cancel()
|
||||
activeJob = null
|
||||
activeRequestKey = null
|
||||
lastRequestKey = null
|
||||
completedRequestKey = null
|
||||
currentDefinitions = emptyList()
|
||||
cachedSections = emptyMap()
|
||||
cachedCollectionHeroItems = emptyList()
|
||||
|
|
@ -178,6 +185,9 @@ object HomeRepository {
|
|||
_uiState.value = HomeUiState()
|
||||
}
|
||||
|
||||
private fun hasRenderableCachedSection(cacheKey: String): Boolean =
|
||||
cachedSections[cacheKey]?.items?.isNotEmpty() == true
|
||||
|
||||
private fun publishCurrentState(
|
||||
isLoading: Boolean,
|
||||
requestKey: String?,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
|
|||
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watched.episodePlaybackId
|
||||
import com.nuvio.app.features.watched.watchedItemKey
|
||||
import com.nuvio.app.features.watchprogress.CachedInProgressItem
|
||||
import com.nuvio.app.features.watchprogress.CachedNextUpItem
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
|
||||
|
|
@ -122,6 +124,7 @@ fun HomeScreen(
|
|||
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
|
||||
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
|
||||
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
|
||||
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
|
|
@ -853,6 +856,7 @@ fun HomeScreen(
|
|||
null
|
||||
},
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
)
|
||||
|
|
@ -1013,6 +1017,23 @@ private suspend fun resolveHomeNextUpCandidate(
|
|||
} else {
|
||||
watchedItems
|
||||
}
|
||||
val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item ->
|
||||
watchedItemKey(item.type, item.id, item.season, item.episode)
|
||||
}
|
||||
|
||||
WatchedRepository.reconcileFullyWatchedSeriesState(
|
||||
meta = meta,
|
||||
todayIsoDate = todayIsoDate,
|
||||
isEpisodeWatched = { episode ->
|
||||
watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys
|
||||
},
|
||||
isEpisodeCompleted = { episode ->
|
||||
val playbackId = meta.episodePlaybackId(episode)
|
||||
resolvedProgressEntries.any { entry ->
|
||||
entry.videoId == playbackId && entry.isEffectivelyCompleted
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
val action = meta.seriesPrimaryAction(
|
||||
content = completedEntry.content,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ fun HomeCatalogRowSection(
|
|||
modifier: Modifier = Modifier,
|
||||
entries: List<MetaPreview> = section.items,
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
fullyWatchedSeriesKeys: Set<String> = emptySet(),
|
||||
sectionPadding: Dp? = null,
|
||||
onViewAllClick: (() -> Unit)? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
|
|
@ -34,6 +35,7 @@ fun HomeCatalogRowSection(
|
|||
section = section,
|
||||
entries = entries,
|
||||
watchedKeys = watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
sectionPadding = sectionPadding,
|
||||
onViewAllClick = onViewAllClick,
|
||||
|
|
@ -46,6 +48,7 @@ fun HomeCatalogRowSection(
|
|||
section = section,
|
||||
entries = entries,
|
||||
watchedKeys = watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||
onViewAllClick = onViewAllClick,
|
||||
|
|
@ -61,6 +64,7 @@ private fun HomeCatalogRowSectionContent(
|
|||
section: HomeCatalogSection,
|
||||
entries: List<MetaPreview>,
|
||||
watchedKeys: Set<String>,
|
||||
fullyWatchedSeriesKeys: Set<String>,
|
||||
modifier: Modifier,
|
||||
sectionPadding: Dp,
|
||||
onViewAllClick: (() -> Unit)?,
|
||||
|
|
@ -90,6 +94,7 @@ private fun HomeCatalogRowSectionContent(
|
|||
isWatched = WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
item = item,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
),
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
onLongClick = onPosterLongClick?.let { { it(item) } },
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ internal fun LazyListScope.discoverContent(
|
|||
onGenreSelected: (String?) -> Unit,
|
||||
onRetry: (() -> Unit)? = null,
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
fullyWatchedSeriesKeys: Set<String> = emptySet(),
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -117,6 +118,7 @@ internal fun LazyListScope.discoverContent(
|
|||
columns = columns,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
watchedKeys = watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
)
|
||||
|
|
@ -197,6 +199,7 @@ private fun DiscoverGridRow(
|
|||
columns: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
fullyWatchedSeriesKeys: Set<String> = emptySet(),
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -216,6 +219,7 @@ private fun DiscoverGridRow(
|
|||
isWatched = WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
item = item,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
),
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
onLongClick = onPosterLongClick?.let { { it(item) } },
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ fun SearchScreen(
|
|||
}.collectAsStateWithLifecycle()
|
||||
val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle()
|
||||
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
|
||||
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
|
||||
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
|
||||
var query by rememberSaveable { mutableStateOf("") }
|
||||
var lastRequestedQuery by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
|
|
@ -305,6 +306,7 @@ fun SearchScreen(
|
|||
SearchRepository.refreshDiscover(addonsUiState.addons)
|
||||
},
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
)
|
||||
|
|
@ -360,6 +362,7 @@ fun SearchScreen(
|
|||
section = section,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.watched
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
|
|
@ -26,6 +27,7 @@ import kotlinx.serialization.json.Json
|
|||
@Serializable
|
||||
private data class StoredWatchedPayload(
|
||||
val items: List<WatchedItem> = emptyList(),
|
||||
val fullyWatchedSeriesKeys: Set<String> = emptySet(),
|
||||
val lastSuccessfulPushEpochMs: Long = 0L,
|
||||
val deltaCursorEventId: Long = 0L,
|
||||
val deltaInitialized: Boolean = false,
|
||||
|
|
@ -56,6 +58,8 @@ object WatchedRepository {
|
|||
|
||||
private val _uiState = MutableStateFlow(WatchedUiState())
|
||||
val uiState: StateFlow<WatchedUiState> = _uiState.asStateFlow()
|
||||
private val _fullyWatchedSeriesKeys = MutableStateFlow<Set<String>>(emptySet())
|
||||
val fullyWatchedSeriesKeys: StateFlow<Set<String>> = _fullyWatchedSeriesKeys.asStateFlow()
|
||||
|
||||
private var hasLoaded = false
|
||||
private var currentProfileId: Int = 1
|
||||
|
|
@ -84,6 +88,7 @@ object WatchedRepository {
|
|||
lastSuccessfulPushEpochMs = 0L
|
||||
deltaCursorEventId = 0L
|
||||
deltaInitialized = false
|
||||
_fullyWatchedSeriesKeys.value = emptySet()
|
||||
_uiState.value = WatchedUiState()
|
||||
}
|
||||
|
||||
|
|
@ -105,10 +110,12 @@ object WatchedRepository {
|
|||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
|
||||
.toMutableMap()
|
||||
_fullyWatchedSeriesKeys.value = storedPayload.fullyWatchedSeriesKeys
|
||||
} else {
|
||||
lastSuccessfulPushEpochMs = 0L
|
||||
deltaCursorEventId = 0L
|
||||
deltaInitialized = false
|
||||
_fullyWatchedSeriesKeys.value = emptySet()
|
||||
}
|
||||
|
||||
publish()
|
||||
|
|
@ -380,14 +387,11 @@ object WatchedRepository {
|
|||
if (!meta.type.isSeriesLikeWatchedType()) return
|
||||
|
||||
ensureLoaded()
|
||||
val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
|
||||
isWatched(
|
||||
id = meta.id,
|
||||
type = meta.type,
|
||||
season = episode.season,
|
||||
episode = episode.episode,
|
||||
) || isEpisodeCompleted(episode)
|
||||
}
|
||||
val shouldMarkSeriesWatched = reconcileFullyWatchedSeriesState(
|
||||
meta = meta,
|
||||
todayIsoDate = todayIsoDate,
|
||||
isEpisodeCompleted = isEpisodeCompleted,
|
||||
)
|
||||
val seriesWatchedItem = meta.toSeriesWatchedItem()
|
||||
val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type)
|
||||
if (shouldMarkSeriesWatched) {
|
||||
|
|
@ -399,6 +403,56 @@ object WatchedRepository {
|
|||
}
|
||||
}
|
||||
|
||||
fun reconcileFullyWatchedSeriesState(
|
||||
meta: MetaDetails,
|
||||
todayIsoDate: String,
|
||||
isEpisodeWatched: (MetaVideo) -> Boolean = { episode ->
|
||||
isWatched(
|
||||
id = meta.id,
|
||||
type = meta.type,
|
||||
season = episode.season,
|
||||
episode = episode.episode,
|
||||
)
|
||||
},
|
||||
isEpisodeCompleted: (MetaVideo) -> Boolean = { false },
|
||||
): Boolean {
|
||||
if (!meta.type.isSeriesLikeWatchedType()) return false
|
||||
|
||||
ensureLoaded()
|
||||
val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
|
||||
isEpisodeWatched(episode) || isEpisodeCompleted(episode)
|
||||
}
|
||||
updateFullyWatchedSeriesKey(
|
||||
key = watchedItemKey(meta.type, meta.id),
|
||||
isFullyWatched = shouldMarkSeriesWatched,
|
||||
)
|
||||
return shouldMarkSeriesWatched
|
||||
}
|
||||
|
||||
fun updateFullyWatchedSeries(
|
||||
id: String,
|
||||
type: String,
|
||||
isFullyWatched: Boolean,
|
||||
) {
|
||||
if (!type.isSeriesLikeWatchedType()) return
|
||||
ensureLoaded()
|
||||
updateFullyWatchedSeriesKey(
|
||||
key = watchedItemKey(type, id),
|
||||
isFullyWatched = isFullyWatched,
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateFullyWatchedSeriesKey(
|
||||
key: String,
|
||||
isFullyWatched: Boolean,
|
||||
) {
|
||||
val current = _fullyWatchedSeriesKeys.value
|
||||
val updated = if (isFullyWatched) current + key else current - key
|
||||
if (updated == current) return
|
||||
_fullyWatchedSeriesKeys.value = updated
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun pushMarksToServer(
|
||||
items: Collection<WatchedItem>,
|
||||
traktHistorySync: WatchedTraktHistorySync,
|
||||
|
|
@ -454,6 +508,7 @@ object WatchedRepository {
|
|||
items = itemsByKey.values
|
||||
.map(WatchedItem::normalizedMarkedAt)
|
||||
.sortedByDescending { it.markedAtEpochMs },
|
||||
fullyWatchedSeriesKeys = _fullyWatchedSeriesKeys.value,
|
||||
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
|
||||
deltaCursorEventId = deltaCursorEventId,
|
||||
deltaInitialized = deltaInitialized,
|
||||
|
|
|
|||
|
|
@ -1,218 +0,0 @@
|
|||
package com.nuvio.app.features.watching.application
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.watched.WatchedItem
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watched.episodePlaybackId
|
||||
import com.nuvio.app.features.watched.watchedItemKey
|
||||
import com.nuvio.app.features.watchprogress.CurrentDateProvider
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressUiState
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
private const val SERIES_RECONCILIATION_DEBOUNCE_MS = 500L
|
||||
private const val SERIES_RECONCILIATION_BATCH_LIMIT = 24
|
||||
private const val SERIES_RECONCILIATION_CONCURRENCY = 3
|
||||
|
||||
private data class SeriesWatchedKey(
|
||||
val profileId: Int,
|
||||
val type: String,
|
||||
val id: String,
|
||||
)
|
||||
|
||||
private data class SeriesReconciliationCandidate(
|
||||
val key: SeriesWatchedKey,
|
||||
val signature: String,
|
||||
val latestUpdatedAt: Long,
|
||||
)
|
||||
|
||||
object SeriesWatchedReconciliationService {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("SeriesWatchedReconciliation")
|
||||
private val lastReconciledSignatures = linkedMapOf<SeriesWatchedKey, String>()
|
||||
private var observeJob: Job? = null
|
||||
|
||||
@OptIn(FlowPreview::class)
|
||||
fun startObserving() {
|
||||
if (observeJob?.isActive == true) return
|
||||
|
||||
observeJob = scope.launch {
|
||||
combine(
|
||||
WatchedRepository.uiState,
|
||||
WatchProgressRepository.uiState,
|
||||
) { watchedState, progressState ->
|
||||
if (!watchedState.isLoaded || !progressState.hasLoadedRemoteProgress) {
|
||||
emptyList()
|
||||
} else {
|
||||
buildReconciliationCandidates(
|
||||
watchedItems = watchedState.items,
|
||||
progressState = progressState,
|
||||
)
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.debounce(SERIES_RECONCILIATION_DEBOUNCE_MS)
|
||||
.collectLatest { candidates ->
|
||||
reconcile(candidates)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
observeJob?.cancel()
|
||||
observeJob = null
|
||||
lastReconciledSignatures.clear()
|
||||
}
|
||||
|
||||
private suspend fun reconcile(candidates: List<SeriesReconciliationCandidate>) {
|
||||
if (candidates.isEmpty()) return
|
||||
val activeProfileId = ProfileRepository.activeProfileId
|
||||
val pending = candidates
|
||||
.filter { candidate -> lastReconciledSignatures[candidate.key] != candidate.signature }
|
||||
.sortedByDescending(SeriesReconciliationCandidate::latestUpdatedAt)
|
||||
.take(SERIES_RECONCILIATION_BATCH_LIMIT)
|
||||
if (pending.isEmpty()) return
|
||||
|
||||
val todayIsoDate = CurrentDateProvider.todayIsoDate()
|
||||
val progressByVideoId = WatchProgressRepository.uiState.value.byVideoId
|
||||
val semaphore = Semaphore(SERIES_RECONCILIATION_CONCURRENCY)
|
||||
|
||||
coroutineScope {
|
||||
pending.map { candidate ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
reconcileCandidate(
|
||||
candidate = candidate,
|
||||
activeProfileId = activeProfileId,
|
||||
todayIsoDate = todayIsoDate,
|
||||
progressByVideoId = progressByVideoId,
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reconcileCandidate(
|
||||
candidate: SeriesReconciliationCandidate,
|
||||
activeProfileId: Int,
|
||||
todayIsoDate: String,
|
||||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
) {
|
||||
if (ProfileRepository.activeProfileId != activeProfileId) return
|
||||
|
||||
val meta = try {
|
||||
MetaDetailsRepository.fetch(
|
||||
type = candidate.key.type,
|
||||
id = candidate.key.id,
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
if (error is CancellationException) throw error
|
||||
log.d { "Skipping series watched reconciliation for ${candidate.key.id}: ${error.message}" }
|
||||
null
|
||||
} ?: return
|
||||
|
||||
if (ProfileRepository.activeProfileId != activeProfileId) return
|
||||
|
||||
WatchedRepository.reconcileSeriesWatchedState(
|
||||
meta = meta,
|
||||
todayIsoDate = todayIsoDate,
|
||||
isEpisodeCompleted = { episode ->
|
||||
progressByVideoId[meta.episodePlaybackId(episode)]?.isEffectivelyCompleted == true
|
||||
},
|
||||
)
|
||||
lastReconciledSignatures[candidate.key] = candidate.signature
|
||||
}
|
||||
|
||||
private fun buildReconciliationCandidates(
|
||||
watchedItems: List<WatchedItem>,
|
||||
progressState: WatchProgressUiState,
|
||||
): List<SeriesReconciliationCandidate> {
|
||||
val groupedWatched = watchedItems
|
||||
.filter { item -> item.type.isSeriesLikeType() }
|
||||
.groupBy { item ->
|
||||
SeriesWatchedKey(
|
||||
profileId = ProfileRepository.activeProfileId,
|
||||
type = item.type,
|
||||
id = item.id,
|
||||
)
|
||||
}
|
||||
val groupedProgress = progressState.entries
|
||||
.filter { entry -> entry.parentMetaType.isSeriesLikeType() && entry.isEpisode }
|
||||
.groupBy { entry ->
|
||||
SeriesWatchedKey(
|
||||
profileId = ProfileRepository.activeProfileId,
|
||||
type = entry.parentMetaType,
|
||||
id = entry.parentMetaId,
|
||||
)
|
||||
}
|
||||
|
||||
return (groupedWatched.keys + groupedProgress.keys)
|
||||
.mapNotNull { key ->
|
||||
val watched = groupedWatched[key].orEmpty()
|
||||
val progress = groupedProgress[key].orEmpty()
|
||||
val hasSeriesMarker = watched.any { item -> item.season == null && item.episode == null }
|
||||
val watchedEpisodes = watched
|
||||
.filter { item -> item.season != null && item.episode != null }
|
||||
.sortedWith(compareBy<WatchedItem> { it.season ?: -1 }.thenBy { it.episode ?: -1 })
|
||||
val completedProgress = progress
|
||||
.filter(WatchProgressEntry::isEffectivelyCompleted)
|
||||
.sortedWith(compareBy<WatchProgressEntry> { it.seasonNumber ?: -1 }.thenBy { it.episodeNumber ?: -1 })
|
||||
|
||||
if (!hasSeriesMarker && watchedEpisodes.isEmpty() && completedProgress.isEmpty()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
SeriesReconciliationCandidate(
|
||||
key = key,
|
||||
latestUpdatedAt = maxOf(
|
||||
watched.maxOfOrNull(WatchedItem::markedAtEpochMs) ?: 0L,
|
||||
progress.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L,
|
||||
),
|
||||
signature = buildString {
|
||||
append("series=")
|
||||
append(hasSeriesMarker)
|
||||
append("|watched=")
|
||||
watchedEpisodes.forEach { item ->
|
||||
append(item.season)
|
||||
append(":")
|
||||
append(item.episode)
|
||||
append(":")
|
||||
append(item.markedAtEpochMs)
|
||||
append(",")
|
||||
}
|
||||
append("|progress=")
|
||||
completedProgress.forEach { entry ->
|
||||
append(entry.seasonNumber)
|
||||
append(":")
|
||||
append(entry.episodeNumber)
|
||||
append(":")
|
||||
append(entry.lastUpdatedEpochMs)
|
||||
append(",")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isSeriesLikeType(): Boolean =
|
||||
trim().lowercase() in setOf("series", "show", "tv", "tvshow")
|
||||
|
|
@ -36,6 +36,11 @@ object WatchingActions {
|
|||
if (meta == null) {
|
||||
if (isCurrentlyWatched) {
|
||||
WatchedRepository.unmarkWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
|
||||
WatchedRepository.updateFullyWatchedSeries(
|
||||
id = preview.id,
|
||||
type = preview.type,
|
||||
isFullyWatched = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -45,6 +50,11 @@ object WatchingActions {
|
|||
if (releasedMainEpisodes.isEmpty()) {
|
||||
if (isCurrentlyWatched) {
|
||||
WatchedRepository.unmarkWatched(meta.toSeriesWatchedItem())
|
||||
WatchedRepository.updateFullyWatchedSeries(
|
||||
id = meta.id,
|
||||
type = meta.type,
|
||||
isFullyWatched = false,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -55,8 +65,18 @@ object WatchingActions {
|
|||
|
||||
if (isCurrentlyWatched) {
|
||||
WatchedRepository.unmarkWatched(seriesItems)
|
||||
WatchedRepository.updateFullyWatchedSeries(
|
||||
id = meta.id,
|
||||
type = meta.type,
|
||||
isFullyWatched = false,
|
||||
)
|
||||
} else {
|
||||
WatchedRepository.markWatched(seriesItems)
|
||||
WatchedRepository.updateFullyWatchedSeries(
|
||||
id = meta.id,
|
||||
type = meta.type,
|
||||
isFullyWatched = true,
|
||||
)
|
||||
WatchProgressRepository.clearProgress(
|
||||
releasedMainEpisodes.map(meta::episodePlaybackId),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ object WatchingState {
|
|||
fun isPosterWatched(
|
||||
watchedKeys: Set<String>,
|
||||
item: MetaPreview,
|
||||
): Boolean = watchedKeys.contains(watchedItemKey(item.type, item.id))
|
||||
fullyWatchedSeriesKeys: Set<String> = emptySet(),
|
||||
): Boolean {
|
||||
val posterKey = watchedItemKey(item.type, item.id)
|
||||
if (watchedKeys.contains(posterKey)) return true
|
||||
return item.type.isSeriesLikePosterType() && fullyWatchedSeriesKeys.contains(posterKey)
|
||||
}
|
||||
|
||||
fun isEpisodeWatched(
|
||||
watchedKeys: Set<String>,
|
||||
|
|
@ -82,6 +87,9 @@ object WatchingState {
|
|||
): List<WatchProgressEntry> = progressEntries.continueWatchingEntries()
|
||||
}
|
||||
|
||||
private fun String.isSeriesLikePosterType(): Boolean =
|
||||
trim().lowercase() in setOf("series", "show", "tv", "tvshow")
|
||||
|
||||
private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord =
|
||||
normalizedCompletion().let { entry ->
|
||||
WatchingProgressRecord(
|
||||
|
|
|
|||
Loading…
Reference in a new issue