feat: add zoom overlay

This commit is contained in:
tapframe 2026-07-10 21:08:10 +05:30
parent d622d1384c
commit 2e1af6ea1a
11 changed files with 971 additions and 487 deletions

1
.gitignore vendored
View file

@ -28,6 +28,7 @@ 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

View file

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

View file

@ -28,7 +28,14 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CheckCircleOutline
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Replay
import com.nuvio.app.core.ui.NuvioLoadingIndicator
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@ -44,6 +51,7 @@ import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
@ -86,8 +94,14 @@ import com.nuvio.app.core.sync.RealtimeSyncConfig
import com.nuvio.app.core.sync.RealtimeSyncInvalidationService
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.ui.NuvioNavigationBar
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet
import com.nuvio.app.core.ui.NuvioPosterActionSheet
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import com.nuvio.app.core.ui.NuvioStatusModal
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.core.ui.platformExitApp
@ -117,6 +131,7 @@ import com.nuvio.app.features.cloud.CloudLibraryFile
import com.nuvio.app.features.cloud.CloudLibraryItem
import com.nuvio.app.features.cloud.CloudLibraryPlaybackResult
import com.nuvio.app.features.cloud.CloudLibraryPlaybackTargetLookupResult
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.playbackVideoId
import com.nuvio.app.features.cloud.providerPosterUrl
@ -775,7 +790,10 @@ private fun MainAppContent(
val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() }
var showExitConfirmation by rememberSaveable { mutableStateOf(false) }
var selectedPosterActionTarget by remember { mutableStateOf<PosterActionTarget?>(null) }
var selectedPosterAnchor by remember { mutableStateOf<PosterZoomAnchor?>(null) }
val posterOverlayHazeState = rememberHazeState()
var selectedContinueWatchingForActions by remember { mutableStateOf<ContinueWatchingItem?>(null) }
var selectedContinueWatchingZoomAnchor by remember { mutableStateOf<PosterZoomAnchor?>(null) }
var requestedSettingsPageName by rememberSaveable { mutableStateOf<String?>(null) }
var showLibraryListPicker by remember { mutableStateOf(false) }
var pickerItem by remember { mutableStateOf<LibraryItem?>(null) }
@ -796,6 +814,7 @@ private fun MainAppContent(
val openPosterActions: (PosterActionTarget) -> Unit = { target ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
focusManager.clearFocus(force = true)
selectedPosterAnchor = PosterZoomAnchorHolder.consume()
coroutineScope.launch {
withFrameNanos { }
selectedPosterActionTarget = target
@ -1741,8 +1760,24 @@ private fun MainAppContent(
openContinueWatching(item, true, false)
}
val onContinueWatchingRemove: (ContinueWatchingItem) -> Unit = { item ->
if (item.isNextUp) {
ContinueWatchingPreferencesRepository.addDismissedNextUpKey(
nextUpDismissKey(
item.parentMetaId,
item.nextUpSeedSeasonNumber,
item.nextUpSeedEpisodeNumber,
),
)
} else {
WatchProgressRepository.removeProgress(contentId = item.parentMetaId)
}
}
val onContinueWatchingLongPress: (ContinueWatchingItem) -> Unit = { item ->
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
val zoomAnchor = PosterZoomAnchorHolder.consume()
selectedContinueWatchingZoomAnchor = zoomAnchor
selectedContinueWatchingForActions = item
}
@ -1751,6 +1786,18 @@ private fun MainAppContent(
.fillMaxSize()
.background(MaterialTheme.nuvio.colors.background),
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (selectedPosterActionTarget != null || selectedContinueWatchingZoomAnchor != null) {
Modifier.hazeSource(state = posterOverlayHazeState)
} else {
Modifier
},
)
.background(MaterialTheme.nuvio.colors.background),
) {
SharedTransitionLayout {
CompositionLocalProvider(
LocalUseNativeNavigation provides useNativeNavigation,
@ -3128,85 +3175,184 @@ private fun MainAppContent(
)
}
}
}
NuvioPosterActionSheet(
item = selectedPosterActionTarget?.preview,
isSaved = selectedPosterActionTarget?.preview?.let { preview ->
LibraryRepository.isSaved(preview.id, preview.type)
} == true,
isWatched = selectedPosterActionTarget?.preview?.let { preview ->
WatchingState.isPosterWatched(
selectedPosterActionTarget?.let { posterActionTarget ->
key(posterActionTarget) {
val preview = posterActionTarget.preview
val isSaved = LibraryRepository.isSaved(preview.id, preview.type)
val isWatched = WatchingState.isPosterWatched(
watchedKeys = watchedUiState.watchedKeys,
item = preview,
)
} == true,
onDismiss = { selectedPosterActionTarget = null },
onToggleLibrary = {
selectedPosterActionTarget?.let { target ->
val preview = target.preview
val libraryItem = target.libraryItem ?: preview.toLibraryItem(savedAtEpochMs = 0L)
if (target.libraryItem != null) {
if (isTraktLibrarySource) {
coroutineScope.launch {
runCatching {
val listKey = target.libraryListKey
if (listKey.isNullOrBlank()) {
val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem)
LibraryRepository.applyMembershipChanges(
item = libraryItem,
desiredMembership = currentMembership.mapValues { false },
)
// Trakt items long-pressed outside the library open the list picker
// instead of removing, so only true removals disintegrate.
val removesFromLibrary = isSaved &&
(posterActionTarget.libraryItem != null || !isTraktLibrarySource)
NuvioPosterZoomActionOverlay(
imageUrl = selectedPosterAnchor?.imageUrl ?: preview.poster,
title = preview.name,
subtitle = preview.releaseInfo
?.takeIf { it.isNotBlank() }
?.let { formatReleaseDateForDisplay(it) }
?: preview.type.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
},
isWatched = isWatched,
anchor = selectedPosterAnchor,
actions = listOf(
PosterZoomOverlayAction(
icon = if (isSaved) Icons.Default.DeleteOutline else Icons.Default.Add,
label = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
stringResource(Res.string.hero_add_to_library)
},
isDestructive = removesFromLibrary,
onSelected = {
val libraryItem = posterActionTarget.libraryItem
?: preview.toLibraryItem(savedAtEpochMs = 0L)
if (posterActionTarget.libraryItem != null) {
if (isTraktLibrarySource) {
coroutineScope.launch {
runCatching {
val listKey = posterActionTarget.libraryListKey
if (listKey.isNullOrBlank()) {
val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem)
LibraryRepository.applyMembershipChanges(
item = libraryItem,
desiredMembership = currentMembership.mapValues { false },
)
} else {
LibraryRepository.removeFromList(libraryItem, listKey)
}
}.onFailure { error ->
NuvioToastController.show(
error.message ?: getString(Res.string.trakt_lists_update_failed),
)
}
}
} else {
LibraryRepository.removeFromList(libraryItem, listKey)
LibraryRepository.remove(libraryItem.id)
}
}.onFailure { error ->
NuvioToastController.show(
error.message ?: getString(Res.string.trakt_lists_update_failed),
)
}
}
} else {
LibraryRepository.remove(libraryItem.id)
}
} else {
if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
} else {
if (!isTraktLibrarySource) {
LibraryRepository.toggleSaved(libraryItem)
} else {
pickerItem = libraryItem
pickerTitle = preview.name
pickerTabs = LibraryRepository.libraryListTabs()
pickerMembership = pickerTabs.associate { it.key to false }
pickerPending = true
pickerError = null
showLibraryListPicker = true
coroutineScope.launch {
runCatching {
val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem)
val tabs = LibraryRepository.libraryListTabs()
pickerTabs = tabs
pickerMembership = tabs.associate { tab ->
tab.key to (snapshot[tab.key] == true)
}
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
}
pickerPending = false
}
}
}.onFailure { error ->
pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed)
}
pickerPending = false
},
),
PosterZoomOverlayAction(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
label = if (isWatched) {
stringResource(Res.string.hero_mark_unwatched)
} else {
stringResource(Res.string.hero_mark_watched)
},
onSelected = {
coroutineScope.launch {
WatchingActions.togglePosterWatched(preview)
}
},
),
),
hazeState = posterOverlayHazeState,
onDismissed = {
selectedPosterActionTarget = null
selectedPosterAnchor = null
},
)
}
}
selectedContinueWatchingForActions?.let { item ->
selectedContinueWatchingZoomAnchor?.let { anchor ->
key(item.videoId, anchor) {
val showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState)
val showDetailsOption = !item.isCloudLibraryContinueWatchingItem()
NuvioPosterZoomActionOverlay(
imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl),
title = item.title,
subtitle = localizedContinueWatchingSubtitle(item),
anchor = anchor,
actions = buildList {
if (showDetailsOption) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.Info,
label = stringResource(Res.string.cw_action_go_to_details),
onSelected = {
navController.navigate(
DetailRoute(
type = item.parentMetaType,
id = item.parentMetaId,
title = item.title,
),
)
},
),
)
}
}
}
if (showManualPlayOption) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.PlayArrow,
label = stringResource(Res.string.play_manually),
onSelected = { onContinueWatchingPlayManually(item) },
),
)
}
if (!item.isNextUp) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.Replay,
label = stringResource(Res.string.cw_action_start_from_beginning),
onSelected = { onContinueWatchingStartFromBeginning(item) },
),
)
}
add(
PosterZoomOverlayAction(
icon = Icons.Default.DeleteOutline,
label = stringResource(Res.string.cw_action_remove),
isDestructive = true,
onSelected = { onContinueWatchingRemove(item) },
),
)
},
hazeState = posterOverlayHazeState,
onDismissed = {
selectedContinueWatchingForActions = null
selectedContinueWatchingZoomAnchor = null
},
)
}
},
onToggleWatched = {
selectedPosterActionTarget?.preview?.let { preview ->
coroutineScope.launch {
WatchingActions.togglePosterWatched(preview)
}
}
},
)
}
}
NuvioContinueWatchingActionSheet(
item = selectedContinueWatchingForActions,
item = selectedContinueWatchingForActions.takeIf { selectedContinueWatchingZoomAnchor == null },
showManualPlayOption = StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState),
showDetailsOption = selectedContinueWatchingForActions?.isCloudLibraryContinueWatchingItem() != true,
onDismiss = { selectedContinueWatchingForActions = null },
@ -3227,19 +3373,7 @@ private fun MainAppContent(
onPlayManually = selectedContinueWatchingForActions
?.let { item -> { onContinueWatchingPlayManually(item) } },
onRemove = {
selectedContinueWatchingForActions?.let { item ->
if (item.isNextUp) {
ContinueWatchingPreferencesRepository.addDismissedNextUpKey(
nextUpDismissKey(
item.parentMetaId,
item.nextUpSeedSeasonNumber,
item.nextUpSeedEpisodeNumber,
),
)
} else {
WatchProgressRepository.removeProgress(contentId = item.parentMetaId)
}
}
selectedContinueWatchingForActions?.let(onContinueWatchingRemove)
},
)

