diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 6fedd1f7..6f851f18 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -475,6 +475,8 @@ %1$d of %2$d selected Show Hero Section Display hero carousel at top of home. + Hide Unreleased Content + Hide movies and shows that haven't been released yet. %1$d of %2$d catalogs visible • %3$d hero sources selected Open a catalog only when you need to rename or reorder it. Visible diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index a46ddcbf..4af61b57 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -2,6 +2,9 @@ package com.nuvio.app.features.catalog import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toMetaPreview +import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.features.home.filterReleasedItems +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -124,7 +127,7 @@ object CatalogRepository { catalogId = request.catalogId, genre = request.genre, skip = requestedSkip.takeIf { it > 0 }, - ) + ).withUnreleasedFilter() }.fold( onSuccess = { page -> if (activeRequest != request) return@fold @@ -158,6 +161,12 @@ object CatalogRepository { } } +private fun CatalogPage.withUnreleasedFilter(): CatalogPage { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + private data class CatalogRequest( val manifestUrl: String, val type: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index fdff2ecd..f58cd2df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -52,6 +52,7 @@ import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.stableKey import kotlinx.coroutines.flow.distinctUntilChanged @@ -74,20 +75,21 @@ fun CatalogScreen( modifier: Modifier = Modifier, ) { val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val posterCardStyle = rememberPosterCardStyleUiState() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() val gridState = rememberLazyGridState() var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } - LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination) { + LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) { CatalogRepository.load( manifestUrl = manifestUrl, type = type, catalogId = catalogId, genre = genre, supportsPagination = supportsPagination, - force = false, + force = true, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 65c0101e..d5c7a172 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -3,14 +3,18 @@ package com.nuvio.app.features.collection import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE +import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.core.i18n.localizedMediaTypeLabel +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.filterReleasedItems import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.trakt.TraktPublicListSourceResolver +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -320,7 +324,7 @@ object FolderDetailRepository { genre = currentTab.genre, skip = requestedSkip.takeIf { it > 0 }, ) - } + }.withUnreleasedFilter() }.onSuccess { page -> updateTab(index) { tab -> val mergedItems = if (reset) { @@ -418,6 +422,12 @@ object FolderDetailRepository { private fun Boolean?.orFalse(): Boolean = this == true +private fun CatalogPage.withUnreleasedFilter(): CatalogPage { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + private fun tmdbCatalogId(source: CollectionSource): String = buildString { append("tmdb_") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 12e42ded..06673586 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -5,11 +5,14 @@ import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText +import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.features.home.filterReleasedItems import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -48,14 +51,14 @@ object MetaDetailsRepository { cachedEntry.metaScreenMeta ?.takeIf { cachedEntry.metaScreenSettingsFingerprint == metaScreenSettingsFingerprint } ?.let { cachedMeta -> - _uiState.value = MetaDetailsUiState(meta = cachedMeta) + _uiState.value = MetaDetailsUiState(meta = cachedMeta.withUnreleasedFilter()) activeRequestKey = requestKey return } val cachedBaseMeta = cachedEntry.baseMeta if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { - _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta) + _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter()) activeRequestKey = requestKey return } @@ -81,7 +84,7 @@ object MetaDetailsRepository { settingsFingerprint = metaScreenSettingsFingerprint, ) } - _uiState.value = MetaDetailsUiState(meta = enrichedMeta) + _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter()) activeRequestKey = requestKey } return @@ -302,7 +305,7 @@ object MetaDetailsRepository { cachedMetaByRequestKey[requestKey] = cachedEntry if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { - _uiState.value = MetaDetailsUiState(meta = meta) + _uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter()) activeRequestKey = requestKey return } @@ -324,7 +327,7 @@ object MetaDetailsRepository { metaScreenMeta = enrichedMeta, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) - _uiState.value = MetaDetailsUiState(meta = enrichedMeta) + _uiState.value = MetaDetailsUiState(meta = enrichedMeta.withUnreleasedFilter()) activeRequestKey = requestKey } @@ -374,6 +377,15 @@ object MetaDetailsRepository { return "${settings.enabled}:${settings.apiKey.trim()}:$providers" } + private fun MetaDetails.withUnreleasedFilter(): MetaDetails { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val todayIsoDate = CurrentDateProvider.todayIsoDate() + return copy( + moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate), + collectionItems = collectionItems.filterReleasedItems(todayIsoDate), + ) + } + fun findEmbeddedStreams(videoId: String): List { val meta = _uiState.value.meta ?: return emptyList() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 7efbf059..611b9109 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -52,6 +52,7 @@ internal object HomeCatalogParser { posterShape = meta.string("posterShape").toPosterShape(), description = meta.string("description"), releaseInfo = meta.string("releaseInfo"), + rawReleaseDate = meta.string("released"), imdbRating = meta.string("imdbRating"), genres = meta.array("genres").mapNotNull { genre -> genre.jsonPrimitive.contentOrNull?.takeIf { it.isNotBlank() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 96b5ba6a..e920de04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem( data class HomeCatalogSettingsUiState( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) { val signature: String get() = buildString { append(heroEnabled) append('|') + append(hideUnreleasedContent) + append('|') append( items.joinToString(separator = "|") { item -> "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" @@ -55,6 +58,7 @@ internal data class HomeCatalogPreference( internal data class HomeCatalogSettingsSnapshot( val heroEnabled: Boolean, + val hideUnreleasedContent: Boolean, val preferences: Map, ) @@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference( @Serializable private data class StoredHomeCatalogSettingsPayload( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) @@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository { private var collectionDefinitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() private var heroEnabled = true + private var hideUnreleasedContent = false fun onProfileChanged() { hasLoaded = false preferences.clear() heroEnabled = true + hideUnreleasedContent = false definitions = emptyList() collectionDefinitions = emptyList() _uiState.value = HomeCatalogSettingsUiState() @@ -105,6 +112,7 @@ object HomeCatalogSettingsRepository { collectionDefinitions = emptyList() preferences.clear() heroEnabled = true + hideUnreleasedContent = false _uiState.value = HomeCatalogSettingsUiState() } @@ -135,6 +143,7 @@ object HomeCatalogSettingsRepository { ensureLoaded() return HomeCatalogSettingsSnapshot( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, preferences = preferences.mapValues { (_, value) -> HomeCatalogPreference( customTitle = value.customTitle, @@ -154,6 +163,15 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun setHideUnreleasedContent(enabled: Boolean) { + ensureLoaded() + if (hideUnreleasedContent == enabled) return + hideUnreleasedContent = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + } + fun setHeroSourceEnabled(key: String, enabled: Boolean) { updatePreference(key) { preference -> if (!enabled) { @@ -181,6 +199,7 @@ object HomeCatalogSettingsRepository { fun resetToDefaults() { ensureLoaded() heroEnabled = true + hideUnreleasedContent = false preferences.clear() normalizePreferences() publish() @@ -226,7 +245,9 @@ object HomeCatalogSettingsRepository { if (parsedPayload != null) { heroEnabled = parsedPayload.heroEnabled + hideUnreleasedContent = parsedPayload.hideUnreleasedContent preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() + publish() return } @@ -235,6 +256,7 @@ object HomeCatalogSettingsRepository { }.getOrDefault(emptyList()) preferences = legacyItems.associateBy { it.key }.toMutableMap() + publish() } private fun normalizePreferences() { @@ -322,6 +344,7 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, items = items, ) } @@ -331,6 +354,7 @@ object HomeCatalogSettingsRepository { json.encodeToString( StoredHomeCatalogSettingsPayload( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, items = preferences.values.sortedBy { it.order }, ), ), @@ -411,26 +435,32 @@ object HomeCatalogSettingsRepository { ) } } - return SyncHomeCatalogPayload(items = items) + return SyncHomeCatalogPayload( + hideUnreleasedContent = hideUnreleasedContent, + items = items, + ) } fun applyFromRemote(payload: SyncHomeCatalogPayload) { ensureLoaded() - val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } - preferences = payload.items.associate { item -> - val key = if (item.isCollection) { - "collection_${item.collectionId}" - } else { - "${item.addonId}:${item.type}:${item.catalogId}" - } - key to StoredHomeCatalogPreference( - key = key, - customTitle = item.customTitle, - enabled = item.enabled, - heroSourceEnabled = existingHeroState[key] ?: true, - order = item.order, - ) - }.toMutableMap() + hideUnreleasedContent = payload.hideUnreleasedContent + if (payload.items.isNotEmpty()) { + val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } + preferences = payload.items.associate { item -> + val key = if (item.isCollection) { + "collection_${item.collectionId}" + } else { + "${item.addonId}:${item.type}:${item.catalogId}" + } + key to StoredHomeCatalogPreference( + key = key, + customTitle = item.customTitle, + enabled = item.enabled, + heroSourceEnabled = existingHeroState[key] ?: true, + order = item.order, + ) + }.toMutableMap() + } hasLoaded = true publish() persist() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index 86f30f31..5fbf8f7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -41,6 +41,7 @@ data class SyncCatalogItem( @Serializable data class SyncHomeCatalogPayload( + @SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) @@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService { } if (remotePayload.items.isEmpty()) { - log.i { "pullFromServer — remote has empty items, preserving local" } + log.i { "pullFromServer — remote has empty items, preserving local catalog order" } + isSyncingFromRemote = true + HomeCatalogSettingsRepository.applyFromRemote(remotePayload) + isSyncingFromRemote = false val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() if (localPayload.items.isNotEmpty()) { pushToRemote(profileId) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 0e24e109..4573db3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.catalog.fetchCatalogPage +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -145,13 +146,17 @@ object HomeRepository { ) { val snapshot = HomeCatalogSettingsRepository.snapshot() val preferences = snapshot.preferences + val todayIsoDate = if (snapshot.hideUnreleasedContent) CurrentDateProvider.todayIsoDate() else null + fun HomeCatalogSection.withReleaseFilter(): HomeCatalogSection = + if (todayIsoDate == null) this else filterReleasedItems(todayIsoDate) + val sections = currentDefinitions .sortedBy { definition -> preferences[definition.key]?.order ?: Int.MAX_VALUE } .mapNotNull { definition -> val preference = preferences[definition.key] if (preference?.enabled == false) return@mapNotNull null - val section = cachedSections[definition.key] ?: return@mapNotNull null + val section = cachedSections[definition.key]?.withReleaseFilter() ?: return@mapNotNull null if (section.items.isEmpty()) return@mapNotNull null val customTitle = preference?.customTitle.orEmpty() section.copy( @@ -164,6 +169,7 @@ object HomeRepository { currentDefinitions .filter { definition -> preferences[definition.key]?.heroSourceEnabled != false } .mapNotNull { definition -> cachedSections[definition.key] } + .map { section -> section.withReleaseFilter() } .flatMap { section -> section.items } .distinctBy { item -> "${item.type}:${item.id}" } .shuffled(heroRandom) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt new file mode 100644 index 00000000..f7b3bf41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.home + +private val yearRegex = Regex("""\b(19|20)\d{2}\b""") +private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""") + +internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { + rawReleaseDate + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { rawReleased -> + isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate -> + return releaseDate > todayIsoDate + } + } + + val info = releaseInfo ?: return false + isoCalendarDateOrNull(info.trim())?.let { releaseDate -> + return releaseDate > todayIsoDate + } + + val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false + val currentYear = todayIsoDate.take(4).toIntOrNull() ?: return false + return releaseYear > currentYear +} + +internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection { + val filteredItems = items.filterReleasedItems(todayIsoDate) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + +internal fun List.filterReleasedItems(todayIsoDate: String): List = + filterNot { item -> item.isUnreleased(todayIsoDate) } + +private fun isoCalendarDateOrNull(value: String?): String? { + val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null + val year = date.substring(0, 4).toIntOrNull() ?: return null + val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = date.substring(8, 10).toIntOrNull() ?: return null + if (day !in 1..daysInMonth(year, month)) return null + return date +} + +private fun daysInMonth(year: Int, month: Int): Int = + when (month) { + 2 -> if (isLeapYear(year)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + +private fun isLeapYear(year: Int): Boolean = + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index 6579e0db..b71d97a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -5,12 +5,16 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.buildCatalogUrl import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems import com.nuvio.app.features.catalog.supportsPagination +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSection import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.filterReleasedItems +import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -37,6 +41,7 @@ object SearchRepository { private var activeDiscoverJob: Job? = null private var lastRequestKey: String? = null private var discoverSources: List = emptyList() + private var lastDiscoverHideUnreleasedContent: Boolean? = null fun search(query: String, addons: List) { val normalizedQuery = query.trim() @@ -71,6 +76,8 @@ object SearchRepository { val requestKey = buildString { append(normalizedQuery.lowercase()) append('|') + append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) + append('|') append( requests.joinToString(separator = "|") { request -> "${request.addon.manifestUrl}:${request.type}:${request.catalogId}" @@ -119,6 +126,7 @@ object SearchRepository { activeDiscoverJob?.cancel() lastRequestKey = null discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null _uiState.value = SearchUiState() _discoverUiState.value = DiscoverUiState() } @@ -128,6 +136,7 @@ object SearchRepository { if (activeAddons.isEmpty()) { activeDiscoverJob?.cancel() discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null log.d { "Discover refresh aborted: no active addons" } _discoverUiState.value = DiscoverUiState( emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons, @@ -137,7 +146,12 @@ object SearchRepository { val sources = buildDiscoverSources(activeAddons) val current = _discoverUiState.value - if (sources == discoverSources && current.canReuseDiscoverState(sources)) { + val hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent + if ( + sources == discoverSources && + lastDiscoverHideUnreleasedContent == hideUnreleasedContent && + current.canReuseDiscoverState(sources) + ) { log.d { "Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " + "genre=${current.selectedGenre ?: ""} items=${current.items.size} nextSkip=${current.nextSkip}" @@ -146,6 +160,7 @@ object SearchRepository { } discoverSources = sources + lastDiscoverHideUnreleasedContent = hideUnreleasedContent if (sources.isEmpty()) { activeDiscoverJob?.cancel() log.d { "Discover refresh found no compatible discover catalogs" } @@ -310,7 +325,7 @@ object SearchRepository { type = type, catalogId = catalogId, search = query, - ) + ).withUnreleasedFilter() val items = page.items require(items.isNotEmpty()) { "No search results returned for $catalogName." } @@ -364,7 +379,7 @@ object SearchRepository { catalogId = selectedCatalog.catalogId, genre = current.selectedGenre, skip = requestedSkip.takeIf { it > 0 }, - ) + ).withUnreleasedFilter() }.fold( onSuccess = { page -> val latest = _discoverUiState.value @@ -421,6 +436,12 @@ object SearchRepository { } } +private fun CatalogPage.withUnreleasedFilter(): CatalogPage { + if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this + val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate()) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + private data class SearchCatalogRequest( val addon: ManagedAddon, val catalogId: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 45e335eb..c25a67fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -46,6 +46,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -88,6 +89,7 @@ fun SearchScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -123,11 +125,11 @@ fun SearchScreen( } } - LaunchedEffect(addonRefreshKey) { + LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) { SearchRepository.refreshDiscover(addonsUiState.addons) } - LaunchedEffect(query, addonRefreshKey) { + LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) { val normalizedQuery = query.trim() if (normalizedQuery.isBlank()) { lastRequestedQuery = null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index adaea670..ee44ba7c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -38,6 +38,8 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.layout_hide_unreleased +import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub import nuvio.composeapp.generated.resources.settings_homescreen_empty_message import nuvio.composeapp.generated.resources.settings_homescreen_empty_title import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused @@ -62,6 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, heroEnabled: Boolean, + hideUnreleasedContent: Boolean, items: List, ) { val selectedHeroSourceCount = items.count { it.heroSourceEnabled } @@ -87,6 +90,14 @@ internal fun LazyListScope.homescreenSettingsContent( isTablet = isTablet, onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.layout_hide_unreleased), + description = stringResource(Res.string.layout_hide_unreleased_sub), + checked = hideUnreleasedContent, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index b8cd870d..143ef517 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -45,7 +45,10 @@ fun HomescreenSettingsScreen( } } } - val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -74,6 +77,7 @@ fun HomescreenSettingsScreen( homescreenSettingsContent( isTablet = false, heroEnabled = homescreenSettingsUiState.heroEnabled, + hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, items = homescreenSettingsUiState.items, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 9818b247..dd9ae84b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -135,6 +135,7 @@ fun SettingsScreen( } } val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val metaScreenSettingsUiState by remember { @@ -199,6 +200,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -240,6 +242,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -291,6 +294,7 @@ private fun MobileSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -399,6 +403,7 @@ private fun MobileSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = false, heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, items = homescreenItems, ) SettingsPage.MetaScreen -> metaScreenSettingsContent( @@ -461,6 +466,7 @@ private fun TabletSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -640,6 +646,7 @@ private fun TabletSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, items = homescreenItems, ) SettingsPage.MetaScreen -> metaScreenSettingsContent( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index 823125a6..cc87a1e5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -1052,6 +1052,7 @@ object TmdbMetadataService { posterShape = PosterShape.Poster, description = recommendation.overview?.trim()?.takeIf(String::isNotBlank), releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4), + rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate, imdbRating = recommendation.voteAverage?.formatRating(), ) } @@ -1087,6 +1088,7 @@ object TmdbMetadataService { posterShape = PosterShape.Landscape, description = part.overview?.trim()?.takeIf(String::isNotBlank), releaseInfo = part.releaseDate?.take(4), + rawReleaseDate = part.releaseDate, imdbRating = part.voteAverage?.formatRating(), ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt index d44a3b82..65c94d8a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt @@ -49,4 +49,26 @@ class HomeCatalogParserTest { result.items.map { it.stableKey() }, ) } + + @Test + fun `parse catalog response keeps raw released date for unreleased filtering`() { + val result = HomeCatalogParser.parseCatalogResponse( + payload = """ + { + "metas": [ + { + "id": "tt1", + "type": "movie", + "name": "Future Movie", + "releaseInfo": "2027", + "released": "2027-05-12T00:00:00.000Z" + } + ] + } + """.trimIndent(), + ) + + assertEquals("2027", result.items.single().releaseInfo) + assertEquals("2027-05-12T00:00:00.000Z", result.items.single().rawReleaseDate) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt new file mode 100644 index 00000000..dc00ef0b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReleaseInfoUtilsTest { + + @Test + fun `raw released date after today is unreleased`() { + val item = preview(rawReleaseDate = "2026-06-15T00:00:00.000Z", releaseInfo = "2026") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `release info full date after today is unreleased`() { + val item = preview(rawReleaseDate = null, releaseInfo = "2026-06-15") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `future release info year is unreleased`() { + val item = preview(rawReleaseDate = null, releaseInfo = "Coming in 2027") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `released and unknown dates are kept`() { + assertFalse(preview(rawReleaseDate = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06")) + } + + @Test + fun `catalog section filters unreleased items`() { + val section = HomeCatalogSection( + key = "addon:movie:popular", + title = "Popular", + subtitle = "Addon", + addonName = "Addon", + type = "movie", + manifestUrl = "https://example.com/manifest.json", + catalogId = "popular", + items = listOf( + preview(id = "released", rawReleaseDate = "2026-05-01", releaseInfo = "2026"), + preview(id = "future", rawReleaseDate = "2026-07-01", releaseInfo = "2026"), + ), + availableItemCount = 2, + ) + + val result = section.filterReleasedItems(todayIsoDate = "2026-05-06") + + assertEquals(listOf("released"), result.items.map { it.id }) + assertEquals(2, result.availableItemCount) + } + + private fun preview( + id: String = "tt1", + rawReleaseDate: String?, + releaseInfo: String?, + ): MetaPreview = MetaPreview( + id = id, + type = "movie", + name = id, + rawReleaseDate = rawReleaseDate, + releaseInfo = releaseInfo, + ) +}