Add an Upcoming home row split from Continue Watching (off by default)

New setting under Settings > Appearance > Home Screen: when enabled,
Continue Watching items whose next episode/season hasn't released yet
move into their own Upcoming row instead of sitting in Continue
Watching with nothing to resume.

Classification runs async per up-next series item (no active playback
progress): HomeContinueWatchingCoordinator resolves the specific
lastVideoId episode via the existing getSeasonEpisodes fetch, checks
its release date through the new isEpisodeReleased FFI call, and
caches the result by id:lastVideoId. Once known, it triggers a
dynamic-rows refresh so the split applies without blocking the
initial Home paint.

HomeCatalogFeedCoordinator/HomeDynamicRowsCoordinator partition the
Continue Watching list into continue_watching/upcoming HomeCategory
rows via the new isUpcoming lambda, gated by
UserProfile.upcomingRowEnabled (resolved through the Rust safe-prefs
struct like every other appearance toggle). HomeCategoryPolicy's
isContinueWatchingCategory()-gated behavior (card layout, action-row
treatment, title passthrough) now also covers the upcoming row so it
renders with the same horizontal episode-card treatment.
This commit is contained in:
KhooLy 2026-07-20 00:06:38 +03:00
parent 6f6378a915
commit f0cf10d757
18 changed files with 108 additions and 10 deletions

View file

@ -30,6 +30,7 @@ internal fun UserProfile?.requiresHomeReload(next: UserProfile): Boolean {
previous.homeFeedToggles != next.homeFeedToggles ||
previous.homeFeedOrder != next.homeFeedOrder ||
previous.safeContinueWatchingEnabled != next.safeContinueWatchingEnabled ||
previous.safeUpcomingRowEnabled != next.safeUpcomingRowEnabled ||
previous.safeContinueWatchingSource != next.safeContinueWatchingSource ||
previous.safeShowHeroSection != next.safeShowHeroSection
}

View file

@ -156,7 +156,7 @@ class AndroidCatalogHomeDataSource(
categoryType = type,
cardLayout = resolveHomeCardLayout(this, profile),
artworkPreference = null,
isActionRow = isContinueWatchingCategory() || id == "library",
isActionRow = isContinueWatchingOrUpcomingCategory() || id == "library",
topTenEnabled = id in profile?.safeTopTenFeedToggles.orEmpty(),
items = items.map { meta -> meta.toCatalogItemUiModel(category = this, profile = profile) }
)
@ -169,7 +169,7 @@ class AndroidCatalogHomeDataSource(
cardScale = 1f,
showHorizontalLogo = true,
topTenRank = null,
isContinueWatchingCard = category?.isContinueWatchingCategory() == true,
isContinueWatchingCard = category?.isContinueWatchingOrUpcomingCategory() == true,
loadArtwork = true
)
return CatalogItemUiModel(

View file

@ -14,7 +14,7 @@ import com.fluxa.app.ui.catalog.posterCardHeight
import com.fluxa.app.ui.catalog.posterCardWidth
fun resolveHomeCardLayout(category: HomeCategory, profile: UserProfile?): String {
return if (category.isContinueWatchingCategory()) {
return if (category.isContinueWatchingOrUpcomingCategory()) {
profile?.resolvedContinueWatchingLayout ?: "horizontal"
} else if (profile?.safePosterLandscapeMode == true) {
"horizontal"

View file

@ -191,7 +191,8 @@ class AndroidSettingsDataSource(
continueWatchingHorizontal = profile.safeContinueWatchingLayout != "vertical",
continueWatchingEnabled = profile.safeContinueWatchingEnabled,
continueWatchingHideTitles = profile.safeContinueWatchingHideTitles,
continueWatchingSource = profile.safeContinueWatchingSource
continueWatchingSource = profile.safeContinueWatchingSource,
upcomingRowEnabled = profile.safeUpcomingRowEnabled
),
appearanceDetail = SettingsAppearanceDetailUiModel(
trailerOnDetailHeroEnabled = profile.safeTrailerOnDetailHeroEnabled,
@ -316,7 +317,8 @@ class AndroidSettingsDataSource(
continueWatchingLayout = if (value.continueWatchingHorizontal) "horizontal" else "vertical",
continueWatchingEnabled = value.continueWatchingEnabled,
continueWatchingHideTitles = value.continueWatchingHideTitles,
continueWatchingSource = value.continueWatchingSource
continueWatchingSource = value.continueWatchingSource,
upcomingRowEnabled = value.upcomingRowEnabled
)
}

View file

@ -28,6 +28,7 @@ internal class HomeCatalogFeedCoordinator(
private val userAddons: () -> List<AddonDescriptor>,
private val setUserAddons: (List<AddonDescriptor>) -> Unit,
private val continueWatchingItems: (String) -> List<Meta>,
private val isUpcoming: (Meta) -> Boolean,
private val normalizeCatalogItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
private val setCategories: (List<HomeCategory>) -> Unit,
private val currentCategories: () -> List<HomeCategory>
@ -38,7 +39,10 @@ internal class HomeCatalogFeedCoordinator(
categories.addAll(buildUserCollectionHomeCategories(profile, showAboveContinueWatching = true))
val continueWatching = if (profile?.safeContinueWatchingEnabled != false) continueWatchingItems(lang) else emptyList()
val allContinueWatching = if (profile?.safeContinueWatchingEnabled != false) continueWatchingItems(lang) else emptyList()
val upcomingEnabled = profile?.safeUpcomingRowEnabled == true
val upcoming = if (upcomingEnabled) allContinueWatching.filter(isUpcoming) else emptyList()
val continueWatching = if (upcomingEnabled) allContinueWatching.filterNot(isUpcoming) else allContinueWatching
if (continueWatching.isNotEmpty()) {
categories.add(
HomeCategory(
@ -50,6 +54,17 @@ internal class HomeCatalogFeedCoordinator(
)
)
}
if (upcoming.isNotEmpty()) {
categories.add(
HomeCategory(
AppStrings.t(lang, "settings.upcoming_row"),
upcoming,
"upcoming",
"upcoming",
canLoadMore = false
)
)
}
categories.addAll(buildUserCollectionHomeCategories(profile, showAboveContinueWatching = false))
val initialFeeds = getMetadataFeeds(profile)
.let { orderedMetadataFeeds(it, profile?.homeFeedOrder) }

View file

@ -32,6 +32,37 @@ internal class HomeContinueWatchingCoordinator(
private val getSeasonEpisodes: suspend (String, Int, String) -> List<Video>
) {
private val artworkCache = LruCache<String, Pair<String?, String?>>(80)
private val upcomingCache = mutableMapOf<String, Boolean>()
fun isUpcoming(meta: Meta): Boolean = upcomingCache[upcomingCacheKey(meta)] == true
fun classifyUpcoming(items: List<Meta>) {
if (activeProfile()?.safeUpcomingRowEnabled != true) return
val lang = activeProfile()?.safeLanguage ?: "en"
val candidates = items.filter { meta ->
val isSeries = meta.type == "series" || meta.type == "tv" || meta.type == "anime"
val isUpNext = isSeries && !meta.lastVideoId.isNullOrBlank() &&
(meta.timeOffset ?: 0L) <= 0L && (meta.duration ?: 0L) <= 0L
isUpNext && upcomingCacheKey(meta) !in upcomingCache
}
if (candidates.isEmpty()) return
scope.launch(Dispatchers.IO) {
var changed = false
candidates.forEach { meta ->
val locator = meta.lastVideoId?.let(::parseEpisodeLocator) ?: return@forEach
val video = runCatching {
getSeasonEpisodes(meta.id, locator.first, lang).firstOrNull { it.number == locator.second }
}.getOrNull() ?: return@forEach
val released = FluxaCoreNative.isEpisodeReleased(video, System.currentTimeMillis())
upcomingCache[upcomingCacheKey(meta)] = !released
changed = true
}
if (changed) refreshDynamicRows()
}
}
private fun upcomingCacheKey(meta: Meta): String = "${meta.id}:${meta.lastVideoId}"
fun buildItems(lang: String, playbackController: HomePlaybackController): List<Meta> {
val source = activeProfile()?.safeContinueWatchingSource ?: "fluxa"
@ -46,12 +77,14 @@ internal class HomeContinueWatchingCoordinator(
}
val filteredProviderItems = providerItems.filterNot(playbackController::isForgotten)
val ranked = FluxaCoreNative.filterHomeContinueWatching(filteredProviderItems, watchedState())
classifyUpcoming(ranked)
return ranked.map { assignHomeBadge(it, lang) }
}
val sourceItems = externalItems() + localItems()
val merged = ContinueWatchingListMerger.mergeDuplicates(sourceItems)
.filterNot(playbackController::isForgotten)
val filtered = FluxaCoreNative.filterHomeContinueWatching(merged, watchedState())
classifyUpcoming(filtered)
return filtered.map { assignHomeBadge(it, lang) }
}

View file

@ -16,6 +16,7 @@ internal class HomeDynamicRowsCoordinator(
private val activeProfile: () -> UserProfile?,
private val buildUserCollectionHomeCategories: (UserProfile?, Boolean?) -> List<HomeCategory>,
private val buildContinueWatchingItems: (String) -> List<Meta>,
private val isUpcoming: (Meta) -> Boolean,
private val optimizeHomeCategories: (List<HomeCategory>, String) -> List<HomeCategory>
) {
private var refreshJob: Job? = null
@ -32,6 +33,7 @@ internal class HomeDynamicRowsCoordinator(
it.id == "watchlist" ||
it.id == "library" ||
it.id == "continue_watching" ||
it.id == "upcoming" ||
it.type == "collection" ||
it.type == "collection_folder"
}
@ -45,7 +47,7 @@ internal class HomeDynamicRowsCoordinator(
?.mapIndexed { index, meta -> ContinueWatchingListMerger.identityKey(meta) to index }
?.toMap()
.orEmpty()
val continueWatching = if (profile?.safeContinueWatchingEnabled != false) {
val allContinueWatching = if (profile?.safeContinueWatchingEnabled != false) {
buildContinueWatchingItems(lang)
.mapIndexed { index, meta -> index to meta }
.sortedWith(
@ -57,6 +59,9 @@ internal class HomeDynamicRowsCoordinator(
} else {
emptyList()
}
val upcomingEnabled = profile?.safeUpcomingRowEnabled == true
val upcoming = if (upcomingEnabled) allContinueWatching.filter(isUpcoming) else emptyList()
val continueWatching = if (upcomingEnabled) allContinueWatching.filterNot(isUpcoming) else allContinueWatching
var insertIndex = 0
staticCategories.addAll(insertIndex, aboveContinueWatching)
insertIndex += aboveContinueWatching.size
@ -73,6 +78,19 @@ internal class HomeDynamicRowsCoordinator(
)
insertIndex += 1
}
if (upcoming.isNotEmpty()) {
staticCategories.add(
insertIndex,
HomeCategory(
name = AppStrings.t(lang, "settings.upcoming_row"),
items = upcoming,
id = "upcoming",
type = "upcoming",
canLoadMore = false
)
)
insertIndex += 1
}
staticCategories.addAll(insertIndex, belowContinueWatching)
setCategories(optimizeHomeCategories(staticCategories, lang))

View file

@ -373,6 +373,7 @@ class HomeViewModel @Inject constructor(
userAddons = { _userAddons.value },
setUserAddons = ::setUserAddonsState,
continueWatchingItems = ::buildContinueWatchingItems,
isUpcoming = continueWatchingCoordinator::isUpcoming,
normalizeCatalogItems = ::normalizeCatalogItems,
setCategories = ::setCategoriesState,
currentCategories = categoryState::currentCategories
@ -399,6 +400,7 @@ class HomeViewModel @Inject constructor(
activeProfile = { currentActiveProfile },
buildUserCollectionHomeCategories = ::buildUserCollectionHomeCategories,
buildContinueWatchingItems = ::buildContinueWatchingItems,
isUpcoming = continueWatchingCoordinator::isUpcoming,
optimizeHomeCategories = ::optimizeHomeCategories
)
}

View file

@ -129,6 +129,7 @@ data class NativeProfileSafePrefs(
val continueWatchingLayout: String = "horizontal",
val continueWatchingArtwork: String = "episode",
val continueWatchingEnabled: Boolean = true,
val upcomingRowEnabled: Boolean = false,
val resolvedContinueWatchingLayout: String = "horizontal",
val subtitleShadow: Boolean = false,
val autoEnableSubtitles: Boolean = true,

View file

@ -238,6 +238,8 @@
"settings.landscape_mode": "Landscape Mode",
"auto.continue_watching_layout": "Continue Watching Layout",
"settings.continue_watching_horizontal": "Continue Watching Horizontal",
"settings.upcoming_row": "Upcoming",
"settings.upcoming_row_desc": "Move shows waiting on an unreleased next episode or season out of Continue Watching into their own Upcoming row.",
"settings.continue_watching_hide_titles": "Hide Labels",
"settings.continue_watching_source": "Continue Watching Source",
"settings.continue_watching_source_fluxa": "Fluxa",

View file

@ -238,6 +238,8 @@
"settings.landscape_mode": "Yatay Mod",
"auto.continue_watching_layout": "İzlemeye Devam Et Düzeni",
"settings.continue_watching_horizontal": "İzlemeye Devam Et Yatay",
"settings.upcoming_row": "Yakında",
"settings.upcoming_row_desc": "Sonraki bölümü veya sezonu henüz yayınlanmamış dizileri İzlemeye Devam Et satırından ayırıp kendi Yakında satırında göster.",
"settings.continue_watching_hide_titles": "Etiketleri Gizle",
"settings.continue_watching_source": "İzlemeye Devam Et Kaynağı",
"settings.continue_watching_source_fluxa": "Fluxa",

View file

@ -1830,6 +1830,14 @@ object FluxaCoreNative {
return value.takeUnless { it.isJsonNull }?.let { gson.fromJson<List<String>>(it, stringListType) } ?: emptyList()
}
fun isEpisodeReleased(video: Video, nowMs: Long): Boolean {
val args = JsonObject().apply {
addProperty("videoJson", gson.toJson(video))
addProperty("nowMs", nowMs)
}
return FluxaCoreUniFfi.coreInvokeValue("isEpisodeReleased", args.toString()).asBoolean
}
fun offlineDownloadPlan(
meta: Meta,
video: Video?,

View file

@ -47,6 +47,7 @@ val UserProfile.safeCardLayout: String get() = safePrefs.cardLayout
val UserProfile.safeContinueWatchingLayout: String get() = safePrefs.continueWatchingLayout
val UserProfile.safeContinueWatchingArtwork: String get() = safePrefs.continueWatchingArtwork
val UserProfile.safeContinueWatchingEnabled: Boolean get() = safePrefs.continueWatchingEnabled
val UserProfile.safeUpcomingRowEnabled: Boolean get() = safePrefs.upcomingRowEnabled
val UserProfile.resolvedContinueWatchingLayout: String get() = safePrefs.resolvedContinueWatchingLayout
val UserProfile.safeSubtitleShadow: Boolean get() = safePrefs.subtitleShadow
val UserProfile.safeAutoEnableSubtitles: Boolean get() = safePrefs.autoEnableSubtitles

View file

@ -75,6 +75,7 @@ data class UserProfile(
val continueWatchingLayout: String? = "horizontal",
val continueWatchingArtwork: String? = "episode",
val continueWatchingEnabled: Boolean? = true,
val upcomingRowEnabled: Boolean? = false,
val continueWatchingHideTitles: Boolean? = false,
val heroFollowsFocusedItem: Boolean? = false,
val blurUnwatchedEpisodes: Boolean? = false,

View file

@ -3,8 +3,12 @@ package com.fluxa.app.ui.catalog
import com.fluxa.app.data.remote.Meta
const val CONTINUE_WATCHING_CATEGORY_ID = "continue_watching"
const val UPCOMING_CATEGORY_ID = "upcoming"
fun HomeCategory.isContinueWatchingCategory(): Boolean = id == CONTINUE_WATCHING_CATEGORY_ID
fun HomeCategory.isUpcomingCategory(): Boolean = id == UPCOMING_CATEGORY_ID
fun HomeCategory.isContinueWatchingOrUpcomingCategory(): Boolean =
isContinueWatchingCategory() || isUpcomingCategory()
fun Meta.matchesFilter(filter: String): Boolean = when (filter) {
"movie" -> type == "movie"
@ -15,7 +19,7 @@ fun Meta.matchesFilter(filter: String): Boolean = when (filter) {
fun orderHomeCategories(categories: List<HomeCategory>, filter: String = "all"): List<HomeCategory> {
return categories.mapNotNull { category ->
val items = when {
category.isContinueWatchingCategory() || category.id == "library" -> {
category.isContinueWatchingOrUpcomingCategory() || category.id == "library" -> {
if (filter == "all") category.items else category.items.filter { it.matchesFilter(filter) }.ifEmpty { category.items }
}
filter == "all" -> category.items

View file

@ -3,7 +3,7 @@ package com.fluxa.app.ui.catalog
import com.fluxa.app.common.AppStrings
fun homeCategoryTitleParts(category: HomeCategory, language: String?): Pair<String, String?> {
if (category.isContinueWatchingCategory() || category.id == "library" || category.id == "watchlist" || category.id.startsWith("cs3_")) {
if (category.isContinueWatchingOrUpcomingCategory() || category.id == "library" || category.id == "watchlist" || category.id.startsWith("cs3_")) {
return category.name to null
}

View file

@ -79,7 +79,8 @@ data class SettingsAppearanceHomeUiModel(
val continueWatchingHorizontal: Boolean = true,
val continueWatchingEnabled: Boolean = true,
val continueWatchingHideTitles: Boolean = false,
val continueWatchingSource: String = "fluxa"
val continueWatchingSource: String = "fluxa",
val upcomingRowEnabled: Boolean = false
)
data class SettingsAppearanceDetailUiModel(

View file

@ -759,6 +759,13 @@ private fun SettingsAppearanceHomeContinueWatchingContent(model: SettingsAppeara
SettingsToggleRow(AppStrings.t(lang, "auto.continue_watching"), value = model.continueWatchingEnabled) {
onAction(SettingsAction.AppearanceHomeChanged(model.copy(continueWatchingEnabled = it)))
}
SettingsToggleRow(
AppStrings.t(lang, "settings.upcoming_row"),
description = AppStrings.t(lang, "settings.upcoming_row_desc"),
value = model.upcomingRowEnabled
) {
onAction(SettingsAction.AppearanceHomeChanged(model.copy(upcomingRowEnabled = it)))
}
SettingsToggleRow(AppStrings.t(lang, "settings.continue_watching_horizontal"), value = model.continueWatchingHorizontal) {
onAction(SettingsAction.AppearanceHomeChanged(model.copy(continueWatchingHorizontal = it)))
}