View file

@ -4,119 +4,23 @@ import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CheckCircleOutline
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.features.home.MetaPreview
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.episodes_cd_watched
import nuvio.composeapp.generated.resources.hero_add_to_library
import nuvio.composeapp.generated.resources.hero_mark_unwatched
import nuvio.composeapp.generated.resources.hero_mark_watched
import nuvio.composeapp.generated.resources.hero_remove_from_library
import org.jetbrains.compose.resources.stringResource
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NuvioPosterActionSheet(
item: MetaPreview?,
isSaved: Boolean,
isWatched: Boolean,
onDismiss: () -> Unit,
onToggleLibrary: () -> Unit,
onToggleWatched: () -> Unit,
) {
if (item == null) return
val tokens = MaterialTheme.nuvio
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
NuvioModalBottomSheet(
onDismissRequest = {
coroutineScope.launch {
dismissNuvioBottomSheet(
sheetState = sheetState,
onDismiss = onDismiss,
)
}
},
sheetState = sheetState,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)),
) {
PosterSheetHeader(item = item)
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = if (isSaved) Icons.Default.Check else Icons.Default.Add,
title = if (isSaved) {
stringResource(Res.string.hero_remove_from_library)
} else {
stringResource(Res.string.hero_add_to_library)
},
onClick = {
onToggleLibrary()
coroutineScope.launch {
dismissNuvioBottomSheet(
sheetState = sheetState,
onDismiss = onDismiss,
)
}
},
)
NuvioBottomSheetDivider()
NuvioBottomSheetActionRow(
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
title = if (isWatched) {
stringResource(Res.string.hero_mark_unwatched)
} else {
stringResource(Res.string.hero_mark_watched)
},
onClick = {
onToggleWatched()
coroutineScope.launch {
dismissNuvioBottomSheet(
sheetState = sheetState,
onDismiss = onDismiss,
)
}
},
)
}
}
}
@Composable
fun NuvioWatchedBadge(
modifier: Modifier = Modifier,
@ -166,69 +70,3 @@ fun BoxScope.NuvioPosterWatchedOverlay(
.padding(padding),
)
}
@Composable
private fun PosterSheetHeader(
item: MetaPreview,
) {
val posterCardStyle = rememberPosterCardStyleUiState()
val tokens = MaterialTheme.nuvio
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12)
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
.background(tokens.colors.surfaceCard),
contentAlignment = Alignment.Center,
) {
if (item.poster != null) {
AsyncImage(
model = item.poster,
contentDescription = item.name,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = item.name,
modifier = Modifier.padding(tokens.spacing.listGap),
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
) {
Text(
text = item.name,
style = MaterialTheme.typography.titleLarge,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
text = item.releaseInfo?.takeIf { it.isNotBlank() }?.let { formatReleaseDateForDisplay(it) }
?: item.type.replaceFirstChar { char ->
if (char.isLowerCase()) char.titlecase() else char.toString()
},
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}

View file

@ -0,0 +1,495 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import androidx.compose.ui.unit.min
import coil3.compose.AsyncImage
import dev.chrisbanes.haze.HazeInputScale
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Geometry of the shelf card that was long-pressed, captured in root coordinates
* right before the long-press callback fires. Used as the start frame of the
* shared-element zoom in [NuvioPosterZoomActionOverlay].
*/
data class PosterZoomAnchor(
val boundsInRoot: Rect,
val imageUrl: String?,
val cornerRadius: Dp,
)
/**
* Hand-off slot between the pressed card and the overlay host. The card stashes
* its anchor synchronously inside the long-press callback; the host consumes it
* in the same call stack, so the value can never go stale.
*/
object PosterZoomAnchorHolder {
private var pending: PosterZoomAnchor? = null
fun stash(anchor: PosterZoomAnchor) {
pending = anchor
}
fun consume(): PosterZoomAnchor? = pending.also { pending = null }
}
class PosterZoomOverlayAction(
val icon: ImageVector,
val label: String,
val isDestructive: Boolean = false,
val onSelected: () -> Unit,
)
private enum class PosterZoomPhase {
Open,
Closing,
Disintegrating,
}
private const val PosterZoomVisibilityThreshold = 0.0005f
private val PosterZoomExpandSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = 340f,
visibilityThreshold = PosterZoomVisibilityThreshold,
)
private val PosterZoomCollapseSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = 460f,
visibilityThreshold = PosterZoomVisibilityThreshold,
)
private val PosterZoomMenuSpring = spring<Float>(
dampingRatio = 0.8f,
stiffness = 420f,
visibilityThreshold = Spring.DefaultDisplacementThreshold,
)
/**
* Apple-style long-press preview: the pressed poster lifts off the shelf and
* springs to the centre of the screen, the app content behind is blurred and
* dimmed, and an action menu cascades in underneath. Destructive actions burn
* the centred poster away with [DisintegratingContainer] instead of zooming back.
*/
@Composable
fun NuvioPosterZoomActionOverlay(
imageUrl: String?,
title: String,
subtitle: String?,
isWatched: Boolean = false,
anchor: PosterZoomAnchor?,
actions: List<PosterZoomOverlayAction>,
hazeState: HazeState,
onDismissed: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val hapticFeedback = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
// A context menu shows a snapshot of the moment it was invoked; don't let
// repository updates mid-animation relabel or reorder the rows.
val frozenActions = remember { actions }
val zoom = remember { Animatable(0f) }
val scrim = remember { Animatable(0f) }
val menu = remember { Animatable(0f) }
val shadowFade = remember { Animatable(1f) }
var phase by remember { mutableStateOf(PosterZoomPhase.Open) }
var rootOrigin by remember { mutableStateOf(Offset.Zero) }
var slotBounds by remember { mutableStateOf<Rect?>(null) }
LaunchedEffect(Unit) {
launch { scrim.animateTo(1f, tween(durationMillis = 260, easing = NuvioTokens.Motion.standard)) }
launch { zoom.animateTo(1f, PosterZoomExpandSpring) }
launch {
delay(60)
menu.animateTo(1f, PosterZoomMenuSpring)
}
}
fun close() {
if (phase != PosterZoomPhase.Open) return
phase = PosterZoomPhase.Closing
scope.launch {
coroutineScope {
launch { menu.animateTo(0f, tween(durationMillis = 140)) }
launch {
delay(80)
scrim.animateTo(0f, tween(durationMillis = 260, easing = NuvioTokens.Motion.standard))
}
launch { zoom.animateTo(0f, PosterZoomCollapseSpring) }
}
onDismissed()
}
}
fun select(action: PosterZoomOverlayAction) {
if (phase != PosterZoomPhase.Open) return
if (action.isDestructive) {
phase = PosterZoomPhase.Disintegrating
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
action.onSelected()
scope.launch {
launch { menu.animateTo(0f, tween(durationMillis = 160)) }
launch { shadowFade.animateTo(0f, tween(durationMillis = 350)) }
}
} else {
action.onSelected()
close()
}
}
PlatformBackHandler(enabled = true) {
close()
}
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { coordinates -> rootOrigin = coordinates.positionInRoot() }
.pointerInput(Unit) {
detectTapGestures { close() }
},
) {
val anchorAspect = anchor
?.boundsInRoot
?.takeIf { it.height > 0f }
?.let { it.width / it.height }
?: 0.675f
val aspect = anchorAspect.coerceIn(0.35f, 2.4f)
val maxPosterWidth = if (aspect >= 1f) maxWidth * 0.8f else maxWidth * 0.6f
val posterHeight = min(maxPosterWidth / aspect, maxHeight * 0.44f)
val posterWidth = posterHeight * aspect
val menuWidth = min(280.dp, maxWidth - NuvioTokens.Space.s48)
val columnWidth = max(posterWidth, menuWidth)
Box(
modifier = Modifier
.fillMaxSize()
.hazeEffect(state = hazeState) {
blurRadius = 36.dp
inputScale = HazeInputScale.Auto
noiseFactor = 0f
blurredEdgeTreatment = BlurredEdgeTreatment.Rectangle
clipToAreasBounds = false
expandLayerBounds = true
alpha = scrim.value.coerceIn(0f, 1f)
},
)
Box(
modifier = Modifier
.fillMaxSize()
.background(
Color.Black.copy(alpha = 0.45f * scrim.value.coerceIn(0f, 1f)),
),
)
Column(
modifier = Modifier
.align(Alignment.Center)
.width(columnWidth),
horizontalAlignment = Alignment.CenterHorizontally,
) {
// Invisible slot marking where the zoomed poster comes to rest.
Box(
modifier = Modifier
.size(width = posterWidth, height = posterHeight)
.onGloballyPositioned { coordinates -> slotBounds = coordinates.boundsInRoot() },
)
Spacer(modifier = Modifier.height(NuvioTokens.Space.s18))
Column(
modifier = Modifier
.width(columnWidth)
.graphicsLayer {
val progress = menu.value.coerceIn(0f, 1f)
alpha = progress
translationY = (1f - progress) * NuvioTokens.Space.s10.toPx()
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = tokens.colors.textPrimary,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s12),
)
if (!subtitle.isNullOrBlank()) {
Spacer(modifier = Modifier.height(NuvioTokens.Space.s4))
Text(
text = subtitle,
style = MaterialTheme.typography.bodyMedium,
color = tokens.colors.textMuted,
textAlign = TextAlign.Center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s12),
)
}
}
Spacer(modifier = Modifier.height(NuvioTokens.Space.s14))
Column(
modifier = Modifier
.width(menuWidth)
.graphicsLayer {
val progress = menu.value
val clamped = progress.coerceIn(0f, 1f)
alpha = clamped
val scale = 0.62f + 0.38f * progress
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0.5f, 0f)
}
.graphicsLayer {
shape = RoundedCornerShape(NuvioTokens.Space.s20.toPx())
clip = true
}
.background(tokens.colors.surfaceElevated),
) {
frozenActions.forEachIndexed { index, action ->
if (index > 0) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(NuvioTokens.Space.hairline)
.background(tokens.colors.textPrimary.copy(alpha = 0.08f)),
)
}
PosterZoomMenuRow(
action = action,
enabled = phase == PosterZoomPhase.Open,
onSelected = { select(action) },
modifier = Modifier.graphicsLayer {
val stagger = index * 0.07f
val progress = ((menu.value.coerceIn(0f, 1f) - stagger) / (1f - stagger))
.coerceIn(0f, 1f)
alpha = progress
translationY = (1f - progress) * NuvioTokens.Space.s8.toPx()
},
)
}
}
}
// The travelling poster itself, drawn above the slot column.
Box(
modifier = Modifier
.size(width = posterWidth, height = posterHeight)
.graphicsLayer {
val slot = slotBounds
if (slot == null || slot.width <= 0f) {
alpha = 0f
return@graphicsLayer
}
val progress = zoom.value
val clamped = progress.coerceIn(0f, 1f)
val start = posterStartBounds(anchor = anchor, slot = slot)
val scale = posterScale(anchor = anchor, slot = slot, progress = progress)
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin(0f, 0f)
translationX = lerp(start.left, slot.left, progress) - rootOrigin.x
translationY = lerp(start.top, slot.top, progress) - rootOrigin.y
alpha = if (anchor == null) {
clamped
} else {
(clamped / 0.08f).coerceIn(0f, 1f)
}
shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale))
clip = false
shadowElevation = NuvioTokens.Space.s24.toPx() * clamped * shadowFade.value
},
) {
DisintegratingContainer(
disintegrating = phase == PosterZoomPhase.Disintegrating,
onDisintegrated = {
scope.launch {
scrim.animateTo(0f, tween(durationMillis = 300, easing = NuvioTokens.Motion.standard))
onDismissed()
}
},
modifier = Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
val clamped = zoom.value.coerceIn(0f, 1f)
val slot = slotBounds
val scale = if (slot != null && slot.width > 0f) {
posterScale(anchor = anchor, slot = slot, progress = zoom.value)
} else {
1f
}
shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale))
clip = true
}
.background(tokens.colors.surfaceCard),
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
AsyncImage(
model = imageUrl,
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
} else {
Text(
text = title,
modifier = Modifier.padding(NuvioTokens.Space.s14),
style = MaterialTheme.typography.titleMedium,
color = tokens.colors.textMuted,
textAlign = TextAlign.Center,
maxLines = 4,
overflow = TextOverflow.Ellipsis,
)
}
NuvioPosterWatchedOverlay(
isWatched = isWatched,
modifier = Modifier.graphicsLayer {
val slot = slotBounds
if (slot != null && slot.width > 0f) {
val scale = posterScale(
anchor = anchor,
slot = slot,
progress = zoom.value,
).coerceAtLeast(0.001f)
scaleX = 1f / scale
scaleY = 1f / scale
transformOrigin = TransformOrigin(1f, 0f)
}
},
)
}
}
}
}
}
@Composable
private fun PosterZoomMenuRow(
action: PosterZoomOverlayAction,
enabled: Boolean,
onSelected: () -> Unit,
modifier: Modifier = Modifier,
) {
val tokens = MaterialTheme.nuvio
val contentColor = if (action.isDestructive) tokens.colors.danger else tokens.colors.textPrimary
Row(
modifier = modifier
.fillMaxWidth()
.clickable(enabled = enabled, onClick = onSelected)
.padding(horizontal = NuvioTokens.Space.s18, vertical = NuvioTokens.Space.s16),
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s12),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = action.label,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = action.icon,
contentDescription = null,
tint = contentColor,
modifier = Modifier.size(NuvioTokens.Icon.md),
)
}
}
private fun lerp(start: Float, stop: Float, fraction: Float): Float =
start + (stop - start) * fraction
private fun posterStartBounds(anchor: PosterZoomAnchor?, slot: Rect): Rect =
anchor?.boundsInRoot ?: slot.scaleAboutCenter(0.85f)
private fun posterScale(anchor: PosterZoomAnchor?, slot: Rect, progress: Float): Float =
lerp(posterStartBounds(anchor = anchor, slot = slot).width / slot.width, 1f, progress)
private fun Rect.scaleAboutCenter(factor: Float): Rect {
val newWidth = width * factor
val newHeight = height * factor
return Rect(
offset = Offset(center.x - newWidth / 2f, center.y - newHeight / 2f),
size = androidx.compose.ui.geometry.Size(newWidth, newHeight),
)
}
private fun androidx.compose.ui.unit.Density.posterCornerRadiusPx(
anchor: PosterZoomAnchor?,
clampedProgress: Float,
scale: Float,
): Float {
val finalRadius = PosterZoomFinalCornerRadius.toPx()
val startRadius = anchor?.cornerRadius?.toPx() ?: finalRadius
val apparent = lerp(startRadius, finalRadius, clampedProgress)
return if (scale > 0f) apparent / scale else apparent
}
private val PosterZoomFinalCornerRadius = NuvioTokens.Space.s18

