Merge branch 'parity' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-06 16:01:49 +05:30
commit 217c1803c7
18 changed files with 301 additions and 33 deletions

View file

@ -475,6 +475,8 @@
<string name="settings_homescreen_selected_count">%1$d of %2$d selected</string>
<string name="settings_homescreen_show_hero">Show Hero Section</string>
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
<string name="settings_homescreen_summary_hint">Open a catalog only when you need to rename or reorder it.</string>
<string name="settings_homescreen_visible">Visible</string>

View file

@ -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,

View file

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

View file

@ -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_")

View file

@ -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<com.nuvio.app.features.streams.StreamItem> {
val meta = _uiState.value.meta ?: return emptyList()

View file

@ -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() }

View file

@ -32,12 +32,15 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<HomeCatalogSettingsItem> = 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<String, HomeCatalogPreference>,
)
@ -70,6 +74,7 @@ private data class StoredHomeCatalogPreference(
@Serializable
private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
val items: List<StoredHomeCatalogPreference> = emptyList(),
)
@ -89,11 +94,13 @@ object HomeCatalogSettingsRepository {
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = 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()

View file

@ -41,6 +41,7 @@ data class SyncCatalogItem(
@Serializable
data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
val items: List<SyncCatalogItem> = 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)

View file

@ -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)

View file

@ -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<MetaPreview>.filterReleasedItems(todayIsoDate: String): List<MetaPreview> =
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)

View file

@ -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<DiscoverCatalogOption> = emptyList()
private var lastDiscoverHideUnreleasedContent: Boolean? = null
fun search(query: String, addons: List<ManagedAddon>) {
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 ?: "<all>"} 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,

View file

@ -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

View file

@ -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<HomeCatalogSettingsItem>,
) {
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,
)
}
}
}

View file

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

View file

@ -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<HomeCatalogSettingsItem>,
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<HomeCatalogSettingsItem>,
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(

View file

@ -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(),
)
}

View file

@ -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)
}
}

View file

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