diff --git a/app/src/main/java/com/nuvio/tv/core/util/ReleaseInfoUtils.kt b/app/src/main/java/com/nuvio/tv/core/util/ReleaseInfoUtils.kt new file mode 100644 index 00000000..20ffc738 --- /dev/null +++ b/app/src/main/java/com/nuvio/tv/core/util/ReleaseInfoUtils.kt @@ -0,0 +1,30 @@ +package com.nuvio.tv.core.util + +import com.nuvio.tv.domain.model.CatalogRow +import com.nuvio.tv.domain.model.MetaPreview +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.DateTimeParseException + +private val YEAR_REGEX = Regex("""\b(19|20)\d{2}\b""") +private val ISO_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE + +fun MetaPreview.isUnreleased(today: LocalDate): Boolean { + val info = releaseInfo ?: return false + // Try full date parse first (e.g. "2026-06-15") + try { + val date = LocalDate.parse(info.trim(), ISO_DATE_FORMATTER) + return date.isAfter(today) + } catch (_: DateTimeParseException) { + // fall through to year-only + } + // Fall back to year extraction + val yearStr = YEAR_REGEX.find(info)?.value ?: return false + val year = yearStr.toIntOrNull() ?: return false + return year > today.year +} + +fun CatalogRow.filterReleasedItems(today: LocalDate): CatalogRow { + val filtered = items.filterNot { it.isUnreleased(today) } + return if (filtered.size == items.size) this else copy(items = filtered) +} diff --git a/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt b/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt index 8df3c483..a93d3d5e 100644 --- a/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt +++ b/app/src/main/java/com/nuvio/tv/data/local/LayoutPreferenceDataStore.kt @@ -62,6 +62,7 @@ class LayoutPreferenceDataStore @Inject constructor( private val blurUnwatchedEpisodesKey = booleanPreferencesKey("blur_unwatched_episodes") private val detailPageTrailerButtonEnabledKey = booleanPreferencesKey("detail_page_trailer_button_enabled") private val preferExternalMetaAddonDetailKey = booleanPreferencesKey("prefer_external_meta_addon_detail") + private val hideUnreleasedContentKey = booleanPreferencesKey("hide_unreleased_content") private fun profileFlow(extract: (prefs: androidx.datastore.preferences.core.Preferences) -> T): Flow = profileManager.activeProfileId.flatMapLatest { pid -> @@ -198,6 +199,10 @@ class LayoutPreferenceDataStore @Inject constructor( prefs[preferExternalMetaAddonDetailKey] ?: false } + val hideUnreleasedContent: Flow = profileFlow { prefs -> + prefs[hideUnreleasedContentKey] ?: false + } + suspend fun setLayout(layout: HomeLayout) { store().edit { prefs -> val hadChosenLayout = prefs[hasChosenKey] ?: false @@ -389,6 +394,12 @@ class LayoutPreferenceDataStore @Inject constructor( } } + suspend fun setHideUnreleasedContent(enabled: Boolean) { + store().edit { prefs -> + prefs[hideUnreleasedContentKey] = enabled + } + } + private fun parseCatalogKeys(json: String?): List { if (json.isNullOrBlank()) return emptyList() return try { diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt index c6798df7..95276905 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/detail/MetaDetailsViewModel.kt @@ -27,6 +27,8 @@ import com.nuvio.tv.domain.repository.WatchProgressRepository import com.nuvio.tv.data.local.WatchedItemsPreferences import com.nuvio.tv.data.local.TrailerSettingsDataStore import com.nuvio.tv.data.trailer.TrailerService +import com.nuvio.tv.core.util.isUnreleased +import java.time.LocalDate import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job @@ -80,6 +82,7 @@ class MetaDetailsViewModel @Inject constructor( private var trailerAutoplayEnabled = false private var isPlayButtonFocused = false + private var hideUnreleasedContent = false init { observeMetaViewSettings() @@ -89,9 +92,20 @@ class MetaDetailsViewModel @Inject constructor( observeWatchedEpisodes() observeMovieWatched() observeBlurUnwatchedEpisodes() + observeHideUnreleasedContent() loadMeta() } + private fun observeHideUnreleasedContent() { + viewModelScope.launch { + layoutPreferenceDataStore.hideUnreleasedContent + .distinctUntilChanged() + .collectLatest { enabled -> + hideUnreleasedContent = enabled + } + } + } + private fun observeMetaViewSettings() { viewModelScope.launch { layoutPreferenceDataStore.detailPageTrailerButtonEnabled @@ -444,7 +458,7 @@ class MetaDetailsViewModel @Inject constructor( return@launch } - val recommendations = runCatching { + val rawRecommendations = runCatching { tmdbMetadataService.fetchMoreLikeThis( tmdbId = tmdbId, contentType = tmdbContentType, @@ -455,6 +469,13 @@ class MetaDetailsViewModel @Inject constructor( emptyList() } + val recommendations = if (hideUnreleasedContent) { + val today = LocalDate.now() + rawRecommendations.filterNot { it.isUnreleased(today) } + } else { + rawRecommendations + } + _uiState.update { state -> if (state.meta == null || state.meta.id == meta.id) { state.copy(moreLikeThis = recommendations) diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt index cb18ddf0..e9249369 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeUiState.kt @@ -45,7 +45,8 @@ data class HomeUiState( val posterListPickerMembership: Map = emptyMap(), val posterListPickerPending: Boolean = false, val posterListPickerError: String? = null, - val gridItems: List = emptyList() + val gridItems: List = emptyList(), + val hideUnreleasedContent: Boolean = false ) @Immutable diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt index bb8b77dc..f3729f15 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelCatalogPipeline.kt @@ -14,7 +14,9 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.withPermit +import com.nuvio.tv.core.util.filterReleasedItems import kotlinx.coroutines.withContext +import java.time.LocalDate internal fun HomeViewModel.loadHomeCatalogOrderPreferencePipeline() { viewModelScope.launch { @@ -259,9 +261,16 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() { val currentLayout = _uiState.value.homeLayout val currentGridItems = _uiState.value.gridItems val heroSectionEnabled = _uiState.value.heroSectionEnabled + val hideUnreleased = _uiState.value.hideUnreleasedContent val (displayRows, baseHeroItems, baseGridItems) = withContext(Dispatchers.Default) { - val orderedRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] } + val rawRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] } + val orderedRows = if (hideUnreleased) { + val today = LocalDate.now() + rawRows.map { it.filterReleasedItems(today) } + } else { + rawRows + } val selectedHeroCatalogSet = heroCatalogKeys.toSet() val selectedHeroRows = if (selectedHeroCatalogSet.isNotEmpty()) { orderedRows.filter { row -> @@ -364,7 +373,13 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() { Triple(computedDisplayRows, computedHeroItems, computedGridItems) } - val fullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] } + val rawFullRows = orderedKeys.mapNotNull { key -> catalogSnapshot[key] } + val fullRows = if (hideUnreleased) { + val today = LocalDate.now() + rawFullRows.map { it.filterReleasedItems(today) } + } else { + rawFullRows + } _fullCatalogRows.update { rows -> if (rows == fullRows) rows else fullRows } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt index 53d34dd2..81b78f79 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/home/HomeViewModelPresentationPipeline.kt @@ -28,7 +28,8 @@ private data class CoreLayoutPrefs( val heroSectionEnabled: Boolean, val posterLabelsEnabled: Boolean, val catalogAddonNameEnabled: Boolean, - val catalogTypeSuffixEnabled: Boolean + val catalogTypeSuffixEnabled: Boolean, + val hideUnreleasedContent: Boolean ) private data class FocusedBackdropPrefs( @@ -46,6 +47,7 @@ private data class LayoutUiPrefs( val posterLabelsEnabled: Boolean, val catalogAddonNameEnabled: Boolean, val catalogTypeSuffixEnabled: Boolean, + val hideUnreleasedContent: Boolean, val modernLandscapePostersEnabled: Boolean, val focusedBackdropExpandEnabled: Boolean, val focusedBackdropExpandDelaySeconds: Int, @@ -73,12 +75,17 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { heroSectionEnabled = heroSectionEnabled, posterLabelsEnabled = posterLabelsEnabled, catalogAddonNameEnabled = catalogAddonNameEnabled, - catalogTypeSuffixEnabled = true + catalogTypeSuffixEnabled = true, + hideUnreleasedContent = false ) }, - layoutPreferenceDataStore.catalogTypeSuffixEnabled - ) { corePrefs, catalogTypeSuffixEnabled -> - corePrefs.copy(catalogTypeSuffixEnabled = catalogTypeSuffixEnabled) + layoutPreferenceDataStore.catalogTypeSuffixEnabled, + layoutPreferenceDataStore.hideUnreleasedContent + ) { corePrefs, catalogTypeSuffixEnabled, hideUnreleasedContent -> + corePrefs.copy( + catalogTypeSuffixEnabled = catalogTypeSuffixEnabled, + hideUnreleasedContent = hideUnreleasedContent + ) } val focusedBackdropPrefsFlow = combine( @@ -113,6 +120,7 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { posterLabelsEnabled = corePrefs.posterLabelsEnabled, catalogAddonNameEnabled = corePrefs.catalogAddonNameEnabled, catalogTypeSuffixEnabled = corePrefs.catalogTypeSuffixEnabled, + hideUnreleasedContent = corePrefs.hideUnreleasedContent, modernLandscapePostersEnabled = false, focusedBackdropExpandEnabled = focusedBackdropPrefs.expandEnabled, focusedBackdropExpandDelaySeconds = focusedBackdropPrefs.expandDelaySeconds, @@ -146,7 +154,8 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { val shouldRefreshCatalogPresentation = currentHeroCatalogKeys != prefs.heroCatalogKeys || previousState.heroSectionEnabled != prefs.heroSectionEnabled || - previousState.homeLayout != prefs.layout + previousState.homeLayout != prefs.layout || + previousState.hideUnreleasedContent != prefs.hideUnreleasedContent currentHeroCatalogKeys = prefs.heroCatalogKeys _uiState.update { it.copy( @@ -156,6 +165,7 @@ internal fun HomeViewModel.observeLayoutPreferencesPipeline() { posterLabelsEnabled = effectivePosterLabelsEnabled, catalogAddonNameEnabled = prefs.catalogAddonNameEnabled, catalogTypeSuffixEnabled = prefs.catalogTypeSuffixEnabled, + hideUnreleasedContent = prefs.hideUnreleasedContent, modernLandscapePostersEnabled = prefs.modernLandscapePostersEnabled, focusedPosterBackdropExpandEnabled = prefs.focusedBackdropExpandEnabled, focusedPosterBackdropExpandDelaySeconds = prefs.focusedBackdropExpandDelaySeconds, diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/search/SearchViewModel.kt index f35f1471..319cf599 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/search/SearchViewModel.kt @@ -7,7 +7,10 @@ import com.nuvio.tv.data.local.LayoutPreferenceDataStore import com.nuvio.tv.domain.model.Addon import com.nuvio.tv.domain.model.CatalogDescriptor import com.nuvio.tv.domain.model.CatalogRow +import com.nuvio.tv.core.util.filterReleasedItems +import com.nuvio.tv.core.util.isUnreleased import com.nuvio.tv.domain.repository.AddonRepository +import java.time.LocalDate import com.nuvio.tv.domain.repository.CatalogRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -41,6 +44,7 @@ class SearchViewModel @Inject constructor( private var hasRenderedFirstCatalog = false private var pendingCatalogResponses = 0 private var revealBatchAfterNextDiscoverFetch = false + private var hideUnreleasedContent = false private companion object { const val DISCOVER_INITIAL_LIMIT = 100 @@ -93,6 +97,12 @@ class SearchViewModel @Inject constructor( _uiState.update { it.copy(catalogTypeSuffixEnabled = enabled) } } } + viewModelScope.launch { + layoutPreferenceDataStore.hideUnreleasedContent.collectLatest { enabled -> + hideUnreleasedContent = enabled + scheduleCatalogRowsUpdate() + } + } } private data class LayoutPrefs( @@ -342,8 +352,14 @@ class SearchViewModel @Inject constructor( private fun updateCatalogRowsNow() { _uiState.update { state -> val orderedRows = catalogOrder.mapNotNull { key -> catalogsMap[key] } + val filteredRows = if (hideUnreleasedContent) { + val today = LocalDate.now() + orderedRows.map { it.filterReleasedItems(today) } + } else { + orderedRows + } state.copy( - catalogRows = orderedRows + catalogRows = filteredRows ) } } @@ -546,7 +562,13 @@ class SearchViewModel @Inject constructor( "${item.apiType}:${item.id}" !in existingKeys } val merged = if (reset) incoming else (existing + incoming) - val deduped = merged.distinctBy { "${it.apiType}:${it.id}" } + val rawDeduped = merged.distinctBy { "${it.apiType}:${it.id}" } + val deduped = if (hideUnreleasedContent) { + val today = LocalDate.now() + rawDeduped.filterNot { it.isUnreleased(today) } + } else { + rawDeduped + } val shouldRevealBatch = !reset && revealBatchAfterNextDiscoverFetch val visibleLimit = if (reset) { DISCOVER_INITIAL_LIMIT diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt index 965c355e..e3c06498 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsScreen.kt @@ -361,6 +361,17 @@ fun LayoutSettingsContent( }, onFocused = { focusedSection = LayoutSettingsSection.HOME_CONTENT } ) + CompactToggleRow( + title = stringResource(R.string.layout_hide_unreleased), + subtitle = stringResource(R.string.layout_hide_unreleased_sub), + checked = uiState.hideUnreleasedContent, + onToggle = { + viewModel.onEvent( + LayoutSettingsEvent.SetHideUnreleasedContent(!uiState.hideUnreleasedContent) + ) + }, + onFocused = { focusedSection = LayoutSettingsSection.HOME_CONTENT } + ) } } diff --git a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt index 0c64e4d6..6bc7ceed 100644 --- a/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt +++ b/app/src/main/java/com/nuvio/tv/ui/screens/settings/LayoutSettingsViewModel.kt @@ -41,7 +41,8 @@ data class LayoutSettingsUiState( val posterCardCornerRadiusDp: Int = 12, val blurUnwatchedEpisodes: Boolean = false, val detailPageTrailerButtonEnabled: Boolean = false, - val preferExternalMetaAddonDetail: Boolean = false + val preferExternalMetaAddonDetail: Boolean = false, + val hideUnreleasedContent: Boolean = false ) data class CatalogInfo( @@ -74,6 +75,7 @@ sealed class LayoutSettingsEvent { data class SetBlurUnwatchedEpisodes(val enabled: Boolean) : LayoutSettingsEvent() data class SetDetailPageTrailerButtonEnabled(val enabled: Boolean) : LayoutSettingsEvent() data class SetPreferExternalMetaAddonDetail(val enabled: Boolean) : LayoutSettingsEvent() + data class SetHideUnreleasedContent(val enabled: Boolean) : LayoutSettingsEvent() data object ResetPosterCardStyle : LayoutSettingsEvent() } @@ -212,6 +214,11 @@ class LayoutSettingsViewModel @Inject constructor( updateUiStateIfChanged { it.copy(preferExternalMetaAddonDetail = enabled) } } } + viewModelScope.launch { + layoutPreferenceDataStore.hideUnreleasedContent.distinctUntilChanged().collectLatest { enabled -> + updateUiStateIfChanged { it.copy(hideUnreleasedContent = enabled) } + } + } loadAvailableCatalogs() } @@ -239,6 +246,7 @@ class LayoutSettingsViewModel @Inject constructor( is LayoutSettingsEvent.SetBlurUnwatchedEpisodes -> setBlurUnwatchedEpisodes(event.enabled) is LayoutSettingsEvent.SetDetailPageTrailerButtonEnabled -> setDetailPageTrailerButtonEnabled(event.enabled) is LayoutSettingsEvent.SetPreferExternalMetaAddonDetail -> setPreferExternalMetaAddonDetail(event.enabled) + is LayoutSettingsEvent.SetHideUnreleasedContent -> setHideUnreleasedContent(event.enabled) LayoutSettingsEvent.ResetPosterCardStyle -> resetPosterCardStyle() } } @@ -397,6 +405,13 @@ class LayoutSettingsViewModel @Inject constructor( } } + private fun setHideUnreleasedContent(enabled: Boolean) { + if (_uiState.value.hideUnreleasedContent == enabled) return + viewModelScope.launch { + layoutPreferenceDataStore.setHideUnreleasedContent(enabled) + } + } + private fun resetPosterCardStyle() { if ( _uiState.value.posterCardWidthDp == 126 && diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 112a5db4..fc387dc6 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -320,6 +320,8 @@ Show source name under catalog titles. Show Catalog Type Show type suffix next to catalog name (Movie/Series). + Hide Unreleased Content + Hide movies and shows that haven\'t been released yet. Detail Page Settings for the detail and episode screens. Blur Unwatched Episodes