feat: add separate upcoming continue watching row

This commit is contained in:
tapframe 2026-07-19 14:06:02 +05:30
parent 0ac38dce43
commit 1e4c125ca1
7 changed files with 249 additions and 54 deletions

View file

@ -589,6 +589,9 @@
<string name="settings_continue_watching_sort_mode_default_desc">Sortuj wszystkie elementy według czasu</string>
<string name="settings_continue_watching_sort_mode_streaming">Styl streamingowy</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Wydane najpierw, nadchodzące na końcu</string>
<string name="settings_continue_watching_sort_mode_split_upcoming">Oddzielny rząd Nadchodzące</string>
<string name="settings_continue_watching_sort_mode_split_upcoming_desc">Przenieś niewydane odcinki do oddzielnego rzędu Nadchodzące</string>
<string name="upcoming_section_title">Nadchodzące</string>
<string name="settings_continue_watching_section_card_style">STYL KARTY</string>
<string name="settings_continue_watching_section_on_launch">PRZY URUCHOMIENIU</string>
<string name="settings_continue_watching_section_up_next_behavior">ZACHOWANIE NASTĘPNEGO</string>

View file

@ -681,6 +681,9 @@
<string name="settings_continue_watching_sort_mode_default_desc">Sort all items by recency</string>
<string name="settings_continue_watching_sort_mode_streaming">Streaming Style</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
<string name="settings_continue_watching_sort_mode_split_upcoming">Separate Upcoming Row</string>
<string name="settings_continue_watching_sort_mode_split_upcoming_desc">Move unaired episodes to a separate Upcoming row</string>
<string name="upcoming_section_title">Upcoming</string>
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
<string name="settings_continue_watching_section_up_next_behavior">NEXT UP BEHAVIOR</string>

View file

@ -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<Map<String, Pair<Long, ContinueWatchingItem>>>(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<ContinueWatchingItem>,
upcomingItems: List<ContinueWatchingItem>,
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<ContinueWatchingItem>,
mode: ContinueWatchingSortMode,
nowEpochMs: Long = WatchProgressClock.nowEpochMs(),
): Pair<List<ContinueWatchingItem>, List<ContinueWatchingItem>> {
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<HomeContinueWatchingCandidate>,
todayIsoDate: String,

View file

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

View file

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

View file

@ -24,6 +24,7 @@ enum class ContinueWatchingSectionStyle {
enum class ContinueWatchingSortMode {
DEFAULT,
STREAMING_STYLE,
SPLIT_UPCOMING,
}
@Serializable

View file

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