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/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/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/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/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/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)) + } + } } } } 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 -> 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/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/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/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( 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 837f01a4..9b4b9d6b 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.0