View file

@ -25,11 +25,16 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.style.TextAlign
@ -143,7 +148,12 @@ fun NuvioPosterCard(
.aspectRatio(shape.aspectRatio)
.clip(cardShape)
.background(tokens.colors.surface)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,
zoomImageUrl = imageUrl,
zoomCornerRadius = posterCardStyle.cornerRadiusDp.dp,
),
contentAlignment = Alignment.Center,
) {
if (imageUrl != null) {
@ -344,15 +354,42 @@ private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp =
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun Modifier.posterCardClickable(
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
): Modifier =
if (onClick != null || onLongClick != null) {
combinedClickable(
zoomImageUrl: String? = null,
zoomCornerRadius: Dp = NuvioTokens.Radius.poster,
): Modifier {
if (onClick == null && onLongClick == null) return this
val bounds = remember { mutableStateOf<Rect?>(null) }
return this
.onGloballyPositioned { coordinates -> bounds.value = coordinates.unclippedBoundsInRoot() }
.combinedClickable(
onClick = { onClick?.invoke() },
onLongClick = onLongClick,
onLongClick = onLongClick?.let { longClick ->
{
bounds.value?.let { cardBounds ->
PosterZoomAnchorHolder.stash(
PosterZoomAnchor(
boundsInRoot = cardBounds,
imageUrl = zoomImageUrl,
cornerRadius = zoomCornerRadius,
),
)
}
longClick()
}
},
)
} else {
this
}
}
private fun androidx.compose.ui.layout.LayoutCoordinates.unclippedBoundsInRoot(): Rect {
val position = positionInRoot()
return Rect(
left = position.x,
top = position.y,
right = position.x + size.width,
bottom = position.y + size.height,
)
}

View file

@ -304,7 +304,12 @@ private fun CatalogPosterTile(
.aspectRatio(item.posterShape.catalogAspectRatio())
.clip(RoundedCornerShape(cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surface)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,
zoomImageUrl = item.poster,
zoomCornerRadius = cornerRadiusDp.dp,
),
) {
if (item.poster != null) {
AsyncImage(

View file

@ -34,6 +34,9 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.CheckCircleOutline
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.PlaylistAddCheckCircle
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import com.nuvio.app.core.ui.NuvioLoadingIndicator
@ -68,9 +71,16 @@ import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.build.TrailerPlaybackMode
import com.nuvio.app.core.network.NetworkCondition
import com.nuvio.app.core.network.NetworkStatusRepository
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.core.ui.NuvioBackButton
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
import com.nuvio.app.core.ui.PosterZoomAnchor
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
import com.nuvio.app.core.ui.PosterZoomOverlayAction
import com.nuvio.app.core.ui.TraktListPickerDialog
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.rememberHazeState
import com.nuvio.app.features.details.components.DetailActionButtons
import com.nuvio.app.features.details.components.DetailSecondaryAction
import com.nuvio.app.features.details.components.CommentDetailSheet
@ -169,6 +179,9 @@ fun MetaDetailsScreen(
WatchProgressRepository.ensureLoaded()
WatchProgressRepository.uiState
}.collectAsStateWithLifecycle()
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
val playerSettingsUiState by remember {
PlayerSettingsRepository.ensureLoaded()
PlayerSettingsRepository.uiState
@ -177,6 +190,8 @@ fun MetaDetailsScreen(
var autoLoadAttempted by remember(type, id) { mutableStateOf(false) }
var observedOfflineState by remember(type, id) { mutableStateOf(false) }
var selectedEpisodeForActions by remember(type, id) { mutableStateOf<MetaVideo?>(null) }
var selectedEpisodeZoomAnchor by remember(type, id) { mutableStateOf<PosterZoomAnchor?>(null) }
val episodeOverlayHazeState = rememberHazeState()
var selectedSeasonForActions by remember(type, id) { mutableStateOf<Int?>(null) }
val commentsEnabled by remember {
TraktCommentsSettings.ensureLoaded()
@ -308,7 +323,19 @@ fun MetaDetailsScreen(
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
) {
when {
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (selectedEpisodeZoomAnchor != null) {
Modifier.hazeSource(state = episodeOverlayHazeState)
} else {
Modifier
},
)
.background(MaterialTheme.colorScheme.background),
) {
when {
displayedMeta == null && uiState.isLoading -> {
NuvioLoadingIndicator(
modifier = Modifier.align(Alignment.Center),
@ -407,9 +434,6 @@ fun MetaDetailsScreen(
Unit
}
}
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
LaunchedEffect(meta.id, meta.type, watchProgressUiState.hasLoadedRemoteProgress) {
if (meta.type.lowercase() in setOf("series", "show", "tv", "tvshow")) {
WatchProgressRepository.refreshEpisodeProgress(meta.id)
@ -953,7 +977,10 @@ fun MetaDetailsScreen(
watchedKeys = watchedUiState.watchedKeys,
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
onEpisodeClick = onEpisodePlayClick,
onEpisodeLongPress = { video -> selectedEpisodeForActions = video },
onEpisodeLongPress = { video ->
selectedEpisodeZoomAnchor = PosterZoomAnchorHolder.consume()
selectedEpisodeForActions = video
},
onSeasonLongPress = { season -> selectedSeasonForActions = season },
onOpenMeta = onOpenMeta,
onCastClick = onCastClick,
@ -1013,7 +1040,9 @@ fun MetaDetailsScreen(
modifier = Modifier.zIndex(2f),
)
selectedEpisodeForActions?.let { selectedEpisode ->
selectedEpisodeForActions
?.takeIf { selectedEpisodeZoomAnchor == null }
?.let { selectedEpisode ->
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
isEpisodeWatchedForActions(
meta = meta,
@ -1255,6 +1284,155 @@ fun MetaDetailsScreen(
contentColor = MaterialTheme.colorScheme.onBackground,
)
}
}
val meta = displayedMeta
val selectedEpisode = selectedEpisodeForActions
val zoomAnchor = selectedEpisodeZoomAnchor
if (meta != null && selectedEpisode != null && zoomAnchor != null) {
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val isSelectedEpisodeWatched = remember(meta, selectedEpisode, watchedUiState.watchedKeys, progressByVideoId) {
isEpisodeWatchedForActions(
meta = meta,
episode = selectedEpisode,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val previousEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.previousReleasedEpisodesBefore(
target = selectedEpisode,
todayIsoDate = todayIsoDate,
)
}
val seasonEpisodes = remember(meta, selectedEpisode, todayIsoDate) {
meta.releasedEpisodesForSeason(
seasonNumber = selectedEpisode.season,
todayIsoDate = todayIsoDate,
)
}
val arePreviousEpisodesWatched = remember(previousEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = previousEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val isSeasonWatched = remember(seasonEpisodes, watchedUiState.watchedKeys, progressByVideoId) {
areEpisodesWatchedForActions(
meta = meta,
episodes = seasonEpisodes,
watchedKeys = watchedUiState.watchedKeys,
progressByVideoId = progressByVideoId,
)
}
val seasonLabel = selectedEpisode.season?.let {
stringResource(Res.string.episodes_season, it)
} ?: stringResource(Res.string.episodes_specials)
NuvioPosterZoomActionOverlay(
imageUrl = zoomAnchor.imageUrl ?: selectedEpisode.thumbnail ?: meta.background ?: meta.poster,
title = selectedEpisode.title,
subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel,
isWatched = isSelectedEpisodeWatched,
anchor = zoomAnchor,
actions = buildList {
add(
PosterZoomOverlayAction(
icon = Icons.Default.CheckCircle,
label = if (isSelectedEpisodeWatched) {
stringResource(Res.string.episode_mark_unwatched)
} else {
stringResource(Res.string.episode_mark_watched)
},
onSelected = {
WatchingActions.toggleEpisodeWatched(
meta = meta,
episode = selectedEpisode,
isCurrentlyWatched = isSelectedEpisodeWatched,
)
},
),
)
if (previousEpisodes.isNotEmpty()) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.DoneAll,
label = if (arePreviousEpisodesWatched) {
stringResource(Res.string.episode_mark_previous_unwatched)
} else {
stringResource(Res.string.episode_mark_previous_watched)
},
onSelected = {
WatchingActions.togglePreviousEpisodesWatched(
meta = meta,
episodes = previousEpisodes,
areCurrentlyWatched = arePreviousEpisodesWatched,
)
},
),
)
}
add(
PosterZoomOverlayAction(
icon = Icons.Default.PlaylistAddCheckCircle,
label = if (isSeasonWatched) {
stringResource(Res.string.episode_mark_season_unwatched, seasonLabel)
} else {
stringResource(Res.string.episode_mark_season_watched, seasonLabel)
},
onSelected = {
WatchingActions.toggleSeasonWatched(
meta = meta,
episodes = seasonEpisodes,
areCurrentlyWatched = isSeasonWatched,
)
},
),
)
if (onPlayManually != null && StreamAutoPlayPolicy.isEffectivelyEnabled(playerSettingsUiState)) {
add(
PosterZoomOverlayAction(
icon = Icons.Default.PlayArrow,
label = stringResource(Res.string.play_manually),
onSelected = {
val playbackVideoId = buildPlaybackVideoId(
parentMetaId = meta.id,
seasonNumber = selectedEpisode.season,
episodeNumber = selectedEpisode.episode,
fallbackVideoId = selectedEpisode.id,
)
val streamVideoId = selectedEpisode.id.takeIf { it.isNotBlank() } ?: playbackVideoId
val savedProgress = progressByVideoId[streamVideoId]
?.takeUnless { it.isCompleted }
onPlayManually.invoke(
meta.type,
streamVideoId,
meta.id,
meta.type,
meta.name,
meta.logo,
meta.poster,
meta.background,
selectedEpisode.season,
selectedEpisode.episode,
selectedEpisode.title,
selectedEpisode.thumbnail,
selectedEpisode.overview,
savedProgress?.lastPositionMs,
)
},
),
)
}
},
hazeState = episodeOverlayHazeState,
onDismissed = {
selectedEpisodeForActions = null
selectedEpisodeZoomAnchor = null
},
)
}
}
}

View file

@ -65,6 +65,7 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
import com.nuvio.app.core.ui.NuvioProgressBar
import com.nuvio.app.core.ui.posterCardClickable
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaEpisodeCardStyle
import com.nuvio.app.features.details.MetaVideo
@ -662,6 +663,7 @@ private fun EpisodeHorizontalCard(
val ratingLabel = remember(imdbRating) { imdbRating?.takeIf { it > 0.0 }?.let(::formatEpisodeRating) }
val formattedDate = remember(video.released) { video.released?.let { formatReleaseDateForDisplay(it) } }
val runtimeLabel = remember(video.runtime) { video.runtime?.takeIf { it > 0 }?.let(::formatEpisodeRuntime) }
val imageUrl = video.thumbnail ?: fallbackImage
Box(
modifier = Modifier
.width(metrics.cardWidth)
@ -673,13 +675,13 @@ private fun EpisodeHorizontalCard(
color = Color.White.copy(alpha = 0.12f),
shape = cardShape,
)
.combinedClickable(
enabled = onClick != null || onLongPress != null,
onClick = { onClick?.invoke() },
.posterCardClickable(
onClick = onClick,
onLongClick = onLongPress,
zoomImageUrl = imageUrl,
zoomCornerRadius = metrics.cornerRadius,
),
) {
val imageUrl = video.thumbnail ?: fallbackImage
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched
if (imageUrl != null) {
AsyncImage(

View file

@ -703,7 +703,12 @@ private fun ContinueWatchingCard(
.aspectRatio(PosterLandscapeAspectRatio)
.clip(RoundedCornerShape(cardMetrics.cornerRadius))
.background(MaterialTheme.colorScheme.surfaceVariant)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,
zoomImageUrl = imageUrl,
zoomCornerRadius = cardMetrics.cornerRadius,
),
) {
if (imageUrl != null) {
AsyncImage(
@ -994,6 +999,7 @@ private fun ContinueWatchingPosterCard(
onClick: (() -> Unit)?,
onLongClick: (() -> Unit)?,
) {
val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails)
Column(
modifier = Modifier.width(layout.posterCardWidth),
verticalArrangement = Arrangement.spacedBy(8.dp),
@ -1004,9 +1010,13 @@ private fun ContinueWatchingPosterCard(
.height(layout.posterCardHeight)
.clip(RoundedCornerShape(layout.cardRadius))
.background(MaterialTheme.colorScheme.surfaceVariant)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,
zoomImageUrl = imageUrl,
zoomCornerRadius = layout.cardRadius,
),
) {
val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails)
val shouldBlurArtwork = blurNextUp &&
useEpisodeThumbnails &&
item.isNextUp &&

View file

@ -252,7 +252,12 @@ private fun DiscoverPosterTile(
.aspectRatio(item.posterShape.discoverAspectRatio())
.clip(RoundedCornerShape(cornerRadiusDp.dp))
.background(MaterialTheme.colorScheme.surface)
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
.posterCardClickable(
onClick = onClick,
onLongClick = onLongClick,
zoomImageUrl = item.poster,
zoomCornerRadius = cornerRadiusDp.dp,
),
) {
if (item.poster != null) {
AsyncImage(