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,
+ )
+}