mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-05 16:29:07 +00:00
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:
parent
6f6378a915
commit
f0cf10d757
18 changed files with 108 additions and 10 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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?,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue