From 1e4c125ca1d7ea900c4d32dc01837288df989016 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:06:02 +0530 Subject: [PATCH] feat: add separate upcoming continue watching row --- .../composeResources/values-pl/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../com/nuvio/app/features/home/HomeScreen.kt | 215 +++++++++++++----- .../components/HomeContinueWatchingSection.kt | 6 +- .../settings/ContinueWatchingSettingsPage.kt | 8 + .../watchprogress/WatchProgressModels.kt | 1 + .../nuvio/app/features/home/HomeScreenTest.kt | 67 ++++++ 7 files changed, 249 insertions(+), 54 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 012ba9ae2..08322b6ff 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -589,6 +589,9 @@ Sortuj wszystkie elementy według czasu Styl streamingowy Wydane najpierw, nadchodzące na końcu + Oddzielny rząd Nadchodzące + Przenieś niewydane odcinki do oddzielnego rzędu Nadchodzące + Nadchodzące STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 34744803e..a69a6ef78 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -681,6 +681,9 @@ Sort all items by recency Streaming Style Released items first, upcoming at the end + Separate Upcoming Row + Move unaired episodes to a separate Upcoming row + Upcoming Poster Card Style ON LAUNCH NEXT UP BEHAVIOR diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 31676eee1..af37817bd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.home import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -13,6 +15,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository @@ -45,6 +48,7 @@ import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding +import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.WatchProgressSource @@ -58,11 +62,13 @@ import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching @@ -132,6 +138,7 @@ fun HomeScreen( }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val continueWatchingListState = rememberLazyListState() + val upcomingListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() @@ -286,6 +293,7 @@ fun HomeScreen( val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 val cwCacheGeneration by ContinueWatchingEnrichmentCache.generation.collectAsStateWithLifecycle() var hasUserScrolledContinueWatching by remember(activeProfileId) { mutableStateOf(false) } + var hasUserScrolledUpcoming by remember(activeProfileId) { mutableStateOf(false) } LaunchedEffect(activeProfileId, continueWatchingListState) { snapshotFlow { continueWatchingListState.isScrollInProgress }.collect { isScrolling -> @@ -293,6 +301,12 @@ fun HomeScreen( } } + LaunchedEffect(activeProfileId, upcomingListState) { + snapshotFlow { upcomingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledUpcoming = true + } + } + var nextUpItemsBySeries by remember(activeProfileId, effectiveWatchProgressSource) { mutableStateOf>>(emptyMap()) } @@ -421,7 +435,7 @@ fun HomeScreen( ) } - val continueWatchingItems = remember( + val allContinueWatchingItems = remember( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, @@ -439,6 +453,17 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } + val (continueWatchingItems, upcomingItems) = remember( + allContinueWatchingItems, + continueWatchingPreferences.sortMode, + ) { + splitUpcomingItems( + items = allContinueWatchingItems, + mode = continueWatchingPreferences.sortMode, + ) + } + val hasContinueWatchingRows = continueWatchingItems.isNotEmpty() || upcomingItems.isNotEmpty() + LaunchedEffect(activeProfileId, continueWatchingItems.isNotEmpty(), hasUserScrolledContinueWatching) { if (!hasUserScrolledContinueWatching && continueWatchingItems.isNotEmpty()) { snapshotFlow { @@ -455,6 +480,22 @@ fun HomeScreen( } } } + LaunchedEffect(activeProfileId, upcomingItems.isNotEmpty(), hasUserScrolledUpcoming) { + if (!hasUserScrolledUpcoming && upcomingItems.isNotEmpty()) { + snapshotFlow { + upcomingListState.firstVisibleItemIndex to + upcomingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledUpcoming && + !upcomingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + upcomingListState.scrollToItem(0) + } + } + } + } val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } @@ -817,7 +858,7 @@ fun HomeScreen( maxWidth.value, continueWatchingPreferences.isVisible, continueWatchingPreferences.style, - continueWatchingItems.isNotEmpty(), + hasContinueWatchingRows, continueWatchingLayout, posterCardStyle.widthDp, homeSettingsUiState.hideCatalogUnderline, @@ -826,7 +867,7 @@ fun HomeScreen( if ( maxWidth.value < 600f && continueWatchingPreferences.isVisible && - continueWatchingItems.isNotEmpty() + hasContinueWatchingRows ) { continueWatchingHeroViewportReserveHeight( style = continueWatchingPreferences.style, @@ -882,22 +923,17 @@ fun HomeScreen( when { !hasActiveAddons && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), @@ -908,22 +944,17 @@ fun HomeScreen( } homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) items(3) { HomeSkeletonRow( modifier = Modifier.padding(horizontal = 16.dp), @@ -933,7 +964,7 @@ fun HomeScreen( } homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && - (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) && + (!continueWatchingPreferences.isVisible || !hasContinueWatchingRows) && !hasRenderableCollectionRows -> { item { if (networkStatusUiState.isOfflineLike) { @@ -957,22 +988,17 @@ fun HomeScreen( } else -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) keyedEnabledHomeItems.forEach { keyedSettingsItem -> val settingsItem = keyedSettingsItem.value @@ -1018,8 +1044,58 @@ fun HomeScreen( } } +private fun LazyListScope.homeContinueWatchingSections( + preferences: ContinueWatchingPreferencesUiState, + continueWatchingItems: List, + upcomingItems: List, + sectionPadding: Dp, + layout: ContinueWatchingLayout, + continueWatchingListState: LazyListState, + upcomingListState: LazyListState, + onItemClick: ((ContinueWatchingItem) -> Unit)?, + onItemLongPress: ((ContinueWatchingItem) -> Unit)?, +) { + if (!preferences.isVisible) return + + if (continueWatchingItems.isNotEmpty()) { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { + HomeContinueWatchingSection( + items = continueWatchingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + sectionPadding = sectionPadding, + layout = layout, + listState = continueWatchingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } + + if (upcomingItems.isNotEmpty()) { + item(key = HOME_UPCOMING_SECTION_KEY) { + HomeContinueWatchingSection( + items = upcomingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + title = stringResource(Res.string.upcoming_section_title), + sectionPadding = sectionPadding, + layout = layout, + listState = upcomingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } +} + private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" +private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L @@ -1461,11 +1537,44 @@ internal fun buildHomeContinueWatchingItems( } return when (sortMode) { - ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item) + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.SPLIT_UPCOMING, + -> deduplicated.map(HomeContinueWatchingCandidate::item) ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate) } } +/** + * Splits unaired next-up episodes into a dedicated row when [ContinueWatchingSortMode.SPLIT_UPCOMING] + * is active. The main row keeps its recency ordering, while Upcoming is ordered by the nearest + * release instant with unknown dates last. + */ +internal fun splitUpcomingItems( + items: List, + mode: ContinueWatchingSortMode, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Pair, List> { + if (mode != ContinueWatchingSortMode.SPLIT_UPCOMING) { + return items to emptyList() + } + + val (upcoming, main) = items.partition { item -> + item.isNextUp && parseReleaseDateToEpochMs(item.released) + ?.let { releaseEpochMs -> releaseEpochMs > nowEpochMs } == true + } + val sortedUpcoming = upcoming.sortedWith { first, second -> + val firstRelease = parseReleaseDateToEpochMs(first.released) + val secondRelease = parseReleaseDateToEpochMs(second.released) + when { + firstRelease == null && secondRelease == null -> 0 + firstRelease == null -> 1 + secondRelease == null -> -1 + else -> firstRelease.compareTo(secondRelease) + } + } + return main to sortedUpcoming +} + private fun applyStreamingStyleSort( candidates: List, todayIsoDate: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index d088bee10..37a660f3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -235,6 +235,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, + title: String? = null, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, listState: LazyListState = rememberLazyListState(), @@ -250,6 +251,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), + title = title, sectionPadding = sectionPadding, layout = layout, listState = listState, @@ -264,6 +266,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), + title = title, sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), listState = listState, @@ -281,6 +284,7 @@ private fun HomeContinueWatchingSectionContent( useEpisodeThumbnails: Boolean, blurNextUp: Boolean, modifier: Modifier, + title: String?, sectionPadding: Dp, layout: ContinueWatchingLayout, listState: LazyListState, @@ -296,7 +300,7 @@ private fun HomeContinueWatchingSectionContent( val displayEntries = disintegration.sync(items) NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), + title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), entries = displayEntries, modifier = modifier, headerHorizontalPadding = sectionPadding, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 75275178d..5608e5904 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title import nuvio.composeapp.generated.resources.settings_continue_watching_style_card import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description @@ -176,6 +178,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( when (sortMode) { ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming + ContinueWatchingSortMode.SPLIT_UPCOMING -> Res.string.settings_continue_watching_sort_mode_split_upcoming } ) SettingsNavigationRow( @@ -325,6 +328,11 @@ private fun ContinueWatchingSortModeDialog( Res.string.settings_continue_watching_sort_mode_streaming, Res.string.settings_continue_watching_sort_mode_streaming_desc, ), + Triple( + ContinueWatchingSortMode.SPLIT_UPCOMING, + Res.string.settings_continue_watching_sort_mode_split_upcoming, + Res.string.settings_continue_watching_sort_mode_split_upcoming_desc, + ), ) BasicAlertDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index aca40d7fe..0314e65a2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -24,6 +24,7 @@ enum class ContinueWatchingSectionStyle { enum class ContinueWatchingSortMode { DEFAULT, STREAMING_STYLE, + SPLIT_UPCOMING, } @Serializable diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 20df1e2e2..5ad99e0fe 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -10,6 +10,7 @@ import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.nextUpDismissKey @@ -162,6 +163,72 @@ class HomeScreenTest { assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `split upcoming mode moves only unaired next up episodes and sorts them by release`() { + val nowEpochMs = requireNotNull(parseReleaseDateToEpochMs("2026-07-19T12:00:00Z")) + val inProgress = progressEntry( + videoId = "movie-1", + title = "Movie", + lastUpdatedEpochMs = 500L, + seasonNumber = null, + episodeNumber = null, + episodeTitle = null, + ).toContinueWatchingItem() + val aired = continueWatchingItem( + videoId = "aired:1:2", + subtitle = "S1E2 • Aired", + ).copy(released = "2026-07-19T11:59:59Z") + val unknownRelease = continueWatchingItem( + videoId = "unknown:1:2", + subtitle = "S1E2 • Unknown", + ) + val laterUpcoming = continueWatchingItem( + videoId = "later:1:2", + subtitle = "S1E2 • Later", + ).copy(released = "2026-07-21T00:00:00Z") + val soonerUpcoming = continueWatchingItem( + videoId = "sooner:1:2", + subtitle = "S1E2 • Sooner", + ).copy(released = "2026-07-20T00:00:00Z") + + val (main, upcoming) = splitUpcomingItems( + items = listOf(laterUpcoming, inProgress, aired, unknownRelease, soonerUpcoming), + mode = ContinueWatchingSortMode.SPLIT_UPCOMING, + nowEpochMs = nowEpochMs, + ) + + assertEquals( + listOf("movie-1", "aired:1:2", "unknown:1:2"), + main.map(ContinueWatchingItem::videoId), + ) + assertEquals( + listOf("sooner:1:2", "later:1:2"), + upcoming.map(ContinueWatchingItem::videoId), + ) + } + + @Test + fun `non split sort modes keep upcoming episodes in continue watching`() { + val futureItem = continueWatchingItem( + videoId = "future:1:2", + subtitle = "S1E2 • Future", + ).copy(released = "2099-01-01T00:00:00Z") + + listOf( + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.STREAMING_STYLE, + ).forEach { mode -> + val (main, upcoming) = splitUpcomingItems( + items = listOf(futureItem), + mode = mode, + nowEpochMs = 0L, + ) + + assertEquals(listOf(futureItem), main) + assertTrue(upcoming.isEmpty()) + } + } + @Test fun `build home continue watching items enriches cloud title from library file`() { val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv")