From 5cdda5791345fa7de995602e3a41ba502ded9777 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 10 May 2026 13:47:36 +0530
Subject: [PATCH 1/5] feat; option to hide catalog underline
---
.../composeResources/values/strings.xml | 2 ++
.../home/HomeCatalogSettingsRepository.kt | 23 +++++++++++++++++++
.../home/HomeCatalogSettingsSyncService.kt | 1 +
.../home/components/HomeCatalogSection.kt | 9 ++++++++
.../components/HomeCollectionRowSection.kt | 10 ++++++++
.../settings/HomescreenSettingsPage.kt | 11 +++++++++
.../settings/SettingsFullScreenPages.kt | 1 +
.../app/features/settings/SettingsScreen.kt | 6 +++++
.../app/features/settings/SettingsSearch.kt | 1 +
9 files changed, 64 insertions(+)
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index cd8a97e2..782a24e0 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -506,6 +506,8 @@
Display hero carousel at top of home.
Hide Unreleased Content
Hide movies and shows that haven't been released yet.
+ Hide Catalog Underline
+ Remove the accent line under catalog and collection titles throughout the app.
%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/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt
index e920de04..202af87a 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
@@ -33,6 +33,7 @@ data class HomeCatalogSettingsItem(
data class HomeCatalogSettingsUiState(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
+ val hideCatalogUnderline: Boolean = false,
val items: List = emptyList(),
) {
val signature: String
@@ -41,6 +42,8 @@ data class HomeCatalogSettingsUiState(
append('|')
append(hideUnreleasedContent)
append('|')
+ append(hideCatalogUnderline)
+ append('|')
append(
items.joinToString(separator = "|") { item ->
"${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}"
@@ -59,6 +62,7 @@ internal data class HomeCatalogPreference(
internal data class HomeCatalogSettingsSnapshot(
val heroEnabled: Boolean,
val hideUnreleasedContent: Boolean,
+ val hideCatalogUnderline: Boolean,
val preferences: Map,
)
@@ -75,6 +79,7 @@ private data class StoredHomeCatalogPreference(
private data class StoredHomeCatalogSettingsPayload(
val heroEnabled: Boolean = true,
val hideUnreleasedContent: Boolean = false,
+ val hideCatalogUnderline: Boolean = false,
val items: List = emptyList(),
)
@@ -95,12 +100,14 @@ object HomeCatalogSettingsRepository {
private var preferences: MutableMap = mutableMapOf()
private var heroEnabled = true
private var hideUnreleasedContent = false
+ private var hideCatalogUnderline = false
fun onProfileChanged() {
hasLoaded = false
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
+ hideCatalogUnderline = false
definitions = emptyList()
collectionDefinitions = emptyList()
_uiState.value = HomeCatalogSettingsUiState()
@@ -113,6 +120,7 @@ object HomeCatalogSettingsRepository {
preferences.clear()
heroEnabled = true
hideUnreleasedContent = false
+ hideCatalogUnderline = false
_uiState.value = HomeCatalogSettingsUiState()
}
@@ -144,6 +152,7 @@ object HomeCatalogSettingsRepository {
return HomeCatalogSettingsSnapshot(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
preferences = preferences.mapValues { (_, value) ->
HomeCatalogPreference(
customTitle = value.customTitle,
@@ -172,6 +181,14 @@ object HomeCatalogSettingsRepository {
HomeRepository.applyCurrentSettings()
}
+ fun setHideCatalogUnderline(enabled: Boolean) {
+ ensureLoaded()
+ if (hideCatalogUnderline == enabled) return
+ hideCatalogUnderline = enabled
+ publish()
+ persist()
+ }
+
fun setHeroSourceEnabled(key: String, enabled: Boolean) {
updatePreference(key) { preference ->
if (!enabled) {
@@ -200,6 +217,7 @@ object HomeCatalogSettingsRepository {
ensureLoaded()
heroEnabled = true
hideUnreleasedContent = false
+ hideCatalogUnderline = false
preferences.clear()
normalizePreferences()
publish()
@@ -246,6 +264,7 @@ object HomeCatalogSettingsRepository {
if (parsedPayload != null) {
heroEnabled = parsedPayload.heroEnabled
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
+ hideCatalogUnderline = parsedPayload.hideCatalogUnderline
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
publish()
return
@@ -345,6 +364,7 @@ object HomeCatalogSettingsRepository {
_uiState.value = HomeCatalogSettingsUiState(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
items = items,
)
}
@@ -355,6 +375,7 @@ object HomeCatalogSettingsRepository {
StoredHomeCatalogSettingsPayload(
heroEnabled = heroEnabled,
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
items = preferences.values.sortedBy { it.order },
),
),
@@ -437,6 +458,7 @@ object HomeCatalogSettingsRepository {
}
return SyncHomeCatalogPayload(
hideUnreleasedContent = hideUnreleasedContent,
+ hideCatalogUnderline = hideCatalogUnderline,
items = items,
)
}
@@ -444,6 +466,7 @@ object HomeCatalogSettingsRepository {
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
ensureLoaded()
hideUnreleasedContent = payload.hideUnreleasedContent
+ hideCatalogUnderline = payload.hideCatalogUnderline
if (payload.items.isNotEmpty()) {
val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled }
preferences = payload.items.associate { item ->
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 5fbf8f7c..bddc4c97 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
@@ -42,6 +42,7 @@ data class SyncCatalogItem(
@Serializable
data class SyncHomeCatalogPayload(
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
+ @SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
val items: List = emptyList(),
)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt
index e7561e09..aecd6626 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt
@@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.NuvioViewAllPillSize
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
+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.stableKey
@@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent(
onPosterLongClick: ((MetaPreview) -> Unit)?,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
+ val homeCatalogSettings by remember {
+ HomeCatalogSettingsRepository.snapshot()
+ HomeCatalogSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
NuvioShelfSection(
title = section.title,
@@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent(
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
+ showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
onViewAllClick = onViewAllClick,
viewAllPillSize = NuvioViewAllPillSize.Compact,
key = { item -> item.stableKey() },
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
index dd053375..da63fe5d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt
@@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
@@ -23,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioShelfSection
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
import com.nuvio.app.core.ui.landscapePosterWidth
@@ -30,6 +33,7 @@ import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionFolder
+import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.PosterShape
@Composable
@@ -71,12 +75,18 @@ private fun HomeCollectionRowSectionContent(
animateGifs: Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
+ val homeCatalogSettings by remember {
+ HomeCatalogSettingsRepository.snapshot()
+ HomeCatalogSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
+
NuvioShelfSection(
title = collection.title,
entries = collection.folders,
modifier = modifier,
headerHorizontalPadding = sectionPadding,
rowContentPadding = PaddingValues(horizontal = sectionPadding),
+ showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
key = { folder -> "collection_${collection.id}_folder_${folder.id}" },
) { folder ->
CollectionFolderCard(
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 ee44ba7c..254d49e1 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
@@ -42,6 +42,8 @@ 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_hide_catalog_underline
+import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline_description
import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused
import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached
import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected
@@ -65,6 +67,7 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet: Boolean,
heroEnabled: Boolean,
hideUnreleasedContent: Boolean,
+ hideCatalogUnderline: Boolean,
items: List,
) {
val selectedHeroSourceCount = items.count { it.heroSourceEnabled }
@@ -98,6 +101,14 @@ internal fun LazyListScope.homescreenSettingsContent(
isTablet = isTablet,
onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent,
)
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsSwitchRow(
+ title = stringResource(Res.string.settings_homescreen_hide_catalog_underline),
+ description = stringResource(Res.string.settings_homescreen_hide_catalog_underline_description),
+ checked = hideCatalogUnderline,
+ isTablet = isTablet,
+ onCheckedChange = HomeCatalogSettingsRepository::setHideCatalogUnderline,
+ )
}
}
}
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 cbb6bfa4..45c6edf3 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
@@ -78,6 +78,7 @@ fun HomescreenSettingsScreen(
isTablet = false,
heroEnabled = homescreenSettingsUiState.heroEnabled,
hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
+ hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
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 668a3c2e..4cd95d64 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
@@ -237,6 +237,7 @@ fun SettingsScreen(
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
+ homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@@ -283,6 +284,7 @@ fun SettingsScreen(
traktSettingsUiState = traktSettingsUiState,
homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled,
homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent,
+ homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline,
homescreenItems = homescreenSettingsUiState.items,
metaScreenSettingsUiState = metaScreenSettingsUiState,
continueWatchingPreferencesUiState = continueWatchingPreferencesUiState,
@@ -339,6 +341,7 @@ private fun MobileSettingsScreen(
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
+ homescreenHideCatalogUnderline: Boolean,
homescreenItems: List,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@@ -530,6 +533,7 @@ private fun MobileSettingsScreen(
isTablet = false,
heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
+ hideCatalogUnderline = homescreenHideCatalogUnderline,
items = homescreenItems,
)
SettingsPage.MetaScreen -> metaScreenSettingsContent(
@@ -638,6 +642,7 @@ private fun TabletSettingsScreen(
traktSettingsUiState: TraktSettingsUiState,
homescreenHeroEnabled: Boolean,
homescreenHideUnreleasedContent: Boolean,
+ homescreenHideCatalogUnderline: Boolean,
homescreenItems: List,
metaScreenSettingsUiState: MetaScreenSettingsUiState,
continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState,
@@ -888,6 +893,7 @@ private fun TabletSettingsScreen(
isTablet = true,
heroEnabled = homescreenHeroEnabled,
hideUnreleasedContent = homescreenHideUnreleasedContent,
+ hideCatalogUnderline = homescreenHideCatalogUnderline,
items = homescreenItems,
)
SettingsPage.MetaScreen -> metaScreenSettingsContent(
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
index 1f3bafee..381ba569 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt
@@ -588,6 +588,7 @@ internal fun settingsSearchEntries(
listOf(
PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)),
PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)),
+ PlaybackSearchRow("home-hide-catalog-underline", stringResource(Res.string.settings_homescreen_hide_catalog_underline), stringResource(Res.string.settings_homescreen_hide_catalog_underline_description)),
PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)),
PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)),
).forEach { row ->
From 95708b9b792cffcdd372606ec57bf263fd43e47c Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 10 May 2026 13:55:24 +0530
Subject: [PATCH 2/5] ref: publish search catalogs as they arrive
---
.../app/features/search/SearchRepository.kt | 68 ++++++++++++++++---
.../nuvio/app/features/search/SearchScreen.kt | 5 ++
2 files changed, 64 insertions(+), 9 deletions(-)
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 b71d97a2..cee95160 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
@@ -20,11 +20,11 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.getString
@@ -91,16 +91,57 @@ object SearchRepository {
_uiState.value = SearchUiState(isLoading = true)
activeJob = scope.launch {
- val results = requests.map { request ->
- async {
+ val resultChannel = Channel(Channel.UNLIMITED)
+ val jobs = requests.mapIndexed { index, request ->
+ launch {
runCatching { request.toSection() }
+ .fold(
+ onSuccess = { section ->
+ resultChannel.send(
+ IndexedSearchResult(
+ index = index,
+ section = section,
+ ),
+ )
+ },
+ onFailure = { error ->
+ if (error is CancellationException) throw error
+ resultChannel.send(
+ IndexedSearchResult(
+ index = index,
+ error = error,
+ ),
+ )
+ },
+ )
}
- }.awaitAll()
+ }
+ val closeChannelJob = launch {
+ jobs.joinAll()
+ resultChannel.close()
+ }
+ val results = arrayOfNulls(requests.size)
- val sections = results
- .mapNotNull { it.getOrNull() }
- val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message }
- val allFailed = results.isNotEmpty() && results.all { it.isFailure }
+ try {
+ for (result in resultChannel) {
+ results[result.index] = result
+ val sections = results.orderedSections()
+ if (sections.isNotEmpty()) {
+ _uiState.value = SearchUiState(
+ isLoading = true,
+ sections = sections,
+ )
+ }
+ }
+ } finally {
+ closeChannelJob.cancel()
+ resultChannel.close()
+ }
+
+ val completedResults = results.filterNotNull()
+ val sections = results.orderedSections()
+ val firstFailure = completedResults.firstNotNullOfOrNull { it.error?.message }
+ val allFailed = completedResults.isNotEmpty() && completedResults.all { it.error != null }
_uiState.value = SearchUiState(
isLoading = false,
@@ -436,6 +477,15 @@ object SearchRepository {
}
}
+private data class IndexedSearchResult(
+ val index: Int,
+ val section: HomeCatalogSection? = null,
+ val error: Throwable? = null,
+)
+
+private fun Array.orderedSections(): List =
+ mapNotNull { result -> result?.section }
+
private fun CatalogPage.withUnreleasedFilter(): CatalogPage {
if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this
val filteredItems = items.filterReleasedItems(CurrentDateProvider.todayIsoDate())
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 bad6cc11..26a3c82f 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
@@ -334,6 +334,11 @@ fun SearchScreen(
onPosterLongClick = onPosterLongClick,
)
}
+ if (uiState.isLoading) {
+ item(key = "search_loading_more") {
+ HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding))
+ }
+ }
}
}
}
From 17c747e5c1dc741dc7fc36d69ffc5b5bbf9f0c55 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 10 May 2026 14:05:22 +0530
Subject: [PATCH 3/5] bump version
---
iosApp/Configuration/Version.xcconfig | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 837f01a4..75fc1867 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=56
-MARKETING_VERSION=0.1.17
+CURRENT_PROJECT_VERSION=58
+MARKETING_VERSION=0.1.18
From ee66440bf598a84adad93559a8aa153ca49f13dc Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Tue, 12 May 2026 12:22:58 +0530
Subject: [PATCH 4/5] fix: adjust behaviour logic of streamlink caching
fixes #1034
---
.../commonMain/kotlin/com/nuvio/app/App.kt | 40 +++++++++++++++++--
.../nuvio/app/features/player/PlayerScreen.kt | 21 +++++++---
.../streams/StreamLinkCacheRepository.kt | 16 +++++++-
.../app/features/streams/StreamModels.kt | 1 +
.../app/features/streams/StreamsRepository.kt | 27 +++++++++++--
.../app/features/streams/StreamsScreen.kt | 2 +-
.../streams/StreamLinkCacheRepositoryTest.kt | 39 ++++++++++++++++++
gradle.properties | 8 ++--
iosApp/Configuration/Version.xcconfig | 2 +-
9 files changed, 135 insertions(+), 21 deletions(-)
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index 1508344b..2d1fbaad 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -1338,7 +1338,13 @@ private fun MainAppContent(
reuseHandled = true
if (launch.manualSelection) return@LaunchedEffect
if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect
- val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L
val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs)
if (cached != null) {
@@ -1378,17 +1384,37 @@ private fun MainAppContent(
}
val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle()
+ val expectedStreamsRequestToken = StreamsRepository.requestToken(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ manualSelection = launch.manualSelection,
+ )
var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) }
- LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) {
+ LaunchedEffect(
+ streamsUiState.autoPlayStream,
+ streamsUiState.requestToken,
+ expectedStreamsRequestToken,
+ reuseHandled,
+ launch.manualSelection,
+ ) {
if (!reuseHandled) return@LaunchedEffect
if (launch.manualSelection) return@LaunchedEffect
if (reuseNavigated) return@LaunchedEffect
if (autoPlayHandled) return@LaunchedEffect
+ if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect
val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect
val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect
autoPlayHandled = true
if (playerSettings.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = sourceUrl,
@@ -1468,7 +1494,13 @@ private fun MainAppContent(
if (sourceUrl != null) {
// Persist for Reuse Last Link
if (playerSettings.streamReuseLastLinkEnabled) {
- val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId)
+ val cacheKey = StreamLinkCacheRepository.contentKey(
+ type = launch.type,
+ videoId = effectiveVideoId,
+ parentMetaId = launch.parentMetaId,
+ season = launch.seasonNumber,
+ episode = launch.episodeNumber,
+ )
StreamLinkCacheRepository.save(
contentKey = cacheKey,
url = sourceUrl,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
index e155ef88..9db6838d 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt
@@ -791,8 +791,11 @@ fun PlayerScreen(
flushWatchProgress()
if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) {
val cacheKey = StreamLinkCacheRepository.contentKey(
- contentType ?: parentMetaType,
- activeVideoId!!,
+ type = contentType ?: parentMetaType,
+ videoId = activeVideoId!!,
+ parentMetaId = parentMetaId,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
@@ -851,8 +854,11 @@ fun PlayerScreen(
val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L
if (playerSettingsUiState.streamReuseLastLinkEnabled) {
val cacheKey = StreamLinkCacheRepository.contentKey(
- contentType ?: parentMetaType,
- epVideoId,
+ type = contentType ?: parentMetaType,
+ videoId = epVideoId,
+ parentMetaId = parentMetaId,
+ season = episode.season,
+ episode = episode.episode,
)
StreamLinkCacheRepository.save(
contentKey = cacheKey,
@@ -1563,8 +1569,11 @@ fun PlayerScreen(
val currentVideoId = activeVideoId
if (currentVideoId != null) {
val cacheKey = StreamLinkCacheRepository.contentKey(
- contentType ?: parentMetaType,
- currentVideoId,
+ type = contentType ?: parentMetaType,
+ videoId = currentVideoId,
+ parentMetaId = parentMetaId,
+ season = activeSeasonNumber,
+ episode = activeEpisodeNumber,
)
StreamLinkCacheRepository.remove(cacheKey)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt
index 0d497166..648eaa9e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt
@@ -22,8 +22,20 @@ internal expect fun epochMs(): Long
object StreamLinkCacheRepository {
private val json = Json { ignoreUnknownKeys = true }
- fun contentKey(type: String, videoId: String): String =
- "${type.lowercase()}|$videoId"
+ fun contentKey(
+ type: String,
+ videoId: String,
+ parentMetaId: String? = null,
+ season: Int? = null,
+ episode: Int? = null,
+ ): String {
+ val normalizedType = type.lowercase()
+ return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) {
+ "$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId"
+ } else {
+ "$normalizedType|$videoId"
+ }
+ }
fun save(
contentKey: String,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt
index c7db8b2d..784dff47 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt
@@ -66,6 +66,7 @@ enum class StreamsEmptyStateReason {
}
data class StreamsUiState(
+ val requestToken: String? = null,
val groups: List = emptyList(),
val activeAddonIds: Set = emptySet(),
val selectedFilter: String? = null,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt
index 674e3352..daa96a7b 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt
@@ -36,6 +36,15 @@ object StreamsRepository {
private var activeJob: Job? = null
private var activeRequestKey: String? = null
+ fun requestToken(
+ type: String,
+ videoId: String,
+ season: Int? = null,
+ episode: Int? = null,
+ manualSelection: Boolean = false,
+ ): String =
+ "$type::$videoId::$season::$episode::$manualSelection"
+
fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) {
load(
type = type,
@@ -65,7 +74,14 @@ object StreamsRepository {
} else {
PluginsUiState(pluginsEnabled = false)
}
- val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
+ val requestToken = requestToken(
+ type = type,
+ videoId = videoId,
+ season = season,
+ episode = episode,
+ manualSelection = manualSelection,
+ )
+ val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}"
val currentState = _uiState.value
if (
!forceRefresh &&
@@ -78,7 +94,7 @@ object StreamsRepository {
activeRequestKey = requestKey
activeJob?.cancel()
- _uiState.value = StreamsUiState()
+ _uiState.value = StreamsUiState(requestToken = requestToken)
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value
@@ -90,6 +106,7 @@ object StreamsRepository {
if (isDirectAutoPlayFlow) {
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
isDirectAutoPlayFlow = true,
showDirectAutoPlayOverlay = true,
)
@@ -105,6 +122,7 @@ object StreamsRepository {
isLoading = false,
)
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
groups = listOf(group),
activeAddonIds = setOf("embedded"),
isAnyLoading = false,
@@ -125,6 +143,7 @@ object StreamsRepository {
if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled,
)
@@ -151,8 +170,9 @@ object StreamsRepository {
log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" }
- if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
+ if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) {
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
isAnyLoading = false,
emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons,
)
@@ -176,6 +196,7 @@ object StreamsRepository {
)
}
_uiState.value = StreamsUiState(
+ requestToken = requestToken,
groups = initialGroups,
activeAddonIds = initialGroups.map { it.addonId }.toSet(),
isAnyLoading = true,
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
index a0cadbc0..22e877bb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt
@@ -160,7 +160,7 @@ fun StreamsScreen(
}
}
- LaunchedEffect(type, videoId, manualSelection) {
+ LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) {
StreamsRepository.load(
type = type,
videoId = videoId,
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt
new file mode 100644
index 00000000..bf43cd42
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt
@@ -0,0 +1,39 @@
+package com.nuvio.app.features.streams
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotEquals
+
+class StreamLinkCacheRepositoryTest {
+
+ @Test
+ fun `movie cache key keeps legacy type and video id shape`() {
+ val key = StreamLinkCacheRepository.contentKey(
+ type = "movie",
+ videoId = "tt123",
+ )
+
+ assertEquals("movie|tt123", key)
+ }
+
+ @Test
+ fun `episode cache key is scoped to parent show and episode`() {
+ val firstEpisode = StreamLinkCacheRepository.contentKey(
+ type = "series",
+ videoId = "video-id",
+ parentMetaId = "tt999",
+ season = 1,
+ episode = 1,
+ )
+ val secondEpisode = StreamLinkCacheRepository.contentKey(
+ type = "series",
+ videoId = "video-id",
+ parentMetaId = "tt999",
+ season = 1,
+ episode = 2,
+ )
+
+ assertNotEquals(firstEpisode, secondEpisode)
+ assertEquals("series|tt999|s1|e1|video-id", firstEpisode)
+ }
+}
diff --git a/gradle.properties b/gradle.properties
index ddcd9b5f..01e9d962 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,14 +1,14 @@
#Kotlin
kotlin.code.style=official
-kotlin.daemon.jvmargs=-Xmx4096M
-kotlin.native.jvmArgs=-Xmx6144M
+kotlin.daemon.jvmargs=-Xmx6144M
+kotlin.native.jvmArgs=-Xmx12288M
kotlin.mpp.enableCInteropCommonization=true
#Gradle
-org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m
+org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m
org.gradle.configuration-cache=true
org.gradle.caching=true
#Android
android.nonTransitiveRClass=true
-android.useAndroidX=true
\ No newline at end of file
+android.useAndroidX=true
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 75fc1867..9b4b9d6b 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=58
-MARKETING_VERSION=0.1.18
+MARKETING_VERSION=0.1.0
From 9056716c068f1b815848c740fe93efc0bec6abab Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Tue, 12 May 2026 12:38:30 +0530
Subject: [PATCH 5/5] fix: update global index fallback logic
fixes #1027
---
.../details/SeriesPlaybackResolver.kt | 9 ++++---
.../watching/domain/SeriesContinuity.kt | 9 ++++---
.../details/SeriesPlaybackResolverTest.kt | 27 +++++++++++++++++++
.../watching/domain/SeriesContinuityTest.kt | 24 +++++++++++++++++
4 files changed, 63 insertions(+), 6 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
index ac964731..d2210058 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
@@ -98,11 +98,14 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
- val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season }
+ val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 }
+ val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
+ normalizeSeasonNumber(episode.season)
+ }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
- if (globalIndex in sortedEpisodes.indices) {
- watchedIndex = globalIndex
+ if (globalIndex in mainEpisodes.indices) {
+ watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
index 59c074ee..10263a55 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
@@ -53,11 +53,14 @@ fun nextReleasedEpisodeAfter(
// Fallback: if the seed wasn't found by season+episode (anime with absolute
// numbering on Trakt vs multi-season on addon), try global index matching.
if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) {
- val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber }
+ val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 }
+ val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode ->
+ normalizeSeasonNumber(episode.seasonNumber)
+ }
if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) {
val globalIndex = episodeNumber - 1
- if (globalIndex in sortedEpisodes.indices) {
- watchedIndex = globalIndex
+ if (globalIndex in mainEpisodes.indices) {
+ watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex])
}
}
}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt
index 1713004f..e5428e16 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt
@@ -88,4 +88,31 @@ class SeriesPlaybackResolverTest {
assertEquals("Up Next • S1E3", action.label)
assertEquals("show:1:3", action.videoId)
}
+
+ @Test
+ fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
+ val meta = MetaDetails(
+ id = "show",
+ type = "series",
+ name = "Show",
+ videos = listOf(
+ MetaVideo(id = "sp1", title = "Special 1", season = 0, episode = 1, released = "2026-01-01"),
+ MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-08"),
+ MetaVideo(id = "s1e2", title = "Episode 2", season = 1, episode = 2, released = "2026-01-15"),
+ MetaVideo(id = "s2e1", title = "Episode 3", season = 2, episode = 1, released = "2026-01-22"),
+ MetaVideo(id = "s2e2", title = "Episode 4", season = 2, episode = 2, released = "2026-01-29"),
+ ),
+ )
+
+ val nextEpisode = meta.nextReleasedEpisodeAfter(
+ seasonNumber = 1,
+ episodeNumber = 3,
+ todayIsoDate = "2026-02-01",
+ )
+
+ assertNotNull(nextEpisode)
+ assertEquals(2, nextEpisode.season)
+ assertEquals(2, nextEpisode.episode)
+ assertEquals("s2e2", nextEpisode.id)
+ }
}
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt
index cb3f6ba7..5aab3131 100644
--- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt
@@ -97,6 +97,30 @@ class SeriesContinuityTest {
assertEquals("show:1:1", action.videoId)
}
+ @Test
+ fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() {
+ val episodesWithSpecials = listOf(
+ WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"),
+ WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-08"),
+ WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-15"),
+ WatchingReleasedEpisode(videoId = "s2e1", seasonNumber = 2, episodeNumber = 1, title = "Episode 3", releasedDate = "2026-01-22"),
+ WatchingReleasedEpisode(videoId = "s2e2", seasonNumber = 2, episodeNumber = 2, title = "Episode 4", releasedDate = "2026-01-29"),
+ )
+
+ val nextEpisode = nextReleasedEpisodeAfter(
+ content = show,
+ episodes = episodesWithSpecials,
+ seasonNumber = 1,
+ episodeNumber = 3,
+ todayIsoDate = "2026-02-01",
+ )
+
+ assertNotNull(nextEpisode)
+ assertEquals(2, nextEpisode.seasonNumber)
+ assertEquals(2, nextEpisode.episodeNumber)
+ assertEquals("s2e2", nextEpisode.videoId)
+ }
+
@Test
fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() {
val specialsOnly = listOf(