Revert "perf: reduce compose recomposition hotspots"

This reverts commit 62d4297063.
This commit is contained in:
tapframe 2026-07-09 23:20:23 +05:30
parent 4e17faa5b8
commit d085bf8f7d
18 changed files with 824 additions and 984 deletions

1
.gitignore vendored
View file

@ -28,7 +28,6 @@ asset
scripts/scrape_android_compose_animation_docs.py
tools
AGENTS.md
PERFORMANCE_FINDINGS.md
# Local MPVKit iOS build environment (sparse APFS image, see MPVKit docs)
.mpvkit-build.sparseimage

221
PERFORMANCE_FINDINGS.md Normal file
View file

@ -0,0 +1,221 @@
# Nuvio Performance Findings — iOS Frame Drops & Navigation Lag
**Date:** 2026-07-09 · **Scope:** HomeScreen, MetaDetailsScreen, StreamsScreen, navigation transitions, shared components.
**Constraint:** every fix below is behavior-preserving — no UI, functional, or visual changes. They only change *when* and *how often* Compose recomposes/relayouts/redraws.
---
## 0. Framework baseline (context, not a code change)
- We are on **CMP 1.11.1**, which already contains the two big JetBrains iOS fixes: the Metal "blocked waiting for next drawable" stall (fixed in `1.11.0-alpha04`, [CMP-9465](https://youtrack.jetbrains.com/issue/CMP-9465)) and broken fling gestures in LazyColumn ([CMP-9297](https://youtrack.jetbrains.com/issue/CMP-9297), fixed in `1.11.0-beta01`).
- JetBrains has acknowledged a *remaining* fluidity gap vs native on 120Hz devices (CMP-9465 comments) with no scheduled fix. `Info.plist` already enables `CADisableMinimumFrameDurationOnPhone`, so we render at 120Hz — an 8.3ms frame budget. **Everything below is about staying inside that budget.**
- **Always profile in Release.** Kotlin/Native debug binaries are drastically slower ([CMP-8912](https://youtrack.jetbrains.com/issue/CMP-8912) was closed for exactly this). A debug build via Xcode/`run-mobile` is not representative.
- Optional experiment: `1.12.0-beta01` exists and continues iOS renderer work — worth a test build against the worst screen.
---
## P0 — Fixes with the largest, most visible wins
### P0-1 · MetaDetailsScreen: raw scroll-offset read in composition recomposes the whole screen every scrolled pixel
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt:732-745`
```kotlin
val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat()
} else { ... }
val heroScrollOffset = detailScrollOffsetPx.toInt()
val headerTarget = if (heroHeightPx > 0 && (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx)) 1f else 0f
```
`listState.firstVisibleItemScrollOffset` is read **directly in composition**, in the scope that contains the entire `BoxWithConstraints``LazyColumn` → all detail sections. Every scrolled pixel invalidates that scope: the whole LazyColumn item DSL re-executes (including `configuredMetaSectionItems` and its `settings.items.filter {…}` / `settings.copy(...)` allocations at `MetaDetailsScreen.kt:1389,1417`), and `DetailHero` receives a new `scrollOffset: Int` param, so the hero item (with its `AsyncImage`, gradients, trailer chrome) recomposes per pixel too. This is the single biggest cause of MetaScreen scroll jank.
**Fix (standard "defer state reads" pattern, zero visual change):**
1. `DetailHero`'s `scrollOffset: Int` param → `scrollOffsetProvider: () -> Float`. In `DetailHero.kt:116` and `:146` the value is only used inside `graphicsLayer { translationY = scrollOffset * 0.5f }` — call the provider *inside* the lambda so the parallax happens purely at draw time with **zero recomposition**.
2. The gradient overlay at `MetaDetailsScreen.kt:978-980` already uses `graphicsLayer {}` but captures `detailScrollOffsetPx` as a composition value — read `listState.firstVisibleItemScrollOffset` inside the `graphicsLayer` lambda instead.
3. `headerTarget`, and the scroll-position part of `heroTrailerPlayWhenReady` (`:752-754`), are threshold booleans — wrap in `remember { derivedStateOf { … } }` so they only invalidate when crossing the threshold, not per pixel.
4. After 13, nothing in composition reads the raw offset anymore.
**Risk:** none — identical rendered output, reads just move to the draw phase.
---
### P0-2 · HomeScreen hero: parallax scroll offset computed in composition recomposes the entire hero every scrolled pixel
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt:116-126`
```kotlin
val scrollOffsetPx by remember(listState, heroHeightPx) { derivedStateOf { … } }
val heroScrollScale = heroBackgroundScrollScale(scrollOffsetPx) // ← composition read
val heroScrollTranslationY = heroBackgroundScrollTranslationY(scrollOffsetPx)
```
The `derivedStateOf` doesn't help here because `scrollOffsetPx` changes continuously and is read in composition at the `BoxWithConstraints` scope. Every scrolled pixel while the hero is visible recomposes the whole hero: all visible `AsyncImage` layers, two gradient boxes, the content column, and the pager dots. Since the hero is on screen at the exact moment users start scrolling Home, this is the "home scroll feels heavy" hot spot.
**Fix:** the computed values are only consumed inside `graphicsLayer {}` blocks (`:177-183`). Move the calls inside: `graphicsLayer { translationY = heroBackgroundScrollTranslationY(scrollOffsetPx); val s = HERO_BACKGROUND_SCALE * heroBackgroundScrollScale(scrollOffsetPx); … }`. State read inside `graphicsLayer` lambda = draw-phase only.
Also in the same file: `heroPageOffset(pagerState, …)` (`:134`) and `heroPageVisibility(pagerState, index)` (`:278`) read pager offset fractions in composition, so every frame of a hero page animation recomposes the full hero, and the dots relayout via `.width(8.dp + 24.dp * activeFraction)` (`:291`). The image/content alpha+translation consumers are already `graphicsLayer` lambdas — pass the layer's page/offset lookup into the lambda the same way. The dot **width** is a real layout property; leave it (tiny), or accept per-frame layout of a 8×8dp box — but stop it from invalidating the whole hero by reading `activeFraction` only inside the dot's own draw/layout modifier scope.
**Risk:** none for the graphicsLayer moves; identical output.
---
### P0-3 · App.kt: back-stack observation recomposes the whole Home tab at the exact frame every push/pop transition starts
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt:673, 1511, 1573, 1578`
`currentBackStackEntry` is observed via `currentBackStackEntryAsState()` (`:673`) and read inside `composable<TabsRoute>` (`:1511`):
```kotlin
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
rootActionsEnabled = tabsRouteActive, // :1573
animateHomeCollectionGifs = tabsRouteActive, // :1578
```
When you tap a poster, `currentBackStackEntry` changes on the first frame of the slide animation → `tabsRouteActive` flips → **`AppTabHost` and the entire HomeScreen tree recompose exactly when the push animation starts competing for the same frame budget**. The same happens again on pop. This is a direct cause of "navigation to meta screen / back is laggy."
**Fix options (either is behavior-preserving):**
- Derive the flag so it only invalidates on actual change and reaches only the consumers that need it: hoist `val tabsRouteActive by remember { derivedStateOf { … } }` (it already only changes on real route change — the win comes from narrowing *where it's read*). Pass it via a `CompositionLocal` or a `State<Boolean>` read *inside* the few leaf composables that use it (settings root actions; the GIF animation gate), instead of as two `AppTabHost` parameters that invalidate the whole host.
- At minimum, pass `State<Boolean>`/lambda providers (`rootActionsEnabled: () -> Boolean`) down so the flip doesn't change `AppTabHost`'s parameters.
Also `NativeProfileSwitcherPopup`'s `tabsRouteActive` read (`:1677`) can use the same derived state.
**Risk:** low — same values, delivered through a narrower channel. Verify GIF pause-on-navigate still works (it will: the leaf reads the same flag).
---
### P0-4 · StreamsScreen: index-based lazy keys make every progressive result batch rebuild the whole list
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt:958-967, 1004-1016`
`streamCardRenderKey` embeds `sourceIndex` and `itemIndex`. While providers stream results in (the screen's hottest phase), each new batch re-groups and re-sorts (`:944-947`), shifting indices — so **most existing items get brand-new keys**, are treated as removed+inserted, fully recomposed, and their `AsyncImage`s (addon logos) restart. Combined with global `crossfade(true)`, arrival of each batch repaints the visible list.
**Fix:** make keys content-derived only (drop `sourceIndex`/`itemIndex`; keep the existing duplicate-safe suffix approach used elsewhere — see `withDuplicateSafeLazyKeys` in `ShelfComponents.kt:90`) so an unchanged stream keeps its identity when neighbors arrive. Additionally hoist `group.streams.groupBy{…}` + `sortedBy` (`:944-947`) into a `remember(group.streams)` at the composable layer or precompute in `StreamsRepository` — the LazyColumn DSL re-executes on every uiState emission and currently re-allocates these every time.
**Risk:** low — same ordering, same visuals. Watch for key collisions (two identical URLs in one source) — the duplicate-safe wrapper handles that.
---
## P1 — Significant wins, slightly more involved
### P1-1 · HomeScreen: 14 top-level StateFlow subscriptions + heavy derived chains run in one giant recomposition scope
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt:115-136, 176-283`
Any emission from any of ~14 flows (watch progress, watched, cloud library, network status, Trakt, collections, …) recomposes the whole `HomeScreen` function scope, re-evaluating every `remember(...)` key comparison and, when keys changed, chains like `filter`/`groupBy`/`associate`/`distinctBy` over all watch-progress entries (`:176-283`) — **on the main thread, during composition**. `WatchProgressRepository` can emit while you're scrolling (e.g. sync), causing hitches unrelated to scrolling itself.
**Fix (incremental, no behavior change):**
1. Move the pure derivations (`effectiveWatchProgressEntries`, `allNextUpSeedCandidates`, `nextUpSuppressedSeriesIds`, `completedSeriesCandidates`, `visibleSeriesPosterTargets`, …) out of composition into a combined flow (`combine(...).map { … }.flowOn(Dispatchers.Default)`) exposed by a small presenter/repository object, collected as **one** state. Composition then just reads precomputed lists.
2. Where a flow feeds only one subtree (e.g. `networkStatusUiState` → offline card), collect it *inside* that subtree so its emissions don't touch the rest of Home.
**Risk:** medium-low — pure refactor of pure functions; verify with existing tests (several of these helpers are `internal` and already unit-tested).
### P1-2 · MetaDetailsScreen: `headerProgress` animation read in composition at top scope
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt:755-762, 994`
`headerProgress by animateFloatAsState(...)` is read at `:994` (`if (headerProgress <= 0.05f)`) in the top content scope — during the 100150ms header fade **every animation frame recomposes the whole detail scope** (which, pre-P0-1, is also recomposing per scroll pixel — they compound).
**Fix:** the `<= 0.05f` gate is a boolean — `remember { derivedStateOf { headerProgress <= 0.05f } }` (or drive the back button's alpha via `graphicsLayer` and keep it always composed — it's a small node). `DetailFloatingHeader` already consumes progress via `graphicsLayer` internally; pass the `State<Float>`/provider instead of the raw float so the header animates without invalidating the parent.
**Risk:** none.
### P1-3 · Poster cards: per-card repository subscription + `ensureLoaded()` on every recomposition
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt:8-12`, used by `NuvioPosterCard` (`ShelfComponents.kt:126`) and per-row (`HomeCatalogSection.kt`)
```kotlin
internal fun rememberPosterCardStyleUiState(): PosterCardStyleUiState {
PosterCardStyleRepository.ensureLoaded() // runs on EVERY recomposition
val uiState by PosterCardStyleRepository.uiState.collectAsState()
return uiState
}
```
Every poster card on screen (dozens on Home) creates its own flow collection, and `ensureLoaded()` is called on every recomposition of every card (it's not inside `remember`).
**Fix:** wrap the side effect: `remember { PosterCardStyleRepository.ensureLoaded(); PosterCardStyleRepository.uiState }.collectAsState()`. Better: collect once near the root and provide via `CompositionLocal` (style changes are rare; cards read the local). Identical visuals.
**Risk:** none.
### P1-4 · LazyRows/LazyColumns: no `contentType` anywhere
**Files:** `ShelfComponents.kt:84-107` (`NuvioShelfSection`), `NuvioScreen` consumers, `StreamsScreen.kt` sections, `MetaDetailsScreen.kt` section items.
Compose's item-reuse pool is keyed by `contentType`; without it, every new-item composition during scroll starts cold. Poster rows are perfectly homogeneous — ideal reuse candidates. Add `contentType = "poster"` to shelf items, `"stream"`/`"header"` in `streamSection`, and a type per section kind in `configuredMetaSectionItems`. On iOS this measurably reduces time-to-first-frame for newly revealed items during fast scroll (prefetch + pausable composition in CMP 1.11 benefit from it too).
**Risk:** none — `contentType` is purely a reuse hint.
### P1-5 · Tab switching recomposes an entire screen from scratch
**File:** `composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt:3089-3153`
`SaveableStateProvider(selectedTab.name)` + `when (selectedTab)` disposes the old tab and builds the new one from zero — switching Home→Search→Home rebuilds HomeScreen completely (all rows, all images decode from memory cache, all flow subscriptions restart). Scroll state survives; composition doesn't.
**Fix (choose one):**
- Wrap each tab's content in `remember { movableContentOf { … } }` so composition is *moved*, not destroyed (needs care with `SaveableStateProvider`); or
- Keep all four tabs composed inside a `Box`, toggling with `Modifier.alpha/zIndex` + `graphicsLayer` and gating each hidden tab's expensive effects — **not recommended** as first step (changes lifecycle semantics: hidden tabs keep collecting).
The `movableContentOf` route preserves current semantics (one active tab) while eliminating the rebuild. Given Home is by far the heaviest tab, even doing this for Home alone helps.
**Risk:** medium — needs testing around saveable state and `LaunchedEffect` re-runs. Do after P0s.
### P1-6 · Pop-back to Home recomposes the whole Home tree during the pop animation
Not a bug — it's how NavHost works (the destination left the composition after push completed; on pop it re-enters on frame 1 of the animation). The fix is indirect: **every P0/P1 item above shrinks the cost of that first frame** (especially P0-2, P1-1, P1-3, P1-4). After those land, re-measure; if the pop hitch is still visible, the remaining spike is image re-decode — Coil memory cache keeps decoded bitmaps, so verify hero/backdrop images aren't evicted (see P2-3).
---
## P2 — Cheap insurance / smaller or conditional wins
### P2-1 · Full-screen `Modifier.blur` on iOS (Skia gaussian blur is expensive)
- `StreamsScreen.kt:482` — full-screen `blur(22.dp)` backdrop behind the whole streams UI, alive while results stream in and the list repaints.
- `MetaDetailsScreen.kt:824-838` — Cinematic mode: full-screen `blur(30.dp)` backdrop (already gated behind `deferredMetaWorkAllowed`, good).
- `HomeContinueWatchingSection.kt:714, 1015` — per-thumbnail `blur(18.dp)` when "blur next up" is on.
These backdrops are *static once loaded* — the pixels never change — but the blur filter is retained on the layer. **Regression-free mitigation:** keep the visual, pay the cost once: render the blurred backdrop into a bitmap (e.g. Coil custom `Transformation` doing the blur at decode time on a background thread, or `rememberGraphicsLayer()` + one-time `toImageBitmap()`), then draw the plain bitmap. Identical look, zero per-frame filter cost. The overlay `Box` with 0.820.92 alpha stays as-is.
### P2-2 · MetaDetailsScreen: self-retriggering reload effect
**File:** `MetaDetailsScreen.kt:268-282` — the `LaunchedEffect` keyed on `uiState.isLoading` calls `MetaDetailsRepository.load(type, id)` whenever `displayedMeta != null && !isLoading`, i.e. it re-runs `load()` immediately after every successful load. `load()` early-returns via cache (`MetaDetailsRepository.kt:57-71`), so it's not an infinite loop, but it *does* run fingerprint building + cache checks on the main thread right when the screen settles, and re-runs on every settings-related emission. Key the effect on the actual settings inputs only (drop `uiState.isLoading`/`displayedMeta` from keys, guard inside) — same behavior, no redundant churn during entry.
### P2-3 · Coil: give iOS an explicit memory-cache budget
**File:** `PlatformImageLoader.ios.kt` (currently a no-op), `App.kt:388-398`. Defaults are fine-ish, but on image-heavy screens the default 25% budget with big hero/backdrop bitmaps can evict poster thumbnails, making pop-back re-decode them (see P1-6). Set an explicit `memoryCache { MemoryCache.Builder().maxSizePercent(context, 0.25).build() }` and consider `.precision(Precision.INEXACT)` defaults; measure before/after. No visual change.
### P2-4 · `NuvioShelfSection`: per-recomposition key-wrapping allocation
**File:** `ShelfComponents.kt:90``entries.withDuplicateSafeLazyKeys(key)` allocates a wrapped list every time the row recomposes. `remember(entries) { … }` it. Micro, but it's in every row on Home.
### P2-5 · Home rows: `watchedKeys` set identity invalidates all rows on any watched change
**File:** `HomeScreen.kt:878-879``HomeCatalogSection.kt`. Marking one item watched rebuilds the `Set` → every visible row's parameters change → all rows recompose (not just the affected poster). Acceptable frequency-wise; if it shows up in traces, derive `isWatched` per item via a stable lookup (e.g. provide the repository state via a local and compute inside the card) so only affected cards invalidate. Do only if profiling shows it.
### P2-6 · App.kt god-composable
3,338 lines with ~20 `collectAsStateWithLifecycle` at various scopes (`:666-746`, `:1037+`). Any of those emitting recomposes large swaths of the nav shell. Splitting the route graph bodies into top-level `@Composable` functions (each collecting only what it needs) creates recomposition firewalls and makes the compiler's skipping effective. Mechanical, no behavior change — do it opportunistically when touching App.kt.
---
## Verification plan (how to confirm each win)
1. Build **Release** to a physical device (ideally 120Hz).
2. Xcode Instruments → *Animation Hitches* + *Time Profiler*: record (a) Home scroll, (b) poster→Meta push, (c) back-swipe pop, (d) Streams while results load. Save as baseline.
3. Land P0-1..P0-4 (each is a small, independent diff) → re-record. Expect the Meta scroll and push/pop hitches to drop first.
4. Land P1 items → re-record, especially pop-back (P1-6).
5. Keep an eye on `Hitch time ratio` in Instruments; target <5ms/s on scroll.
## Suggested landing order
| Order | Item | Effort | Expected impact |
|---|---|---|---|
| 1 | P0-1 Meta scroll-read deferral | S | Large (Meta scroll + push) |
| 2 | P0-2 Home hero parallax deferral | S | Large (Home scroll) |
| 3 | P0-3 backstack flag narrowing | S | Large (push/pop start hitch) |
| 4 | P0-4 stream list keys + grouping hoist | S | Large (Streams loading phase) |
| 5 | P1-2, P1-3, P1-4 | S | Medium, broad |
| 6 | P1-1 Home derivation hoist | M | Medium-large |
| 7 | P2-1 blur pre-render, P2-3 cache budget | M | Medium (Streams/Meta entry, pop-back) |
| 8 | P1-5 movable tab content | M | Medium (tab switches) |

View file

@ -2,17 +2,14 @@ package com.nuvio.app.core.ui
import android.os.Build
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(
context: PlatformContext,
): ImageLoader.Builder =
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder =
components {
if (Build.VERSION.SDK_INT >= 28) {
add(AnimatedImageDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
}

View file

@ -42,13 +42,11 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
import androidx.compose.ui.draw.alpha
@ -67,7 +65,6 @@ import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.NavDestination.Companion.hasRoute
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
@ -384,63 +381,6 @@ private enum class AppGateScreen {
Main,
}
@Composable
private fun rememberTabsRouteActiveState(navController: NavController): State<Boolean> {
val routeActiveState = remember(navController) {
mutableStateOf(navController.currentDestination?.hasRoute<TabsRoute>() ?: true)
}
DisposableEffect(navController) {
fun update(destination: NavDestination?) {
routeActiveState.value = destination.isTabsRoute()
}
val destinationChangedListener = NavController.OnDestinationChangedListener { _, destination, _ ->
update(destination)
}
navController.currentDestination?.let(::update)
navController.addOnDestinationChangedListener(destinationChangedListener)
onDispose {
navController.removeOnDestinationChangedListener(destinationChangedListener)
}
}
return routeActiveState
}
private fun NavDestination?.isTabsRoute(): Boolean =
this?.hasRoute<TabsRoute>() == true
@Composable
private fun DismissResumePromptOnPlaybackDestination(
navController: NavController,
onPlaybackDestination: () -> Unit,
) {
val currentOnPlaybackDestination by rememberUpdatedState(onPlaybackDestination)
DisposableEffect(navController) {
fun maybeDismiss(destination: NavDestination?) {
if (
destination?.hasRoute<StreamRoute>() == true ||
destination?.hasRoute<PlayerRoute>() == true
) {
currentOnPlaybackDestination()
}
}
val destinationChangedListener = NavController.OnDestinationChangedListener { _, destination, _ ->
maybeDismiss(destination)
}
maybeDismiss(navController.currentDestination)
navController.addOnDestinationChangedListener(destinationChangedListener)
onDispose {
navController.removeOnDestinationChangedListener(destinationChangedListener)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@Preview
@ -453,7 +393,7 @@ fun App() {
.components {
add(SvgDecoder.Factory())
}
.configurePlatformImageLoader(context)
.configurePlatformImageLoader()
.build()
}
val selectedTheme by remember {
@ -709,7 +649,6 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
val tabsRouteActiveState = rememberTabsRouteActiveState(navController)
val appUpdaterController = rememberAppUpdaterController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
@ -731,6 +670,7 @@ private fun MainAppContent(
val libraryScrollToTopRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
val settingsRootActionRequests = remember { MutableSharedFlow<Unit>(extraBufferCapacity = 1) }
var nativeProfileSwitcherVisible by remember { mutableStateOf(false) }
val currentBackStackEntry by navController.currentBackStackEntryAsState()
val liquidGlassNativeTabBarEnabled by remember {
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled
}.collectAsStateWithLifecycle()
@ -1112,8 +1052,12 @@ private fun MainAppContent(
}
}
DismissResumePromptOnPlaybackDestination(navController) {
resumePromptItem = null
LaunchedEffect(currentBackStackEntry?.destination) {
val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute<StreamRoute>() == true ||
currentBackStackEntry?.destination?.hasRoute<PlayerRoute>() == true
if (inPlaybackFlow) {
resumePromptItem = null
}
}
LaunchedEffect(navController) {
@ -1564,6 +1508,7 @@ private fun MainAppContent(
.calculateBottomPadding()
val nativeProfileTabAnchorBottomPadding =
nativeTabSafeBottomPadding + NuvioTokens.Space.s10
val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute<TabsRoute>() == true
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
nativeProfileSwitcherVisible = false
profileSwitchLoading = true
@ -1624,12 +1569,13 @@ private fun MainAppContent(
.fillMaxSize()
.padding(innerPadding),
selectedTab = selectedTab,
tabsRouteActiveState = tabsRouteActiveState,
searchFocusRequestCount = searchFocusRequestCount,
rootActionsEnabled = tabsRouteActive,
homeScrollToTopRequests = homeScrollToTopRequests,
searchScrollToTopRequests = searchScrollToTopRequests,
libraryScrollToTopRequests = libraryScrollToTopRequests,
settingsRootActionRequests = settingsRootActionRequests,
animateHomeCollectionGifs = tabsRouteActive,
onCatalogClick = onCatalogClick,
onPosterClick = { meta ->
navController.navigate(DetailRoute(type = meta.type, id = meta.id))
@ -1728,22 +1674,21 @@ private fun MainAppContent(
)
}
NativeProfileSwitcherPopupHost(
tabsRouteActiveState = tabsRouteActiveState,
isTabletLayout = isTabletLayout,
useNativeBottomTabs = useNativeBottomTabs,
visible = nativeProfileSwitcherVisible,
isSwitchingProfile = profileSwitchLoading,
onDismissRequest = { nativeProfileSwitcherVisible = false },
onProfileSelected = onProfileSelected,
onAddProfileRequested = {
nativeProfileSwitcherVisible = false
onSwitchProfile()
},
modifier = Modifier
.fillMaxSize()
.padding(bottom = nativeProfileTabAnchorBottomPadding),
)
if (!isTabletLayout && useNativeBottomTabs && tabsRouteActive) {
NativeProfileSwitcherPopup(
visible = nativeProfileSwitcherVisible,
isSwitchingProfile = profileSwitchLoading,
onDismissRequest = { nativeProfileSwitcherVisible = false },
onProfileSelected = onProfileSelected,
onAddProfileRequested = {
nativeProfileSwitcherVisible = false
onSwitchProfile()
},
modifier = Modifier
.fillMaxSize()
.padding(bottom = nativeProfileTabAnchorBottomPadding),
)
}
}
}
}
@ -3106,13 +3051,14 @@ private fun rememberGuardedPopBackStack(
@Composable
private fun AppTabHost(
selectedTab: AppScreenTab,
tabsRouteActiveState: State<Boolean>,
modifier: Modifier = Modifier,
searchFocusRequestCount: Int = 0,
rootActionsEnabled: Boolean = true,
homeScrollToTopRequests: Flow<Unit>,
searchScrollToTopRequests: Flow<Unit>,
libraryScrollToTopRequests: Flow<Unit>,
settingsRootActionRequests: Flow<Unit>,
animateHomeCollectionGifs: Boolean = true,
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
@ -3141,216 +3087,73 @@ private fun AppTabHost(
onInitialHomeContentRendered: () -> Unit = {},
) {
val tabStateHolder = rememberSaveableStateHolder()
val isHomeSelected = selectedTab == AppScreenTab.Home
Box(modifier = modifier.fillMaxSize()) {
tabStateHolder.SaveableStateProvider(AppScreenTab.Home.name) {
AppHomeTabContent(
tabsRouteActiveState = tabsRouteActiveState,
homeSelected = isHomeSelected,
homeScrollToTopRequests = homeScrollToTopRequests,
modifier = Modifier
.fillMaxSize()
.zIndex(if (isHomeSelected) 1f else 0f)
.alpha(if (isHomeSelected) 1f else 0f),
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onInitialHomeContentRendered = onInitialHomeContentRendered,
)
}
tabStateHolder.SaveableStateProvider(selectedTab.name) {
when (selectedTab) {
AppScreenTab.Home -> {
HomeScreen(
modifier = Modifier.fillMaxSize(),
animateCollectionGifs = animateHomeCollectionGifs,
scrollToTopRequests = homeScrollToTopRequests,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
if (!isHomeSelected) {
tabStateHolder.SaveableStateProvider(selectedTab.name) {
Box(
modifier = Modifier
.fillMaxSize()
.zIndex(1f),
) {
when (selectedTab) {
AppScreenTab.Home -> Unit
AppScreenTab.Search -> {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
scrollToTopRequests = searchScrollToTopRequests,
)
}
AppScreenTab.Search -> {
AppSearchTabContent(
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
searchScrollToTopRequests = searchScrollToTopRequests,
)
}
AppScreenTab.Library -> {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
scrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = onCloudFilePlay,
onConnectCloudClick = onConnectCloudClick,
)
}
AppScreenTab.Library -> {
AppLibraryTabContent(
libraryScrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onLibraryPosterClick,
onPosterLongClick = onLibraryPosterLongClick,
onSectionViewAllClick = onLibrarySectionViewAllClick,
onCloudFilePlay = onCloudFilePlay,
onConnectCloudClick = onConnectCloudClick,
)
}
AppScreenTab.Settings -> {
AppSettingsTabContent(
tabsRouteActiveState = tabsRouteActiveState,
rootActionRequests = settingsRootActionRequests,
requestedPageName = requestedSettingsPageName,
onRequestedPageConsumed = onRequestedSettingsPageConsumed,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onDownloadsClick = onDownloadsSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}
}
AppScreenTab.Settings -> {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = settingsRootActionRequests,
requestedPageName = requestedSettingsPageName,
onRequestedPageConsumed = onRequestedSettingsPageConsumed,
rootActionsEnabled = rootActionsEnabled,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenSettingsClick,
onMetaScreenClick = onMetaScreenSettingsClick,
onContinueWatchingClick = onContinueWatchingSettingsClick,
onDownloadsClick = onDownloadsSettingsClick,
onAddonsClick = onAddonsSettingsClick,
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}
}
}
}
}
@Composable
private fun AppHomeTabContent(
tabsRouteActiveState: State<Boolean>,
homeSelected: Boolean,
homeScrollToTopRequests: Flow<Unit>,
modifier: Modifier,
onCatalogClick: ((HomeCatalogSection) -> Unit)?,
onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?,
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)?,
onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)?,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
onInitialHomeContentRendered: () -> Unit,
) {
val animateCollectionGifsProvider = remember(tabsRouteActiveState, homeSelected) {
{ homeSelected && tabsRouteActiveState.value }
}
HomeScreen(
modifier = modifier,
animateCollectionGifsProvider = animateCollectionGifsProvider,
scrollToTopRequests = homeScrollToTopRequests,
onCatalogClick = onCatalogClick,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onContinueWatchingClick = onContinueWatchingClick,
onContinueWatchingLongPress = onContinueWatchingLongPress,
onFolderClick = onFolderClick,
onFirstCatalogRendered = onInitialHomeContentRendered,
)
}
@Composable
private fun AppSearchTabContent(
onPosterClick: ((MetaPreview) -> Unit)?,
onPosterLongClick: ((MetaPreview) -> Unit)?,
searchFocusRequestCount: Int,
searchScrollToTopRequests: Flow<Unit>,
) {
SearchScreen(
modifier = Modifier.fillMaxSize(),
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
searchFocusRequestCount = searchFocusRequestCount,
scrollToTopRequests = searchScrollToTopRequests,
)
}
@Composable
private fun AppLibraryTabContent(
libraryScrollToTopRequests: Flow<Unit>,
onPosterClick: ((LibraryItem) -> Unit)?,
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)?,
onConnectCloudClick: (() -> Unit)?,
) {
LibraryScreen(
modifier = Modifier.fillMaxSize(),
scrollToTopRequests = libraryScrollToTopRequests,
onPosterClick = onPosterClick,
onPosterLongClick = onPosterLongClick,
onSectionViewAllClick = onSectionViewAllClick,
onCloudFilePlay = onCloudFilePlay,
onConnectCloudClick = onConnectCloudClick,
)
}
@Composable
private fun AppSettingsTabContent(
tabsRouteActiveState: State<Boolean>,
rootActionRequests: Flow<Unit>,
requestedPageName: String?,
onRequestedPageConsumed: () -> Unit,
onSwitchProfile: (() -> Unit)?,
onHomescreenClick: () -> Unit,
onMetaScreenClick: () -> Unit,
onContinueWatchingClick: () -> Unit,
onDownloadsClick: () -> Unit,
onAddonsClick: () -> Unit,
onPluginsClick: () -> Unit,
onAccountClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
onLicensesAttributionsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)?,
onCollectionsClick: () -> Unit,
) {
SettingsScreen(
modifier = Modifier.fillMaxSize(),
rootActionRequests = rootActionRequests,
requestedPageName = requestedPageName,
onRequestedPageConsumed = onRequestedPageConsumed,
rootActionsEnabled = tabsRouteActiveState.value,
onSwitchProfile = onSwitchProfile,
onHomescreenClick = onHomescreenClick,
onMetaScreenClick = onMetaScreenClick,
onContinueWatchingClick = onContinueWatchingClick,
onDownloadsClick = onDownloadsClick,
onAddonsClick = onAddonsClick,
onPluginsClick = onPluginsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
onLicensesAttributionsClick = onLicensesAttributionsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
}
@Composable
private fun NativeProfileSwitcherPopupHost(
tabsRouteActiveState: State<Boolean>,
isTabletLayout: Boolean,
useNativeBottomTabs: Boolean,
visible: Boolean,
isSwitchingProfile: Boolean,
onDismissRequest: () -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
if (!isTabletLayout && useNativeBottomTabs && tabsRouteActiveState.value) {
NativeProfileSwitcherPopup(
visible = visible,
isSwitchingProfile = isSwitchingProfile,
onDismissRequest = onDismissRequest,
onProfileSelected = onProfileSelected,
onAddProfileRequested = onAddProfileRequested,
modifier = modifier,
)
}
}
@Composable
private fun TabletFloatingTopBar(
selectedTab: AppScreenTab,

View file

@ -1,8 +1,5 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
import coil3.PlatformContext
internal expect fun ImageLoader.Builder.configurePlatformImageLoader(
context: PlatformContext,
): ImageLoader.Builder
internal expect fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder

View file

@ -1,16 +1,12 @@
package com.nuvio.app.core.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.collectAsState
@Composable
internal fun rememberPosterCardStyleUiState(): PosterCardStyleUiState {
val uiStateFlow = remember {
PosterCardStyleRepository.ensureLoaded()
PosterCardStyleRepository.uiState
}
val uiState by uiStateFlow.collectAsState()
PosterCardStyleRepository.ensureLoaded()
val uiState by PosterCardStyleRepository.uiState.collectAsState()
return uiState
}
}

View file

@ -25,7 +25,6 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
@ -69,10 +68,6 @@ fun <T> NuvioShelfSection(
itemContent: @Composable (T) -> Unit,
) {
val tokens = MaterialTheme.nuvio
val duplicateSafeEntries = remember(entries, key) {
key?.let { entries.withDuplicateSafeLazyKeys(it) }
}
Column(
modifier = modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2),
@ -90,11 +85,10 @@ fun <T> NuvioShelfSection(
contentPadding = rowContentPadding,
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
) {
if (duplicateSafeEntries != null) {
if (key != null) {
items(
items = duplicateSafeEntries,
items = entries.withDuplicateSafeLazyKeys(key),
key = { entry -> entry.lazyKey },
contentType = { "poster" },
) { keyedEntry ->
if (animatePlacement) {
Box(modifier = Modifier.animateItem()) { itemContent(keyedEntry.value) }
@ -103,10 +97,7 @@ fun <T> NuvioShelfSection(
}
}
} else {
items(
items = entries,
contentType = { "poster" },
) { entry ->
items(entries) { entry ->
if (animatePlacement) {
Box(modifier = Modifier.animateItem()) { itemContent(entry) }
} else {

View file

@ -41,7 +41,6 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
@ -269,6 +268,8 @@ fun MetaDetailsScreen(
LaunchedEffect(
type,
id,
displayedMeta?.id,
uiState.isLoading,
traktSettingsUiState.moreLikeThisSource,
traktAuthUiState.mode,
tmdbSettingsUiState.enabled,
@ -726,43 +727,22 @@ fun MetaDetailsScreen(
.calculateTopPadding()
.toPx()
}
val heroHeightPxState = remember(meta.id) { mutableIntStateOf(0) }
val heroHeightPx = heroHeightPxState.intValue
val detailScrollOffsetProvider = remember(listState, heroHeightPxState) {
{
if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat()
} else {
heroHeightPxState.intValue.toFloat() + listState.firstVisibleItemScrollOffset
}
}
var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) }
val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset.toFloat()
} else {
heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset
}
val isScrolledPastHeroHeaderThreshold by remember(
listState,
heroHeightPxState,
safeAreaTopPx,
detailScrollOffsetProvider,
val heroScrollOffset = detailScrollOffsetPx.toInt()
val headerTarget = if (
heroHeightPx > 0 &&
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx)
) {
derivedStateOf {
val measuredHeroHeightPx = heroHeightPxState.intValue
val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
measuredHeroHeightPx > 0 &&
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetProvider() > thresholdPx)
}
1f
} else {
0f
}
val isHeroTrailerWithinPlayThreshold by remember(
listState,
heroHeightPxState,
safeAreaTopPx,
detailScrollOffsetProvider,
) {
derivedStateOf {
val measuredHeroHeightPx = heroHeightPxState.intValue
val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
measuredHeroHeightPx == 0 || detailScrollOffsetProvider() <= thresholdPx
}
}
val headerTarget = if (isScrolledPastHeroHeaderThreshold) 1f else 0f
val heroTrailerSourceUrl = heroTrailerPlaybackSource
?.videoUrl
?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails }
@ -771,8 +751,8 @@ fun MetaDetailsScreen(
?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() }
val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null &&
!isLeavingDetails &&
isHeroTrailerWithinPlayThreshold
val headerProgressState = animateFloatAsState(
(heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx)
val headerProgress by animateFloatAsState(
targetValue = headerTarget,
animationSpec = tween(
durationMillis = if (headerTarget > 0f) 150 else 100,
@ -780,15 +760,6 @@ fun MetaDetailsScreen(
),
label = "detail_floating_header_progress",
)
val headerProgressProvider = remember(headerProgressState) {
{ headerProgressState.value }
}
val showHeroBackButton by remember(headerProgressState) {
derivedStateOf { headerProgressState.value <= 0.05f }
}
val headerInteractive by remember(headerProgressState) {
derivedStateOf { headerProgressState.value > 0.05f }
}
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
val colorScheme = MaterialTheme.colorScheme
@ -879,16 +850,13 @@ fun MetaDetailsScreen(
.fillMaxSize()
.zIndex(1f),
) {
item(
key = "detail-hero",
contentType = "detail-hero",
) {
item(key = "detail-hero") {
DetailHero(
meta = meta,
isTablet = isTablet,
contentMaxWidth = contentMaxWidth,
scrollOffsetProvider = detailScrollOffsetProvider,
onHeightChanged = { heroHeightPxState.intValue = it },
scrollOffset = heroScrollOffset,
onHeightChanged = { heroHeightPx = it },
heroTrailerSourceUrl = heroTrailerSourceUrl,
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
heroTrailerReady = heroTrailerReady,
@ -994,10 +962,7 @@ fun MetaDetailsScreen(
animatedVisibilityScope = animatedVisibilityScope,
)
item(
key = "detail-bottom-spacer",
contentType = "detail-spacer",
) {
item(key = "detail-bottom-spacer") {
Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp)))
}
}
@ -1011,7 +976,7 @@ fun MetaDetailsScreen(
.fillMaxWidth()
.height(132.dp)
.graphicsLayer {
translationY = heroHeightPx.toFloat() - detailScrollOffsetProvider()
translationY = heroHeightPx.toFloat() - detailScrollOffsetPx
}
.background(
Brush.verticalGradient(
@ -1026,7 +991,7 @@ fun MetaDetailsScreen(
)
}
if (showHeroBackButton) {
if (headerProgress <= 0.05f) {
NuvioBackButton(
onClick = onBackFromDetails,
modifier = Modifier.padding(
@ -1041,8 +1006,7 @@ fun MetaDetailsScreen(
DetailFloatingHeader(
meta = meta,
isSaved = isSaved,
progressProvider = headerProgressProvider,
interactive = headerInteractive,
progress = headerProgress,
backgroundColor = dominantBackdropColor.takeIf { dominantColorEnabled },
onBack = onBackFromDetails,
onToggleSaved = toggleSaved,
@ -1444,15 +1408,7 @@ private fun LazyListScope.configuredMetaSectionItems(
sectionItems: List<MetaScreenSectionItem>,
forceTabLayout: Boolean = settings.tabLayout,
) {
val contentType = if (sectionItems.size == 1) {
"detail-section-${sectionItems.first().key.name}"
} else {
"detail-section-tab-group"
}
item(
key = key,
contentType = contentType,
) {
item(key = key) {
DetailSectionContainer(
horizontalPadding = contentHorizontalPadding,
contentMaxWidth = contentMaxWidth,

View file

@ -48,8 +48,7 @@ import org.jetbrains.compose.resources.stringResource
fun DetailFloatingHeader(
meta: MetaDetails,
isSaved: Boolean,
progressProvider: () -> Float,
interactive: Boolean,
progress: Float,
backgroundColor: Color? = null,
onBack: () -> Unit,
onToggleSaved: () -> Unit,
@ -57,6 +56,7 @@ fun DetailFloatingHeader(
) {
val safeAreaTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
val headerTopPadding = (safeAreaTop - 6.dp).coerceAtLeast(safeAreaTop * 0.8f)
val interactive = progress > 0.05f
val surfaceColor = backgroundColor ?: if (isIos) {
MaterialTheme.colorScheme.surface.copy(alpha = 1.0f)
} else {
@ -70,7 +70,6 @@ fun DetailFloatingHeader(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
val progress = progressProvider()
alpha = progress
translationY = lerp((-20).dp, 0.dp, progress).toPx()
shadowElevation = 4.dp.toPx()
@ -90,7 +89,7 @@ fun DetailFloatingHeader(
.fillMaxWidth()
.padding(top = headerTopPadding, start = 16.dp, end = 16.dp)
.height(56.dp)
.graphicsLayer { alpha = progressProvider() },
.graphicsLayer { alpha = progress },
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {

View file

@ -56,7 +56,7 @@ import org.jetbrains.compose.resources.stringResource
fun DetailHero(
meta: MetaDetails,
isTablet: Boolean = false,
scrollOffsetProvider: () -> Float = { 0f },
scrollOffset: Int = 0,
contentMaxWidth: Dp = 560.dp,
onHeightChanged: (Int) -> Unit = {},
heroTrailerSourceUrl: String? = null,
@ -113,7 +113,7 @@ fun DetailHero(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
translationY = scrollOffsetProvider() * 0.5f
translationY = scrollOffset * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},
@ -143,7 +143,7 @@ fun DetailHero(
.fillMaxSize()
.graphicsLayer {
alpha = trailerAlpha
translationY = scrollOffsetProvider() * 0.5f
translationY = scrollOffset * 0.5f
scaleX = 1.08f
scaleY = 1.08f
},

View file

@ -42,12 +42,10 @@ import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomP
import com.nuvio.app.features.trakt.TraktAuthRepository
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.TraktSettingsUiState
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.shouldUseTraktProgress
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.WatchedUiState
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.watchedItemKey
import com.nuvio.app.features.watchprogress.CachedInProgressItem
@ -55,7 +53,6 @@ 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
@ -66,7 +63,6 @@ import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueW
import com.nuvio.app.features.watchprogress.WatchProgressClock
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import com.nuvio.app.features.watchprogress.WatchProgressUiState
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback
import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle
import com.nuvio.app.features.watchprogress.continueWatchingEntries
@ -81,9 +77,7 @@ import com.nuvio.app.features.home.components.HomeCollectionRowSection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@ -100,7 +94,7 @@ import org.jetbrains.compose.resources.stringResource
@Composable
fun HomeScreen(
modifier: Modifier = Modifier,
animateCollectionGifsProvider: () -> Boolean = { true },
animateCollectionGifs: Boolean = true,
scrollToTopRequests: Flow<Unit> = emptyFlow(),
onCatalogClick: ((HomeCatalogSection) -> Unit)? = null,
onPosterClick: ((MetaPreview) -> Unit)? = null,
@ -116,8 +110,6 @@ fun HomeScreen(
ContinueWatchingPreferencesRepository.ensureLoaded()
WatchedRepository.ensureLoaded()
WatchProgressRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
}
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
@ -128,20 +120,20 @@ fun HomeScreen(
}.collectAsStateWithLifecycle()
val homeListState = rememberLazyListState()
val collections by CollectionRepository.collections.collectAsStateWithLifecycle()
val homeProgressDerivedState by remember {
TraktSettingsRepository.ensureLoaded()
TraktAuthRepository.ensureLoaded()
homeProgressDerivedStateFlow()
}.collectAsStateWithLifecycle(HomeProgressDerivedState())
val continueWatchingPreferences = homeProgressDerivedState.continueWatchingPreferences
val watchedUiState = homeProgressDerivedState.watchedUiState
val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()
val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle()
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
val watchProgressUiState = homeProgressDerivedState.watchProgressUiState
val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle()
val cloudLibraryUiState by CloudLibraryRepository.uiState.collectAsStateWithLifecycle()
val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle()
val visibleSeriesPosterTargets by remember {
homeVisibleSeriesPosterTargetsFlow()
}.collectAsStateWithLifecycle(emptyList())
val traktSettingsUiState by remember {
TraktSettingsRepository.ensureLoaded()
TraktSettingsRepository.uiState
}.collectAsStateWithLifecycle()
val isTraktAuthenticated by remember {
TraktAuthRepository.ensureLoaded()
TraktAuthRepository.isAuthenticated
}.collectAsStateWithLifecycle()
var observedOfflineState by remember { mutableStateOf(false) }
LaunchedEffect(scrollToTopRequests) {
@ -171,11 +163,91 @@ fun HomeScreen(
}
}
val isTraktProgressActive = homeProgressDerivedState.isTraktProgressActive
val activeNextUpSeedContentIds = homeProgressDerivedState.activeNextUpSeedContentIds
val currentNextUpSeedByContentId = homeProgressDerivedState.currentNextUpSeedByContentId
val visibleContinueWatchingEntries = homeProgressDerivedState.visibleContinueWatchingEntries
val watchProgressSeedKey = homeProgressDerivedState.watchProgressSeedKey
val isTraktProgressActive = remember(
isTraktAuthenticated,
traktSettingsUiState.watchProgressSource,
) {
shouldUseTraktProgress(
isAuthenticated = isTraktAuthenticated,
source = traktSettingsUiState.watchProgressSource,
)
}
val effectiveWatchProgressEntries = remember(
watchProgressUiState.entries,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
val filtered = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) }
} else {
watchProgressUiState.entries
}
filterEntriesForTraktContinueWatchingWindow(
entries = filtered,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val allNextUpSeedCandidates = remember(
watchProgressUiState.entries,
watchedUiState.items,
isTraktProgressActive,
continueWatchingPreferences.upNextFromFurthestEpisode,
) {
val filteredEntries = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { !WatchProgressRepository.isDroppedShow(it.parentMetaId) }
} else {
watchProgressUiState.entries
}
val filteredWatchedItems = if (isTraktProgressActive) {
watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) }
} else {
watchedUiState.items
}
buildHomeNextUpSeedCandidates(
progressEntries = filteredEntries,
watchedItems = filteredWatchedItems,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val recentNextUpSeedCandidates = remember(
allNextUpSeedCandidates,
isTraktProgressActive,
traktSettingsUiState.continueWatchingDaysCap,
) {
filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = WatchProgressClock.nowEpochMs(),
)
}
val activeNextUpSeedContentIds = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id }
}
val currentNextUpSeedByContentId = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates.associate { candidate ->
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
}.toMap()
}
val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) {
effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
}
val watchProgressSeedKey = remember(watchProgressUiState.entries) {
watchProgressUiState.entries.map { entry ->
Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber)
}
}
LaunchedEffect(visibleContinueWatchingEntries) {
if (visibleContinueWatchingEntries.any(WatchProgressEntry::isCloudLibraryProgressEntry)) {
@ -183,8 +255,32 @@ fun HomeScreen(
}
}
val nextUpSuppressedSeriesIds = homeProgressDerivedState.nextUpSuppressedSeriesIds
val completedSeriesCandidates = homeProgressDerivedState.completedSeriesCandidates
val latestCompletedAtBySeries = remember(allNextUpSeedCandidates) {
allNextUpSeedCandidates
.groupBy { candidate -> candidate.content.id }
.mapValues { (_, candidates) -> candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE }
}
val nextUpSuppressedSeriesIds = remember(visibleContinueWatchingEntries, latestCompletedAtBySeries) {
visibleContinueWatchingEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry ->
shouldTreatAsActiveInProgressForNextUpSuppression(
progress = entry,
latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId],
)
}
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
}
val completedSeriesCandidates = remember(recentNextUpSeedCandidates, nextUpSuppressedSeriesIds) {
recentNextUpSeedCandidates.filter { candidate ->
candidate.content.id !in nextUpSuppressedSeriesIds
}
}
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
val activeProfileId = profileState.activeProfile?.profileIndex ?: 1
val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle()
@ -201,7 +297,17 @@ fun HomeScreen(
val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) {
ContinueWatchingEnrichmentCache.getSnapshots(activeProfileId)
}
val shouldValidateMissingNextUpSeeds = homeProgressDerivedState.shouldValidateMissingNextUpSeeds
val shouldValidateMissingNextUpSeeds = remember(
isTraktProgressActive,
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.isLoaded,
) {
if (isTraktProgressActive) {
watchProgressUiState.hasLoadedRemoteProgress
} else {
watchedUiState.isLoaded
}
}
val cachedNextUpItems = remember(
cachedSnapshots.first,
continueWatchingPreferences.dismissedNextUpKeys,
@ -553,6 +659,14 @@ fun HomeScreen(
val enabledHomeItems = remember(homeSettingsUiState.items) {
homeSettingsUiState.items.filter { it.enabled }
}
val visibleSeriesPosterTargets = remember(enabledHomeItems, sectionsMap) {
enabledHomeItems
.filterNot { it.isCollection }
.mapNotNull { settingsItem -> sectionsMap[settingsItem.key] }
.flatMap { section -> section.items.take(HOME_CATALOG_PREVIEW_LIMIT) }
.filter { item -> item.type.isHomeSeriesLikeType() }
.distinctBy { item -> watchedItemKey(item.type, item.id) }
}
LaunchedEffect(
visibleSeriesPosterTargets,
watchedUiState.items,
@ -739,7 +853,7 @@ fun HomeScreen(
collection = collection,
modifier = Modifier.padding(bottom = 12.dp),
sectionPadding = homeSectionPadding,
animateGifsProvider = animateCollectionGifsProvider,
animateGifs = animateCollectionGifs,
onFolderClick = onFolderClick,
)
}
@ -781,160 +895,6 @@ private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4
internal data class HomeProgressDerivedState(
val watchProgressUiState: WatchProgressUiState = WatchProgressUiState(),
val watchedUiState: WatchedUiState = WatchedUiState(),
val continueWatchingPreferences: ContinueWatchingPreferencesUiState = ContinueWatchingPreferencesUiState(),
val isTraktProgressActive: Boolean = false,
val activeNextUpSeedContentIds: Set<String> = emptySet(),
val currentNextUpSeedByContentId: Map<String, Pair<Int, Int>> = emptyMap(),
val visibleContinueWatchingEntries: List<WatchProgressEntry> = emptyList(),
val watchProgressSeedKey: List<Triple<String, Int?, Int?>> = emptyList(),
val nextUpSuppressedSeriesIds: Set<String> = emptySet(),
val completedSeriesCandidates: List<CompletedSeriesCandidate> = emptyList(),
val shouldValidateMissingNextUpSeeds: Boolean = false,
)
private fun homeProgressDerivedStateFlow(): Flow<HomeProgressDerivedState> =
combine(
WatchProgressRepository.uiState,
WatchedRepository.uiState,
ContinueWatchingPreferencesRepository.uiState,
TraktSettingsRepository.uiState,
TraktAuthRepository.isAuthenticated,
) { watchProgressUiState, watchedUiState, continueWatchingPreferences, traktSettingsUiState, isTraktAuthenticated ->
buildHomeProgressDerivedState(
watchProgressUiState = watchProgressUiState,
watchedUiState = watchedUiState,
continueWatchingPreferences = continueWatchingPreferences,
traktSettingsUiState = traktSettingsUiState,
isTraktAuthenticated = isTraktAuthenticated,
)
}.flowOn(Dispatchers.Default)
internal fun buildHomeProgressDerivedState(
watchProgressUiState: WatchProgressUiState,
watchedUiState: WatchedUiState,
continueWatchingPreferences: ContinueWatchingPreferencesUiState,
traktSettingsUiState: TraktSettingsUiState,
isTraktAuthenticated: Boolean,
nowEpochMs: Long = WatchProgressClock.nowEpochMs(),
): HomeProgressDerivedState {
val isTraktProgressActive = shouldUseTraktProgress(
isAuthenticated = isTraktAuthenticated,
source = traktSettingsUiState.watchProgressSource,
)
val filteredEntries = if (isTraktProgressActive) {
watchProgressUiState.entries.filter { entry ->
!WatchProgressRepository.isDroppedShow(entry.parentMetaId)
}
} else {
watchProgressUiState.entries
}
val filteredWatchedItems = if (isTraktProgressActive) {
watchedUiState.items.filter { item ->
!WatchProgressRepository.isDroppedShow(item.id)
}
} else {
watchedUiState.items
}
val effectiveWatchProgressEntries = filterEntriesForTraktContinueWatchingWindow(
entries = filteredEntries,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = nowEpochMs,
)
val allNextUpSeedCandidates = buildHomeNextUpSeedCandidates(
progressEntries = filteredEntries,
watchedItems = filteredWatchedItems,
isTraktProgressActive = isTraktProgressActive,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
nowEpochMs = nowEpochMs,
)
val recentNextUpSeedCandidates = filterHomeNextUpCandidatesForTraktContinueWatchingWindow(
candidates = allNextUpSeedCandidates,
isTraktProgressActive = isTraktProgressActive,
daysCap = traktSettingsUiState.continueWatchingDaysCap,
nowEpochMs = nowEpochMs,
)
val activeNextUpSeedContentIds = allNextUpSeedCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
val currentNextUpSeedByContentId = allNextUpSeedCandidates.associate { candidate ->
candidate.content.id to (candidate.seasonNumber to candidate.episodeNumber)
}.toMap()
val visibleContinueWatchingEntries = effectiveWatchProgressEntries
.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
val watchProgressSeedKey = watchProgressUiState.entries.map { entry ->
Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber)
}
val latestCompletedAtBySeries = allNextUpSeedCandidates
.groupBy { candidate -> candidate.content.id }
.mapValues { (_, candidates) ->
candidates.maxOfOrNull { candidate -> candidate.markedAtEpochMs } ?: Long.MIN_VALUE
}
val nextUpSuppressedSeriesIds = visibleContinueWatchingEntries
.asSequence()
.filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() }
.filter { entry ->
shouldTreatAsActiveInProgressForNextUpSuppression(
progress = entry,
latestCompletedAt = latestCompletedAtBySeries[entry.parentMetaId],
)
}
.map { entry -> entry.parentMetaId }
.filter(String::isNotBlank)
.toSet()
val completedSeriesCandidates = recentNextUpSeedCandidates.filter { candidate ->
candidate.content.id !in nextUpSuppressedSeriesIds
}
val shouldValidateMissingNextUpSeeds = if (isTraktProgressActive) {
watchProgressUiState.hasLoadedRemoteProgress
} else {
watchedUiState.isLoaded
}
return HomeProgressDerivedState(
watchProgressUiState = watchProgressUiState,
watchedUiState = watchedUiState,
continueWatchingPreferences = continueWatchingPreferences,
isTraktProgressActive = isTraktProgressActive,
activeNextUpSeedContentIds = activeNextUpSeedContentIds,
currentNextUpSeedByContentId = currentNextUpSeedByContentId,
visibleContinueWatchingEntries = visibleContinueWatchingEntries,
watchProgressSeedKey = watchProgressSeedKey,
nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds,
completedSeriesCandidates = completedSeriesCandidates,
shouldValidateMissingNextUpSeeds = shouldValidateMissingNextUpSeeds,
)
}
private fun homeVisibleSeriesPosterTargetsFlow(): Flow<List<MetaPreview>> =
combine(
HomeRepository.uiState,
HomeCatalogSettingsRepository.uiState,
) { homeUiState, homeSettingsUiState ->
buildVisibleSeriesPosterTargets(
settingsItems = homeSettingsUiState.items,
sections = homeUiState.sections,
)
}.flowOn(Dispatchers.Default)
internal fun buildVisibleSeriesPosterTargets(
settingsItems: List<HomeCatalogSettingsItem>,
sections: List<HomeCatalogSection>,
): List<MetaPreview> {
val sectionsMap = sections.associateBy(HomeCatalogSection::key)
return settingsItems
.asSequence()
.filter { settingsItem -> settingsItem.enabled && !settingsItem.isCollection }
.mapNotNull { settingsItem -> sectionsMap[settingsItem.key] }
.flatMap { section -> section.items.take(HOME_CATALOG_PREVIEW_LIMIT).asSequence() }
.filter { item -> item.type.isHomeSeriesLikeType() }
.distinctBy { item -> watchedItemKey(item.type, item.id) }
.toList()
}
private suspend fun reconcileVisibleSeriesPosterBadges(
items: List<MetaPreview>,
watchedItems: List<WatchedItem>,

View file

@ -41,7 +41,7 @@ fun HomeCollectionRowSection(
collection: Collection,
modifier: Modifier = Modifier,
sectionPadding: Dp? = null,
animateGifsProvider: () -> Boolean = { true },
animateGifs: Boolean = true,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
) {
if (collection.folders.isEmpty()) return
@ -51,7 +51,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = modifier.fillMaxWidth(),
sectionPadding = sectionPadding,
animateGifsProvider = animateGifsProvider,
animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
} else {
@ -60,7 +60,7 @@ fun HomeCollectionRowSection(
collection = collection,
modifier = Modifier.fillMaxWidth(),
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
animateGifsProvider = animateGifsProvider,
animateGifs = animateGifs,
onFolderClick = onFolderClick,
)
}
@ -72,7 +72,7 @@ private fun HomeCollectionRowSectionContent(
collection: Collection,
modifier: Modifier,
sectionPadding: Dp,
animateGifsProvider: () -> Boolean,
animateGifs: Boolean,
onFolderClick: ((collectionId: String, folderId: String) -> Unit)?,
) {
val homeCatalogSettings by remember {
@ -91,7 +91,7 @@ private fun HomeCollectionRowSectionContent(
) { folder ->
CollectionFolderCard(
folder = folder,
animateGifsProvider = animateGifsProvider,
animateGifs = animateGifs,
onClick = onFolderClick?.let { { it(collection.id, folder.id) } },
)
}
@ -101,7 +101,7 @@ private fun HomeCollectionRowSectionContent(
private fun CollectionFolderCard(
folder: CollectionFolder,
modifier: Modifier = Modifier,
animateGifsProvider: () -> Boolean = { true },
animateGifs: Boolean = true,
onClick: (() -> Unit)? = null,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
@ -154,7 +154,7 @@ private fun CollectionFolderCard(
contentDescription = folder.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
animateIfPossible = animateGifsProvider() && isAnimatedCollectionFolderImage(folder, imageUrl),
animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl),
)
}
!folder.coverEmoji.isNullOrBlank() -> {

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.pager.HorizontalPager
@ -28,6 +29,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -41,7 +43,6 @@ import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -56,7 +57,6 @@ import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs
import kotlin.math.roundToInt
private const val HERO_BACKGROUND_PARALLAX = 0.055f
private const val HERO_BACKGROUND_SCALE = 1.14f
@ -95,7 +95,6 @@ fun HomeHeroSection(
val pagerState = rememberPagerState(pageCount = { items.size })
val coroutineScope = rememberCoroutineScope()
var pagerDragActive by remember { mutableStateOf(false) }
BoxWithConstraints(
modifier = modifier
@ -104,7 +103,6 @@ fun HomeHeroSection(
pagerState = pagerState,
itemCount = items.size,
coroutineScope = coroutineScope,
onDragActiveChange = { pagerDragActive = it },
)
.clip(RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)),
) {
@ -115,6 +113,42 @@ fun HomeHeroSection(
)
val heroWidthPx = with(LocalDensity.current) { maxWidth.toPx() }
val heroHeightPx = with(LocalDensity.current) { layout.heroHeight.toPx() }
val scrollOffsetPx by remember(listState, heroHeightPx) {
derivedStateOf {
when {
listState == null -> 0f
listState.firstVisibleItemIndex > 0 -> heroHeightPx
else -> listState.firstVisibleItemScrollOffset.toFloat()
}
}
}
val heroScrollScale = heroBackgroundScrollScale(scrollOffsetPx)
val heroScrollTranslationY = heroBackgroundScrollTranslationY(scrollOffsetPx)
val currentPage = pagerState.currentPage.coerceIn(items.indices)
val visiblePages = listOf(
currentPage,
(currentPage - 1).coerceIn(items.indices),
(currentPage + 1).coerceIn(items.indices),
).distinct()
.mapNotNull { index ->
val pageOffset = heroPageOffset(pagerState, index)
val visibility = (1f - abs(pageOffset)).coerceIn(0f, 1f)
if (visibility <= 0f) {
null
} else {
HeroPageLayer(
page = index,
visibility = visibility,
offset = pageOffset,
)
}
}
.sortedBy(HeroPageLayer::visibility)
val currentItem = visiblePages
.lastOrNull()
?.page
?.let(items::get)
?: items[currentPage]
Box(
modifier = Modifier
@ -134,15 +168,23 @@ fun HomeHeroSection(
Box(
modifier = Modifier.fillMaxSize(),
) {
HeroBackgroundLayers(
items = items,
pagerState = pagerState,
listState = listState,
layout = layout,
heroWidthPx = heroWidthPx,
heroHeightPx = heroHeightPx,
includePagerNeighbors = pagerDragActive,
)
visiblePages.forEach { layer ->
AsyncImage(
model = items[layer.page].banner ?: items[layer.page].poster,
contentDescription = items[layer.page].name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
translationY = heroScrollTranslationY
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
)
}
Box(
modifier = Modifier
@ -190,14 +232,20 @@ fun HomeHeroSection(
.widthIn(max = layout.contentMaxWidth),
contentAlignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center,
) {
HeroContentLayers(
items = items,
pagerState = pagerState,
layout = layout,
heroWidthPx = heroWidthPx,
onItemClick = onItemClick,
includePagerNeighbors = pagerDragActive,
)
visiblePages.forEach { layer ->
Box(
modifier = Modifier.graphicsLayer {
alpha = layer.visibility
translationX = -layer.offset * heroWidthPx * HERO_CONTENT_PARALLAX
},
) {
HeroContentBlock(
item = items[layer.page],
layout = layout,
onItemClick = onItemClick,
)
}
}
}
if (!layout.isTablet) {
@ -205,7 +253,7 @@ fun HomeHeroSection(
Surface(
modifier = Modifier
.clickable(enabled = onItemClick != null) {
onItemClick?.invoke(currentHeroItem(items, pagerState))
onItemClick?.invoke(currentItem)
},
color = MaterialTheme.colorScheme.onBackground,
contentColor = MaterialTheme.colorScheme.background,
@ -227,14 +275,21 @@ fun HomeHeroSection(
verticalAlignment = Alignment.CenterVertically,
) {
items.forEachIndexed { index, _ ->
HeroPageIndicatorDot(
pagerState = pagerState,
page = index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
val activeFraction = heroPageVisibility(pagerState, index)
Box(
modifier = Modifier
.clickable {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
}
},
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground)
.graphicsLayer {
alpha = 0.35f + (0.57f * activeFraction)
}
.width(8.dp + (24.dp * activeFraction))
.height(8.dp),
)
}
}
@ -245,111 +300,11 @@ fun HomeHeroSection(
}
}
@Composable
private fun HeroBackgroundLayers(
items: List<MetaPreview>,
pagerState: PagerState,
listState: LazyListState?,
layout: HomeHeroLayout,
heroWidthPx: Float,
heroHeightPx: Float,
includePagerNeighbors: Boolean,
) {
val layerPages = rememberHeroLayerPages(
pagerState = pagerState,
itemCount = items.size,
includePagerNeighbors = includePagerNeighbors,
)
layerPages.forEach { page ->
val item = items[page]
AsyncImage(
model = item.banner ?: item.poster,
contentDescription = item.name,
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val pageOffset = heroPageOffset(pagerState, page)
val scrollOffsetPx = heroScrollOffsetPx(listState, heroHeightPx)
val scrollScale = heroBackgroundScrollScale(scrollOffsetPx)
alpha = heroPageVisibility(pageOffset)
translationX = -pageOffset * heroWidthPx * HERO_BACKGROUND_PARALLAX
translationY = heroBackgroundScrollTranslationY(scrollOffsetPx)
scaleX = HERO_BACKGROUND_SCALE * scrollScale
scaleY = HERO_BACKGROUND_SCALE * scrollScale
},
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
contentScale = ContentScale.Crop,
)
}
}
@Composable
private fun HeroContentLayers(
items: List<MetaPreview>,
pagerState: PagerState,
layout: HomeHeroLayout,
heroWidthPx: Float,
onItemClick: ((MetaPreview) -> Unit)?,
includePagerNeighbors: Boolean,
) {
val layerPages = rememberHeroLayerPages(
pagerState = pagerState,
itemCount = items.size,
includePagerNeighbors = includePagerNeighbors,
)
layerPages.forEach { page ->
Box(
modifier = Modifier.graphicsLayer {
val pageOffset = heroPageOffset(pagerState, page)
alpha = heroPageVisibility(pageOffset)
translationX = -pageOffset * heroWidthPx * HERO_CONTENT_PARALLAX
},
) {
HeroContentBlock(
item = items[page],
layout = layout,
onItemClick = onItemClick,
)
}
}
}
@Composable
private fun rememberHeroLayerPages(
pagerState: PagerState,
itemCount: Int,
includePagerNeighbors: Boolean,
): List<Int> {
if (itemCount <= 0) return emptyList()
val currentPage = pagerState.currentPage.coerceIn(0, itemCount - 1)
val includeNeighbors = includePagerNeighbors || pagerState.isScrollInProgress
return remember(currentPage, includeNeighbors, itemCount) {
heroLayerPages(
currentPage = currentPage,
itemCount = itemCount,
includeNeighbors = includeNeighbors,
)
}
}
private fun heroLayerPages(
currentPage: Int,
itemCount: Int,
includeNeighbors: Boolean,
): List<Int> {
if (!includeNeighbors || itemCount == 1) return listOf(currentPage)
val neighbors = listOf(currentPage - 1, currentPage + 1)
.map { page -> page.coerceIn(0, itemCount - 1) }
.filter { page -> page != currentPage }
.distinct()
return neighbors + currentPage
}
private data class HeroPageLayer(
val page: Int,
val visibility: Float,
val offset: Float,
)
private fun heroPageOffset(
pagerState: PagerState,
@ -359,66 +314,8 @@ private fun heroPageOffset(
private fun heroPageVisibility(
pagerState: PagerState,
page: Int,
): Float = heroPageVisibility(heroPageOffset(pagerState, page))
private fun heroPageVisibility(pageOffset: Float): Float = (1f - abs(pageOffset)).coerceIn(0f, 1f)
private fun currentHeroItem(
items: List<MetaPreview>,
pagerState: PagerState,
): MetaPreview {
val currentPage = pagerState.currentPage.coerceIn(0, items.lastIndex)
val currentVisiblePages = heroLayerPages(
currentPage = currentPage,
itemCount = items.size,
includeNeighbors = true,
)
val selectedPage = currentVisiblePages.maxBy { page ->
heroPageVisibility(pagerState, page)
}
return items[selectedPage]
}
@Composable
private fun HeroPageIndicatorDot(
pagerState: PagerState,
page: Int,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
.clickable(onClick = onClick)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.onBackground)
.graphicsLayer {
val activeFraction = heroPageVisibility(pagerState, page)
alpha = 0.35f + (0.57f * activeFraction)
}
.heroPageIndicatorSize(pagerState = pagerState, page = page),
)
}
private fun Modifier.heroPageIndicatorSize(
pagerState: PagerState,
page: Int,
): Modifier = layout { measurable, constraints ->
val activeFraction = heroPageVisibility(pagerState, page)
val widthPx = (8.dp.toPx() + (24.dp.toPx() * activeFraction)).roundToInt()
val heightPx = 8.dp.roundToPx()
val constrainedWidth = widthPx.coerceIn(constraints.minWidth, constraints.maxWidth)
val constrainedHeight = heightPx.coerceIn(constraints.minHeight, constraints.maxHeight)
val placeable = measurable.measure(
constraints.copy(
minWidth = constrainedWidth,
maxWidth = constrainedWidth,
minHeight = constrainedHeight,
maxHeight = constrainedHeight,
),
)
layout(constrainedWidth, constrainedHeight) {
placeable.place(0, 0)
}
): Float {
return (1f - abs(heroPageOffset(pagerState, page))).coerceIn(0f, 1f)
}
@Composable
@ -622,15 +519,6 @@ private fun HeroMetaDot() {
)
}
private fun heroScrollOffsetPx(
listState: LazyListState?,
heroHeightPx: Float,
): Float = when {
listState == null -> 0f
listState.firstVisibleItemIndex > 0 -> heroHeightPx
else -> listState.firstVisibleItemScrollOffset.toFloat()
}
private fun heroBackgroundScrollScale(scrollOffsetPx: Float): Float {
val scaleIncrease = if (scrollOffsetPx < 0f) {
abs(scrollOffsetPx) * HERO_SCROLL_UP_SCALE_MULTIPLIER
@ -648,7 +536,6 @@ private fun Modifier.homeHeroPagerGesture(
pagerState: PagerState,
itemCount: Int,
coroutineScope: CoroutineScope,
onDragActiveChange: (Boolean) -> Unit,
): Modifier {
if (itemCount <= 1) return this
@ -663,62 +550,47 @@ private fun Modifier.homeHeroPagerGesture(
var totalDx = 0f
var totalDy = 0f
var dragging = false
var settleAnimationStarted = false
try {
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
velocityTracker.addPosition(change.uptimeMillis, change.position)
while (true) {
val event = awaitPointerEvent(pass = PointerEventPass.Initial)
val change = event.changes.firstOrNull { it.id == down.id } ?: break
velocityTracker.addPosition(change.uptimeMillis, change.position)
if (!change.pressed) {
if (dragging) {
val targetPage = resolveHeroTargetPage(
startPage = startPage,
itemCount = itemCount,
totalDx = totalDx,
velocityX = velocityTracker.calculateVelocity().x,
widthPx = widthPx,
)
settleAnimationStarted = true
coroutineScope.launch {
try {
pagerState.animateScrollToPage(targetPage)
} finally {
onDragActiveChange(false)
}
}
}
break
}
val delta = change.position - change.previousPosition
totalDx += delta.x
totalDy += delta.y
if (!dragging) {
val horizontalDrag =
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
val verticalDrag =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
when {
verticalDrag -> break
horizontalDrag -> {
dragging = true
onDragActiveChange(true)
}
else -> continue
if (!change.pressed) {
if (dragging) {
val targetPage = resolveHeroTargetPage(
startPage = startPage,
itemCount = itemCount,
totalDx = totalDx,
velocityX = velocityTracker.calculateVelocity().x,
widthPx = widthPx,
)
coroutineScope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
break
}
pagerState.dispatchRawDelta(-delta.x)
change.consume()
}
} finally {
if (dragging && !settleAnimationStarted) {
onDragActiveChange(false)
val delta = change.position - change.previousPosition
totalDx += delta.x
totalDy += delta.y
if (!dragging) {
val horizontalDrag =
abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy)
val verticalDrag =
abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx)
when {
verticalDrag -> break
horizontalDrag -> dragging = true
else -> continue
}
}
pagerState.dispatchRawDelta(-delta.x)
change.consume()
}
}
}

View file

@ -137,7 +137,7 @@ internal fun LazyListScope.advancedSettingsContent(
ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId)
cleared = true
scope.launch {
WatchProgressRepository.forceSnapshotRefreshFromServer(
WatchProgressRepository.clearLocalAndForceSnapshotRefreshFromServer(
ProfileRepository.activeProfileId,
)
}

View file

@ -27,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalUriHandler
@ -38,6 +39,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.nuvio.app.features.library.LibrarySourceMode
import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktBrandAsset
import com.nuvio.app.features.trakt.TraktAuthUiState
@ -50,6 +52,8 @@ import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap
import com.nuvio.app.features.trakt.traktBrandPainter
import com.nuvio.app.features.watchprogress.WatchProgressRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.settings_playback_dialog_close
@ -157,6 +161,7 @@ private fun TraktFeatureRows(
var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) }
var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) }
var statusMessage by rememberSaveable { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode)
val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource)
@ -234,7 +239,12 @@ private fun TraktFeatureRows(
WatchProgressSourceDialog(
selectedSource = settingsUiState.watchProgressSource,
onSourceSelected = { source ->
TraktSettingsRepository.setWatchProgressSource(source)
scope.launch {
WatchProgressRepository.selectWatchProgressSource(
profileId = ProfileRepository.activeProfileId,
source = source,
)
}
statusMessage = if (source == WatchProgressSource.TRAKT) {
traktProgressSelectedMessage
} else {

View file

@ -33,6 +33,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@ -77,7 +78,6 @@ import com.nuvio.app.core.ui.NuvioBottomSheetDivider
import com.nuvio.app.core.ui.NuvioModalBottomSheet
import com.nuvio.app.core.ui.NuvioToastController
import com.nuvio.app.core.ui.dismissNuvioBottomSheet
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.downloads.DownloadsRepository
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.compose.material3.ExperimentalMaterial3Api
@ -842,32 +842,6 @@ private fun FilterChip(
// Stream List
// ---------------------------------------------------------------------------
private const val STREAM_CONTENT_TYPE_LOADING = "streams_loading"
private const val STREAM_CONTENT_TYPE_EMPTY = "streams_empty"
private const val STREAM_CONTENT_TYPE_SECTION_HEADER = "streams_section_header"
private const val STREAM_CONTENT_TYPE_SOURCE_HEADER = "streams_source_header"
private const val STREAM_CONTENT_TYPE_STREAM = "streams_stream"
private const val STREAM_CONTENT_TYPE_FOOTER_LOADING = "streams_footer_loading"
private const val STREAM_CONTENT_TYPE_BOTTOM_SPACER = "streams_bottom_spacer"
private data class StreamSectionRenderModel(
val sectionKey: String,
val group: AddonStreamGroup,
val sources: List<StreamSourceRenderModel>,
val showSourceHeaders: Boolean,
)
private data class StreamSourceRenderModel(
val sourceKey: String,
val sourceName: String,
val streams: List<StreamCardRenderModel>,
)
private data class StreamCardRenderModel(
val lazyKey: String,
val stream: StreamItem,
)
@Composable
internal fun StreamList(
uiState: StreamsUiState,
@ -883,9 +857,6 @@ internal fun StreamList(
val hasGroups = filteredGroups.isNotEmpty()
val hasAnyStreams = filteredGroups.any { it.streams.isNotEmpty() }
val anyLoading = filteredGroups.any { it.isLoading }
val streamSections = remember(filteredGroups) {
buildStreamSectionRenderModels(filteredGroups)
}
val torrentNotSupportedText = stringResource(Res.string.streams_torrent_not_supported)
val streamBadgeSettings by remember {
StreamBadgeSettingsRepository.ensureLoaded()
@ -902,27 +873,22 @@ internal fun StreamList(
) {
when {
hasGroups && anyLoading && !hasAnyStreams -> {
item(
key = "streams_loading",
contentType = STREAM_CONTENT_TYPE_LOADING,
) {
item {
LoadingStateBlock()
}
}
!hasAnyStreams && !uiState.isAnyLoading -> {
item(
key = "streams_empty",
contentType = STREAM_CONTENT_TYPE_EMPTY,
) {
item {
EmptyStateBlock(reason = uiState.emptyStateReason)
}
}
else -> {
streamSections.forEach { section ->
filteredGroups.forEachIndexed { groupIndex, group ->
streamSection(
section = section,
sectionKey = streamSectionRenderKey(groupIndex = groupIndex, group = group),
group = group,
showHeader = uiState.selectedFilter == null,
debridEnabled = debridEnabled,
appendInstantServiceToDefaultName = appendInstantServiceToDefaultName,
@ -937,17 +903,11 @@ internal fun StreamList(
)
}
if (anyLoading) {
item(
key = "streams_footer_loading",
contentType = STREAM_CONTENT_TYPE_FOOTER_LOADING,
) {
item {
FooterLoadingBlock()
}
}
item(
key = "streams_bottom_spacer",
contentType = STREAM_CONTENT_TYPE_BOTTOM_SPACER,
) {
item {
Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(80.dp)))
}
}
@ -955,45 +915,9 @@ internal fun StreamList(
}
}
private fun buildStreamSectionRenderModels(groups: List<AddonStreamGroup>): List<StreamSectionRenderModel> =
groups
.withDuplicateSafeLazyKeys { group -> streamSectionRenderKey(group) }
.map { keyedGroup ->
val group = keyedGroup.value
val sectionKey = keyedGroup.lazyKey.toString()
val streamsBySource = group.streams.groupBy(::streamSourceName)
val sortedSources = streamsBySource.keys.sortedBy { it.lowercase() }
StreamSectionRenderModel(
sectionKey = sectionKey,
group = group,
sources = sortedSources.map { sourceName ->
StreamSourceRenderModel(
sourceKey = streamSourceRenderKey(sectionKey = sectionKey, sourceName = sourceName),
sourceName = sourceName,
streams = streamsBySource[sourceName]
.orEmpty()
.withDuplicateSafeLazyKeys { stream ->
streamCardRenderKey(
sectionKey = sectionKey,
sourceName = sourceName,
stream = stream,
)
}
.map { keyedStream ->
StreamCardRenderModel(
lazyKey = keyedStream.lazyKey.toString(),
stream = keyedStream.value,
)
},
)
},
showSourceHeaders = sortedSources.size > 1,
)
}
private fun LazyListScope.streamSection(
section: StreamSectionRenderModel,
sectionKey: String,
group: AddonStreamGroup,
showHeader: Boolean,
debridEnabled: Boolean,
appendInstantServiceToDefaultName: Boolean,
@ -1006,14 +930,10 @@ private fun LazyListScope.streamSection(
resumePositionMs: Long?,
resumeProgressFraction: Float?,
) {
val group = section.group
if (group.streams.isEmpty() && !group.isLoading) return
if (showHeader) {
item(
key = "stream_section_header_${section.sectionKey}",
contentType = STREAM_CONTENT_TYPE_SECTION_HEADER,
) {
item(key = "header_$sectionKey") {
StreamSectionHeader(
addonName = group.addonName,
isLoading = group.isLoading,
@ -1021,22 +941,31 @@ private fun LazyListScope.streamSection(
}
}
section.sources.forEach { source ->
if (section.showSourceHeaders) {
item(
key = source.sourceKey,
contentType = STREAM_CONTENT_TYPE_SOURCE_HEADER,
) {
StreamSourceHeader(sourceName = source.sourceName)
val streamsBySource = group.streams.groupBy { stream ->
stream.sourceName?.takeIf { it.isNotBlank() } ?: stream.addonName
}
val sortedSources = streamsBySource.keys.sortedBy { it.lowercase() }
val showSourceHeaders = sortedSources.size > 1
sortedSources.forEachIndexed { sourceIndex, sourceName ->
val sourceStreams = streamsBySource[sourceName].orEmpty()
if (showSourceHeaders) {
item(key = "source_${sectionKey}_$sourceIndex") {
StreamSourceHeader(sourceName = sourceName)
}
}
items(
items = source.streams,
key = { renderItem -> renderItem.lazyKey },
contentType = { STREAM_CONTENT_TYPE_STREAM },
) { renderItem ->
val stream = renderItem.stream
itemsIndexed(
items = sourceStreams,
key = { index, stream ->
streamCardRenderKey(
sectionKey = sectionKey,
sourceIndex = sourceIndex,
itemIndex = index,
stream = stream,
)
},
) { _, stream ->
val isSelectable = stream.isSelectableForPlayback(debridEnabled)
val isUnsupportedTorrentStream =
stream.needsLocalDebridResolve &&
@ -1067,42 +996,28 @@ private fun LazyListScope.streamSection(
}
}
internal fun streamSectionRenderKey(group: AddonStreamGroup): String = buildString {
append("stream_section")
appendLazyKeyPart(group.addonId.takeIf { it.isNotBlank() } ?: group.addonName)
}
private fun streamSourceName(stream: StreamItem): String =
stream.sourceName?.takeIf { it.isNotBlank() } ?: stream.addonName
private fun streamSourceRenderKey(
sectionKey: String,
sourceName: String,
): String = buildString {
append("stream_source")
appendLazyKeyPart(sectionKey)
appendLazyKeyPart(sourceName)
}
internal fun streamSectionRenderKey(
groupIndex: Int,
group: AddonStreamGroup,
): String = "$groupIndex:${group.addonId}"
internal fun streamCardRenderKey(
sectionKey: String,
sourceName: String,
sourceIndex: Int,
itemIndex: Int,
stream: StreamItem,
): String = buildString {
append("stream_card")
appendLazyKeyPart(sectionKey)
appendLazyKeyPart(sourceName)
appendLazyKeyPart(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
appendLazyKeyPart(stream.fileIdx)
appendLazyKeyPart(stream.externalUrl)
}
private fun StringBuilder.appendLazyKeyPart(value: Any?) {
val text = value?.toString()?.trim().orEmpty()
append(sectionKey)
append(':')
append(text.length)
append(sourceIndex)
append(':')
append(text)
append(itemIndex)
append(':')
append(stream.url ?: stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.streamLabel)
stream.externalUrl?.let {
append(':')
append(it)
}
}
// ---------------------------------------------------------------------------

View file

@ -14,6 +14,7 @@ import com.nuvio.app.features.profiles.ProfileRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktProgressRepository
import com.nuvio.app.features.trakt.TraktSettingsRepository
import com.nuvio.app.features.trakt.WatchProgressSource
import com.nuvio.app.features.trakt.isTraktCompatibleId
import com.nuvio.app.features.trakt.resolveEffectiveContentId
import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource
@ -302,6 +303,119 @@ object WatchProgressRepository {
pullFromServer(profileId)
}
suspend fun selectWatchProgressSource(profileId: Int, source: WatchProgressSource) {
TraktSettingsRepository.ensureLoaded()
val previousSource = TraktSettingsRepository.uiState.value.watchProgressSource
if (previousSource == source) return
ensureLoaded()
if (currentProfileId != profileId) {
loadFromDisk(profileId)
}
ContinueWatchingEnrichmentCache.clearAll(profileId)
metadataResolutionJob?.cancel()
TraktSettingsRepository.setWatchProgressSource(source)
val removedLocalEntries = removeLocalEntriesMatching { entry ->
isTraktCompatibleId(entry.parentMetaId)
}
if (removedLocalEntries) {
persist()
}
when (source) {
WatchProgressSource.TRAKT -> {
TraktProgressRepository.clearLocalState()
publish()
if (TraktAuthRepository.isAuthenticated.value) {
runCatching { TraktProgressRepository.invalidateAndRefresh() }
.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to refresh Trakt progress after source selection" }
}
publish()
}
}
WatchProgressSource.NUVIO_SYNC -> {
publish()
forceSnapshotRefreshFromServer(profileId)
}
}
}
suspend fun clearLocalAndForceSnapshotRefreshFromServer(profileId: Int) {
ensureLoaded()
if (currentProfileId != profileId) {
loadFromDisk(profileId)
}
val operationGeneration = activeOperationGeneration(profileId) ?: run {
log.d { "Skipping clear and force watch progress refresh for inactive profile $profileId" }
return
}
metadataResolutionJob?.cancel()
clearLocalEntries()
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
deltaInitialized = false
publish()
persist()
if (shouldUseTraktProgress()) {
log.d { "Clearing local Trakt watch progress cache and force refreshing profile $profileId" }
TraktProgressRepository.clearLocalState()
TraktProgressRepository.invalidateAndRefresh()
if (isActiveOperation(profileId, operationGeneration)) {
publish()
}
return
}
val authState = AuthRepository.state.value
if (authState !is AuthState.Authenticated || authState.isAnonymous) {
log.d { "Cleared local watch progress but skipped remote refresh because Nuvio Sync is not authenticated" }
return
}
if (isPullingNuvioSyncFromServer) {
log.d { "Cleared local watch progress but skipped remote refresh because a Nuvio sync pull is already running" }
return
}
isPullingNuvioSyncFromServer = true
try {
val pullStartedEpochMs = WatchProgressClock.nowEpochMs()
val cursorBeforeSnapshot = try {
syncAdapter.getDeltaCursor(profileId)
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
log.w { "Watch progress delta cursor unavailable during clear refresh, falling back to snapshot reset: ${error.message}" }
null
}
pullFullFromAdapter(
profileId = profileId,
pullStartedEpochMs = pullStartedEpochMs,
resetDeltaState = cursorBeforeSnapshot == null,
operationGeneration = operationGeneration,
preserveLocalEntries = false,
)
if (!isActiveOperation(profileId, operationGeneration)) return
if (cursorBeforeSnapshot != null) {
deltaCursorEventId = cursorBeforeSnapshot
deltaInitialized = true
persist()
}
} finally {
isPullingNuvioSyncFromServer = false
}
}
private suspend fun pullSupabaseDeltaFromServer(
profileId: Int,
pullStartedEpochMs: Long,
@ -432,21 +546,27 @@ object WatchProgressRepository {
pullStartedEpochMs: Long,
resetDeltaState: Boolean,
operationGeneration: Long,
preserveLocalEntries: Boolean = true,
) {
val serverEntries = syncAdapter.pull(profileId = profileId)
if (!isActiveOperation(profileId, operationGeneration)) return
log.d {
"Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " +
"resetDeltaState=$resetDeltaState"
"resetDeltaState=$resetDeltaState preserveLocalEntries=$preserveLocalEntries"
}
replaceLocalEntries(
val updatedEntries = if (preserveLocalEntries) {
mergeWatchProgressEntriesPreservingUnsynced(
serverEntries = serverEntries,
localEntries = localEntriesSnapshot(),
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
pullStartedEpochMs = pullStartedEpochMs,
),
)
serverEntries = serverEntries,
localEntries = localEntriesSnapshot(),
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
pullStartedEpochMs = pullStartedEpochMs,
)
} else {
serverEntries.associate { record ->
record.videoId to record.toWatchProgressEntry(cached = null)
}
}
replaceLocalEntries(updatedEntries)
if (resetDeltaState) {
deltaCursorEventId = 0L
deltaInitialized = false
@ -1105,6 +1225,19 @@ object WatchProgressRepository {
}
}
private fun removeLocalEntriesMatching(predicate: (WatchProgressEntry) -> Boolean): Boolean =
synchronized(entriesLock) {
val filteredEntries = entriesByVideoId
.filterValues { entry -> !predicate(entry) }
.toMutableMap()
if (filteredEntries.size == entriesByVideoId.size) {
false
} else {
entriesByVideoId = filteredEntries
true
}
}
private fun replaceLocalEntries(entries: Collection<WatchProgressEntry>) {
synchronized(entriesLock) {
entriesByVideoId = entries

View file

@ -1,14 +1,5 @@
package com.nuvio.app.core.ui
import coil3.ImageLoader
import coil3.PlatformContext
import coil3.memory.MemoryCache
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(
context: PlatformContext,
): ImageLoader.Builder =
memoryCache {
MemoryCache.Builder()
.maxSizePercent(context, 0.25)
.build()
}
internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this