mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-27 01:22:18 +00:00
feat: add zoom overlay
This commit is contained in:
parent
d622d1384c
commit
2e1af6ea1a
11 changed files with 971 additions and 487 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 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<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 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<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.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) |
|
||||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue