diff --git a/.gitignore b/.gitignore index 77f5bac83..93f75cabd 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/PERFORMANCE_FINDINGS.md b/PERFORMANCE_FINDINGS.md new file mode 100644 index 000000000..aaa45f984 --- /dev/null +++ b/PERFORMANCE_FINDINGS.md @@ -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 1–3, 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` (`:1511`): + +```kotlin +val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == 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` 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`/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 100–150ms 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`/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.82–0.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) | diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt index 9aeda2152..cda2e814b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.android.kt @@ -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()) } - } + } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 5f61816cc..b42f920b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 { - val routeActiveState = remember(navController) { - mutableStateOf(navController.currentDestination?.hasRoute() ?: 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() == true - -@Composable -private fun DismissResumePromptOnPlaybackDestination( - navController: NavController, - onPlaybackDestination: () -> Unit, -) { - val currentOnPlaybackDestination by rememberUpdatedState(onPlaybackDestination) - - DisposableEffect(navController) { - fun maybeDismiss(destination: NavDestination?) { - if ( - destination?.hasRoute() == true || - destination?.hasRoute() == 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(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(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() == true || + currentBackStackEntry?.destination?.hasRoute() == 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() == 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, modifier: Modifier = Modifier, searchFocusRequestCount: Int = 0, + rootActionsEnabled: Boolean = true, homeScrollToTopRequests: Flow, searchScrollToTopRequests: Flow, libraryScrollToTopRequests: Flow, settingsRootActionRequests: Flow, + 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, - homeSelected: Boolean, - homeScrollToTopRequests: Flow, - 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, -) { - SearchScreen( - modifier = Modifier.fillMaxSize(), - onPosterClick = onPosterClick, - onPosterLongClick = onPosterLongClick, - searchFocusRequestCount = searchFocusRequestCount, - scrollToTopRequests = searchScrollToTopRequests, - ) -} - -@Composable -private fun AppLibraryTabContent( - libraryScrollToTopRequests: Flow, - 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, - rootActionRequests: Flow, - 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, - 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt index e20979046..925ce3ba3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.kt @@ -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 \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt index 8b98eab31..4d69e732e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleCompose.kt @@ -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 -} +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index 77eee6e41..00d907518 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -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 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 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 NuvioShelfSection( } } } else { - items( - items = entries, - contentType = { "poster" }, - ) { entry -> + items(entries) { entry -> if (animatePlacement) { Box(modifier = Modifier.animateItem()) { itemContent(entry) } } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 636a59510..f3b9555ef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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, 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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 73cc9d973..89aecce61 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 8bd0c7d11..32d6dd332 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -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 }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 0a8566273..4183e30dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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 = 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 = emptySet(), - val currentNextUpSeedByContentId: Map> = emptyMap(), - val visibleContinueWatchingEntries: List = emptyList(), - val watchProgressSeedKey: List> = emptyList(), - val nextUpSuppressedSeriesIds: Set = emptySet(), - val completedSeriesCandidates: List = emptyList(), - val shouldValidateMissingNextUpSeeds: Boolean = false, -) - -private fun homeProgressDerivedStateFlow(): Flow = - 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> = - combine( - HomeRepository.uiState, - HomeCatalogSettingsRepository.uiState, - ) { homeUiState, homeSettingsUiState -> - buildVisibleSeriesPosterTargets( - settingsItems = homeSettingsUiState.items, - sections = homeUiState.sections, - ) - }.flowOn(Dispatchers.Default) - -internal fun buildVisibleSeriesPosterTargets( - settingsItems: List, - sections: List, -): List { - 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, watchedItems: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index 9cd753a5a..da63fe5d1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -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() -> { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 57d172f83..30d73d555 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -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, - 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, - 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 { - 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 { - 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, - 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() } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt index d4e6ab18d..7dc361eed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt @@ -137,7 +137,7 @@ internal fun LazyListScope.advancedSettingsContent( ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId) cleared = true scope.launch { - WatchProgressRepository.forceSnapshotRefreshFromServer( + WatchProgressRepository.clearLocalAndForceSnapshotRefreshFromServer( ProfileRepository.activeProfileId, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 6d314e07a..0b2b42360 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -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(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 { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index bb3f2a2c7..f7176ef77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -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, - val showSourceHeaders: Boolean, -) - -private data class StreamSourceRenderModel( - val sourceKey: String, - val sourceName: String, - val streams: List, -) - -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): List = - 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) + } } // --------------------------------------------------------------------------- diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 7c392e678..c012db183 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -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) { synchronized(entriesLock) { entriesByVideoId = entries diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt index 0b0cf034a..3240c6e30 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PlatformImageLoader.ios.kt @@ -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 \ No newline at end of file