Publish current app and server
This commit is contained in:
+30
-5
@@ -16,15 +16,21 @@ MEMBY_PORT=32768
|
||||
|
||||
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
|
||||
MEMBY_LOG_LEVEL=INFO
|
||||
MEMBY_LOG_BUFFER_CAPACITY=5000
|
||||
MEMBY_GOMEMLIMIT=384MiB
|
||||
MEMBY_SERVER_MEMORY_LIMIT=512m
|
||||
|
||||
# Used for "today" boundaries and human-readable Sonarr air/download times.
|
||||
# Used for local Sonarr air times and Radarr digital-release day boundaries.
|
||||
MEMBY_TIMEZONE=Pacific/Auckland
|
||||
|
||||
# How long a cached home payload stays warm.
|
||||
MEMBY_HOME_TTL=60s
|
||||
MEMBY_RECOMMEND_TTL=24h
|
||||
# Optional JSON overlay on the explainable weighted-ranking defaults.
|
||||
# MEMBY_RECOMMENDATION_WEIGHTS={"ExplorationRate":0.08,"MinimumEvidence":2,"ImpressionPenalty":0.12}
|
||||
MEMBY_RECOMMENDATION_WEIGHTS=
|
||||
|
||||
# Maximum number of distinct Memby TVs one Emby user may keep signed in.
|
||||
MEMBY_MAX_CLIENTS_PER_USER=1
|
||||
|
||||
# Admin interface at https://mserver.sublogue.com/admin/ — library imports, the
|
||||
# maintenance switch, and row analytics. Leave blank to disable /admin entirely. Generate with
|
||||
@@ -34,7 +40,7 @@ MEMBY_ADMIN_TOKEN=4fad67d508558efee5cc5ae05694105421d4c79d35ee2333a817b3791235cd
|
||||
# Public gateway address and a dedicated token used only by the Gitea release workflow.
|
||||
# Generate the token with: openssl rand -hex 32
|
||||
MEMBY_PUBLIC_URL=https://mserver.sublogue.com
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN=
|
||||
MEMBY_RELEASE_PUBLISH_TOKEN=4b0a891382f056d6ab2ddb43ad08fbbadda0a5a7db9371c7d90fd57b38d083d2
|
||||
|
||||
# Library import. Hourly incremental keeps up with episodes added through the day.
|
||||
MEMBY_SYNC_INTERVAL=1h
|
||||
@@ -55,6 +61,24 @@ MEMBY_SONARR_TTL=5m
|
||||
# 0 turns the banners off and leaves the airing-today row alone.
|
||||
MEMBY_SONARR_ALERT_WINDOW=3h
|
||||
|
||||
# Optional Radarr calendar integration. Upcoming movies are selected strictly from
|
||||
# Radarr's digital release date; theatrical and physical dates are ignored.
|
||||
MEMBY_RADARR_URL=http://10.0.0.2:7878
|
||||
MEMBY_RADARR_API_KEY=d393acb157a44dc2b0e2aede96278ad5
|
||||
MEMBY_RADARR_TTL=5m
|
||||
# Shared secret for Radarr's "On Import" webhook, which announces a newly added film on
|
||||
# every TV that is awake. In Radarr: Settings > Connect > + > Webhook, On Import only,
|
||||
# URL https://<public gateway>/hooks/radarr?token=<this value>. Empty makes the hook 404.
|
||||
MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758
|
||||
# How long an imported film keeps being announced, so a TV switched on shortly after the
|
||||
# import still hears about it. 0 turns the banners off.
|
||||
MEMBY_RADARR_ALERT_WINDOW=3h
|
||||
|
||||
# How often the gateway checks that Emby is answering. A run of failures raises the
|
||||
# "server not responding" banner on every TV, including mid-playback, and recovery
|
||||
# raises the matching "back online" one. 0 turns the probe and both banners off.
|
||||
MEMBY_EMBY_HEALTH_INTERVAL=60s
|
||||
|
||||
# Optional Tracearr-powered For You signals. Create a read-only public API key in
|
||||
# Tracearr Settings. Server ID is optional unless Tracearr monitors multiple servers.
|
||||
MEMBY_TRACEARR_URL=https://tracearr.sublogue.com/
|
||||
@@ -62,5 +86,6 @@ MEMBY_TRACEARR_API_KEY=trr_pub_WKSdiZFGZ_10d-zgQ2Wx0H4Ym4NAiIeLBSvf8a6rvZ0
|
||||
MEMBY_TRACEARR_SERVER_ID=6964b9ed-3a21-4f51-b94b-cd344ec42c1b
|
||||
MEMBY_TRACEARR_SYNC_INTERVAL=5m
|
||||
MEMBY_TRACEARR_FULL_INTERVAL=24h
|
||||
MEMBY_FOR_YOU_MIN_REBUILD_AGE=10m
|
||||
MEMBY_FOR_YOU_REFRESH_INTERVAL=30m
|
||||
MEMBY_FOR_YOU_MIN_REBUILD_AGE=24h
|
||||
MEMBY_FOR_YOU_REFRESH_INTERVAL=24h
|
||||
MEMBY_FOR_YOU_REBUILD_HOUR=4
|
||||
|
||||
@@ -18,6 +18,7 @@ local.properties
|
||||
# Holds the Postgres password, admin token and Emby address.
|
||||
.env
|
||||
/server/bin/
|
||||
/server/.tmp-go-cache/
|
||||
|
||||
# Release artefacts and signing material. The keystore must never be committed:
|
||||
# whoever holds it can publish an update that installs over Memby.
|
||||
|
||||
@@ -203,9 +203,38 @@ the rail) because otherwise D-pad focus has nowhere to go once the rows are gone
|
||||
`MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it
|
||||
carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar
|
||||
across the top of the screen, broadcast-notice style (it spans the navigation rail too).
|
||||
The only producer today is `api/alerts.go` — an episode whose Sonarr air time has passed
|
||||
but which Emby has not imported yet ("aired, coming soon"). It reads the *cached*
|
||||
airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve:
|
||||
Alerts come from two shapes of producer. **Derived**: `api/alerts.go` announces an episode
|
||||
whose Sonarr air time has passed but which Emby has not imported yet ("aired, coming
|
||||
soon"), recomputed per poll from the *cached* airing-today calendar, so polling clients
|
||||
never cost a Sonarr request. **Events**, which are published into one shared Redis list
|
||||
(`publishAlert` / `publishedAlerts`, also in `alerts.go`) and served from it until their
|
||||
window closes — a list rather than a push because the gateway holds no connection to a
|
||||
television, and a window is what lets a set that was off or in the screensaver at the time
|
||||
still hear the news. Three publishers today:
|
||||
|
||||
- `api/radarr_alerts.go` — a film Radarr just imported. `POST /hooks/radarr` is the "On
|
||||
Import" webhook and the one thing that pushes *into* the gateway, guarded by
|
||||
`MEMBY_RADARR_WEBHOOK_TOKEN` (unset ⇒ 404, the stance `/admin` takes) and mounted
|
||||
outside both the auth middleware and the maintenance gate, because an event dropped
|
||||
during maintenance is lost rather than delayed. A quality upgrade is deliberately
|
||||
silent: the film was already there.
|
||||
- `AnnounceLibrarySync` in `api/server_alerts.go`, hung off `syncer.SetAfterSync` in
|
||||
`main.go` — "24 titles added or updated". Only a run that *changed* something is
|
||||
announced; the import is scheduled, most passes find nothing, and an hourly "no news"
|
||||
banner would train viewers to ignore the real ones.
|
||||
- `WatchEmbyReachability`, same file — "Emby has stopped communicating" and the matching
|
||||
"back online". Only transitions are announced, and only after
|
||||
`embyFailureThreshold` consecutive failures, because one timeout is a hiccup and
|
||||
repeating an outage every minute would bury everything else. This is the alert that
|
||||
earns the banner its place over playback: video direct-plays from Emby, so when Emby
|
||||
stops answering the film stalls with no explanation, and the gateway is still up to
|
||||
say why. `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe and both banners off.
|
||||
|
||||
`mergeAlerts` interleaves derived and published alerts newest-first and caps at
|
||||
`maxAlerts`, which is why every alert carries a timestamp. Alerts also carry their own
|
||||
`label` (the banner's eyebrow — "JUST AIRED", "NEW MOVIE ADDED", "SERVER NOT RESPONDING"),
|
||||
so a new kind of news reads correctly on an app that predates it; a client that receives
|
||||
none falls back to the episode wording. Things to preserve:
|
||||
the server has no idea which TVs saw what, so the client dedupes by id against
|
||||
`SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an
|
||||
alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no
|
||||
@@ -219,7 +248,21 @@ times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring c
|
||||
down — take the duration from that constant, or the ring and the timer drift apart),
|
||||
because stealing D-pad focus mid-browse is worse than a missed notice;
|
||||
and alerts are suppressed under maintenance and under a mandatory update, which own the
|
||||
screen. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row.
|
||||
screen.
|
||||
|
||||
**The bar appears over playback too**, not only over the launcher: `PlayerActivity`
|
||||
mounts the same composable in a `ComposeView` (`player_service_alerts` in
|
||||
`activity_player.xml`, declared before the loading and error overlays so those cover it).
|
||||
`alertsSuppressed` starts *true* and is cleared only by `hidePlaybackLoading`, which is
|
||||
reached once the preroll is over and the first frame is up — an alert composed behind an
|
||||
overlay would be marked seen by a viewer who never saw it, which is the same failure
|
||||
`alertShown` exists to prevent. Because it now covers somebody's film, the bar is
|
||||
deliberately small (76dp), near-black, and eases in over ~680ms rather than snapping
|
||||
down. It shows the **Emby mark**, not item artwork: a library refresh and an outage have
|
||||
no artwork, and one constant mark reads as "your server is talking" where a poster made
|
||||
every alert look like a different feature. The wire still carries `itemId`/`imageTag`;
|
||||
the client just does not render them. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row,
|
||||
and `MEMBY_RADARR_ALERT_WINDOW=0` does the same for movie imports.
|
||||
|
||||
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
|
||||
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
|
||||
@@ -254,6 +297,15 @@ step, no CDN — a strict no-dependency page is the whole point). It polls
|
||||
`/admin/api/status` every 5s. Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin`
|
||||
route 404s.
|
||||
|
||||
**Explaining a recommendation** is `recommend/explain.go`: `Why(profile, item, limit)` is a
|
||||
pure function turning the learned weights into the phrases a detail page shows. It is kept
|
||||
apart from `Score` on purpose — the scorer decides *order* and may be opaque, this decides
|
||||
*wording* and must never invent an affinity, which is what `reasonFloor` and the
|
||||
unit tests enforce. `PersonWeights` exists only for this: casting is a good reason to tell
|
||||
someone about a title and a poor reason to rank by it. `api/related.go` serves it beside
|
||||
Emby's similarity list at `GET /v1/items/{id}/related`, cached per user and item because
|
||||
building the profile costs the same Emby fan-out the home rows pay for.
|
||||
|
||||
**Recommendations** live in `server/internal/recommend`: `profile.go` is pure scoring
|
||||
(recency-weighted genre/studio affinity, exclusion of anything seen) and `engine.go` does
|
||||
the Emby fan-out. Both are unit-tested without a network — `engine.go` takes a narrow
|
||||
@@ -279,15 +331,58 @@ Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks
|
||||
`resumePositionMs`).
|
||||
|
||||
**Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server,
|
||||
token, userId, plus that profile's cached home JSON) *and* mirrors the active profile into
|
||||
the flat top-level keys the rest of the app reads. `switchProfile`/`saveSession` must keep
|
||||
both in sync; `legacyProfile()` synthesises a profile from the flat keys for installs that
|
||||
predate the list. `deviceId` is intentionally preserved across `clearSession()`.
|
||||
token, userId) *and* mirrors the active profile into the flat top-level keys the rest of
|
||||
the app reads. `switchProfile`/`saveSession` must keep both in sync; `legacyProfile()`
|
||||
synthesises a profile from the flat keys for installs that predate the list. `deviceId` is
|
||||
intentionally preserved across `clearSession()`.
|
||||
|
||||
The profiles blob deliberately does **not** carry cached home JSON. Preferences DataStore
|
||||
rewrites and fsyncs the whole file on every edit, so embedding a several-hundred-KB cache
|
||||
per profile meant every settings toggle rewrote all of them. `writeProfiles` is the single
|
||||
writer and strips the field out into a per-profile `home_cache::<userId>@<serverUrl>` key,
|
||||
which doubles as the migration for installs that still embed one — `applyProfile` reads
|
||||
the dedicated key and falls back to the embedded copy. Add a profiles write and it must go
|
||||
through `writeProfiles`.
|
||||
|
||||
That per-profile key is also the **read** path (`activeHomeCache`), and the only place the
|
||||
cache is written. The flat `home_cache` key remains only for a store with no active profile
|
||||
to key against, and for installs written before the split. It briefly held a second copy of
|
||||
every cache alongside the per-profile one, which put the largest value in the store into the
|
||||
file twice — on a format that rewrites and fsyncs the whole file per edit, and on a value
|
||||
rewritten by every home refresh.
|
||||
|
||||
**Home startup path.** `HomeCache` (last successful home response) is persisted per profile
|
||||
and used as the initial `HomeUiState`, so the launcher renders rows before the network
|
||||
returns; sections then refresh in parallel under a `Mutex` and re-persist. Playback stops
|
||||
are broadcast through `repository.playbackStops` and refresh only the Continue/Next-Up rows.
|
||||
Three things protect that "before the network returns" promise, and all three are easy to
|
||||
undo by accident:
|
||||
|
||||
- **Nothing on the signed-in path may block on a request.** `AppRoot` used to hold the
|
||||
launcher on the loading screen until `getRecommendationOnboarding()` answered, which on a
|
||||
slow connection meant the cached rows could not be drawn until the connect timeout
|
||||
expired. Onboarding completion is now persisted (`Settings.hasCompletedOnboarding`,
|
||||
`markOnboardingCompleted`) and consulted first; the gateway is still asked in the
|
||||
background and stays authoritative for anyone not yet recorded. For a profile with no
|
||||
record yet the ask is still on the critical path, so it is bounded by
|
||||
`ONBOARDING_CHECK_TIMEOUT_MS` and fails open to the launcher — and only a verdict the
|
||||
gateway actually returned is persisted, or a single slow response would retire the rating
|
||||
screen for someone who has never seen it. The `onboardingToken` guard that stops repeat
|
||||
asks must always be paired with a null check on the verdict itself: on its own it can
|
||||
match a restarted effect's own token and strand the launcher on the loading screen
|
||||
permanently.
|
||||
- **The settings flow must never be able to terminate.** It is the gate everything waits
|
||||
behind, and a `shareIn`ed flow that completes exceptionally never emits again — so a
|
||||
single failed read shows up as "Opening Memby…" forever, surviving relaunch. Hence the
|
||||
`ReplaceFileCorruptionHandler` on the DataStore and the `catch` before `shareIn`. Do not
|
||||
remove either: this store is rewritten on every home refresh, so a process killed
|
||||
mid-write is an ordinary event on a TV.
|
||||
- **The cache is decoded off the main thread.** `SettingsStore.primeHomeCache` parses it on
|
||||
the store's IO scope as the settings flow emits, and `homeCache()` returns that memoized
|
||||
copy — `HomeViewModel`'s constructor runs during composition, so decoding there parsed
|
||||
the whole blob on the main thread at exactly the wrong moment.
|
||||
- **`setHomeCache` skips unchanged writes** (`lastPersistedHomeCache`). It runs on every
|
||||
refresh *and* every playback stop, and most passes find nothing new.
|
||||
|
||||
**Screensaver hosting.** `ScreensaverContent` is shared by `MembyDreamService` and
|
||||
`ScreensaverActivity`. A `DreamService` is not a `ComponentActivity`, so
|
||||
@@ -324,9 +419,32 @@ inside the running player instead of relaunching the activity, so `itemId`/`play
|
||||
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
|
||||
|
||||
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
|
||||
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
|
||||
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
|
||||
debug-influenced.
|
||||
tag `EmbyClientPerf`. `benchmark/` is a `com.android.test` macrobenchmark module targeting
|
||||
the release variants the `androidx.baselineprofile` plugin generates, so its numbers are
|
||||
real rather than debug-influenced. `HomeBenchmark` deliberately measures cold start twice —
|
||||
`CompilationMode.None()` and `Partial()` — because the only way to know the baseline
|
||||
profile is earning its keep is to see both numbers.
|
||||
|
||||
**Release builds are minified.** `isMinifyEnabled`/`isShrinkResources` are on, which takes
|
||||
the APK from ~14.9MB to ~3.1MB and the dex from 49MB across four files to 4.9MB in one —
|
||||
no multidex, which matters because `minSdk` is 23. `proguard-rules.pro` is what keeps that
|
||||
safe: it matches `@kotlinx.serialization.Serializable` on the *annotation* rather than
|
||||
listing packages, because the previous rules named `com.mattcohen.embyscreensaver.data.model`
|
||||
and had silently matched nothing since v0.1.53. Lint still has `abortOnError = false`, so
|
||||
R8 warnings do not fail the build — check the task output after changing dependencies.
|
||||
|
||||
**Baseline profile.** `androidx.profileinstaller` plus a profile generated by
|
||||
`benchmark/BaselineProfileGenerator.kt`. Regenerate against a real television with
|
||||
`.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked in under
|
||||
`app/src/release/generated/baselineProfiles`, so an ordinary `assembleRelease` needs no
|
||||
device. A stale profile is not harmful, only progressively less useful.
|
||||
|
||||
**One HTTP stack.** `data/remote/HttpStack.kt` owns the single `OkHttpClient` that the
|
||||
Emby API, the gateway API and Coil's artwork loader all derive from with `newBuilder()`,
|
||||
so they share one connection pool and dispatcher. This matters most for artwork: in
|
||||
gateway mode the images are proxied by the same HTTPS host that serves `/v1/home`, so a
|
||||
separate client would repeat the TLS handshake for every poster. Don't construct a bare
|
||||
`OkHttpClient.Builder()` — derive from `HttpStack.base`.
|
||||
|
||||
## UI conventions
|
||||
|
||||
@@ -346,6 +464,125 @@ recompositions of one number over ten seconds instead of ~600 of the whole bar.
|
||||
rule applies to collecting flows — collect in the smallest composable that needs the value,
|
||||
not at the top of `MainActivity`, or every emission recomposes the launcher.
|
||||
|
||||
**Where a composable is too large to split, narrow the state instead.** `HomeScreen` is
|
||||
the case: it cannot reasonably collect `HomeUiState` in one place, because reading the
|
||||
whole object there meant an arriving update verdict, a slow-connection banner or any one
|
||||
of the four section loads invalidated the launcher *and* rebuilt every row with it. So
|
||||
`HomeViewModel` exposes three `distinctUntilChanged` projections — `content` (rows and
|
||||
their loading flags), `status` (connection health) and `appUpdate` — and the screen
|
||||
subscribes to each where it is rendered. `contentSlice()` blanks the non-row fields rather
|
||||
than introducing a separate type, which is what lets `homeRowsFor` keep taking a
|
||||
`HomeUiState` and the tests that pin it keep working; read only rows and `loading` from it.
|
||||
|
||||
**Design tokens.** `ui/theme/DesignTokens.kt` is the one vocabulary both surfaces read:
|
||||
`MembySurface` (the near-black), `MembyAccent`, `MembyOnSurface`/`MembyMutedText`/
|
||||
`MembyQuietText`, `MembyScore` for the community rating, three corner radii
|
||||
(`MembyChipCorner` 8dp, `MembyCardCorner` 10dp, `MembyPanelCorner` 14dp), and the two
|
||||
separators — `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a
|
||||
list. `HomeComponents`' `EmbyGreen`/`MutedText`/`QuietText` and `DetailPageComponents`'
|
||||
`Detail*` colours are aliases of these; they had drifted into four near-blacks, four greens
|
||||
and two secondary greys, which is visible the moment a detail page opens from a row. A new
|
||||
colour or radius belongs in the token file, or is a considered exception — not a fifth
|
||||
value.
|
||||
|
||||
**One button language.** `ui/MembyButtons.kt` — `MembyPlayButton` (focusable),
|
||||
`MembyPlayChip` (the same surface as decoration inside an already-focusable parent, for the
|
||||
home hero) and `MembyChoiceChip`. There were three: this one, a hand-rolled copy in the hero
|
||||
with the same look and different metrics, and raw `androidx.tv.material3.Button`s with
|
||||
glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours
|
||||
nothing around them uses.
|
||||
|
||||
**One runtime formatter, one 4K threshold.** `detail/DetailFacts.kt` owns `formatRuntime`,
|
||||
`heroFacts`, `ratingLabel`, `dynamicRangeLabel` and `UHD_MIN_WIDTH`; the home hero and the
|
||||
card metadata call them rather than carrying private copies. That is why the hero and the
|
||||
card directly beneath it agree on "2h 4m", and why `mediaBadges` and the spec row's `(4K)`
|
||||
suffix fire at the same width.
|
||||
|
||||
**Home rows all lead with the same header**: `HomeRowHeaderIcon` + `HomeRowHeaderIconGap`,
|
||||
`HomeRowHeaderSpacing`. A header that skips the icon chip starts its title 38dp left of
|
||||
every other row, and the launcher's row titles are read as one column.
|
||||
|
||||
**The home hero** (`ui/HomeMovieHero.kt`) is a featured card plus three minis, each a
|
||||
`HomeHeroPick` — the item *and the row it was drawn from*. The caption used to be the card's
|
||||
slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interleaves sources
|
||||
and falls back to every movie in the response, so it routinely lied. The featured card is a
|
||||
fixed height with its content centred, so anything over budget is lost top and bottom and
|
||||
Play, being last, goes first: a wrapped title stands the synopsis down rather than the
|
||||
button.
|
||||
|
||||
**Detail pages** are one editorial layout shared by movies and series: `DetailPageScaffold`
|
||||
in `ui/DetailPageComponents.kt` over the pure vocabulary in `ui/detail/DetailFacts.kt`. It
|
||||
is a full-bleed cinematic hero — backdrop under two scrims, logo or title, `heroFacts` line
|
||||
(year · length · certificate) with the score and format badges trailing it, genres, three
|
||||
lines of synopsis, one recommendation reason, then Play and the circular secondary actions —
|
||||
with an uppercase tab strip on a hairline rule anchored under it and the tab's content
|
||||
beginning below the fold. `MediaDetailContent` is the movie page, `SeriesDetailContent` the
|
||||
series one; they differ only in which tabs they offer. The tabs are Overview, Episodes
|
||||
(series only), More Like This and Cast & Details.
|
||||
|
||||
- **The page is a `LazyColumn` of exactly three items** — hero, tabs, content — and the hero
|
||||
owns the opening frame: while focus is in it the list is pinned to offset 0 (see
|
||||
`detailHeroScrollTarget` and the `snapshotFlow` beside it), because LazyColumn's own focus
|
||||
relocation would otherwise leave Play visible with the title scrolled off the top. Moving
|
||||
to the strip or the content releases the pin.
|
||||
- **A pane never scrolls.** Every section is a tab and every tab fits its slot, so adding
|
||||
content means adding a tab: a page that scrolls *and* has tabs gives the D-pad two
|
||||
meanings for Down. The slot is `detailPaneHeight(viewportHeight)`, derived from the screen
|
||||
rather than fixed — it was a hard 250dp, and everything `technicalSpecs()` produces fell
|
||||
off the bottom of Cast & Details, which is the whole reason that tab exists. If a pane
|
||||
needs more than the budget, cut rows; do not add a scroller.
|
||||
- **The strip keeps a safe-area inset.** `DetailFoldPeek` holds it off the bottom edge,
|
||||
where overscan was cutting the selection underline in half, and leaves the top of the pane
|
||||
showing beneath it. That peek and the chevron at the end of the strip are the only things
|
||||
on screen saying that Down reveals anything.
|
||||
- **Focus is selection** in the tab strip. A remote has no hover, so a strip that highlights
|
||||
one tab while a different one stays open would need a second press to mean anything and
|
||||
would show content that contradicts the highlight.
|
||||
- **The strip is decided by what the item is, never by what has loaded.**
|
||||
`detailTabs(isSeries)` returns a fixed list, and a section with nothing in it yet says so
|
||||
in its own pane. It used to offer only the sections that already had content, which meant
|
||||
a movie opened with one tab and grew two more when its detail record landed, moving the
|
||||
strip under the viewer's thumb. `detailTab(key, available)` still resolves a remembered
|
||||
key, but now only has to catch a key carried over from the other kind of item.
|
||||
- **One `FocusRequester` per pane**, never one shared between them. `AnimatedContent` keeps
|
||||
the outgoing pane composed for its 80ms fade, so a requester attached by both the Overview
|
||||
and the Cast & Details pane is attached to two live nodes, and a Down press landing in that
|
||||
window can focus the pane that is disappearing.
|
||||
- **Position is remembered per item** in `ui/detail/DetailPosition.kt` — tab, season, which
|
||||
band held focus, and both rails' scroll offsets — in a process-scoped, capped, LRU store
|
||||
outside the composition, because closing a detail overlay destroys `rememberSaveable` with
|
||||
it. `RestoreDetailFocus` focuses Play first (it exists on frame one, so the remote is live)
|
||||
and then restores the band, once, only if that band has something placed to land on.
|
||||
Deliberately not persisted: a TV switched on the next morning should open a show where the
|
||||
*show* is up to.
|
||||
- Every `focusProperties { up/down/left/right = … }` target must be attached **on the
|
||||
current frame**. Season chips and episode cards do not exist while the episode request is
|
||||
in flight, on a one-season show, or on any tab but Episodes — pointing at their
|
||||
`FocusRequester` anyway throws the moment the viewer presses that direction.
|
||||
`SeriesDetailContent` resolves each target to `FocusRequester.Default` when its
|
||||
destination is off screen; keep that.
|
||||
- The backdrop is held under two gradients before any text is drawn. The reference is flat
|
||||
black and that flatness is most of why it reads as modern; the artwork is there for tone,
|
||||
not as a picture.
|
||||
- A tab item is `Modifier.width(IntrinsicSize.Max)`. Without it the underline's
|
||||
`fillMaxWidth` claims the whole strip and pushes every later tab off the screen.
|
||||
- The hero honours `Settings.showTitleLogo` and `useTextTitleForLogo` (`ui/TitleLogo.kt`,
|
||||
shared with the screensaver): transparent Emby logos are commonly black, and a black title
|
||||
treatment on this scrim is an invisible heading.
|
||||
- **Why you might enjoy it** is the single accent line above the actions, from
|
||||
`GET /v1/items/{id}/related`. It comes from the same profile the home rows are built from
|
||||
(`recommend.Why`), so the page can only claim a taste the engine actually learned; a cold
|
||||
profile falls back to catalogue facts. Never focusable.
|
||||
- **More Like This** is the same response's `items`, as its own tab. Selecting one opens
|
||||
*its* detail page, and `MainActivity.detailsTrail` walks Back home one page at a time. On
|
||||
the direct path there is no engine, so `EmbyRepository.getRelated` returns Emby's
|
||||
`Items/{id}/Similar` with no reasons at all — both halves are allowed to be empty and the
|
||||
page still opens.
|
||||
- `SeriesDetailsOverlay` and `MediaDetailsOverlay` only load (episodes, related, trailer) and
|
||||
delegate; `SeriesDetailContent`/`MediaDetailContent` are parameter-driven so they can be
|
||||
screenshotted (`DetailPageScreenshotTest`, which drives the tab strip by clicking it and
|
||||
also renders each pane on its own at `detailPaneHeight`) without a server.
|
||||
|
||||
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
|
||||
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
|
||||
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
|
||||
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
# Design fixes — home screen and detail pages
|
||||
|
||||
Audit of 2026-08-01. Findings only; no product code was changed. The three items under
|
||||
"Confirmed layout bugs" were reproduced by rendering the real composables at TV 1080p
|
||||
(`w960dp-h540dp-television-xhdpi`) through the existing Roborazzi harness — everything
|
||||
else is read from the source.
|
||||
|
||||
**All of it is implemented.** The findings below are left as written — they are the
|
||||
diagnosis, and each one says why the fix is shaped the way it is. What was done:
|
||||
|
||||
| # | Fixed in |
|
||||
|---|----------|
|
||||
| 1 | `HomeMovieHero.kt` — column padding 28→22dp, and a wrapped title stands the synopsis down (`onTextLayout` line count) so Play is never what gets cut. Captured as `df_home-movie-hero-long-title.png`. |
|
||||
| 2 | `DetailPageComponents.detailPaneHeight()` — the slot is derived from the viewport (250–420dp) instead of a hard 250dp. Every technical spec now renders; `df_detail-pane-cast-details.png`. Studio, which both columns claimed, is dropped from the technical column. |
|
||||
| 3 | `DetailFoldPeek` (34dp) holds the tab strip off the bottom edge. |
|
||||
| 4 | The hero's private `heroFacts`/runtime formatter is gone; it calls `detail/DetailFacts.kt`. `HomeComponents.formatTvRuntime` too — one formatter left in the app. |
|
||||
| 5 | `MembyScore` token, and every rating goes through `ratingLabel` (`Locale.US`). The home metadata panel renders the score as its own run of text so it can carry the same gold. |
|
||||
| 6 | "No favourite shows yet" / "Mark a series as a favourite…". |
|
||||
| 7 | `FactSeparator` between facts, `ValueSeparator` inside a fact that holds a list. |
|
||||
| 8 | `ui/theme/DesignTokens.kt`; `HomeComponents` and `DetailPageComponents` colours are aliases of it, and `Theme.kt` uses the same near-blacks. |
|
||||
| 9 | Same — the detail page picked up the raised TV neutrals. |
|
||||
| 10 | Three radii: `MembyChipCorner` 8, `MembyCardCorner` 10, `MembyPanelCorner` 14. |
|
||||
| 11 | `ui/MembyButtons.kt` — `MembyPlayButton`, `MembyPlayChip`, `MembyChoiceChip`. The hero chip, the detail Play button, the metadata panel's "▶ Resume" and the For You time budget all use them. |
|
||||
| 12 | `DetailHero` honours `Settings.showTitleLogo` (new `EmbyRepository.showTitleLogo`) and shares `useTextTitleForLogo` with the screensaver (`ui/TitleLogo.kt`). |
|
||||
| 13 | One `UHD_MIN_WIDTH` (3800) for the badge and the `(4K)` suffix. Unit-tested. |
|
||||
| 14 | `mediaBadges` reads `dynamicRangeLabel`, so HDR10+ stays HDR10+. Unit-tested. |
|
||||
| 15 | One `FocusRequester` per pane in both overlays; none is attached to two live nodes. |
|
||||
| 16 | A series passes `mediaBadges(item)`, and `DetailFactRow` takes 4 badges so the airing badge is not squeezed out. |
|
||||
| 17 | `HomeRowHeaderIcon` / `HomeRowHeaderIconGap` / `HomeRowHeaderSpacing`, used by `MediaRow`, `MyShowsStrip` and `RecentSearchesRow` — which also gained the vertical padding its focus-scaled chips needed. |
|
||||
| 18 | `HomeHeroPick` carries the row a title was drawn from; the caption is no longer the card's slot. Unit-tested. |
|
||||
| 19 | Deleted (≈260 lines: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData`, `HomeRowSkeleton` and the two runtime formatters only they used). |
|
||||
| 20 | The peek under the strip plus a chevron at its end. |
|
||||
| Docs | `CLAUDE.md`'s detail-page section rewritten to describe this code, with the token, button and header conventions above it. |
|
||||
|
||||
Screenshots of the result are `app/build/screenshots/df_*.png`
|
||||
(`.\gradlew.bat :app:testDebugUnitTest --tests "*ScreenshotTest"`).
|
||||
|
||||
---
|
||||
|
||||
## Confirmed layout bugs
|
||||
|
||||
These clip real content on a real TV. Fix these first.
|
||||
|
||||
### 1. The featured home hero drops its Play button when the title wraps to two lines
|
||||
|
||||
`ui/HomeMovieHero.kt:171-236`, `ui/MainActivity.kt:137` (`homeHeaderHeight`)
|
||||
|
||||
The card's content column measures ~229dp with a one-line title and ~261dp with two.
|
||||
`homeHeaderHeight(540dp, showHero = true)` yields 248dp, minus the hero row's 16dp top and
|
||||
10dp bottom padding, so the card gets **222dp** — and `FocusScaleContainer` clips it to a
|
||||
14dp rounded rect. Rendered with a two-line title, the kicker, title, fact line and
|
||||
synopsis draw and the green Play chip is **gone entirely**. A one-line title is already
|
||||
7dp over budget; it only survives because the part cut off is the chip's shadow.
|
||||
|
||||
Fix direction: the column is `align(Alignment.CenterStart)` inside a fixed-height box, so
|
||||
overflow is split top and bottom and the button is always the first thing lost. Either
|
||||
give the hero a height derived from its content, cap the title at one line, or drop the
|
||||
synopsis when the title wraps.
|
||||
|
||||
### 2. The Cast & Details tab silently discards every technical spec
|
||||
|
||||
`ui/DetailPageComponents.kt:258-278` (the 250dp pane), `:563-580` (`DetailFocusablePane`),
|
||||
`:613-643` (`DetailCastAndDetailsPane`)
|
||||
|
||||
The tab content slot is a hard `.height(250.dp)` and `DetailFocusablePane` applies
|
||||
`.clip(RoundedCornerShape(10.dp))`. `releaseAndTechnical` builds Released / Certificate /
|
||||
Runtime and then `addAll(specs)` — Video, Codec, Audio, Subtitles, Studio. Rendered at the
|
||||
real slot geometry, only the first three rows survive; the entire output of
|
||||
`technicalSpecs()` is clipped below the fold of a pane that cannot scroll. That is the
|
||||
whole reason the tab exists.
|
||||
|
||||
`DetailOverviewPane` shares the ceiling: a five-line synopsis plus the series "Up next"
|
||||
supporting line pushes its credit rows past the same boundary.
|
||||
|
||||
Fix direction: the pane needs a height budget that accounts for its worst case, or the
|
||||
two-column meta block needs to page/scroll. Note the pane deliberately does not scroll
|
||||
(one screen per tab), so the honest fix is probably fewer rows per column, not a scroller.
|
||||
|
||||
### 3. The detail tab strip sits flush against the bottom screen edge
|
||||
|
||||
`ui/DetailPageComponents.kt:209` (`heroHeight = maxHeight - DetailTabHeight`), `:494-550`
|
||||
|
||||
The selection underline is cut in half at y=1080 in both `detail-movie-more-like-this.png`
|
||||
and `detail-series-cast-details.png`. On a TV with overscan the underline and part of the
|
||||
labels are off-screen. This is the only element in the app with zero safe-area inset —
|
||||
gutters are 36-58dp and the home clock keeps 18dp.
|
||||
|
||||
---
|
||||
|
||||
## Copy and formatting
|
||||
|
||||
### 4. The home hero formats runtime differently from everywhere else
|
||||
|
||||
`ui/HomeMovieHero.kt:351` is a private `heroFacts` shadowing `ui/detail/DetailFacts.kt:62`.
|
||||
Different field order (year · certificate · runtime vs year · runtime · certificate) and
|
||||
`"${it}m"` instead of `formatRuntime`. The checked-in `home-movie-hero.png` shows
|
||||
**"2026 • M • 124m"** in the hero and **"2026 • 2h 4m"** on the card directly beneath it.
|
||||
|
||||
There are three runtime formatters in the app: `DetailFacts.formatRuntime`,
|
||||
`HomeComponents.formatTvRuntime` (private, identical) and this one.
|
||||
|
||||
### 5. The community score changes colour and locale by screen
|
||||
|
||||
Gold `0xFFF5C518` on detail (`DetailPageComponents.kt:484`), grey `MutedText` on home
|
||||
(`HomeComponents.kt:1025`). `HomeMovieHero.kt:355` uses `"★ %.1f".format(it)` with the
|
||||
default locale while every other rating goes through `Locale.US` — a comma decimal in a
|
||||
non-US locale.
|
||||
|
||||
### 6. American spelling in two user-facing strings
|
||||
|
||||
`ui/HomeComponents.kt:1549` "No **favorite** shows yet" and `:1555` "Mark a series as a
|
||||
**favorite** and it'll be waiting here." The rail says Favourites, the quick menu says
|
||||
"Add to favourites", the detail hero action says "Add to Favourites".
|
||||
|
||||
### 7. Four separator styles for the same kind of fact list
|
||||
|
||||
`" • "` (home metadata), `" • "` (detail fact row, schedule metadata, home hero),
|
||||
`" · "` (home genres), `", "` (credit rows).
|
||||
|
||||
---
|
||||
|
||||
## Design-token drift
|
||||
|
||||
### 8. Four near-blacks
|
||||
|
||||
Theme `background 0xFF0B0E11` and `surface 0xFF101418` (`ui/theme/Theme.kt`, effectively
|
||||
unused), home `0xFF090B0D`, detail `0xFF080A0C`. The accent green is duplicated four ways:
|
||||
`EmbyGreen`, `DetailAccent`, and the hero's `0xFF69C762` / `0xFF7BD574`.
|
||||
|
||||
### 9. Secondary-text contrast diverged between the two screens
|
||||
|
||||
`ui/HomeComponents.kt:129-132` carries a comment about raising the neutrals for TV distance
|
||||
(`MutedText 0xFFD0D6DB`, `QuietText 0xFFAEB7BF`). The detail page still uses the pre-fix
|
||||
values (`DetailMutedText 0xFFB6BDC3`, `DetailQuietText 0xFF8C959D`). The two sit side by
|
||||
side the moment a detail page is opened from a row.
|
||||
|
||||
### 10. Corner radii are ad hoc
|
||||
|
||||
9dp home cards, 8dp related posters, 8/10dp cast cards, 14dp featured hero, 11dp mini hero,
|
||||
12dp overlays, 7dp chips, 999dp search chips.
|
||||
|
||||
### 11. Three button languages
|
||||
|
||||
The hand-rolled `DetailPlayButton` (23/12dp padding, 16sp), the hero's hand-rolled play chip
|
||||
(12/7dp, 13sp — same look, different metrics), and raw `androidx.tv.material3.Button` with
|
||||
glyph text in `MediaMetadataPanel` ("▶ Resume") and `ForYouTimeBudget` ("✓ 30 min"), which
|
||||
picks up theme colours nothing else in the app uses.
|
||||
|
||||
---
|
||||
|
||||
## Logic and behaviour
|
||||
|
||||
### 12. `showTitleLogo` is ignored by the detail pages
|
||||
|
||||
The Settings copy promises "shows each title's logo artwork from Emby instead of plain
|
||||
text", but only `ui/screensaver/ScreensaverContent.kt:773` honours it;
|
||||
`ui/DetailPageComponents.kt:322` always fetches the logo. The screensaver also has
|
||||
`useTextTitleForLogo`, a fallback for logos too dark to read — the detail hero has no
|
||||
equivalent, so a dark logo is invisible on the near-black scrim.
|
||||
|
||||
### 13. Two different 4K thresholds
|
||||
|
||||
The badge fires at video width ≥ 3800 (`HomeComponents.kt:1184`); the `(4K)` suffix at
|
||||
≥ 3400 (`DetailFacts.kt:190`). A 3600-wide file is 4K in the spec row and not on the badge.
|
||||
|
||||
### 14. HDR10+ is named in `dynamicRangeLabel` but collapses to plain "HDR" in `mediaBadges`
|
||||
|
||||
### 15. One `FocusRequester` attached to two live nodes
|
||||
|
||||
`informationPane` is attached by the Overview pane, the Cast & Details pane and the Episodes
|
||||
empty states (`ui/MediaDetailsOverlay.kt:202-216`, `ui/SeriesDetailsOverlay.kt:291-329`).
|
||||
`AnimatedContent`'s 80ms fade-out keeps the outgoing pane composed, so a Down press landing
|
||||
in that window can request focus on a pane that is disappearing.
|
||||
|
||||
### 16. A series can never show format badges
|
||||
|
||||
`ui/SeriesDetailsOverlay.kt:248` passes `badges = emptyList()` where movies pass real ones.
|
||||
Related: `DetailFactRow` does `badges.take(3)`, so on a 4K/HDR/HEVC movie the airing badge
|
||||
appended in `ui/MediaDetailsOverlay.kt:147` is silently dropped.
|
||||
|
||||
### 17. Home row headers do not align
|
||||
|
||||
`MediaRow` and `MyShowsStrip` lead with a 28dp icon chip plus 10dp; `RecentSearchesRow`
|
||||
(`ui/MainActivity.kt:2084`) has no chip, so its title starts 38dp further left. It also uses
|
||||
9dp header spacing against everyone else's 6dp and gives its `LazyRow` no vertical padding,
|
||||
so focus-scaled chips have no room to grow.
|
||||
|
||||
### 18. The mini hero labels are positional fiction
|
||||
|
||||
`ui/HomeMovieHero.kt:124`: `listOf("POPULAR", "NEW RELEASE", "TRENDING")[index]`. But
|
||||
`selectHomeHeroMovies` interleaves new releases and popular picks and then falls back to
|
||||
every movie in the response. In the current screenshot a 2025 title is labelled NEW RELEASE
|
||||
and a 2026 one POPULAR.
|
||||
|
||||
### 19. A dead second home implementation
|
||||
|
||||
`ui/MainActivity.kt:2752-2900`: `HomeHero`, `HomeRow`, `ContentCard`, `HomeRowData` and
|
||||
`HomeRowSkeleton` are unreferenced (only the gateway *model* named `HomeRow` is in use).
|
||||
They carry a competing 48dp gutter and SemiBold header style — a live-looking template for
|
||||
the wrong conventions.
|
||||
|
||||
### 20. Tab content sits entirely below the fold with no affordance
|
||||
|
||||
By design per the code comments, but nothing on screen tells the viewer that Down reveals
|
||||
anything.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
`CLAUDE.md`'s detail-page section no longer describes this code. It documents a poster-left
|
||||
layout with Play hanging off the poster's bottom-right corner, "nothing scrolls vertically",
|
||||
a tab list of "Overview, Episodes, Cast, Details", and a `DetailReasonStrip` of several
|
||||
short phrases. The code is a full-bleed scrolling hero with Overview / Episodes / More Like
|
||||
This / Cast & Details and a single reason line (`ui/DetailPageComponents.kt:375-385`).
|
||||
Worth correcting before it misleads the next change.
|
||||
@@ -0,0 +1,340 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
Memby
|
||||
Copyright (C) 2026 Memby contributors
|
||||
|
||||
Memby is free software licensed under the GNU General Public License,
|
||||
version 2. A copy of that licence is included in the LICENSE file.
|
||||
|
||||
Source code:
|
||||
https://g.sublogue.com/admin/memby
|
||||
|
||||
Wholphin
|
||||
--------
|
||||
|
||||
Portions of Memby's Android playback capability probing and server-side
|
||||
device-profile generation are adapted from Wholphin, an Android TV client
|
||||
licensed under GNU GPL version 2:
|
||||
|
||||
https://github.com/damontecres/Wholphin
|
||||
|
||||
Wholphin's profiling implementation is itself derived from Jellyfin Android TV:
|
||||
|
||||
https://github.com/jellyfin/jellyfin-androidtv
|
||||
|
||||
The adapted Memby files identify both upstream projects, identify Memby's 2026
|
||||
modifications, and remain under GPLv2. Copyright in the upstream work remains
|
||||
with its respective contributors.
|
||||
|
||||
Third-party software
|
||||
--------------------
|
||||
|
||||
The Android application uses AndroidX, Jetpack Compose, Media3, Coil,
|
||||
Retrofit, OkHttp, kotlinx.serialization, and other open-source libraries.
|
||||
Their copyright notices and licence metadata remain in their respective
|
||||
source distributions and packaged dependency metadata. Those components
|
||||
remain governed by their own licences; GPLv2 applies to Memby's original
|
||||
and combined application code as required by the licence.
|
||||
|
||||
Memby is an independent project and is not affiliated with or endorsed by
|
||||
Emby LLC. Emby is a trademark of Emby LLC.
|
||||
@@ -4,6 +4,8 @@ An independent Android TV client for Emby, by **ponzischeme89**. Sign in to brow
|
||||
personalised home screen, resume titles, play recent movies, and choose audio or subtitle
|
||||
tracks during playback. The screensaver (Dream) code remains isolated from the client work.
|
||||
|
||||
Source: [g.sublogue.com/admin/memby](https://g.sublogue.com/admin/memby)
|
||||
|
||||
## Features
|
||||
|
||||
- **System screensaver** via `DreamService` — auto-starts on idle once selected in the
|
||||
@@ -163,7 +165,9 @@ install. The build prints a warning saying so.
|
||||
### Each release
|
||||
|
||||
```powershell
|
||||
.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" -BaseUrl https://nas.example.com/memby
|
||||
.\release.ps1 -Version 0.1.54 -Notes "Faster home screen" `
|
||||
-BaseUrl https://nas.example.com/memby `
|
||||
-SourceUrl https://g.sublogue.com/admin/memby
|
||||
```
|
||||
|
||||
That bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and fills
|
||||
@@ -173,6 +177,8 @@ That bumps `versionName`/`versionCode`, runs the tests, builds a signed APK, and
|
||||
index.html the page people are sent to
|
||||
latest.json the update manifest the app polls
|
||||
memby-0.1.54.apk the build
|
||||
LICENSE GNU GPL v2 terms
|
||||
NOTICE copyright and third-party acknowledgements
|
||||
```
|
||||
|
||||
Copy those to the folder the NAS serves. Old APKs can stay alongside — only `latest.json`
|
||||
@@ -205,12 +211,26 @@ different app, so on every TV:
|
||||
|
||||
## Notes & limitations
|
||||
|
||||
- Playback uses Emby's direct stream (`/Videos/{id}/stream?static=true`). This direct-plays
|
||||
containers/codecs ExoPlayer supports (most MP4/H.264, many MKV). Server-side transcoding
|
||||
is not requested; unusual codecs may need it — a future enhancement is to call Emby's
|
||||
`PlaybackInfo` endpoint and use the returned HLS transcode URL (the `media3-exoplayer-hls`
|
||||
dependency is already included).
|
||||
- Playback negotiates through Emby's `PlaybackInfo` endpoint, then streams directly from
|
||||
Emby. The Wholphin/Jellyfin-derived capability engine reports Android's H.264 and HEVC
|
||||
profiles, maximum levels and resolutions rather than assuming every decoder handles every
|
||||
file. Emby can preserve a supported video stream while converting only incompatible audio
|
||||
or subtitles. If a vendor decoder still fails, Media3 tries another decoder and Memby
|
||||
ultimately requests an H.264 HLS transcode instead of abandoning playback.
|
||||
- Cleartext HTTP is enabled so local `http://` servers work out of the box. For an HTTPS-only
|
||||
server this is unnecessary but harmless.
|
||||
- The device is remembered across sign-outs (stable `DeviceId`); credentials are cleared.
|
||||
|
||||
## Licence
|
||||
|
||||
Memby is free software licensed under the [GNU General Public License v2](LICENSE).
|
||||
Copyright and third-party acknowledgements are recorded in [NOTICE](NOTICE). Distributed
|
||||
APKs and server binaries must be accompanied by the corresponding source in accordance
|
||||
with GPLv2. The Android TV app also exposes the source link, notices, and complete licence
|
||||
under **Settings → About / Licences**.
|
||||
|
||||
Playback capability probing and device-profile generation contain GPLv2 adaptations from
|
||||
[Wholphin](https://github.com/damontecres/Wholphin), itself derived in part from
|
||||
[Jellyfin Android TV](https://github.com/jellyfin/jellyfin-androidtv). Attribution and
|
||||
modification notices are preserved in the adapted source files.
|
||||
```
|
||||
|
||||
+42
-2
@@ -5,6 +5,7 @@ plugins {
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("org.jetbrains.kotlin.plugin.compose")
|
||||
id("org.jetbrains.kotlin.plugin.serialization")
|
||||
id("androidx.baselineprofile")
|
||||
}
|
||||
|
||||
// Set in gradle.properties (or ~/.gradle/gradle.properties, or -Pmemby.serverUrl=...).
|
||||
@@ -15,9 +16,30 @@ val embyServerUrl: String = (project.findProperty("memby.serverUrl") as String?)
|
||||
// becomes a thin renderer; blank keeps the direct-to-Emby path above.
|
||||
val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as String?).orEmpty().trim()
|
||||
|
||||
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
|
||||
// the complete legal documents offline. Deployments can override the public source URL
|
||||
// without changing application code.
|
||||
val membySourceUrl: String =
|
||||
(project.findProperty("memby.sourceUrl") as String?)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?: "https://g.sublogue.com/admin/memby"
|
||||
|
||||
fun buildConfigString(value: String): String =
|
||||
"\"" + value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\r\n", "\\n")
|
||||
.replace("\n", "\\n") + "\""
|
||||
|
||||
val gplLicenseText = rootProject.file("LICENSE").readText()
|
||||
val projectNoticeText =
|
||||
rootProject.file("NOTICE").readText()
|
||||
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
|
||||
|
||||
// A release workflow can derive the app version from its Git tag without editing the
|
||||
// source tree. Local builds keep using the checked-in default.
|
||||
val defaultVersionName = "0.1.69"
|
||||
val defaultVersionName = "0.2.9"
|
||||
val membyVersionName: String =
|
||||
(project.findProperty("memby.versionName") as String?)
|
||||
?.trim()
|
||||
@@ -56,6 +78,9 @@ android {
|
||||
|
||||
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
|
||||
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
|
||||
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
|
||||
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
|
||||
}
|
||||
|
||||
// Release signing. Android identifies an app by (applicationId, signing key), so
|
||||
@@ -79,7 +104,13 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
// R8 is the single biggest cold-start lever on the weak TV boxes this ships
|
||||
// to: a smaller dex is less to load and verify before the first frame. The
|
||||
// keep rules in proguard-rules.pro are what stop it stripping the
|
||||
// kotlinx.serialization models the gateway contract depends on — if you add a
|
||||
// @Serializable package, add it there too.
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@@ -155,6 +186,9 @@ dependencies {
|
||||
implementation("androidx.savedstate:savedstate-ktx:1.2.1")
|
||||
// Measurement only: JankStats is enabled by PerformanceMonitor for debug builds.
|
||||
implementation("androidx.metrics:metrics-performance:1.0.0")
|
||||
// Installs the baseline profile below. Without it the profile is only honoured on
|
||||
// API 31+; a TV box on Android 9-11 — most of the installed base — would get nothing.
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
|
||||
|
||||
// Compose (versions from BOM)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
@@ -185,6 +219,12 @@ dependencies {
|
||||
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
|
||||
// Ahead-of-time compiles the startup + first-scroll path. Regenerate against a real
|
||||
// TV with `.\gradlew.bat :app:generateReleaseBaselineProfile`; the result is checked
|
||||
// in under app/src/release/generated/baselineProfiles so ordinary release builds
|
||||
// don't need a device.
|
||||
baselineProfile(project(":benchmark"))
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
|
||||
// Screenshot rendering only. Everything else under app/src/test stays plain JUnit
|
||||
|
||||
Vendored
+43
-5
@@ -1,8 +1,46 @@
|
||||
# kotlinx.serialization keeps @Serializable metadata via generated serializers.
|
||||
-keepattributes *Annotation*, InnerClasses
|
||||
# R8 rules for release builds (isMinifyEnabled = true).
|
||||
#
|
||||
# Most of what this app depends on ships its own consumer rules inside the AAR/JAR —
|
||||
# Retrofit, OkHttp, Coil, Media3 and kotlinx.serialization all do. What follows is the
|
||||
# part that is ours, plus a little insurance around reflection-shaped code.
|
||||
|
||||
# --- kotlinx.serialization -----------------------------------------------------------
|
||||
# Every wire model is @Serializable and the gateway contract is pinned by field name, so
|
||||
# a stripped or renamed serializer is a silent parse failure at runtime rather than a
|
||||
# build error. The same models are also persisted to DataStore as JSON, which means an
|
||||
# existing install decodes a blob written by the previous build.
|
||||
#
|
||||
# These rules match on the *annotation*, not on a package list. The previous version
|
||||
# enumerated com.mattcohen.embyscreensaver.data.model — the package the app used through
|
||||
# v0.1.53 — and so had quietly matched nothing at all. Serializable types today live in
|
||||
# data.model, data and update; keying off @Serializable means moving one never breaks it.
|
||||
-keepattributes *Annotation*, InnerClasses, Signature, RuntimeVisibleAnnotations, AnnotationDefault
|
||||
-dontnote kotlinx.serialization.**
|
||||
-keepclassmembers class **$$serializer { *; }
|
||||
-keepclasseswithmembers class com.mattcohen.embyscreensaver.data.model.** {
|
||||
|
||||
-keep @kotlinx.serialization.Serializable class com.ponzischeme89.memby.** { *; }
|
||||
-keepclassmembers class com.ponzischeme89.memby.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class com.ponzischeme89.memby.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class com.mattcohen.embyscreensaver.data.model.**$$serializer { *; }
|
||||
-keepclassmembers class **$$serializer { *; }
|
||||
-keep,includedescriptorclasses class com.ponzischeme89.memby.**$$serializer { *; }
|
||||
|
||||
# --- Retrofit ------------------------------------------------------------------------
|
||||
# The API interfaces are consumed reflectively; their generic return types must survive
|
||||
# or Retrofit cannot work out what to deserialize into.
|
||||
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.EmbyApi
|
||||
-keep,allowobfuscation interface com.ponzischeme89.memby.data.remote.GatewayApi
|
||||
-keepattributes Exceptions
|
||||
|
||||
# --- Media3 / coroutines -------------------------------------------------------------
|
||||
-dontwarn androidx.media3.**
|
||||
-dontwarn kotlinx.coroutines.**
|
||||
|
||||
# --- Crash readability ---------------------------------------------------------------
|
||||
# Releases are self-hosted with no crash reporter, so a stack trace read off a TV over
|
||||
# adb is the only diagnostic there is. Line numbers cost a little dex size and are worth
|
||||
# it; SourceFile is renamed so it does not leak original paths.
|
||||
-keepattributes SourceFile,LineNumberTable
|
||||
-renamesourcefileattribute SourceFile
|
||||
|
||||
@@ -74,8 +74,8 @@
|
||||
android:theme="@style/Theme.Memby.Fullscreen"
|
||||
tools:ignore="DiscouragedApi" />
|
||||
|
||||
<!-- An APK replacement kills an active Dream process. Reopen our launcher so
|
||||
the TV is never left displaying the old, black Dream surface. -->
|
||||
<!-- Re-enter the normal app lifecycle after replacement. This preserves the
|
||||
stored profile/session and performs the next update check before login. -->
|
||||
<receiver
|
||||
android:name=".update.UpdateRecoveryReceiver"
|
||||
android:exported="false">
|
||||
|
||||
@@ -5,15 +5,31 @@ import coil.Coil
|
||||
import coil.ImageLoader
|
||||
import coil.disk.DiskCache
|
||||
import coil.memory.MemoryCache
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class MembyApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Coil.setImageLoader(
|
||||
ImageLoader.Builder(this)
|
||||
// Coil builds its own OkHttpClient when not given one, which would mean a
|
||||
// third connection pool alongside the Emby and gateway APIs. In gateway
|
||||
// mode artwork is proxied by the same HTTPS host that serves /v1/home, so
|
||||
// sharing the stack lets every poster resume the connection the home
|
||||
// request already opened rather than repeating the TLS handshake.
|
||||
.okHttpClient { artworkHttpClient() }
|
||||
.memoryCache {
|
||||
MemoryCache.Builder(this)
|
||||
.maxSizePercent(0.08)
|
||||
// A backdrop is requested at 1280x720 — 3.7MB as ARGB_8888 — and
|
||||
// it is replaced on every focus change. At the previous 8% the
|
||||
// cache held barely two of them before a poster could be cached
|
||||
// at all, so ordinary D-pad movement re-decoded artwork it had
|
||||
// just evicted. These are hardware bitmaps (allowHardware is on
|
||||
// at every call site), so the extra headroom is graphics memory
|
||||
// rather than Java heap.
|
||||
.maxSizePercent(0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
@@ -31,4 +47,14 @@ class MembyApp : Application() {
|
||||
)
|
||||
ServiceLocator.init(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Artwork tolerates a shorter read timeout than the API does: a poster that has not
|
||||
* arrived in ten seconds has already missed the moment it was wanted for, and the
|
||||
* card falls back to its placeholder.
|
||||
*/
|
||||
private fun artworkHttpClient(): OkHttpClient = HttpStack.base.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
@@ -3,24 +3,27 @@ package com.ponzischeme89.memby.data
|
||||
import com.ponzischeme89.memby.data.model.AuthRequest
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthError
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevice
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvent
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.PlaybackReport
|
||||
import com.ponzischeme89.memby.data.model.PlaybackInfoRequest
|
||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
||||
import com.ponzischeme89.memby.data.remote.EmbyApi
|
||||
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
|
||||
import com.ponzischeme89.memby.data.remote.GatewayApi
|
||||
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -80,6 +83,11 @@ data class Playable(
|
||||
val mediaSourceId: String = "",
|
||||
val playSessionId: String = "",
|
||||
val playMethod: String = "DirectPlay",
|
||||
val overview: String? = null,
|
||||
val episodeCode: String? = null,
|
||||
val runtimeMs: Long = 0L,
|
||||
val prerollEnabled: Boolean = true,
|
||||
val prerollDurationMs: Long = 6_500L,
|
||||
)
|
||||
|
||||
class EmbyRepository(private val settings: SettingsStore) {
|
||||
@@ -96,11 +104,24 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
|
||||
val settingsFlow: Flow<Settings> get() = settings.settingsFlow
|
||||
|
||||
/**
|
||||
* Read synchronously off the snapshot, like [rotationIntervalMillis], because the
|
||||
* composables that decide between logo artwork and a text title do so while building a
|
||||
* layout and must not collect a flow to answer a question that changes once a year.
|
||||
*/
|
||||
val showTitleLogo: Boolean get() = snapshot.showTitleLogo
|
||||
private val _playbackStops = MutableSharedFlow<String>(extraBufferCapacity = 1)
|
||||
val playbackStops = _playbackStops.asSharedFlow()
|
||||
private val playableMutex = Mutex()
|
||||
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
|
||||
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
|
||||
private val seriesEpisodesMutex = Mutex()
|
||||
private val seriesEpisodesCache =
|
||||
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
|
||||
private val relatedMutex = Mutex()
|
||||
private val relatedCache =
|
||||
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
|
||||
|
||||
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
|
||||
|
||||
@@ -168,14 +189,14 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
deviceName: String,
|
||||
): String {
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
settings.ensureDeviceId()
|
||||
observedSettings = settings.snapshot() // pick up the freshly-generated device id
|
||||
|
||||
ServerConfig.gatewayUrl?.let { gateway ->
|
||||
// The gateway holds the Emby token; this device only ever stores the gateway
|
||||
// token, so the same `token` slot in DataStore serves both modes.
|
||||
val result = try {
|
||||
requireGateway().login(
|
||||
val result = requireGateway().login(
|
||||
GatewayLoginRequest(
|
||||
username = username,
|
||||
password = password,
|
||||
@@ -183,12 +204,6 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
deviceName = deviceName.trim(),
|
||||
),
|
||||
)
|
||||
} catch (error: HttpException) {
|
||||
if (error.code() == 409) {
|
||||
parseDeviceLimit(error.response()?.errorBody()?.string().orEmpty())?.let { throw it }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
require(result.token.isNotBlank() && result.userId.isNotBlank()) {
|
||||
"Gateway did not return a session"
|
||||
}
|
||||
@@ -220,13 +235,39 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return username
|
||||
}
|
||||
|
||||
suspend fun authPolicy(): GatewayAuthPolicy? =
|
||||
if (ServerConfig.isGateway) requireGateway().authPolicy() else null
|
||||
suspend fun devices(): List<GatewayDevice> =
|
||||
if (ServerConfig.isGateway) requireGateway().devices().devices else emptyList()
|
||||
|
||||
/** True only when the gateway still recognises the token restored from storage. */
|
||||
suspend fun removeDevice(deviceId: String) {
|
||||
if (ServerConfig.isGateway) requireGateway().removeDevice(deviceId)
|
||||
}
|
||||
|
||||
suspend fun renameDevice(device: GatewayDevice, deviceName: String) {
|
||||
val trimmed = deviceName.trim()
|
||||
require(trimmed.isNotEmpty() && trimmed.length <= 80) { "Invalid device name" }
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().renameDevice(device.deviceId, GatewayDeviceNameRequest(trimmed))
|
||||
if (device.current) settings.setDeviceName(trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* False only when the gateway explicitly rejects the restored token with 401.
|
||||
*
|
||||
* A deployment, maintenance window, or disconnected TV is not evidence that a
|
||||
* viewer's credentials are invalid. Treating every network failure as rejection used
|
||||
* to delete the active profile precisely when the server was being updated.
|
||||
*/
|
||||
suspend fun validateSession(): Boolean {
|
||||
if (!ServerConfig.isGateway || snapshot.token.isNullOrBlank()) return true
|
||||
return runCatching { requireGateway().session() }.isSuccess
|
||||
return try {
|
||||
requireGateway().session()
|
||||
true
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Throwable) {
|
||||
shouldPreserveSessionAfterValidationFailure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -239,6 +280,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
|
||||
suspend fun signOut() {
|
||||
@@ -252,6 +294,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
|
||||
suspend fun switchProfile(profile: EmbyProfile) {
|
||||
@@ -260,6 +303,24 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
|
||||
suspend fun removeProfile(profile: EmbyProfile) {
|
||||
val removingActiveProfile = snapshot.activeProfileId == profile.id
|
||||
if (removingActiveProfile && ServerConfig.isGateway && !snapshot.token.isNullOrBlank()) {
|
||||
// Retire the active gateway session where possible. Local removal still
|
||||
// succeeds when this TV is offline.
|
||||
runCatching { requireGateway().logout() }
|
||||
}
|
||||
settings.removeProfile(profile.id)
|
||||
observedSettings = settings.snapshot()
|
||||
if (removingActiveProfile) {
|
||||
cachedApi = null
|
||||
cachedBaseUrl = null
|
||||
clearPlayableCache()
|
||||
clearSeriesEpisodeCache()
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content -------------------------------------------------------------
|
||||
@@ -345,7 +406,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
"SortOrder" to "Ascending",
|
||||
"Limit" to limit.toString(),
|
||||
),
|
||||
fields = "ProductionYear,RunTimeTicks,PrimaryImageAspectRatio",
|
||||
fields = "ProductionYear,RunTimeTicks,RecursiveItemCount,PrimaryImageAspectRatio",
|
||||
imageTypes = "Backdrop,Primary,Logo",
|
||||
includeUserData = true,
|
||||
)
|
||||
@@ -443,6 +504,23 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
suspend fun lookupMediaRequests(term: String): List<com.ponzischeme89.memby.data.model.GatewayRequestCandidate> {
|
||||
if (!ServerConfig.isGateway) return emptyList()
|
||||
return requireGateway().requestLookup(term.trim()).candidates
|
||||
}
|
||||
|
||||
suspend fun requestMedia(
|
||||
candidate: com.ponzischeme89.memby.data.model.GatewayRequestCandidate,
|
||||
): String {
|
||||
check(ServerConfig.isGateway) { "Media requests require the Memby gateway" }
|
||||
return requireGateway().requestMedia(
|
||||
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
||||
mediaType = candidate.mediaType,
|
||||
foreignId = candidate.foreignId,
|
||||
),
|
||||
).title
|
||||
}
|
||||
|
||||
/**
|
||||
* Recommendation rows on their own, forcing the gateway to build them synchronously
|
||||
* if its cache is cold. [getHome] already carries them once warm, so this is only
|
||||
@@ -456,21 +534,72 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return requireGateway().forYou(availableMinutes.coerceIn(0, 360)).rows
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway's verdict on this build. Null on the direct path, where nobody is in a
|
||||
* position to decide, and null on failure — an unreachable gateway must never leave
|
||||
* a TV stuck behind a blocking update prompt.
|
||||
*/
|
||||
suspend fun checkAppUpdate(): GatewayUpdate? {
|
||||
if (!ServerConfig.isGateway) return null
|
||||
return runCatching { requireGateway().updateStatus() }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isActionable }
|
||||
suspend fun getRecommendationOnboarding():
|
||||
com.ponzischeme89.memby.data.model.RecommendationOnboarding {
|
||||
if (!ServerConfig.isGateway) {
|
||||
return com.ponzischeme89.memby.data.model.RecommendationOnboarding(completed = true)
|
||||
}
|
||||
return requireGateway().recommendationPreferences()
|
||||
}
|
||||
|
||||
suspend fun saveRecommendationRatings(ratings: Map<String, Int>) {
|
||||
if (!ServerConfig.isGateway) return
|
||||
requireGateway().saveRecommendationPreferences(
|
||||
com.ponzischeme89.memby.data.model.RecommendationPreferences(
|
||||
ratings = ratings.filterValues { it in 1..5 },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun getMyShows(): List<com.ponzischeme89.memby.data.model.MyShow> =
|
||||
if (ServerConfig.isGateway) requireGateway().myShows().shows else emptyList()
|
||||
|
||||
suspend fun saveMyShow(item: BaseItem): List<com.ponzischeme89.memby.data.model.MyShow> {
|
||||
if (!ServerConfig.isGateway || !item.isSeries) return getMyShows()
|
||||
return requireGateway().saveMyShow(
|
||||
com.ponzischeme89.memby.data.model.SaveMyShowRequest(
|
||||
itemId = item.id,
|
||||
title = item.name,
|
||||
year = item.productionYear,
|
||||
imageTag = item.imageTags["Primary"].orEmpty(),
|
||||
),
|
||||
).shows
|
||||
}
|
||||
|
||||
suspend fun removeMyShow(itemId: String) {
|
||||
if (ServerConfig.isGateway) requireGateway().removeMyShow(itemId)
|
||||
}
|
||||
|
||||
suspend fun getNotifications(): com.ponzischeme89.memby.data.model.NotificationsResponse =
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().notifications()
|
||||
} else {
|
||||
com.ponzischeme89.memby.data.model.NotificationsResponse()
|
||||
}
|
||||
|
||||
suspend fun setNotificationPreferences(
|
||||
value: com.ponzischeme89.memby.data.model.NotificationPreferences,
|
||||
): com.ponzischeme89.memby.data.model.NotificationsResponse =
|
||||
requireGateway().setNotificationPreferences(value)
|
||||
|
||||
suspend fun markNotificationRead(id: Long) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "read")
|
||||
}
|
||||
|
||||
suspend fun dismissNotification(id: Long) {
|
||||
if (ServerConfig.isGateway) requireGateway().updateNotification(id, "dismiss")
|
||||
}
|
||||
|
||||
fun myShowImageUrl(itemId: String, imageTag: String, maxWidth: Int = 320): String? =
|
||||
imageTag.takeIf(String::isNotBlank)?.let {
|
||||
imageUrl(itemId, "Primary", it, maxWidth)
|
||||
}
|
||||
|
||||
/** Live gateway state. Unlike content routes, this remains available in maintenance. */
|
||||
suspend fun serviceStatus(): GatewayServiceStatus = requireGateway().serviceStatus()
|
||||
|
||||
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
|
||||
|
||||
/** Full item metadata, requested only after focus settles on an item. */
|
||||
suspend fun getItemDetails(itemId: String): BaseItem {
|
||||
if (ServerConfig.isGateway) return requireGateway().item(itemId)
|
||||
@@ -478,21 +607,117 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
return requireApi().getItem(
|
||||
userId = userId,
|
||||
itemId = itemId,
|
||||
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio",
|
||||
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName",
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
|
||||
suspend fun seasonFinale(itemId: String): com.ponzischeme89.memby.data.model.GatewaySeasonFinale {
|
||||
if (itemId.isBlank()) return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
if (ServerConfig.isGateway) return requireGateway().seasonFinale(itemId)
|
||||
|
||||
// Direct mode has no Sonarr catalogue. Use Emby's full episode list as the best
|
||||
// available fallback; gateway mode remains authoritative because it sees future
|
||||
// episodes and cannot confuse the newest download with a finale.
|
||||
val current = getItemDetails(itemId)
|
||||
val season = current.parentIndexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
val episode = current.indexNumber ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
val seriesId = current.seriesId ?: return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
val lastEpisode = getSeriesEpisodes(seriesId)
|
||||
.filter { it.parentIndexNumber == season }
|
||||
.mapNotNull(BaseItem::indexNumber)
|
||||
.maxOrNull()
|
||||
if (season <= 0 || lastEpisode == null || episode != lastEpisode) {
|
||||
return com.ponzischeme89.memby.data.model.GatewaySeasonFinale()
|
||||
}
|
||||
return com.ponzischeme89.memby.data.model.GatewaySeasonFinale(
|
||||
seasonFinale = true,
|
||||
seriesName = current.seriesName.orEmpty(),
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The detail page's "why you might like this" strip and its related carousel.
|
||||
*
|
||||
* Dual-path like everything else, but the two paths differ in what they can say: the
|
||||
* gateway runs the recommendation engine and returns reasons drawn from this user's
|
||||
* own history, while direct mode has only Emby's similarity ranking and therefore no
|
||||
* reasons at all. An empty result is normal, never an error — a detail page that
|
||||
* cannot explain itself still has to open.
|
||||
*/
|
||||
suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent {
|
||||
if (item.id.isBlank()) return RelatedContent()
|
||||
val now = System.currentTimeMillis()
|
||||
relatedMutex.withLock {
|
||||
relatedCache[item.id]
|
||||
?.takeIf { it.expiresAtMs > now }
|
||||
?.let { return it.content }
|
||||
relatedCache.remove(item.id)
|
||||
}
|
||||
|
||||
val loaded = runCatching {
|
||||
if (ServerConfig.isGateway) {
|
||||
val response = requireGateway().related(item.id)
|
||||
RelatedContent(
|
||||
reasons = response.reasons.filter(String::isNotBlank),
|
||||
items = response.items.filter { it.id != item.id },
|
||||
)
|
||||
} else {
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
RelatedContent(
|
||||
items = requireApi().getSimilar(
|
||||
itemId = item.id,
|
||||
params = mapOf(
|
||||
"UserId" to userId,
|
||||
"Limit" to limit.toString(),
|
||||
"Fields" to "ProductionYear,RunTimeTicks,CommunityRating,PrimaryImageAspectRatio,CollectionName",
|
||||
"EnableUserData" to "true",
|
||||
"EnableImages" to "true",
|
||||
"EnableImageTypes" to "Primary,Backdrop,Logo",
|
||||
"ImageTypeLimit" to "1",
|
||||
),
|
||||
).items.filter { it.id != item.id },
|
||||
)
|
||||
}
|
||||
}.getOrElse { RelatedContent() }
|
||||
|
||||
relatedMutex.withLock {
|
||||
relatedCache[item.id] = CachedRelated(
|
||||
content = loaded.copy(items = loaded.items.take(limit)),
|
||||
expiresAtMs = now + RELATED_CACHE_TTL_MS,
|
||||
)
|
||||
while (relatedCache.size > RELATED_CACHE_SIZE) {
|
||||
relatedCache.entries.iterator().run {
|
||||
next()
|
||||
remove()
|
||||
}
|
||||
}
|
||||
return relatedCache.getValue(item.id).content
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* All episodes for one show in a single request. Season switching is then a local
|
||||
* list filter, keeping the detail screen immediate after its first load.
|
||||
*/
|
||||
suspend fun getSeriesEpisodes(seriesId: String): List<BaseItem> {
|
||||
if (seriesId.isBlank()) return emptyList()
|
||||
if (ServerConfig.isGateway) {
|
||||
return requireGateway().seriesEpisodes(seriesId).items
|
||||
val now = System.currentTimeMillis()
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache[seriesId]
|
||||
?.takeIf { it.expiresAtMs > now }
|
||||
?.episodes
|
||||
?.let { return it }
|
||||
seriesEpisodesCache.remove(seriesId)
|
||||
}
|
||||
|
||||
val loaded = if (ServerConfig.isGateway) {
|
||||
requireGateway().seriesEpisodes(seriesId).items
|
||||
} else {
|
||||
val userId = snapshot.userId ?: error("Not connected")
|
||||
return requireApi().getEpisodes(
|
||||
requireApi().getEpisodes(
|
||||
seriesId,
|
||||
mapOf(
|
||||
"UserId" to userId,
|
||||
@@ -505,6 +730,20 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
),
|
||||
).items
|
||||
}
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache[seriesId] = CachedSeriesEpisodes(
|
||||
episodes = loaded,
|
||||
expiresAtMs = now + SERIES_EPISODE_CACHE_TTL_MS,
|
||||
)
|
||||
while (seriesEpisodesCache.size > SERIES_EPISODE_CACHE_SIZE) {
|
||||
seriesEpisodesCache.entries.iterator().run {
|
||||
next()
|
||||
remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
return loaded
|
||||
}
|
||||
|
||||
/** Tight list endpoint shape: detail-only fields are never fetched on home. */
|
||||
private suspend fun getHomeItems(
|
||||
@@ -597,6 +836,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
.getOrDefault(GatewayPrerollSchedule())
|
||||
}
|
||||
|
||||
fun prerollArtworkUrl(itemId: String, imageType: String, maxWidth: Int): String? {
|
||||
if (!ServerConfig.isGateway || itemId.isBlank() || imageType.isBlank()) return null
|
||||
return imageUrl(
|
||||
itemId = itemId,
|
||||
imageType = imageType,
|
||||
tag = "sonarr",
|
||||
maxWidth = maxWidth,
|
||||
)
|
||||
}
|
||||
|
||||
/** Backdrop rotation interval, clamped to a sane range. */
|
||||
fun rotationIntervalMillis(): Long =
|
||||
snapshot.rotationIntervalSeconds.coerceIn(4, 600).toLong() * 1000L
|
||||
@@ -641,14 +890,15 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
itemId: String,
|
||||
title: String,
|
||||
resumePositionMs: Long,
|
||||
forceTranscode: Boolean = false,
|
||||
): Playable {
|
||||
require(itemId.isNotBlank()) { "A media item is required to refresh playback" }
|
||||
if (!ServerConfig.isGateway) {
|
||||
val discovery = directPlayback(itemId, resumePositionMs)
|
||||
val discovery = directPlayback(itemId, resumePositionMs, forceTranscode = forceTranscode)
|
||||
return Playable(
|
||||
itemId = itemId,
|
||||
title = title,
|
||||
url = buildStreamUrl(itemId),
|
||||
url = discovery.url ?: buildStreamUrl(itemId),
|
||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||
subtitles = discovery.subtitles,
|
||||
mediaSourceId = discovery.mediaSourceId,
|
||||
@@ -662,6 +912,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
itemType = "",
|
||||
title = title,
|
||||
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
|
||||
forceTranscode = forceTranscode,
|
||||
)
|
||||
return Playable(
|
||||
itemId = playback.itemId,
|
||||
@@ -700,6 +951,9 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
mediaSourceId = playback.mediaSourceId,
|
||||
playSessionId = playback.playSessionId,
|
||||
playMethod = playback.playMethod,
|
||||
overview = playback.overview.ifBlank { null },
|
||||
episodeCode = playback.episodeCode.ifBlank { null },
|
||||
runtimeMs = playback.runtimeMs,
|
||||
)
|
||||
}
|
||||
val discovery = directPlayback(
|
||||
@@ -765,6 +1019,12 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
mediaSourceId = playback.mediaSourceId,
|
||||
playSessionId = playback.playSessionId,
|
||||
playMethod = playback.playMethod,
|
||||
overview = playback.overview.ifBlank { item.overview },
|
||||
episodeCode = playback.episodeCode.ifBlank { episodeCode(item) },
|
||||
runtimeMs = playback.runtimeMs.takeIf { it > 0L }
|
||||
?: item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||
prerollEnabled = playback.prerollEnabled,
|
||||
prerollDurationMs = playback.prerollDurationMs,
|
||||
)
|
||||
}
|
||||
if (item.isSeries) {
|
||||
@@ -777,28 +1037,34 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
val discovery = directPlayback(episode.id, episode.resumePositionMs)
|
||||
return Playable(
|
||||
episode.id,
|
||||
title,
|
||||
buildStreamUrl(episode.id),
|
||||
episode.resumePositionMs,
|
||||
logoUrl(item),
|
||||
discovery.subtitles,
|
||||
discovery.mediaSourceId,
|
||||
discovery.playSessionId,
|
||||
discovery.playMethod,
|
||||
itemId = episode.id,
|
||||
title = title,
|
||||
url = discovery.url ?: buildStreamUrl(episode.id),
|
||||
resumePositionMs = episode.resumePositionMs,
|
||||
logoUrl = logoUrl(item),
|
||||
subtitles = discovery.subtitles,
|
||||
mediaSourceId = discovery.mediaSourceId,
|
||||
playSessionId = discovery.playSessionId,
|
||||
playMethod = discovery.playMethod,
|
||||
overview = episode.overview,
|
||||
episodeCode = episodeCode(episode),
|
||||
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||
)
|
||||
}
|
||||
val discovery = directPlayback(item.id, item.resumePositionMs)
|
||||
return Playable(
|
||||
item.id,
|
||||
item.name,
|
||||
buildStreamUrl(item.id),
|
||||
item.resumePositionMs,
|
||||
logoUrl(item),
|
||||
discovery.subtitles,
|
||||
discovery.mediaSourceId,
|
||||
discovery.playSessionId,
|
||||
discovery.playMethod,
|
||||
itemId = item.id,
|
||||
title = item.name,
|
||||
url = discovery.url ?: buildStreamUrl(item.id),
|
||||
resumePositionMs = item.resumePositionMs,
|
||||
logoUrl = logoUrl(item),
|
||||
subtitles = discovery.subtitles,
|
||||
mediaSourceId = discovery.mediaSourceId,
|
||||
playSessionId = discovery.playSessionId,
|
||||
playMethod = discovery.playMethod,
|
||||
overview = item.overview,
|
||||
episodeCode = episodeCode(item),
|
||||
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -810,6 +1076,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun clearSeriesEpisodeCache() {
|
||||
seriesEpisodesMutex.withLock {
|
||||
seriesEpisodesCache.clear()
|
||||
}
|
||||
relatedMutex.withLock {
|
||||
// Reasons are personal: another profile must never inherit this one's.
|
||||
relatedCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStarted(session: PlaybackSession, positionMs: Long) {
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report("started", session.gatewayReport(positionMs, false, null))
|
||||
@@ -823,12 +1099,16 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
positionMs: Long,
|
||||
isPaused: Boolean,
|
||||
eventName: String,
|
||||
) {
|
||||
durationMs: Long = 0L,
|
||||
): String? {
|
||||
if (ServerConfig.isGateway) {
|
||||
requireGateway().report("progress", session.gatewayReport(positionMs, isPaused, eventName))
|
||||
return
|
||||
return requireGateway().report(
|
||||
"progress",
|
||||
session.gatewayReport(positionMs, isPaused, eventName, durationMs),
|
||||
).autoFollowedShowTitle.takeIf(String::isNotBlank)
|
||||
}
|
||||
requireApi().reportPlaybackProgress(playbackReport(session, positionMs, isPaused, eventName))
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun reportPlaybackStopped(session: PlaybackSession, positionMs: Long) {
|
||||
@@ -842,6 +1122,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
}
|
||||
} finally {
|
||||
clearPlayableCache()
|
||||
// Episode progress and watched badges may have changed during playback.
|
||||
clearSeriesEpisodeCache()
|
||||
_playbackStops.tryEmit(session.itemId)
|
||||
}
|
||||
}
|
||||
@@ -894,7 +1176,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
val next = episodes.getOrNull(current + 1) ?: return null
|
||||
val discovery = directPlayback(next.id, next.resumePositionMs)
|
||||
return nextEpisodeOf(
|
||||
next, buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
|
||||
next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
|
||||
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
|
||||
)
|
||||
}
|
||||
@@ -926,6 +1208,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
positionMs: Long,
|
||||
subtitleStreamIndex: Int? = null,
|
||||
currentPlaySessionId: String? = null,
|
||||
forceTranscode: Boolean = false,
|
||||
): PlaybackDiscovery {
|
||||
val userId = snapshot.userId ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
||||
val serverUrl = activeServerUrl ?: return PlaybackDiscovery(mediaSourceId = itemId)
|
||||
@@ -938,11 +1221,27 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
id = itemId,
|
||||
userId = userId,
|
||||
startTimeTicks = millisecondsToTicks(positionMs),
|
||||
enableDirectPlay = !forceTranscode,
|
||||
enableDirectStream = !forceTranscode,
|
||||
subtitleStreamIndex = subtitleStreamIndex,
|
||||
currentPlaySessionId = currentPlaySessionId,
|
||||
deviceProfile = if (forceTranscode) {
|
||||
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
||||
capabilities = devicePlaybackCapabilities,
|
||||
)
|
||||
.copy(directPlayProfiles = emptyList())
|
||||
} else {
|
||||
com.ponzischeme89.memby.data.model.DeviceProfile.embyAndroidTv(
|
||||
capabilities = devicePlaybackCapabilities,
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
info.mediaSources.firstOrNull()?.let { source ->
|
||||
val delivery = selectPlaybackDelivery(
|
||||
source = source,
|
||||
forceTranscode = forceTranscode || subtitleStreamIndex != null,
|
||||
)
|
||||
PlaybackDiscovery(
|
||||
subtitles = subtitleTracks(
|
||||
streams = source.mediaStreams,
|
||||
@@ -953,12 +1252,8 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
),
|
||||
mediaSourceId = source.id.ifBlank { itemId },
|
||||
playSessionId = info.playSessionId,
|
||||
playMethod = if (subtitleStreamIndex != null && !source.transcodingUrl.isNullOrBlank()) {
|
||||
"Transcode"
|
||||
} else "DirectPlay",
|
||||
url = source.transcodingUrl
|
||||
?.takeIf { subtitleStreamIndex != null }
|
||||
?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
|
||||
playMethod = delivery.playMethod,
|
||||
url = delivery.url?.let { authenticatedDeliveryUrl(serverUrl, it, token) },
|
||||
)
|
||||
} ?: PlaybackDiscovery(mediaSourceId = itemId)
|
||||
}.getOrElse { PlaybackDiscovery(mediaSourceId = itemId) }
|
||||
@@ -1026,7 +1321,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
|
||||
/**
|
||||
* Poster for an item the app never received as a [BaseItem] — a service alert names
|
||||
* its subject by id and tag only, and the Sonarr ids it carries resolve through the
|
||||
* its subject by id and tag only, and the TV schedule ids it carries resolve through the
|
||||
* gateway's image proxy like any other.
|
||||
*/
|
||||
fun posterUrl(itemId: String, tag: String, maxWidth: Int = 300): String? {
|
||||
@@ -1060,7 +1355,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
append(gateway.trimEnd('/'))
|
||||
append("/v1/images/").append(itemId).append('/').append(imageType.lowercase())
|
||||
append("?maxWidth=").append(maxWidth)
|
||||
append("&quality=90")
|
||||
append("&quality=").append(ARTWORK_QUALITY)
|
||||
append("&tag=").append(encode(tag))
|
||||
append("&t=").append(encode(token))
|
||||
}
|
||||
@@ -1072,7 +1367,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
append("/Items/").append(itemId).append("/Images/").append(imageType)
|
||||
if (directIndex) append("/0")
|
||||
append("?maxWidth=").append(maxWidth)
|
||||
append("&quality=90")
|
||||
append("&quality=").append(ARTWORK_QUALITY)
|
||||
append("&tag=").append(encode(tag))
|
||||
token?.let { append("&api_key=").append(encode(it)) }
|
||||
}
|
||||
@@ -1109,6 +1404,22 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
)
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, "UTF-8")
|
||||
|
||||
private companion object {
|
||||
/**
|
||||
* JPEG quality asked of Emby (or of the gateway's image proxy) for every poster,
|
||||
* backdrop and logo.
|
||||
*
|
||||
* 80 rather than 90: at the distance a television is watched from the two are
|
||||
* indistinguishable, and it is roughly a third fewer bytes on every cold image —
|
||||
* which is time-to-poster on a launcher that paints fifteen of them at once, and
|
||||
* a disk cache that holds correspondingly more.
|
||||
*
|
||||
* Note this is part of the image URL, and the URL is Coil's cache key, so
|
||||
* changing it makes every existing install re-fetch its artwork once.
|
||||
*/
|
||||
const val ARTWORK_QUALITY = 80
|
||||
}
|
||||
}
|
||||
|
||||
data class PlaybackSession(
|
||||
@@ -1126,10 +1437,52 @@ private data class PlaybackDiscovery(
|
||||
val url: String? = null,
|
||||
)
|
||||
|
||||
private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, eventName: String?) =
|
||||
internal data class PlaybackDelivery(
|
||||
val url: String?,
|
||||
val playMethod: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Honour Emby's negotiated delivery instead of blindly opening the original file.
|
||||
* A null URL means the original static stream is genuinely suitable for direct play.
|
||||
*/
|
||||
internal fun selectPlaybackDelivery(
|
||||
source: MediaSourceInfo,
|
||||
forceTranscode: Boolean = false,
|
||||
): PlaybackDelivery {
|
||||
val transcode = source.transcodingUrl?.takeIf(String::isNotBlank)
|
||||
val directStream = source.directStreamUrl?.takeIf(String::isNotBlank)
|
||||
if (forceTranscode && transcode != null) {
|
||||
return PlaybackDelivery(transcode, "Transcode")
|
||||
}
|
||||
if (source.supportsDirectPlay == true) {
|
||||
return PlaybackDelivery(null, "DirectPlay")
|
||||
}
|
||||
if (source.supportsDirectStream != false && directStream != null) {
|
||||
return PlaybackDelivery(directStream, "DirectStream")
|
||||
}
|
||||
if (source.supportsTranscoding != false && transcode != null) {
|
||||
return PlaybackDelivery(transcode, "Transcode")
|
||||
}
|
||||
if (directStream != null) {
|
||||
return PlaybackDelivery(directStream, "DirectStream")
|
||||
}
|
||||
if (transcode != null) {
|
||||
return PlaybackDelivery(transcode, "Transcode")
|
||||
}
|
||||
return PlaybackDelivery(null, "DirectPlay")
|
||||
}
|
||||
|
||||
private fun PlaybackSession.gatewayReport(
|
||||
positionMs: Long,
|
||||
isPaused: Boolean,
|
||||
eventName: String?,
|
||||
durationMs: Long = 0L,
|
||||
) =
|
||||
GatewayPlaybackReport(
|
||||
itemId = itemId,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs.coerceAtLeast(0L),
|
||||
isPaused = isPaused,
|
||||
mediaSourceId = mediaSourceId,
|
||||
playSessionId = playSessionId,
|
||||
@@ -1137,13 +1490,51 @@ private fun PlaybackSession.gatewayReport(positionMs: Long, isPaused: Boolean, e
|
||||
eventName = eventName,
|
||||
)
|
||||
|
||||
private fun episodeCode(item: BaseItem): String? {
|
||||
val season = item.parentIndexNumber ?: return null
|
||||
val episode = item.indexNumber ?: return null
|
||||
if (season < 0 || episode <= 0) return null
|
||||
return "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
||||
private data class CachedPlayable(
|
||||
val playable: Playable,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private data class CachedSeriesEpisodes(
|
||||
val episodes: List<BaseItem>,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* What a detail page can add beyond the item itself.
|
||||
*
|
||||
* [reasons] is the recommendation engine's explanation of this title *to this viewer* and
|
||||
* is empty on the direct path, which has no profile to reason from.
|
||||
*/
|
||||
data class RelatedContent(
|
||||
val reasons: List<String> = emptyList(),
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
) {
|
||||
val isEmpty: Boolean get() = reasons.isEmpty() && items.isEmpty()
|
||||
}
|
||||
|
||||
private data class CachedRelated(
|
||||
val content: RelatedContent,
|
||||
val expiresAtMs: Long,
|
||||
)
|
||||
|
||||
private const val PLAYABLE_CACHE_SIZE = 16
|
||||
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||
private const val SERIES_EPISODE_CACHE_SIZE = 6
|
||||
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
|
||||
private const val RELATED_CACHE_SIZE = 12
|
||||
private const val RELATED_LIMIT = 12
|
||||
|
||||
// Long enough that walking back and forth between a row and a detail page never re-asks,
|
||||
// short enough that a newly watched title drops out of "more like this" the same evening.
|
||||
private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L
|
||||
|
||||
internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||
milliseconds.coerceAtLeast(0L) * 10_000L
|
||||
@@ -1151,29 +1542,9 @@ internal fun millisecondsToTicks(milliseconds: Long): Long =
|
||||
private val BaseItem.resumePositionMs: Long
|
||||
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
|
||||
|
||||
class DeviceLimitException(
|
||||
val activeClients: Int,
|
||||
val maxClients: Int,
|
||||
) : Exception("Memby device allowance reached")
|
||||
|
||||
/** Shared across the error-body parsers below; building a Json format per call is costly. */
|
||||
private val errorBodyJson = Json { ignoreUnknownKeys = true }
|
||||
|
||||
internal fun parseDeviceLimit(body: String): DeviceLimitException? {
|
||||
if (body.isBlank()) return null
|
||||
return runCatching {
|
||||
val parsed = errorBodyJson.decodeFromString<GatewayAuthError>(body)
|
||||
parsed.takeIf {
|
||||
it.error == "device_limit_reached" && it.maxClientsPerUser > 0
|
||||
}?.let {
|
||||
DeviceLimitException(
|
||||
activeClients = it.activeClients.coerceAtLeast(0),
|
||||
maxClients = it.maxClientsPerUser,
|
||||
)
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an exception to a short, TV-readable message. Never surfaces raw HTTP
|
||||
* bodies or stack traces (which could contain tokens) to the screen.
|
||||
@@ -1217,6 +1588,10 @@ fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() =
|
||||
/** True when the gateway has rejected the persisted session token. */
|
||||
fun isUnauthorizedError(t: Throwable): Boolean = t is HttpException && t.code() == 401
|
||||
|
||||
/** Only an authoritative authentication rejection permits deleting a saved profile. */
|
||||
internal fun shouldPreserveSessionAfterValidationFailure(t: Throwable): Boolean =
|
||||
!isUnauthorizedError(t)
|
||||
|
||||
@Serializable
|
||||
private data class MaintenanceResponse(
|
||||
val maintenance: Boolean = false,
|
||||
|
||||
@@ -20,14 +20,19 @@ data class MaintenanceNotice(val message: String)
|
||||
data class CompatibilityNotice(val message: String)
|
||||
|
||||
/**
|
||||
* One informational banner: a show aired, its episode is on its way into Emby. It is
|
||||
* never actionable and never focusable — it slides in, says its piece and goes.
|
||||
* One informational banner: a show aired, a film was added, the library finished
|
||||
* refreshing, the server stopped answering. It is never actionable and never focusable —
|
||||
* it slides in, says its piece and goes, over the launcher or over playback alike.
|
||||
*
|
||||
* [label] is the eyebrow above the title and comes from the server, so a kind of news
|
||||
* this build has never heard of still reads correctly; a server that sends none gets the
|
||||
* original wording back.
|
||||
*/
|
||||
data class ServiceAlert(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val message: String,
|
||||
val posterUrl: String?,
|
||||
val label: String = "",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -177,7 +182,10 @@ class MaintenanceMonitor(
|
||||
id = next.id,
|
||||
title = next.title.trim(),
|
||||
message = next.message.trim(),
|
||||
posterUrl = repository.posterUrl(next.itemId, next.imageTag),
|
||||
// The banner shows the Emby mark rather than item artwork — half these
|
||||
// alerts (a refresh, an outage) have no artwork — so the itemId and
|
||||
// imageTag the gateway still sends are deliberately unused here.
|
||||
label = next.label.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@ package com.ponzischeme89.memby.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.MutablePreferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -25,7 +29,20 @@ import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.util.UUID
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "emby_settings")
|
||||
/**
|
||||
* The corruption handler is not optional here. Without one, a Preferences file that fails
|
||||
* to parse makes [DataStore.data] throw on every read for the life of the install — and
|
||||
* because [SettingsStore.settingsFlow] is the gate the launcher waits behind, that shows
|
||||
* up as "Opening Memby…" forever, surviving every relaunch. Preferences DataStore rewrites
|
||||
* and fsyncs the whole file on each edit, and this app edits it on every home refresh, so
|
||||
* a process killed mid-write (backgrounding a TV app is exactly when that happens) is a
|
||||
* real possibility rather than a theoretical one. Starting again from empty preferences
|
||||
* costs a sign-in; throwing costs the app.
|
||||
*/
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "emby_settings",
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
|
||||
)
|
||||
|
||||
/** Persisted connection state. */
|
||||
data class Settings(
|
||||
@@ -46,6 +63,8 @@ data class Settings(
|
||||
val showTitleLogo: Boolean = true,
|
||||
// Slide up a "next up" banner near the end of an episode and roll into the next one.
|
||||
val autoPlayNextEpisode: Boolean = true,
|
||||
// Show the compact lower-third when playback crosses ten minutes remaining.
|
||||
val showTenMinuteReminder: Boolean = true,
|
||||
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
|
||||
val ringColorHex: String = DEFAULT_RING_COLOR,
|
||||
val lastBackdropUrl: String? = null,
|
||||
@@ -57,7 +76,25 @@ data class Settings(
|
||||
/** Whether this profile has discovered the dedicated For You destination. */
|
||||
val hasOpenedForYou: Boolean = false,
|
||||
val homeCardDensity: String = DEFAULT_HOME_CARD_DENSITY,
|
||||
/** Card image selection on browse rows: automatic, poster, or backdrop. */
|
||||
val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE,
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
/** Hide fully watched movies from browse rows and hero selections. */
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
/** Newline-separated server row ids. These are profile-specific home customisations. */
|
||||
val homeRowOrder: String = "",
|
||||
val homePinnedRows: String = "",
|
||||
val homeHiddenRows: String = "",
|
||||
/** Tone used for the short welcome line after sign-in and during startup. */
|
||||
val welcomeQuoteStyle: String = DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
/**
|
||||
* User ids known to have finished recommendation onboarding on this TV. Cold start
|
||||
* consults this instead of waiting on the gateway: onboarding is a one-time,
|
||||
* monotonic fact, and blocking the launcher on a round-trip to re-learn it defeats
|
||||
* the whole point of [homeCacheJson]. The gateway is still asked in the background
|
||||
* and remains authoritative for anyone not listed here.
|
||||
*/
|
||||
val onboardedUserIds: Set<String> = emptySet(),
|
||||
val profiles: List<EmbyProfile> = emptyList(),
|
||||
) {
|
||||
val isSignedIn: Boolean
|
||||
@@ -68,11 +105,17 @@ data class Settings(
|
||||
it.userId == userId && it.serverUrl == serverUrl
|
||||
}?.id
|
||||
|
||||
/** True once this TV has seen the active profile finish onboarding. */
|
||||
val hasCompletedOnboarding: Boolean
|
||||
get() = userId?.let { it in onboardedUserIds } == true
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ROTATION_SECONDS = 15
|
||||
const val DEFAULT_RING_COLOR = "FFFFFF"
|
||||
const val DEFAULT_HOME_SECTIONS = "continue,favorites,latest"
|
||||
const val DEFAULT_HOME_CARD_DENSITY = "standard"
|
||||
const val DEFAULT_HOME_ARTWORK_STYLE = "automatic"
|
||||
const val DEFAULT_WELCOME_QUOTE_STYLE = "neutral"
|
||||
val EMPTY = Settings()
|
||||
}
|
||||
}
|
||||
@@ -88,6 +131,15 @@ data class EmbyProfile(
|
||||
val homeCacheJson: String? = null,
|
||||
val forYouMinutes: Int = 0,
|
||||
val hasOpenedForYou: Boolean = false,
|
||||
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
val homeSections: String = Settings.DEFAULT_HOME_SECTIONS,
|
||||
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
val showHomeCardMetadata: Boolean = true,
|
||||
val hideWatchedMovies: Boolean = false,
|
||||
val homeRowOrder: String = "",
|
||||
val homePinnedRows: String = "",
|
||||
val homeHiddenRows: String = "",
|
||||
)
|
||||
|
||||
class SettingsStore(private val context: Context) {
|
||||
@@ -95,6 +147,14 @@ class SettingsStore(private val context: Context) {
|
||||
@Volatile
|
||||
private var latestSettings: Settings? = null
|
||||
|
||||
/** Last home cache actually written, so an unchanged refresh skips the disk entirely. */
|
||||
@Volatile
|
||||
private var lastPersistedHomeCache: String? = null
|
||||
|
||||
/** The decoded home cache, paired with the JSON it came from. See [primeHomeCache]. */
|
||||
@Volatile
|
||||
private var decodedHomeCache: Pair<String, HomeCache?>? = null
|
||||
|
||||
val current: Settings?
|
||||
get() = latestSettings
|
||||
|
||||
@@ -113,6 +173,7 @@ class SettingsStore(private val context: Context) {
|
||||
val UPDATE_TOKEN = stringPreferencesKey("update_token")
|
||||
val SHOW_TITLE_LOGO = booleanPreferencesKey("show_title_logo")
|
||||
val AUTO_PLAY_NEXT = booleanPreferencesKey("auto_play_next_episode")
|
||||
val SHOW_TEN_MINUTE_REMINDER = booleanPreferencesKey("show_ten_minute_reminder")
|
||||
val RING_COLOR = stringPreferencesKey("ring_color")
|
||||
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
|
||||
val HOME_SECTIONS = stringPreferencesKey("home_sections")
|
||||
@@ -120,9 +181,16 @@ class SettingsStore(private val context: Context) {
|
||||
val FOR_YOU_MINUTES = intPreferencesKey("for_you_minutes")
|
||||
val HAS_OPENED_FOR_YOU = booleanPreferencesKey("has_opened_for_you")
|
||||
val HOME_CARD_DENSITY = stringPreferencesKey("home_card_density")
|
||||
val HOME_ARTWORK_STYLE = stringPreferencesKey("home_artwork_style")
|
||||
val SHOW_HOME_CARD_METADATA = booleanPreferencesKey("show_home_card_metadata")
|
||||
val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
|
||||
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
|
||||
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
|
||||
val HOME_HIDDEN_ROWS = stringPreferencesKey("home_hidden_rows")
|
||||
val WELCOME_QUOTE_STYLE = stringPreferencesKey("welcome_quote_style")
|
||||
val PROFILES = stringPreferencesKey("profiles")
|
||||
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
|
||||
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,7 +202,24 @@ class SettingsStore(private val context: Context) {
|
||||
val settingsFlow: Flow<Settings> = context.dataStore.data
|
||||
.map(::settingsFrom)
|
||||
.distinctUntilChanged()
|
||||
.onEach { latestSettings = it }
|
||||
.onEach {
|
||||
latestSettings = it
|
||||
// Decode the launcher's cached rows here, on this store's IO scope, rather
|
||||
// than leaving it for HomeViewModel's constructor — which Compose runs on the
|
||||
// main thread during the first composition, parsing a blob that can hold
|
||||
// every row and every item in it. AppRoot does not compose HomeScreen until
|
||||
// this flow has emitted, so by the time the view model asks, the answer is
|
||||
// already in memory.
|
||||
primeHomeCache(it)
|
||||
}
|
||||
// A throw here would complete the sharing coroutine, and a SharedFlow that has
|
||||
// completed never emits again — every collector for the rest of the process waits
|
||||
// forever, which the launcher renders as its loading screen. Nothing this pipeline
|
||||
// does is worth that, so a failed read degrades to defaults and the app carries on
|
||||
// to sign-in instead of hanging. The corruption handler on [dataStore] covers the
|
||||
// parse failure; this covers everything else, including an I/O error on a TV whose
|
||||
// storage is briefly unavailable.
|
||||
.catch { emit(Settings.EMPTY.also { latestSettings = it }) }
|
||||
.shareIn(scope, started = SharingStarted.Eagerly, replay = 1)
|
||||
|
||||
suspend fun setRotationIntervalSeconds(seconds: Int) {
|
||||
@@ -189,6 +274,10 @@ class SettingsStore(private val context: Context) {
|
||||
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
|
||||
context.dataStore.edit { it[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setRingColor(hex: String) {
|
||||
context.dataStore.edit { it[Keys.RING_COLOR] = hex }
|
||||
}
|
||||
@@ -199,41 +288,140 @@ class SettingsStore(private val context: Context) {
|
||||
|
||||
suspend fun setHomeSections(sections: List<String>) {
|
||||
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
|
||||
context.dataStore.edit {
|
||||
it[Keys.HOME_SECTIONS] = valid.ifEmpty { listOf("favorites") }.joinToString(",")
|
||||
val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_SECTIONS] = selected
|
||||
updateActiveProfile(preferences) { it.copy(homeSections = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeCardDensity(density: String) {
|
||||
context.dataStore.edit {
|
||||
it[Keys.HOME_CARD_DENSITY] = density.takeIf { value -> value in setOf("compact", "standard", "large") }
|
||||
val selected = density.takeIf { it in setOf("compact", "standard", "large") }
|
||||
?: Settings.DEFAULT_HOME_CARD_DENSITY
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_CARD_DENSITY] = selected
|
||||
updateActiveProfile(preferences) { it.copy(homeCardDensity = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeArtworkStyle(style: String) {
|
||||
val selected = style.takeIf { it in setOf("automatic", "poster", "backdrop") }
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_ARTWORK_STYLE] = selected
|
||||
updateActiveProfile(preferences) { it.copy(homeArtworkStyle = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setShowHomeCardMetadata(show: Boolean) {
|
||||
context.dataStore.edit { it[Keys.SHOW_HOME_CARD_METADATA] = show }
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.SHOW_HOME_CARD_METADATA] = show
|
||||
updateActiveProfile(preferences) { it.copy(showHomeCardMetadata = show) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeCache(cache: HomeCache) {
|
||||
suspend fun setHideWatchedMovies(hide: Boolean) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HIDE_WATCHED_MOVIES] = hide
|
||||
updateActiveProfile(preferences) { it.copy(hideWatchedMovies = hide) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setHomeRowPreferences(
|
||||
order: List<String>,
|
||||
pinned: Set<String>,
|
||||
hidden: Set<String>,
|
||||
) {
|
||||
fun encode(values: Iterable<String>): String = values
|
||||
.map(String::trim)
|
||||
.filter { it.isNotEmpty() && '\n' !in it }
|
||||
.distinct()
|
||||
.joinToString("\n")
|
||||
val encodedOrder = encode(order)
|
||||
val encodedPinned = encode(pinned)
|
||||
val encodedHidden = encode(hidden - pinned)
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HOME_ROW_ORDER] = encodedOrder
|
||||
preferences[Keys.HOME_PINNED_ROWS] = encodedPinned
|
||||
preferences[Keys.HOME_HIDDEN_ROWS] = encodedHidden
|
||||
updateActiveProfile(preferences) {
|
||||
it.copy(
|
||||
homeRowOrder = encodedOrder,
|
||||
homePinnedRows = encodedPinned,
|
||||
homeHiddenRows = encodedHidden,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setWelcomeQuoteStyle(style: String) {
|
||||
val selected = style.takeIf { it in setOf("neutral", "positive", "homicidal") }
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.WELCOME_QUOTE_STYLE] = selected
|
||||
updateActiveProfile(preferences) { it.copy(welcomeQuoteStyle = selected) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the launcher's rows so the next cold start can draw before the network
|
||||
* answers.
|
||||
*
|
||||
* This used to encode the cache, then re-encode the entire profiles blob with a copy
|
||||
* of that same JSON embedded in the matching profile — and Preferences DataStore
|
||||
* rewrites and fsyncs the whole file on every edit, so each call wrote the cache
|
||||
* twice over, plus a copy for every other profile that was already carrying one. It
|
||||
* runs on every home refresh and again on every playback stop, which is exactly when
|
||||
* the viewer is watching the launcher redraw.
|
||||
*
|
||||
* Now each profile's cache lives under its own key ([profileHomeCacheKey]) and the
|
||||
* profiles blob carries none, so a refresh writes one value and the blob stays small.
|
||||
*/
|
||||
suspend fun setHomeCache(cache: HomeCache) {
|
||||
val encodedCache = Json.encodeToString(cache)
|
||||
// Most refreshes find nothing new — a scheduled poll, or a playback stop on a row
|
||||
// that did not move. Skipping the write means skipping the whole file rewrite.
|
||||
if (encodedCache == lastPersistedHomeCache) return
|
||||
context.dataStore.edit { preferences ->
|
||||
val profileKey = profileHomeCacheKey(
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
)
|
||||
if (profileKey == null) {
|
||||
// No active profile to key against; the flat slot is all there is.
|
||||
preferences[Keys.HOME_CACHE] = encodedCache
|
||||
val activeUserId = preferences[Keys.USER_ID]
|
||||
val activeServer = preferences[Keys.SERVER_URL]
|
||||
val profiles = profilesFrom(preferences).map { profile ->
|
||||
if (profile.userId == activeUserId && profile.serverUrl == activeServer) {
|
||||
profile.copy(homeCacheJson = encodedCache)
|
||||
} else {
|
||||
profile
|
||||
// Exactly one copy. Writing the flat key as well put the largest value
|
||||
// this store holds into the file twice, and DataStore rewrites and fsyncs
|
||||
// the whole file on every edit — so each home refresh was paying double
|
||||
// for a value nothing read twice. [activeHomeCache] reads this key first.
|
||||
preferences[profileKey] = encodedCache
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
}
|
||||
}
|
||||
if (profiles.isNotEmpty()) {
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
}
|
||||
lastPersistedHomeCache = encodedCache
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a given profile's home cache is stored. Null when there is no active profile
|
||||
* to key it against, in which case only the flat [Keys.HOME_CACHE] is written.
|
||||
*/
|
||||
private fun profileHomeCacheKey(userId: String?, serverUrl: String?): Preferences.Key<String>? {
|
||||
if (userId.isNullOrBlank() || serverUrl.isNullOrBlank()) return null
|
||||
return stringPreferencesKey("home_cache::$userId@$serverUrl")
|
||||
}
|
||||
|
||||
/**
|
||||
* The active profile's cached rows. The per-profile key is authoritative; the flat
|
||||
* [Keys.HOME_CACHE] slot survives only for installs written before the split and for
|
||||
* the case where there is no profile to key against.
|
||||
*/
|
||||
private fun activeHomeCache(preferences: Preferences): String? =
|
||||
profileHomeCacheKey(
|
||||
userId = preferences[Keys.USER_ID],
|
||||
serverUrl = preferences[Keys.SERVER_URL],
|
||||
)?.let { preferences[it] } ?: preferences[Keys.HOME_CACHE]
|
||||
|
||||
suspend fun setForYouMinutes(minutes: Int) {
|
||||
val selected = minutes.takeIf { it in setOf(0, 30, 60, 120) } ?: 0
|
||||
context.dataStore.edit { preferences ->
|
||||
@@ -242,6 +430,21 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a profile has finished recommendation onboarding, so the next cold
|
||||
* start can go straight to the launcher instead of waiting on the gateway to say so.
|
||||
* Additive only — nothing here ever un-onboards a user, because the failure mode of a
|
||||
* wrongly-cleared flag (a returning viewer shown the rating screen again) is worse
|
||||
* than the failure mode of a stale one (a request the gateway answers anyway).
|
||||
*/
|
||||
suspend fun markOnboardingCompleted(userId: String) {
|
||||
val trimmed = userId.trim()
|
||||
if (trimmed.isEmpty()) return
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.ONBOARDED_USERS] = preferences[Keys.ONBOARDED_USERS].orEmpty() + trimmed
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun markForYouOpened() {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[Keys.HAS_OPENED_FOR_YOU] = true
|
||||
@@ -263,13 +466,55 @@ class SettingsStore(private val context: Context) {
|
||||
}
|
||||
}
|
||||
if (profiles.isNotEmpty()) {
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
writeProfiles(preferences, profiles)
|
||||
}
|
||||
}
|
||||
|
||||
fun homeCache(settings: Settings): HomeCache? = settings.homeCacheJson?.let {
|
||||
runCatching { Json.decodeFromString<HomeCache>(it) }.getOrNull()
|
||||
/**
|
||||
* The single writer for the profiles blob. Strips each profile's home cache out into
|
||||
* its own key, so this blob stays small enough that the many settings toggles which
|
||||
* rewrite it — every one of which rewrites the whole DataStore file — cost nothing
|
||||
* proportional to the size of the library.
|
||||
*
|
||||
* The strip doubles as the migration: an install whose blob still embeds a cache has
|
||||
* it moved across the first time anything rewrites the blob, so a profile switch
|
||||
* still restores that profile's rows.
|
||||
*/
|
||||
private fun writeProfiles(preferences: MutablePreferences, profiles: List<EmbyProfile>) {
|
||||
val stripped = profiles.map { profile ->
|
||||
profile.homeCacheJson?.takeIf { it.isNotBlank() }?.let { embedded ->
|
||||
profileHomeCacheKey(profile.userId, profile.serverUrl)?.let { key ->
|
||||
if (preferences[key] == null) preferences[key] = embedded
|
||||
}
|
||||
}
|
||||
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
|
||||
}
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(stripped)
|
||||
}
|
||||
|
||||
/**
|
||||
* The launcher's cached rows. Returns the copy decoded by [primeHomeCache] when the
|
||||
* JSON has not changed since, so the common path is a reference comparison rather
|
||||
* than a parse — see the note on [settingsFlow].
|
||||
*/
|
||||
fun homeCache(settings: Settings): HomeCache? {
|
||||
val json = settings.homeCacheJson ?: return null
|
||||
decodedHomeCache?.let { (source, decoded) -> if (source == json) return decoded }
|
||||
return decodeHomeCache(json).also { decodedHomeCache = json to it }
|
||||
}
|
||||
|
||||
private fun primeHomeCache(settings: Settings) {
|
||||
val json = settings.homeCacheJson
|
||||
if (json == null) {
|
||||
decodedHomeCache = null
|
||||
return
|
||||
}
|
||||
if (decodedHomeCache?.first == json) return
|
||||
decodedHomeCache = json to decodeHomeCache(json)
|
||||
}
|
||||
|
||||
private fun decodeHomeCache(json: String): HomeCache? =
|
||||
runCatching { Json.decodeFromString<HomeCache>(json) }.getOrNull()
|
||||
|
||||
/**
|
||||
* Reads DataStore directly instead of taking the replayed flow value. This matters
|
||||
@@ -302,10 +547,21 @@ class SettingsStore(private val context: Context) {
|
||||
homeCacheJson = previous?.homeCacheJson,
|
||||
forYouMinutes = previous?.forYouMinutes ?: 0,
|
||||
hasOpenedForYou = previous?.hasOpenedForYou ?: false,
|
||||
welcomeQuoteStyle = previous?.welcomeQuoteStyle
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
homeSections = previous?.homeSections ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCardDensity = previous?.homeCardDensity ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
homeArtworkStyle = previous?.homeArtworkStyle
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
|
||||
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
|
||||
homeRowOrder = previous?.homeRowOrder.orEmpty(),
|
||||
homePinnedRows = previous?.homePinnedRows.orEmpty(),
|
||||
homeHiddenRows = previous?.homeHiddenRows.orEmpty(),
|
||||
)
|
||||
profiles.removeAll { it.id == id }
|
||||
profiles.add(profile)
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
writeProfiles(preferences, profiles)
|
||||
applyProfile(preferences, profile)
|
||||
}
|
||||
}
|
||||
@@ -315,12 +571,30 @@ class SettingsStore(private val context: Context) {
|
||||
val profiles = profilesFrom(preferences).toMutableList()
|
||||
if (profiles.none { it.id == profile.id }) {
|
||||
profiles.add(profile)
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(profiles)
|
||||
writeProfiles(preferences, profiles)
|
||||
}
|
||||
applyProfile(preferences, profile)
|
||||
}
|
||||
}
|
||||
|
||||
/** Forgets a saved profile on this device. */
|
||||
suspend fun removeProfile(profileId: String) {
|
||||
context.dataStore.edit { preferences ->
|
||||
val profiles = profilesFrom(preferences)
|
||||
val removed = profiles.firstOrNull { it.id == profileId } ?: return@edit
|
||||
writeProfiles(preferences, profiles.filterNot { it.id == profileId })
|
||||
// Forget the departing profile's cached rows too; nothing will read that key
|
||||
// again and it is the largest single value this store holds.
|
||||
profileHomeCacheKey(removed.userId, removed.serverUrl)?.let(preferences::remove)
|
||||
if (
|
||||
preferences[Keys.USER_ID] == removed.userId &&
|
||||
preferences[Keys.SERVER_URL] == removed.serverUrl
|
||||
) {
|
||||
clearActiveSession(preferences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearSession() {
|
||||
context.dataStore.edit {
|
||||
clearActiveSession(it)
|
||||
@@ -340,7 +614,8 @@ class SettingsStore(private val context: Context) {
|
||||
val remaining = profilesFrom(preferences).filterNot {
|
||||
it.userId == activeUserId && it.serverUrl == activeServer
|
||||
}
|
||||
preferences[Keys.PROFILES] = Json.encodeToString(remaining)
|
||||
writeProfiles(preferences, remaining)
|
||||
profileHomeCacheKey(activeUserId, activeServer)?.let(preferences::remove)
|
||||
clearActiveSession(preferences)
|
||||
}
|
||||
}
|
||||
@@ -352,8 +627,18 @@ class SettingsStore(private val context: Context) {
|
||||
preferences.remove(Keys.SERVER_ID)
|
||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
lastPersistedHomeCache = null
|
||||
preferences.remove(Keys.FOR_YOU_MINUTES)
|
||||
preferences.remove(Keys.HAS_OPENED_FOR_YOU)
|
||||
preferences.remove(Keys.WELCOME_QUOTE_STYLE)
|
||||
preferences.remove(Keys.HOME_SECTIONS)
|
||||
preferences.remove(Keys.HOME_CARD_DENSITY)
|
||||
preferences.remove(Keys.HOME_ARTWORK_STYLE)
|
||||
preferences.remove(Keys.SHOW_HOME_CARD_METADATA)
|
||||
preferences.remove(Keys.HIDE_WATCHED_MOVIES)
|
||||
preferences.remove(Keys.HOME_ROW_ORDER)
|
||||
preferences.remove(Keys.HOME_PINNED_ROWS)
|
||||
preferences.remove(Keys.HOME_HIDDEN_ROWS)
|
||||
preferences.remove(Keys.USERNAME)
|
||||
}
|
||||
|
||||
@@ -364,10 +649,36 @@ class SettingsStore(private val context: Context) {
|
||||
preferences[Keys.USERNAME] = profile.username
|
||||
if (profile.serverId.isNullOrBlank()) preferences.remove(Keys.SERVER_ID)
|
||||
else preferences[Keys.SERVER_ID] = profile.serverId
|
||||
if (profile.homeCacheJson.isNullOrBlank()) preferences.remove(Keys.HOME_CACHE)
|
||||
else preferences[Keys.HOME_CACHE] = profile.homeCacheJson
|
||||
// Prefer the profile's dedicated cache key; fall back to a copy embedded in the
|
||||
// profiles blob, which is how installs predating the split stored it — and move
|
||||
// that copy across, so the migration completes here too.
|
||||
val profileKey = profileHomeCacheKey(profile.userId, profile.serverUrl)
|
||||
val cached = profileKey?.let { preferences[it] } ?: profile.homeCacheJson
|
||||
if (profileKey != null) {
|
||||
if (preferences[profileKey] == null && !cached.isNullOrBlank()) {
|
||||
preferences[profileKey] = cached
|
||||
}
|
||||
// Reads go through [activeHomeCache], which prefers the profile key; keeping a
|
||||
// second copy in the flat slot only inflates the file.
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
} else if (cached.isNullOrBlank()) {
|
||||
preferences.remove(Keys.HOME_CACHE)
|
||||
} else {
|
||||
preferences[Keys.HOME_CACHE] = cached
|
||||
}
|
||||
// The active cache belongs to a different profile now.
|
||||
lastPersistedHomeCache = cached
|
||||
preferences[Keys.FOR_YOU_MINUTES] = profile.forYouMinutes
|
||||
preferences[Keys.HAS_OPENED_FOR_YOU] = profile.hasOpenedForYou
|
||||
preferences[Keys.WELCOME_QUOTE_STYLE] = profile.welcomeQuoteStyle
|
||||
preferences[Keys.HOME_SECTIONS] = profile.homeSections
|
||||
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
|
||||
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
|
||||
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
|
||||
preferences[Keys.HIDE_WATCHED_MOVIES] = profile.hideWatchedMovies
|
||||
preferences[Keys.HOME_ROW_ORDER] = profile.homeRowOrder
|
||||
preferences[Keys.HOME_PINNED_ROWS] = profile.homePinnedRows
|
||||
preferences[Keys.HOME_HIDDEN_ROWS] = profile.homeHiddenRows
|
||||
preferences.remove(Keys.LAST_BACKDROP_URL)
|
||||
}
|
||||
|
||||
@@ -383,9 +694,21 @@ class SettingsStore(private val context: Context) {
|
||||
userId = userId,
|
||||
username = username,
|
||||
serverId = preferences[Keys.SERVER_ID],
|
||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
||||
homeCacheJson = activeHomeCache(preferences),
|
||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
||||
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY]
|
||||
?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -413,14 +736,24 @@ class SettingsStore(private val context: Context) {
|
||||
updateToken = preferences[Keys.UPDATE_TOKEN],
|
||||
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
|
||||
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
|
||||
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
|
||||
ringColorHex = preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
|
||||
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
|
||||
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
|
||||
homeCacheJson = preferences[Keys.HOME_CACHE],
|
||||
homeCacheJson = activeHomeCache(preferences),
|
||||
forYouMinutes = preferences[Keys.FOR_YOU_MINUTES] ?: 0,
|
||||
hasOpenedForYou = preferences[Keys.HAS_OPENED_FOR_YOU] ?: false,
|
||||
homeCardDensity = preferences[Keys.HOME_CARD_DENSITY] ?: Settings.DEFAULT_HOME_CARD_DENSITY,
|
||||
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
|
||||
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
|
||||
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
|
||||
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
|
||||
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
|
||||
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
|
||||
homeHiddenRows = preferences[Keys.HOME_HIDDEN_ROWS].orEmpty(),
|
||||
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
|
||||
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
|
||||
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
|
||||
profiles = profiles,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,17 +27,32 @@ class RowAnalytics(
|
||||
private val lock = Any()
|
||||
private val buffer = ArrayList<GatewayRowEvent>()
|
||||
private val impressed = HashSet<String>()
|
||||
private val impressedItems = HashSet<String>()
|
||||
|
||||
private var focusedRowId: String? = null
|
||||
private var focusedRowKind: String = ""
|
||||
private var focusStartedAt: Long = 0
|
||||
|
||||
/** Records that a row was drawn. Repeats are ignored until [reset]. */
|
||||
fun rowImpression(rowId: String, rowKind: String) {
|
||||
fun rowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) {
|
||||
synchronized(lock) {
|
||||
if (!impressed.add(rowId)) return
|
||||
if (impressed.add(rowId)) {
|
||||
add(GatewayRowEvent(rowId = rowId, rowKind = rowKind, event = EVENT_IMPRESSION, occurredAt = timestamp()))
|
||||
}
|
||||
visibleItemIds.filter(String::isNotBlank).forEach { itemId ->
|
||||
if (impressedItems.add("$rowId:$itemId")) {
|
||||
add(
|
||||
GatewayRowEvent(
|
||||
rowId = rowId,
|
||||
rowKind = rowKind,
|
||||
event = EVENT_IMPRESSION,
|
||||
itemId = itemId,
|
||||
occurredAt = timestamp(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,6 +114,7 @@ class RowAnalytics(
|
||||
synchronized(lock) {
|
||||
buffer.clear()
|
||||
impressed.clear()
|
||||
impressedItems.clear()
|
||||
focusedRowId = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.ponzischeme89.memby.data.model
|
||||
|
||||
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@@ -33,6 +36,7 @@ data class UserItemData(
|
||||
@SerialName("IsFavorite") val isFavorite: Boolean = false,
|
||||
@SerialName("Played") val played: Boolean = false,
|
||||
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
|
||||
@SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -53,6 +57,11 @@ data class PlaybackInfoRequest(
|
||||
@SerialName("Id") val id: String,
|
||||
@SerialName("UserId") val userId: String,
|
||||
@SerialName("IsPlayback") val isPlayback: Boolean = true,
|
||||
@SerialName("EnableDirectPlay") val enableDirectPlay: Boolean = true,
|
||||
@SerialName("EnableDirectStream") val enableDirectStream: Boolean = true,
|
||||
@SerialName("EnableTranscoding") val enableTranscoding: Boolean = true,
|
||||
@SerialName("AllowVideoStreamCopy") val allowVideoStreamCopy: Boolean = true,
|
||||
@SerialName("AllowAudioStreamCopy") val allowAudioStreamCopy: Boolean = true,
|
||||
@SerialName("StartTimeTicks") val startTimeTicks: Long = 0,
|
||||
@SerialName("SubtitleStreamIndex") val subtitleStreamIndex: Int? = null,
|
||||
@SerialName("CurrentPlaySessionId") val currentPlaySessionId: String? = null,
|
||||
@@ -66,9 +75,12 @@ data class DeviceProfile(
|
||||
@SerialName("SubtitleProfiles") val subtitleProfiles: List<SubtitleProfile>,
|
||||
@SerialName("DirectPlayProfiles") val directPlayProfiles: List<DirectPlayProfile>,
|
||||
@SerialName("TranscodingProfiles") val transcodingProfiles: List<TranscodingProfile>,
|
||||
@SerialName("CodecProfiles") val codecProfiles: List<CodecProfile> = emptyList(),
|
||||
) {
|
||||
companion object {
|
||||
fun embyAndroidTv() = DeviceProfile(
|
||||
fun embyAndroidTv(
|
||||
capabilities: DevicePlaybackCapabilities = devicePlaybackCapabilities,
|
||||
) = DeviceProfile(
|
||||
name = "Memby Android TV",
|
||||
subtitleProfiles = listOf(
|
||||
"srt", "subrip", "ass", "ssa", "vtt", "webvtt", "mov_text", "tx3g",
|
||||
@@ -77,23 +89,143 @@ data class DeviceProfile(
|
||||
).map { SubtitleProfile(it, "Encode") },
|
||||
directPlayProfiles = listOf(
|
||||
DirectPlayProfile(
|
||||
container = "mkv,mp4,m4v,mov,webm,ts,mpegts,avi",
|
||||
videoCodec = "h264,hevc,vp8,vp9,av1,mpeg2video,mpeg4",
|
||||
audioCodec = "aac,ac3,eac3,mp3,opus,vorbis,flac,pcm",
|
||||
// The broad codec declaration is bounded by CodecProfiles below.
|
||||
container = "mkv,mp4,m4v,mov,ts,mpegts",
|
||||
videoCodec = directPlayVideoCodecs(capabilities),
|
||||
audioCodec = "aac,mp3",
|
||||
),
|
||||
),
|
||||
transcodingProfiles = listOf(
|
||||
TranscodingProfile(
|
||||
container = "ts",
|
||||
videoCodec = "h264",
|
||||
// Permits Emby to remux a supported HEVC/H.264 video stream while
|
||||
// converting only incompatible audio or subtitles.
|
||||
videoCodec = directPlayVideoCodecs(capabilities),
|
||||
audioCodec = "aac",
|
||||
protocol = "hls",
|
||||
),
|
||||
),
|
||||
codecProfiles = codecProfiles(capabilities),
|
||||
)
|
||||
|
||||
/** Compatibility for callers and older tests that only know the HEVC boolean. */
|
||||
fun embyAndroidTv(supportsHevc: Boolean): DeviceProfile = embyAndroidTv(
|
||||
DevicePlaybackCapabilities(
|
||||
hevc = VideoDecoderCapabilities(
|
||||
supported = supportsHevc,
|
||||
profiles = if (supportsHevc) setOf("main") else emptySet(),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun directPlayVideoCodecs(capabilities: DevicePlaybackCapabilities): String =
|
||||
if (capabilities.hevc.supported) "h264,hevc" else "h264"
|
||||
|
||||
private fun codecProfiles(capabilities: DevicePlaybackCapabilities): List<CodecProfile> =
|
||||
buildList {
|
||||
addVideoProfiles("h264", capabilities.h264)
|
||||
addVideoProfiles("hevc", capabilities.hevc)
|
||||
}
|
||||
|
||||
private fun MutableList<CodecProfile>.addVideoProfiles(
|
||||
codec: String,
|
||||
capability: VideoDecoderCapabilities,
|
||||
) {
|
||||
if (!capability.supported) return
|
||||
val allowedProfiles = when (codec) {
|
||||
"h264" -> capability.profiles.mapNotNull {
|
||||
when (it) {
|
||||
"baseline" -> "baseline"
|
||||
"constrained_baseline" -> "constrained baseline"
|
||||
"main" -> "main"
|
||||
"high" -> "high"
|
||||
"high10" -> "high 10"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
else -> capability.profiles.mapNotNull {
|
||||
when (it) {
|
||||
"main" -> "main"
|
||||
"main10" -> "main 10"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
if (allowedProfiles.isNotEmpty()) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("EqualsAny", "VideoProfile", allowedProfiles.joinToString("|")),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (capability.mainLevel > 0) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("LessThanEqual", "VideoLevel", capability.mainLevel.toString()),
|
||||
),
|
||||
applyConditions = listOf(
|
||||
ProfileCondition(
|
||||
"EqualsAny",
|
||||
"VideoProfile",
|
||||
if (codec == "h264") "baseline|constrained baseline|main|high" else "main",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (capability.tenBitLevel > 0) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("LessThanEqual", "VideoLevel", capability.tenBitLevel.toString()),
|
||||
),
|
||||
applyConditions = listOf(
|
||||
ProfileCondition(
|
||||
"Equals",
|
||||
"VideoProfile",
|
||||
if (codec == "h264") "high 10" else "main 10",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
if (capability.maxWidth > 0 && capability.maxHeight > 0) {
|
||||
add(
|
||||
CodecProfile(
|
||||
codec = codec,
|
||||
conditions = listOf(
|
||||
ProfileCondition("LessThanEqual", "Width", capability.maxWidth.toString()),
|
||||
ProfileCondition("LessThanEqual", "Height", capability.maxHeight.toString()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CodecProfile(
|
||||
@SerialName("Type") val type: String = "Video",
|
||||
@SerialName("Codec") val codec: String,
|
||||
@SerialName("Conditions") val conditions: List<ProfileCondition>,
|
||||
@SerialName("ApplyConditions") val applyConditions: List<ProfileCondition> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ProfileCondition(
|
||||
@SerialName("Condition") val condition: String,
|
||||
@SerialName("Property") val property: String,
|
||||
@SerialName("Value") val value: String,
|
||||
@SerialName("IsRequired") val isRequired: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class DirectPlayProfile(
|
||||
@SerialName("Container") val container: String,
|
||||
@@ -156,6 +288,9 @@ data class PlaybackInfo(
|
||||
data class MediaSourceInfo(
|
||||
@SerialName("Id") val id: String = "",
|
||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
||||
@SerialName("SupportsDirectPlay") val supportsDirectPlay: Boolean? = null,
|
||||
@SerialName("SupportsDirectStream") val supportsDirectStream: Boolean? = null,
|
||||
@SerialName("SupportsTranscoding") val supportsTranscoding: Boolean? = null,
|
||||
@SerialName("DirectStreamUrl") val directStreamUrl: String? = null,
|
||||
@SerialName("TranscodingUrl") val transcodingUrl: String? = null,
|
||||
)
|
||||
@@ -183,7 +318,9 @@ data class BaseItem(
|
||||
@SerialName("CommunityRating") val communityRating: Double? = null,
|
||||
@SerialName("Studios") val studios: List<Studio> = emptyList(),
|
||||
@SerialName("RunTimeTicks") val runTimeTicks: Long? = null,
|
||||
@SerialName("RecursiveItemCount") val recursiveItemCount: Int? = null,
|
||||
@SerialName("Genres") val genres: List<String> = emptyList(),
|
||||
@SerialName("CollectionName") val collectionName: String? = null,
|
||||
@SerialName("MediaStreams") val mediaStreams: List<MediaStream> = emptyList(),
|
||||
@SerialName("People") val people: List<EmbyPerson> = emptyList(),
|
||||
@SerialName("PrimaryImageAspectRatio") val primaryImageAspectRatio: Double? = null,
|
||||
@@ -205,21 +342,32 @@ data class BaseItem(
|
||||
@SerialName("MembyEpisodeCode") val membyEpisodeCode: String? = null,
|
||||
@SerialName("MembyAirsAt") val membyAirsAt: String? = null,
|
||||
@SerialName("MembyAddedAt") val membyAddedAt: String? = null,
|
||||
@SerialName("MembyAirDayLabel") val membyAirDayLabel: String? = null,
|
||||
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
|
||||
@SerialName("MembyAvailability") val membyAvailability: String? = null,
|
||||
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
|
||||
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
|
||||
// Derived by the TV from the Sonarr schedule row and retained in the local home cache.
|
||||
// Derived by the TV from the weekly schedule row and retained in the local home cache.
|
||||
@SerialName("MembyAiringToday") val membyAiringToday: Boolean = false,
|
||||
// Explainability supplied only by the gateway's dedicated For You endpoint.
|
||||
@SerialName("MembyRecommendationReason") val membyRecommendationReason: String? = null,
|
||||
@SerialName("MembyCompatibility") val membyCompatibility: String? = null,
|
||||
// Backend diagnostics for the shared explainable ranker. These remain optional so
|
||||
// direct-to-Emby mode and older cached payloads decode unchanged.
|
||||
@SerialName("MembyRecommendationScore") val membyRecommendationScore: Double? = null,
|
||||
@SerialName("MembyRecommendationComponents")
|
||||
val membyRecommendationComponents: Map<String, Double> = emptyMap(),
|
||||
@SerialName("MembyRecommendationReasonCodes")
|
||||
val membyRecommendationReasonCodes: List<String> = emptyList(),
|
||||
@SerialName("MembyExploration") val membyExploration: Boolean = false,
|
||||
) {
|
||||
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
|
||||
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
|
||||
val isEpisode: Boolean get() = type.equals("Episode", ignoreCase = true)
|
||||
val isFavorite: Boolean get() = userData?.isFavorite == true
|
||||
val isSonarrSchedule: Boolean get() = membySource == "sonarr"
|
||||
val isTvSchedule: Boolean get() = membySource == "sonarr"
|
||||
val isMovieSchedule: Boolean get() = membySource == "radarr"
|
||||
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
|
||||
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
|
||||
|
||||
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
|
||||
|
||||
@@ -24,23 +24,25 @@ data class GatewayLoginResponse(
|
||||
val userId: String = "",
|
||||
val username: String = "",
|
||||
val serverId: String = "",
|
||||
val activeClients: Int = 0,
|
||||
val maxClientsPerUser: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayAuthPolicy(
|
||||
val maxClientsPerUser: Int = 0,
|
||||
data class GatewayDevice(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val clientVersion: String = "",
|
||||
val lastSeenAt: String = "",
|
||||
val current: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayAuthError(
|
||||
val error: String = "",
|
||||
val message: String = "",
|
||||
val activeClients: Int = 0,
|
||||
val maxClientsPerUser: Int = 0,
|
||||
data class GatewayDevices(
|
||||
val devices: List<GatewayDevice> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayDeviceNameRequest(val deviceName: String)
|
||||
|
||||
/**
|
||||
* One horizontal strip, described entirely by the server.
|
||||
*
|
||||
@@ -76,8 +78,8 @@ data class GatewayHome(
|
||||
/**
|
||||
* The gateway's verdict on this build, from `GET /v1/update`.
|
||||
*
|
||||
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the home screen — the
|
||||
* operator has decided this version may no longer be used.
|
||||
* [status] is `none`, `optional` or `mandatory`. Mandatory blocks the app before login or
|
||||
* home is composed — the operator has decided this version may no longer be used.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayUpdate(
|
||||
@@ -85,6 +87,8 @@ data class GatewayUpdate(
|
||||
val version: String = "",
|
||||
val notes: String = "",
|
||||
val downloadUrl: String = "",
|
||||
val sha256: String = "",
|
||||
val sizeBytes: Long = 0,
|
||||
) {
|
||||
val isMandatory: Boolean get() = status == STATUS_MANDATORY
|
||||
val isOptional: Boolean get() = status == STATUS_OPTIONAL
|
||||
@@ -110,18 +114,47 @@ data class GatewayServiceStatus(
|
||||
val clientVersion: String = "",
|
||||
val clientProtocol: String = "",
|
||||
val serverProtocol: Int = 0,
|
||||
val featureSchemaVersion: Int = 0,
|
||||
val featureRevision: Long = 0,
|
||||
val safeMode: Boolean = false,
|
||||
val features: Map<String, Boolean> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayFeature(
|
||||
val key: String,
|
||||
val name: String = "",
|
||||
val description: String = "",
|
||||
val area: String = "",
|
||||
val enabled: Boolean = false,
|
||||
val source: String = "default",
|
||||
val compatible: Boolean = true,
|
||||
val minimumProtocol: Int = 0,
|
||||
val capability: String = "",
|
||||
val recovery: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayFeatures(
|
||||
val schemaVersion: Int = 0,
|
||||
val revision: Long = 0,
|
||||
val safeMode: Boolean = false,
|
||||
val canRollback: Boolean = false,
|
||||
val features: List<GatewayFeature> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* An informational nudge riding along on the status poll — currently "this episode aired
|
||||
* and is on its way into Emby". It is never actionable: the banner slides in, states the
|
||||
* news and leaves. Unknown [kind] values still render, so the server can add one without
|
||||
* an app release.
|
||||
* An informational nudge riding along on the status poll — "this episode aired and is on
|
||||
* its way into Emby", or "Radarr just imported this film". It is never actionable: the
|
||||
* banner slides in, states the news and leaves. Unknown [kind] values still render, and
|
||||
* the server supplies the wording, so a new kind of news needs no app release.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayAlert(
|
||||
val id: String = "",
|
||||
val kind: String = "",
|
||||
/** Eyebrow text for the banner ("JUST AIRED", "NEW MOVIE ADDED"). May be absent. */
|
||||
val label: String = "",
|
||||
val title: String = "",
|
||||
val message: String = "",
|
||||
val itemId: String = "",
|
||||
@@ -135,16 +168,70 @@ data class GatewayRows(
|
||||
val rows: List<HomeRow> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendationOnboarding(
|
||||
val completed: Boolean = false,
|
||||
val ratings: Map<String, Int> = emptyMap(),
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RecommendationPreferences(
|
||||
val ratings: Map<String, Int> = emptyMap(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayItems(
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* Response of `GET /v1/items/{id}/related` — the detail page's two additions.
|
||||
*
|
||||
* [reasons] are short phrases from the recommendation engine explaining *this viewer's*
|
||||
* relationship to the title ("Because you watch Thriller"); [items] is the carousel of
|
||||
* what else is like it. Either half may be empty: a cold profile has no reasons to give,
|
||||
* and an obscure title has nothing beside it.
|
||||
*/
|
||||
@Serializable
|
||||
data class GatewayRelated(
|
||||
val reasons: List<String> = emptyList(),
|
||||
val items: List<BaseItem> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewaySearchHistory(
|
||||
val queries: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRequestCandidate(
|
||||
val mediaType: String,
|
||||
val foreignId: Int,
|
||||
val title: String,
|
||||
val year: Int = 0,
|
||||
val overview: String = "",
|
||||
val posterUrl: String = "",
|
||||
val alreadyAdded: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayRequestLookup(
|
||||
val candidates: List<GatewayRequestCandidate> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayMediaRequest(
|
||||
val mediaType: String,
|
||||
val foreignId: Int,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayMediaRequestResult(
|
||||
val status: String = "",
|
||||
val title: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayPrerollSchedule(
|
||||
val today: List<GatewayPrerollEntry> = emptyList(),
|
||||
@@ -153,6 +240,8 @@ data class GatewayPrerollSchedule(
|
||||
|
||||
@Serializable
|
||||
data class GatewayPrerollEntry(
|
||||
val itemId: String = "",
|
||||
val imageType: String = "",
|
||||
val series: String = "",
|
||||
val episode: String = "",
|
||||
val episodeCode: String = "",
|
||||
@@ -164,6 +253,12 @@ data class GatewayPrerollEntry(
|
||||
data class GatewayPlayback(
|
||||
val itemId: String,
|
||||
val title: String = "",
|
||||
val overview: String = "",
|
||||
val seriesName: String = "",
|
||||
val episodeCode: String = "",
|
||||
val runtimeMs: Long = 0,
|
||||
val prerollEnabled: Boolean = true,
|
||||
val prerollDurationMs: Long = 6_500L,
|
||||
val url: String,
|
||||
val resumePositionMs: Long = 0,
|
||||
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
|
||||
@@ -188,11 +283,72 @@ data class GatewayNextEpisode(
|
||||
val playMethod: String = "DirectPlay",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewaySeasonFinale(
|
||||
val seasonFinale: Boolean = false,
|
||||
val seriesName: String = "",
|
||||
val seasonNumber: Int = 0,
|
||||
val episodeNumber: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayFlagRequest(
|
||||
val value: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyShow(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val year: Int? = null,
|
||||
val imageTag: String = "",
|
||||
val addedAt: String = "",
|
||||
val sonarrStatus: String = "Not found",
|
||||
val nextEpisode: String? = null,
|
||||
val lifecycle: String = "Unknown",
|
||||
val monitored: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MyShowsResponse(
|
||||
val shows: List<MyShow> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SaveMyShowRequest(
|
||||
val itemId: String,
|
||||
val title: String,
|
||||
val year: Int? = null,
|
||||
val imageTag: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NotificationPreferences(
|
||||
val enabled: Boolean = true,
|
||||
val showReturnAlerts: Boolean = true,
|
||||
val leadDays: Int = 7,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class UserNotification(
|
||||
val id: Long,
|
||||
val kind: String = "",
|
||||
val itemId: String = "",
|
||||
val title: String = "",
|
||||
val message: String = "",
|
||||
val eventAt: String? = null,
|
||||
val createdAt: String = "",
|
||||
val readAt: String? = null,
|
||||
) {
|
||||
val unread: Boolean get() = readAt.isNullOrBlank()
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class NotificationsResponse(
|
||||
val notifications: List<UserNotification> = emptyList(),
|
||||
val preferences: NotificationPreferences = NotificationPreferences(),
|
||||
)
|
||||
|
||||
/** One row-engagement event. See `data/analytics/RowAnalytics.kt`. */
|
||||
@Serializable
|
||||
data class GatewayRowEvent(
|
||||
@@ -213,9 +369,15 @@ data class GatewayRowEvents(
|
||||
data class GatewayPlaybackReport(
|
||||
val itemId: String,
|
||||
val positionMs: Long,
|
||||
val durationMs: Long = 0,
|
||||
val isPaused: Boolean = false,
|
||||
val mediaSourceId: String = "",
|
||||
val playSessionId: String = "",
|
||||
val playMethod: String = "DirectPlay",
|
||||
val eventName: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GatewayPlaybackReportResponse(
|
||||
val autoFollowedShowTitle: String = "",
|
||||
)
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Playback capability probing adapted from Wholphin's
|
||||
* MediaCodecCapabilitiesTest.kt, which is itself derived from Jellyfin Android TV.
|
||||
*
|
||||
* Wholphin: https://github.com/damontecres/Wholphin
|
||||
* Jellyfin Android TV: https://github.com/jellyfin/jellyfin-androidtv
|
||||
*
|
||||
* Modifications Copyright (C) 2026 Memby contributors
|
||||
* SPDX-License-Identifier: GPL-2.0-only
|
||||
*/
|
||||
|
||||
package com.ponzischeme89.memby.data.playback
|
||||
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.media.MediaCodecList
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
|
||||
data class VideoDecoderCapabilities(
|
||||
val supported: Boolean = false,
|
||||
val profiles: Set<String> = emptySet(),
|
||||
val mainLevel: Int = 0,
|
||||
val tenBitLevel: Int = 0,
|
||||
val maxWidth: Int = 0,
|
||||
val maxHeight: Int = 0,
|
||||
val hdr10: Boolean = false,
|
||||
val hdr10Plus: Boolean = false,
|
||||
val dolbyVision: Boolean = false,
|
||||
)
|
||||
|
||||
data class DevicePlaybackCapabilities(
|
||||
val h264: VideoDecoderCapabilities = VideoDecoderCapabilities(supported = true),
|
||||
val hevc: VideoDecoderCapabilities = VideoDecoderCapabilities(),
|
||||
)
|
||||
|
||||
/**
|
||||
* The expensive platform query is performed once, off the UI thread on first network or
|
||||
* direct-play negotiation. A single immutable result then describes this installed build
|
||||
* to both the Memby gateway and Emby itself.
|
||||
*/
|
||||
val devicePlaybackCapabilities: DevicePlaybackCapabilities by lazy {
|
||||
runCatching { AndroidVideoCapabilityProbe().probe() }
|
||||
// H.264 is Android's baseline playback format and remains the safe fallback when
|
||||
// a vendor codec exposes incomplete or broken MediaCodec metadata.
|
||||
.getOrElse { DevicePlaybackCapabilities() }
|
||||
}
|
||||
|
||||
internal fun DevicePlaybackCapabilities.gatewayCapabilityTokens(): List<String> = buildList {
|
||||
if (h264.supported) add("video_h264_decode")
|
||||
h264.profiles.sorted().forEach { add("video_h264_profile_$it") }
|
||||
if (h264.mainLevel > 0) add("video_h264_level_${h264.mainLevel}")
|
||||
if (h264.tenBitLevel > 0) add("video_h264_high10_level_${h264.tenBitLevel}")
|
||||
addResolution("video_h264", h264)
|
||||
|
||||
if (hevc.supported) add("video_hevc_decode")
|
||||
hevc.profiles.sorted().forEach { add("video_hevc_profile_$it") }
|
||||
if (hevc.mainLevel > 0) add("video_hevc_main_level_${hevc.mainLevel}")
|
||||
if (hevc.tenBitLevel > 0) add("video_hevc_main10_level_${hevc.tenBitLevel}")
|
||||
addResolution("video_hevc", hevc)
|
||||
if (hevc.hdr10) add("video_hevc_hdr10")
|
||||
if (hevc.hdr10Plus) add("video_hevc_hdr10plus")
|
||||
if (hevc.dolbyVision) add("video_hevc_dolby_vision")
|
||||
}
|
||||
|
||||
private fun MutableList<String>.addResolution(prefix: String, codec: VideoDecoderCapabilities) {
|
||||
if (codec.maxWidth > 0 && codec.maxHeight > 0) {
|
||||
add("${prefix}_max_${codec.maxWidth}x${codec.maxHeight}")
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidVideoCapabilityProbe {
|
||||
private val codecInfos by lazy { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
|
||||
|
||||
fun probe(): DevicePlaybackCapabilities = DevicePlaybackCapabilities(
|
||||
h264 = probeH264(),
|
||||
hevc = probeHevc(),
|
||||
)
|
||||
|
||||
private fun probeH264(): VideoDecoderCapabilities {
|
||||
val mime = MediaFormat.MIMETYPE_VIDEO_AVC
|
||||
val supported = hasCodecForMime(mime)
|
||||
// As in Wholphin, ordinary AVC support covers the backwards-compatible
|
||||
// baseline/main/high family even when vendor metadata lists only its highest
|
||||
// profile. High10 remains opt-in because it is not universally supported.
|
||||
val high10Level = mappedLevel(
|
||||
mime,
|
||||
setOf(CodecProfileLevel.AVCProfileHigh10),
|
||||
AVC_LEVELS,
|
||||
)
|
||||
val profiles = buildSet {
|
||||
if (supported) addAll(listOf("baseline", "constrained_baseline", "main", "high"))
|
||||
if (high10Level > 0) add("high10")
|
||||
}
|
||||
val (maxWidth, maxHeight) = maxResolution(mime)
|
||||
return VideoDecoderCapabilities(
|
||||
supported = supported,
|
||||
profiles = profiles,
|
||||
mainLevel = mappedLevel(
|
||||
mime,
|
||||
setOf(
|
||||
CodecProfileLevel.AVCProfileBaseline,
|
||||
CodecProfileLevel.AVCProfileMain,
|
||||
CodecProfileLevel.AVCProfileHigh,
|
||||
),
|
||||
AVC_LEVELS,
|
||||
),
|
||||
tenBitLevel = high10Level,
|
||||
maxWidth = maxWidth,
|
||||
maxHeight = maxHeight,
|
||||
)
|
||||
}
|
||||
|
||||
private fun probeHevc(): VideoDecoderCapabilities {
|
||||
val mime = MediaFormat.MIMETYPE_VIDEO_HEVC
|
||||
val supported = hasCodecForMime(mime)
|
||||
val mainLevel = mappedLevel(
|
||||
mime,
|
||||
setOf(CodecProfileLevel.HEVCProfileMain),
|
||||
HEVC_LEVELS,
|
||||
)
|
||||
val main10Level = mappedLevel(
|
||||
mime,
|
||||
setOf(CodecProfileLevel.HEVCProfileMain10),
|
||||
HEVC_LEVELS,
|
||||
)
|
||||
val (maxWidth, maxHeight) = maxResolution(mime)
|
||||
return VideoDecoderCapabilities(
|
||||
supported = supported,
|
||||
profiles = buildSet {
|
||||
if (supported) add("main")
|
||||
if (main10Level > 0) add("main10")
|
||||
},
|
||||
mainLevel = mainLevel,
|
||||
tenBitLevel = main10Level,
|
||||
maxWidth = maxWidth,
|
||||
maxHeight = maxHeight,
|
||||
hdr10 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10),
|
||||
hdr10Plus = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
|
||||
hasProfile(mime, CodecProfileLevel.HEVCProfileMain10HDR10Plus),
|
||||
dolbyVision = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N &&
|
||||
hasCodecForMime(MediaFormat.MIMETYPE_VIDEO_DOLBY_VISION),
|
||||
)
|
||||
}
|
||||
|
||||
private fun hasCodecForMime(mime: String): Boolean = codecInfos.any { info ->
|
||||
!info.isEncoder && info.supportedTypes.any { it.equals(mime, ignoreCase = true) }
|
||||
}
|
||||
|
||||
private fun hasProfile(mime: String, profile: Int): Boolean =
|
||||
maxPlatformLevel(mime, setOf(profile)) > 0
|
||||
|
||||
private fun mappedLevel(
|
||||
mime: String,
|
||||
profiles: Set<Int>,
|
||||
levels: List<Pair<Int, Int>>,
|
||||
): Int {
|
||||
val platformLevel = maxPlatformLevel(mime, profiles)
|
||||
return levels.asReversed().firstOrNull { platformLevel >= it.first }?.second ?: 0
|
||||
}
|
||||
|
||||
private fun maxPlatformLevel(mime: String, profiles: Set<Int>): Int {
|
||||
var maximum = 0
|
||||
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
|
||||
runCatching { info.getCapabilitiesForType(mime) }.getOrNull()
|
||||
?.profileLevels
|
||||
?.filter { it.profile in profiles }
|
||||
?.forEach { maximum = maxOf(maximum, it.level) }
|
||||
}
|
||||
return maximum
|
||||
}
|
||||
|
||||
private fun maxResolution(mime: String): Pair<Int, Int> {
|
||||
var maxWidth = 0
|
||||
var maxHeight = 0
|
||||
codecInfos.asSequence().filterNot { it.isEncoder }.forEach { info ->
|
||||
val video = runCatching { info.getCapabilitiesForType(mime).videoCapabilities }.getOrNull()
|
||||
?: return@forEach
|
||||
maxWidth = maxOf(maxWidth, video.supportedWidths?.upper ?: 0)
|
||||
maxHeight = maxOf(maxHeight, video.supportedHeights?.upper ?: 0)
|
||||
}
|
||||
return maxWidth to maxHeight
|
||||
}
|
||||
|
||||
companion object {
|
||||
// ffprobe/Emby represent AVC levels multiplied by 10 (4.1 -> 41).
|
||||
private val AVC_LEVELS = listOf(
|
||||
CodecProfileLevel.AVCLevel1b to 9,
|
||||
CodecProfileLevel.AVCLevel1 to 10,
|
||||
CodecProfileLevel.AVCLevel11 to 11,
|
||||
CodecProfileLevel.AVCLevel12 to 12,
|
||||
CodecProfileLevel.AVCLevel13 to 13,
|
||||
CodecProfileLevel.AVCLevel2 to 20,
|
||||
CodecProfileLevel.AVCLevel21 to 21,
|
||||
CodecProfileLevel.AVCLevel22 to 22,
|
||||
CodecProfileLevel.AVCLevel3 to 30,
|
||||
CodecProfileLevel.AVCLevel31 to 31,
|
||||
CodecProfileLevel.AVCLevel32 to 32,
|
||||
CodecProfileLevel.AVCLevel4 to 40,
|
||||
CodecProfileLevel.AVCLevel41 to 41,
|
||||
CodecProfileLevel.AVCLevel42 to 42,
|
||||
CodecProfileLevel.AVCLevel5 to 50,
|
||||
CodecProfileLevel.AVCLevel51 to 51,
|
||||
CodecProfileLevel.AVCLevel52 to 52,
|
||||
)
|
||||
|
||||
// ffprobe/Emby represent HEVC levels multiplied by 30 (4.1 -> 123).
|
||||
private val HEVC_LEVELS = listOf(
|
||||
CodecProfileLevel.HEVCMainTierLevel1 to 30,
|
||||
CodecProfileLevel.HEVCMainTierLevel2 to 60,
|
||||
CodecProfileLevel.HEVCMainTierLevel21 to 63,
|
||||
CodecProfileLevel.HEVCMainTierLevel3 to 90,
|
||||
CodecProfileLevel.HEVCMainTierLevel31 to 93,
|
||||
CodecProfileLevel.HEVCMainTierLevel4 to 120,
|
||||
CodecProfileLevel.HEVCMainTierLevel41 to 123,
|
||||
CodecProfileLevel.HEVCMainTierLevel5 to 150,
|
||||
CodecProfileLevel.HEVCMainTierLevel51 to 153,
|
||||
CodecProfileLevel.HEVCMainTierLevel52 to 156,
|
||||
CodecProfileLevel.HEVCMainTierLevel6 to 180,
|
||||
CodecProfileLevel.HEVCMainTierLevel61 to 183,
|
||||
CodecProfileLevel.HEVCMainTierLevel62 to 186,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,16 @@ interface EmbyApi {
|
||||
@Path("itemId") itemId: String,
|
||||
): ItemsResult
|
||||
|
||||
/**
|
||||
* Emby's own similarity ranking. The direct path has no recommendation engine behind
|
||||
* it, so this is the whole of "More like this" when the gateway is not in play.
|
||||
*/
|
||||
@GET("Items/{itemId}/Similar")
|
||||
suspend fun getSimilar(
|
||||
@Path("itemId") itemId: String,
|
||||
@QueryMap params: Map<String, String>,
|
||||
): ItemsResult
|
||||
|
||||
@GET("Shows/NextUp")
|
||||
suspend fun getNextUp(@QueryMap params: Map<String, String>): ItemsResult
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.ponzischeme89.memby.BuildConfig
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import okhttp3.logging.HttpLoggingInterceptor
|
||||
import retrofit2.Retrofit
|
||||
@@ -36,7 +35,9 @@ object EmbyServiceFactory {
|
||||
redactHeader("X-Emby-Authorization")
|
||||
}
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
// Derived from the shared stack, so this keeps the one connection pool and
|
||||
// dispatcher the artwork loader also uses. See HttpStack.
|
||||
val client = HttpStack.base.newBuilder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(EmbyAuthInterceptor(deviceIdProvider, tokenProvider))
|
||||
|
||||
@@ -2,11 +2,15 @@ package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayFlagRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayAuthPolicy
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||
import com.ponzischeme89.memby.data.model.GatewayDeviceNameRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayItems
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayLoginResponse
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequest
|
||||
import com.ponzischeme89.memby.data.model.GatewayMediaRequestResult
|
||||
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReport
|
||||
@@ -14,12 +18,17 @@ import com.ponzischeme89.memby.data.model.GatewayPrerollSchedule
|
||||
import com.ponzischeme89.memby.data.model.GatewayRowEvents
|
||||
import com.ponzischeme89.memby.data.model.GatewayRows
|
||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestLookup
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.RecommendationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
@@ -35,9 +44,6 @@ interface GatewayApi {
|
||||
@POST("v1/auth/login")
|
||||
suspend fun login(@Body body: GatewayLoginRequest): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/policy")
|
||||
suspend fun authPolicy(): GatewayAuthPolicy
|
||||
|
||||
@POST("v1/auth/logout")
|
||||
suspend fun logout()
|
||||
|
||||
@@ -45,6 +51,18 @@ interface GatewayApi {
|
||||
@GET("v1/auth/session")
|
||||
suspend fun session(): GatewayLoginResponse
|
||||
|
||||
@GET("v1/auth/devices")
|
||||
suspend fun devices(): GatewayDevices
|
||||
|
||||
@DELETE("v1/auth/devices/{deviceId}")
|
||||
suspend fun removeDevice(@Path("deviceId") deviceId: String)
|
||||
|
||||
@PUT("v1/auth/devices/{deviceId}")
|
||||
suspend fun renameDevice(
|
||||
@Path("deviceId") deviceId: String,
|
||||
@Body body: GatewayDeviceNameRequest,
|
||||
)
|
||||
|
||||
@GET("v1/home")
|
||||
suspend fun home(@Query("limit") limit: Int): GatewayHome
|
||||
|
||||
@@ -60,6 +78,12 @@ interface GatewayApi {
|
||||
@GET("v1/search/history")
|
||||
suspend fun recentSearches(): GatewaySearchHistory
|
||||
|
||||
@GET("v1/requests/lookup")
|
||||
suspend fun requestLookup(@Query("q") term: String): GatewayRequestLookup
|
||||
|
||||
@POST("v1/requests")
|
||||
suspend fun requestMedia(@Body body: GatewayMediaRequest): GatewayMediaRequestResult
|
||||
|
||||
/** Recommendation rows on their own. `/v1/home` already embeds these when warm. */
|
||||
@GET("v1/recommendations")
|
||||
suspend fun recommendations(): GatewayRows
|
||||
@@ -67,6 +91,12 @@ interface GatewayApi {
|
||||
@GET("v1/for-you")
|
||||
suspend fun forYou(@Query("minutes") availableMinutes: Int): GatewayRows
|
||||
|
||||
@GET("v1/recommendations/preferences")
|
||||
suspend fun recommendationPreferences(): RecommendationOnboarding
|
||||
|
||||
@PUT("v1/recommendations/preferences")
|
||||
suspend fun saveRecommendationPreferences(@Body body: RecommendationPreferences)
|
||||
|
||||
@GET("v1/preroll")
|
||||
suspend fun prerollSchedule(): GatewayPrerollSchedule
|
||||
|
||||
@@ -77,17 +107,57 @@ interface GatewayApi {
|
||||
@GET("v1/update")
|
||||
suspend fun updateStatus(): GatewayUpdate
|
||||
|
||||
@GET("v1/my-shows")
|
||||
suspend fun myShows(): com.ponzischeme89.memby.data.model.MyShowsResponse
|
||||
|
||||
@POST("v1/my-shows")
|
||||
suspend fun saveMyShow(
|
||||
@Body body: com.ponzischeme89.memby.data.model.SaveMyShowRequest,
|
||||
): com.ponzischeme89.memby.data.model.MyShowsResponse
|
||||
|
||||
@DELETE("v1/my-shows/{id}")
|
||||
suspend fun removeMyShow(@Path("id") itemId: String)
|
||||
|
||||
@GET("v1/notifications")
|
||||
suspend fun notifications(): com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||
|
||||
@PUT("v1/notifications")
|
||||
suspend fun setNotificationPreferences(
|
||||
@Body body: com.ponzischeme89.memby.data.model.NotificationPreferences,
|
||||
): com.ponzischeme89.memby.data.model.NotificationsResponse
|
||||
|
||||
@POST("v1/notifications/{id}/{action}")
|
||||
suspend fun updateNotification(
|
||||
@Path("id") id: Long,
|
||||
@Path("action") action: String,
|
||||
)
|
||||
|
||||
/** Available during maintenance so an open app can be interrupted immediately. */
|
||||
@GET("v1/status")
|
||||
suspend fun serviceStatus(): GatewayServiceStatus
|
||||
|
||||
/** Versioned server control-plane document; unknown flags remain safely ignorable. */
|
||||
@GET("v1/features")
|
||||
suspend fun features(): GatewayFeatures
|
||||
|
||||
@GET("v1/items/{id}")
|
||||
suspend fun item(@Path("id") itemId: String): BaseItem
|
||||
|
||||
@GET("v1/items/{id}/season-finale")
|
||||
suspend fun seasonFinale(
|
||||
@Path("id") itemId: String,
|
||||
): com.ponzischeme89.memby.data.model.GatewaySeasonFinale
|
||||
|
||||
/** All episodes for a series in display order; the client groups them into seasons. */
|
||||
@GET("v1/items/{id}/episodes")
|
||||
suspend fun seriesEpisodes(@Path("id") seriesId: String): GatewayItems
|
||||
|
||||
/** Why this viewer might enjoy the item, and what else in the library is like it. */
|
||||
@GET("v1/items/{id}/related")
|
||||
suspend fun related(
|
||||
@Path("id") itemId: String,
|
||||
): com.ponzischeme89.memby.data.model.GatewayRelated
|
||||
|
||||
@GET("v1/items/{id}/playback")
|
||||
suspend fun playback(
|
||||
@Path("id") itemId: String,
|
||||
@@ -95,6 +165,7 @@ interface GatewayApi {
|
||||
@Query("title") title: String,
|
||||
@Query("resumePositionMs") resumePositionMs: Long,
|
||||
@Query("subtitleIndex") subtitleIndex: Int? = null,
|
||||
@Query("forceTranscode") forceTranscode: Boolean = false,
|
||||
): GatewayPlayback
|
||||
|
||||
/** 404 when nothing follows this item: a movie, or a series finale. */
|
||||
@@ -114,7 +185,10 @@ interface GatewayApi {
|
||||
suspend fun setPlayed(@Path("id") itemId: String, @Body body: GatewayFlagRequest): UserItemData
|
||||
|
||||
@POST("v1/playback/{phase}")
|
||||
suspend fun report(@Path("phase") phase: String, @Body body: GatewayPlaybackReport)
|
||||
suspend fun report(
|
||||
@Path("phase") phase: String,
|
||||
@Body body: GatewayPlaybackReport,
|
||||
): com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
||||
|
||||
/** Row engagement, uploaded in batches. Fire-and-forget: failures are not retried. */
|
||||
@POST("v1/analytics/rows")
|
||||
|
||||
@@ -2,10 +2,11 @@ package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Response
|
||||
import retrofit2.Retrofit
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -23,7 +24,9 @@ object GatewayServiceFactory {
|
||||
fun create(baseUrl: String, tokenProvider: () -> String?): GatewayApi {
|
||||
val contentType = "application/json".toMediaType()
|
||||
|
||||
val client = OkHttpClient.Builder()
|
||||
// Derived from the shared stack: artwork in gateway mode is proxied by this very
|
||||
// host, so the poster fetches reuse the connection this client established.
|
||||
val client = HttpStack.base.newBuilder()
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
// The gateway answers home from Redis in single-digit milliseconds; a long
|
||||
// read timeout here only ever means Emby itself is struggling behind it.
|
||||
@@ -52,6 +55,11 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
// says which build it is.
|
||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||
.header("X-Memby-Protocol", MEMBY_PROTOCOL_VERSION.toString())
|
||||
.header(
|
||||
"X-Memby-Capabilities",
|
||||
(MEMBY_CAPABILITIES + devicePlaybackCapabilities.gatewayCapabilityTokens())
|
||||
.joinToString(","),
|
||||
)
|
||||
tokenProvider()?.takeIf { it.isNotBlank() }?.let {
|
||||
builder.header("Authorization", "Bearer $it")
|
||||
}
|
||||
@@ -60,3 +68,12 @@ private class GatewayAuthInterceptor(private val tokenProvider: () -> String?) :
|
||||
}
|
||||
|
||||
internal const val MEMBY_PROTOCOL_VERSION = 1
|
||||
|
||||
internal val MEMBY_CAPABILITIES = listOf(
|
||||
"server_features_v1",
|
||||
"live_feature_refresh_v1",
|
||||
"sonarr_preroll_v1",
|
||||
"auto_my_shows_v1",
|
||||
)
|
||||
|
||||
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.ponzischeme89.memby.data.remote
|
||||
|
||||
import okhttp3.ConnectionPool
|
||||
import okhttp3.Dispatcher
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The one HTTP stack in the app. Everything that talks to the network — the Emby API, the
|
||||
* gateway API and Coil's artwork loader — is built from [base], so they share a single
|
||||
* connection pool, dispatcher and thread pool.
|
||||
*
|
||||
* Sharing matters most for artwork. In gateway mode the images are proxied by the same
|
||||
* HTTPS host that serves `/v1/home`, so a poster fetched on a client of its own would
|
||||
* open a fresh TCP connection and repeat the TLS handshake that the home request had just
|
||||
* finished — perhaps a couple of hundred milliseconds per cold image over a domestic
|
||||
* connection, multiplied by the fifteen-odd posters a launcher paints at once. Reusing
|
||||
* the pool makes those fetches resume an established connection instead.
|
||||
*
|
||||
* Timeouts and auth headers still differ per caller, so each factory calls
|
||||
* [base].newBuilder() and adds its own. That is the intended way to specialise an OkHttp
|
||||
* client: the derived client keeps the shared pool and dispatcher.
|
||||
*/
|
||||
internal object HttpStack {
|
||||
|
||||
/**
|
||||
* A launcher paints far more than the default five concurrent requests per host, and
|
||||
* in gateway mode every one of them is the same host. Five means posters arrive in
|
||||
* visible waves; this lets a screenful start together while staying well short of
|
||||
* what would swamp a TV's radio.
|
||||
*/
|
||||
private const val MAX_REQUESTS_PER_HOST = 15
|
||||
|
||||
private val connectionPool = ConnectionPool(
|
||||
maxIdleConnections = 8,
|
||||
keepAliveDuration = 5,
|
||||
timeUnit = TimeUnit.MINUTES,
|
||||
)
|
||||
|
||||
private val dispatcher = Dispatcher().apply {
|
||||
maxRequests = 32
|
||||
maxRequestsPerHost = MAX_REQUESTS_PER_HOST
|
||||
}
|
||||
|
||||
val base: OkHttpClient = OkHttpClient.Builder()
|
||||
.connectionPool(connectionPool)
|
||||
.dispatcher(dispatcher)
|
||||
// Sensible floor; every caller overrides these to suit what it is fetching.
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true)
|
||||
.build()
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.IntrinsicSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.TechnicalSpec
|
||||
import com.ponzischeme89.memby.ui.detail.formatRuntime
|
||||
import com.ponzischeme89.memby.ui.detail.ratingLabel
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyScore
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// The detail page's names for the shared tokens. The neutrals used to be a shade darker
|
||||
// here than on the launcher, which is visible the moment a page opens from a row.
|
||||
internal val DetailBackground = MembySurface
|
||||
internal val DetailAccent = MembyAccent
|
||||
internal val DetailText = MembyOnSurface
|
||||
internal val DetailMutedText = MembyMutedText
|
||||
internal val DetailQuietText = MembyQuietText
|
||||
internal val DetailHairline = MembyHairline
|
||||
internal val DetailSideGutter = 58.dp
|
||||
private val DetailTabHeight = 66.dp
|
||||
|
||||
/**
|
||||
* How much of the content pane is left showing under the tab strip.
|
||||
*
|
||||
* It does two jobs. The strip used to be anchored to the very bottom of the screen, where a
|
||||
* TV's overscan ate the selection underline and part of the labels — this is the safe-area
|
||||
* inset the rest of the app already keeps. And because what fills the gap is the top of the
|
||||
* pane rather than more background, it is the one thing on screen saying that Down from the
|
||||
* strip reveals something.
|
||||
*/
|
||||
private val DetailFoldPeek = 34.dp
|
||||
|
||||
/**
|
||||
* The height one tab's pane gets, from the viewport it has to fit inside.
|
||||
*
|
||||
* It was a hard 250dp, and `technicalSpecs()` — Video, Codec, Audio, Subtitles, Studio —
|
||||
* fell off the bottom of a pane that deliberately cannot scroll. The budget is what is left
|
||||
* of the screen once the pane has been scrolled to its resting position under the strip.
|
||||
*/
|
||||
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
|
||||
(viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)
|
||||
|
||||
internal data class DetailHeroAction(
|
||||
val icon: ImageVector,
|
||||
val description: String,
|
||||
val active: Boolean = false,
|
||||
val label: String? = null,
|
||||
val onClick: () -> Unit,
|
||||
)
|
||||
|
||||
/** Full-bleed artwork with a protected reading area on the left and at the fold. */
|
||||
@Composable
|
||||
internal fun DetailBackdrop(item: BaseItem, modifier: Modifier = Modifier) {
|
||||
val repository = ServiceLocator.repository
|
||||
val artwork = remember(item.id, item.backdropImageTags, item.imageTags) {
|
||||
repository.backdropUrl(item, 1920) ?: repository.primaryUrl(item, 1280)
|
||||
}
|
||||
Box(modifier.background(DetailBackground)) {
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopCenter,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xF2080A0C),
|
||||
0.42f to Color(0xCC080A0C),
|
||||
0.72f to Color(0x38080A0C),
|
||||
1f to Color(0x10080A0C),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color(0x18000000),
|
||||
0.48f to Color(0x26080A0C),
|
||||
0.78f to Color(0xE6080A0C),
|
||||
1f to DetailBackground,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared movie/series frame. The list owns vertical motion so focused bands stay visible. */
|
||||
@Composable
|
||||
internal fun DetailPageScaffold(
|
||||
item: BaseItem,
|
||||
facts: List<String>,
|
||||
badges: List<String>,
|
||||
tabs: List<DetailTab>,
|
||||
selectedTab: DetailTab,
|
||||
onSelectTab: (DetailTab) -> Unit,
|
||||
playLabel: String,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
tabFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
progress: Float = 0f,
|
||||
progressLabel: String? = null,
|
||||
reasons: List<String> = emptyList(),
|
||||
heroActions: List<DetailHeroAction> = emptyList(),
|
||||
confirmation: String? = null,
|
||||
pageListState: LazyListState = remember(item.id) { LazyListState() },
|
||||
onZoneFocused: (DetailZone) -> Unit = {},
|
||||
content: @Composable BoxScope.(DetailTab) -> Unit,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
// Keep the same requesters when an async trailer action appears. Replacing the list
|
||||
// while the viewer is already on Favourites would detach the focused node.
|
||||
val allActionRequesters = remember(item.id) {
|
||||
List(6) { FocusRequester() }
|
||||
}
|
||||
val actionRequesters = allActionRequesters.take(heroActions.size)
|
||||
var lastHeroIndex by remember(item.id) { mutableIntStateOf(-1) }
|
||||
var focusedZone by remember(item.id) { mutableStateOf(DetailZone.PLAY) }
|
||||
val heroReturn = if (lastHeroIndex in actionRequesters.indices) {
|
||||
actionRequesters[lastHeroIndex]
|
||||
} else {
|
||||
playFocusRequester
|
||||
}
|
||||
fun reveal(index: Int, offset: Int = 0) {
|
||||
scope.launch { pageListState.animateScrollToItem(index, offset) }
|
||||
}
|
||||
fun revealHero() {
|
||||
scope.launch {
|
||||
// LazyColumn also scrolls a newly focused descendant into view. That request
|
||||
// can land after onFocusChanged and used to win over reveal(0), leaving Play
|
||||
// focused with the logo and metadata above the viewport. Snap once now and
|
||||
// once after focus relocation has completed so hero focus always means the
|
||||
// complete opening frame.
|
||||
pageListState.scrollToItem(0)
|
||||
withFrameNanos { }
|
||||
pageListState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
// Focus relocation belongs to LazyColumn and may run after the focus callback. Keep
|
||||
// the opening frame pinned for as long as focus remains in the hero, regardless of
|
||||
// which relocation wins a particular frame. Moving to Tabs or Content releases it.
|
||||
androidx.compose.runtime.LaunchedEffect(pageListState, focusedZone) {
|
||||
if (detailHeroScrollTarget(focusedZone) == null) return@LaunchedEffect
|
||||
snapshotFlow {
|
||||
pageListState.firstVisibleItemIndex to pageListState.firstVisibleItemScrollOffset
|
||||
}.collect { (index, offset) ->
|
||||
if (index != 0 || offset != 0) pageListState.scrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(DetailBackground)) {
|
||||
// The opening composition is one deliberate TV frame: hero above, tabs anchored
|
||||
// to its bottom edge. Content begins below the fold and only enters when the
|
||||
// viewer presses Down from the tabs.
|
||||
val heroHeight = (maxHeight - DetailTabHeight - DetailFoldPeek).coerceAtLeast(340.dp)
|
||||
val paneHeight = detailPaneHeight(maxHeight)
|
||||
LazyColumn(
|
||||
state = pageListState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
item(key = "hero") {
|
||||
DetailHero(
|
||||
item = item,
|
||||
facts = facts,
|
||||
badges = badges,
|
||||
playLabel = playLabel,
|
||||
onPlay = onPlay,
|
||||
playFocusRequester = playFocusRequester,
|
||||
tabFocusRequester = tabFocusRequester,
|
||||
progress = progress,
|
||||
progressLabel = progressLabel,
|
||||
reasons = reasons,
|
||||
actions = heroActions,
|
||||
actionRequesters = actionRequesters,
|
||||
height = heroHeight,
|
||||
onActionFocused = { index ->
|
||||
lastHeroIndex = index
|
||||
focusedZone = DetailZone.PLAY
|
||||
onZoneFocused(DetailZone.PLAY)
|
||||
revealHero()
|
||||
},
|
||||
onPlayFocused = {
|
||||
lastHeroIndex = -1
|
||||
focusedZone = DetailZone.PLAY
|
||||
onZoneFocused(DetailZone.PLAY)
|
||||
revealHero()
|
||||
},
|
||||
)
|
||||
}
|
||||
item(key = "tabs") {
|
||||
DetailTabStrip(
|
||||
tabs = tabs,
|
||||
selected = selectedTab,
|
||||
onSelect = onSelectTab,
|
||||
selectedFocusRequester = tabFocusRequester,
|
||||
heroFocusRequester = heroReturn,
|
||||
contentFocusRequester = contentFocusRequester,
|
||||
onFocused = {
|
||||
focusedZone = DetailZone.TABS
|
||||
onZoneFocused(DetailZone.TABS)
|
||||
reveal(1, -18)
|
||||
},
|
||||
)
|
||||
}
|
||||
item(key = "content") {
|
||||
AnimatedContent(
|
||||
targetState = selectedTab,
|
||||
transitionSpec = { fadeIn(tween(110)) togetherWith fadeOut(tween(80)) },
|
||||
label = "detail-tab-content",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = DetailSideGutter, end = DetailSideGutter, top = 16.dp, bottom = 64.dp)
|
||||
.height(paneHeight)
|
||||
.onFocusChanged {
|
||||
if (it.hasFocus) {
|
||||
focusedZone = DetailZone.CONTENT
|
||||
onZoneFocused(DetailZone.CONTENT)
|
||||
reveal(2, -72)
|
||||
}
|
||||
}
|
||||
.focusProperties { up = tabFocusRequester },
|
||||
) { visibleTab ->
|
||||
Box(Modifier.fillMaxSize()) { content(visibleTab) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
confirmation?.let {
|
||||
Text(
|
||||
text = it,
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 24.dp)
|
||||
.shadow(16.dp, RoundedCornerShape(MembyPanelCorner))
|
||||
.clip(RoundedCornerShape(MembyPanelCorner))
|
||||
.background(Color(0xEE20252A))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(MembyPanelCorner))
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A focused hero is always the complete opening frame, never a scrolled Play-only crop. */
|
||||
internal fun detailHeroScrollTarget(zone: DetailZone): Int? =
|
||||
if (zone == DetailZone.PLAY) 0 else null
|
||||
|
||||
@Composable
|
||||
private fun DetailHero(
|
||||
item: BaseItem,
|
||||
facts: List<String>,
|
||||
badges: List<String>,
|
||||
playLabel: String,
|
||||
onPlay: () -> Unit,
|
||||
playFocusRequester: FocusRequester,
|
||||
tabFocusRequester: FocusRequester,
|
||||
progress: Float,
|
||||
progressLabel: String?,
|
||||
reasons: List<String>,
|
||||
actions: List<DetailHeroAction>,
|
||||
actionRequesters: List<FocusRequester>,
|
||||
height: Dp,
|
||||
onPlayFocused: () -> Unit,
|
||||
onActionFocused: (Int) -> Unit,
|
||||
) {
|
||||
// Settings promises the logo preference applies to titles generally; only the
|
||||
// screensaver honoured it. The dark-logo fallback comes with it — a black title
|
||||
// treatment on this near-black scrim is an invisible heading.
|
||||
val repository = ServiceLocator.repository
|
||||
val logoUrl = remember(item.id, item.imageTags, repository.showTitleLogo) {
|
||||
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
|
||||
}
|
||||
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
|
||||
Box(Modifier.fillMaxWidth().height(height)) {
|
||||
DetailBackdrop(item, Modifier.fillMaxSize())
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(start = DetailSideGutter, end = DetailSideGutter, bottom = 30.dp)
|
||||
.fillMaxWidth(0.58f),
|
||||
) {
|
||||
if (logo != null) {
|
||||
AsyncImage(
|
||||
model = logo,
|
||||
contentDescription = item.name,
|
||||
contentScale = ContentScale.Fit,
|
||||
alignment = Alignment.CenterStart,
|
||||
modifier = Modifier.width(330.dp).height(92.dp),
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = item.name,
|
||||
color = Color.White,
|
||||
fontSize = 38.sp,
|
||||
lineHeight = 42.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
|
||||
if (item.genres.isNotEmpty()) {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
text = item.genres.take(4).joinToString(ValueSeparator),
|
||||
color = DetailMutedText,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = DetailText,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (progress > 0f) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
DetailProgress(progress, progressLabel)
|
||||
}
|
||||
if (reasons.isNotEmpty()) {
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(
|
||||
text = reasons.first(),
|
||||
color = DetailAccent,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.focusGroup().focusProperties { down = tabFocusRequester },
|
||||
) {
|
||||
MembyPlayButton(
|
||||
label = playLabel,
|
||||
onClick = onPlay,
|
||||
onFocused = onPlayFocused,
|
||||
modifier = Modifier.testTag("detail-play").focusRequester(playFocusRequester),
|
||||
)
|
||||
actions.forEachIndexed { index, action ->
|
||||
DetailCircularAction(
|
||||
action = action,
|
||||
onFocused = { onActionFocused(index) },
|
||||
modifier = Modifier.focusRequester(actionRequesters[index]),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailCircularAction(
|
||||
action: DetailHeroAction,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.1f else 1f, tween(100), label = "hero-action-focus")
|
||||
Row(
|
||||
modifier = modifier
|
||||
.then(if (action.label == null) Modifier.size(48.dp) else Modifier.height(48.dp))
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
|
||||
.shadow(if (focused) 16.dp else 5.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(if (action.active) DetailAccent else Color(0xB3262B30))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else Color.White.copy(alpha = 0.38f), CircleShape)
|
||||
.semantics { contentDescription = action.description }
|
||||
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
|
||||
.clickable(onClick = action.onClick)
|
||||
.padding(horizontal = if (action.label == null) 0.dp else 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(action.icon, action.description, tint = Color.White, modifier = Modifier.size(22.dp))
|
||||
action.label?.let { label ->
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(label, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Bold, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailFactRow(
|
||||
facts: List<String>,
|
||||
badges: List<String> = emptyList(),
|
||||
rating: String? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
val values = buildList {
|
||||
addAll(facts)
|
||||
rating?.let { add("★ $it") }
|
||||
}
|
||||
values.forEachIndexed { index, value ->
|
||||
if (index > 0) Text(FactSeparator, color = DetailQuietText, fontSize = 13.sp)
|
||||
Text(value, color = if (value.startsWith("★")) MembyScore else DetailMutedText, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
// Four, not three: a 4K/HDR/HEVC movie used up the whole allowance and dropped
|
||||
// the airing badge appended after them, which is the one that is news.
|
||||
badges.take(4).forEach { badge ->
|
||||
Spacer(Modifier.width(8.dp))
|
||||
MediaBadge(badge)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailTabStrip(
|
||||
tabs: List<DetailTab>,
|
||||
selected: DetailTab,
|
||||
onSelect: (DetailTab) -> Unit,
|
||||
selectedFocusRequester: FocusRequester,
|
||||
heroFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester,
|
||||
onFocused: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(DetailTabHeight)
|
||||
.background(DetailBackground)
|
||||
.padding(horizontal = DetailSideGutter),
|
||||
) {
|
||||
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.focusGroup()
|
||||
.focusProperties { up = heroFocusRequester; down = contentFocusRequester },
|
||||
horizontalArrangement = Arrangement.spacedBy(34.dp),
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
) {
|
||||
tabs.forEach { tab ->
|
||||
var focused by remember(tab) { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(IntrinsicSize.Max)
|
||||
.then(if (tab == selected) Modifier.focusRequester(selectedFocusRequester) else Modifier)
|
||||
.testTag("detail-tab-${tab.key}")
|
||||
.onFocusChanged {
|
||||
focused = it.isFocused
|
||||
if (it.isFocused) { onSelect(tab); onFocused() }
|
||||
}
|
||||
.clickable { onSelect(tab) },
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
tab.label,
|
||||
color = if (selected == tab || focused) Color.White else DetailQuietText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = if (selected == tab || focused) FontWeight.Bold else FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 4.dp, end = 4.dp, bottom = 13.dp),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxWidth().height(3.dp).background(
|
||||
if (selected == tab || focused) DetailAccent else Color.Transparent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
// The tab's content begins below the fold by design. Nothing said so; the
|
||||
// peek under this strip and this chevron are what say it. Never focusable —
|
||||
// it is a caption on the Down key, not another thing to land on.
|
||||
Icon(
|
||||
Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = DetailQuietText,
|
||||
modifier = Modifier.size(18.dp).padding(bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailProgress(progress: Float, label: String?, modifier: Modifier = Modifier) {
|
||||
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.width(250.dp).height(5.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.22f))) {
|
||||
Box(Modifier.fillMaxWidth(progress.coerceIn(0f, 1f)).height(5.dp).background(DetailAccent))
|
||||
}
|
||||
label?.let { Text(it, color = DetailMutedText, fontSize = 12.sp, modifier = Modifier.padding(start = 12.dp)) }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailFocusablePane(
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.focusRequester(focusRequester)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.focusable()
|
||||
.clip(RoundedCornerShape(MembyCardCorner))
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White.copy(alpha = 0.8f) else Color.Transparent, RoundedCornerShape(MembyCardCorner))
|
||||
.background(if (focused) Color.White.copy(alpha = 0.035f) else Color.Transparent)
|
||||
.padding(16.dp),
|
||||
) { content() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailMetaRows(rows: List<TechnicalSpec>, modifier: Modifier = Modifier, labelWidth: androidx.compose.ui.unit.Dp = 110.dp) {
|
||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Text(row.label, color = DetailQuietText, fontSize = 13.sp, fontWeight = FontWeight.Medium, modifier = Modifier.width(labelWidth))
|
||||
Text(row.value, color = DetailText, fontSize = 14.sp, lineHeight = 19.sp, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailOverviewPane(
|
||||
item: BaseItem,
|
||||
credits: List<TechnicalSpec>,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
supportingText: String? = null,
|
||||
) {
|
||||
DetailFocusablePane(focusRequester, modifier) {
|
||||
Column {
|
||||
Text(item.overview?.takeIf(String::isNotBlank) ?: "No description available.", color = DetailText, fontSize = 16.sp, lineHeight = 23.sp, maxLines = 5, overflow = TextOverflow.Ellipsis)
|
||||
supportingText?.let { Spacer(Modifier.height(10.dp)); Text(it, color = DetailAccent, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) }
|
||||
Spacer(Modifier.height(16.dp))
|
||||
DetailMetaRows(credits.take(4))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailCastAndDetailsPane(
|
||||
item: BaseItem,
|
||||
credits: List<TechnicalSpec>,
|
||||
specs: List<TechnicalSpec>,
|
||||
detailsLoaded: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val releaseAndTechnical = remember(item.id, specs, credits) {
|
||||
val alreadyCredited = credits.map(TechnicalSpec::label).toSet()
|
||||
buildList {
|
||||
item.productionYear?.let { add(TechnicalSpec("Released", it.toString())) }
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let { add(TechnicalSpec("Certificate", it)) }
|
||||
item.runtimeMinutes?.let { add(TechnicalSpec("Runtime", formatRuntime(it))) }
|
||||
// Studio is in both vocabularies. It only became visible as a duplicate once
|
||||
// the pane stopped clipping its own second half.
|
||||
addAll(specs.filterNot { it.label in alreadyCredited })
|
||||
}
|
||||
}
|
||||
DetailFocusablePane(focusRequester, modifier) {
|
||||
if (!detailsLoaded && item.people.isEmpty() && specs.isEmpty()) {
|
||||
Text("Loading cast and details…", color = DetailQuietText, fontSize = 15.sp)
|
||||
} else {
|
||||
Column {
|
||||
if (item.cast.isNotEmpty()) CastRail(people = item.cast, compact = true, showTitle = true)
|
||||
Spacer(Modifier.height(12.dp))
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(42.dp)) {
|
||||
DetailMetaRows(credits, Modifier.weight(1f))
|
||||
DetailMetaRows(releaseAndTechnical, Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailMoreLikeThisPane(
|
||||
items: List<BaseItem>,
|
||||
loading: Boolean,
|
||||
onSelect: (BaseItem) -> Unit,
|
||||
firstFocusRequester: FocusRequester,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when {
|
||||
loading -> DetailFocusablePane(firstFocusRequester, modifier) { Text("Finding similar titles…", color = DetailQuietText, fontSize = 15.sp) }
|
||||
items.isEmpty() -> DetailFocusablePane(firstFocusRequester, modifier) { Text("No similar titles are available.", color = DetailQuietText, fontSize = 15.sp) }
|
||||
else -> LazyRow(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize().focusGroup(),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
contentPadding = PaddingValues(horizontal = 7.dp, vertical = 7.dp),
|
||||
) {
|
||||
itemsIndexed(items, key = { _, it -> it.id }) { index, related ->
|
||||
DetailPosterCard(
|
||||
item = related,
|
||||
onClick = { onSelect(related) },
|
||||
modifier = if (index == 0) Modifier.focusRequester(firstFocusRequester) else Modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailPosterCard(item: BaseItem, onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
val artwork = remember(item.id) { ServiceLocator.repository.primaryUrl(item, 420) ?: ServiceLocator.repository.backdropUrl(item, 420) }
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.06f else 1f, tween(100), label = "related-poster-focus")
|
||||
Column(
|
||||
modifier.width(128.dp).graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }.zIndex(if (focused) 1f else 0f).onFocusChanged { focused = it.isFocused }.clickable(onClick = onClick),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).clip(RoundedCornerShape(MembyCardCorner)).background(Color(0xFF171B1F)).border(if (focused) 2.dp else 1.dp, if (focused) Color.White else DetailHairline, RoundedCornerShape(MembyCardCorner))) {
|
||||
if (artwork != null) AsyncImage(artwork, null, Modifier.fillMaxSize(), contentScale = ContentScale.Crop)
|
||||
}
|
||||
Text(item.name, color = if (focused) Color.White else DetailText, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 7.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun DetailPanePlaceholder(message: String, modifier: Modifier = Modifier) {
|
||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { Text(message, color = DetailQuietText, fontSize = 15.sp) }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
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.fillMaxHeight
|
||||
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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
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.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.heroFacts
|
||||
import com.ponzischeme89.memby.ui.detail.ratingLabel
|
||||
import com.ponzischeme89.memby.ui.theme.FactSeparator
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyPanelCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembyScore
|
||||
|
||||
internal const val HOME_HERO_ROW_ID = "home-movie-hero"
|
||||
|
||||
/**
|
||||
* The movie feature owns the header only while focus is still in that feature. Once the
|
||||
* viewer moves into a home shelf, the header becomes the same focused-item metadata panel
|
||||
* used by Movies, Shows and the other browse destinations.
|
||||
*/
|
||||
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, focusedRowId: String?): Boolean =
|
||||
hasMovies && (focusedRowId == null || focusedRowId == HOME_HERO_ROW_ID)
|
||||
|
||||
/**
|
||||
* A hero card and the reason it is there.
|
||||
*
|
||||
* The label used to be the card's *position* — `listOf("POPULAR", "NEW RELEASE",
|
||||
* "TRENDING")[index]` — while the selection below interleaves two sources and then falls
|
||||
* back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
|
||||
* one POPULAR. A caption that can be wrong is worse than no caption.
|
||||
*/
|
||||
internal data class HomeHeroPick(val item: BaseItem, val label: String)
|
||||
|
||||
private const val LABEL_NEW = "NEW RELEASE"
|
||||
private const val LABEL_POPULAR = "POPULAR"
|
||||
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
|
||||
|
||||
/** Picks a deliberate mix of fresh and popular movies while preserving server ranking. */
|
||||
internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick> {
|
||||
fun HomeBrowseRow.matches(vararg words: String): Boolean {
|
||||
val label = "$id $title".lowercase()
|
||||
return words.any(label::contains)
|
||||
}
|
||||
|
||||
val newReleases = rows
|
||||
.filter { it.matches("latest", "recent", "new release", "just added") }
|
||||
.flatMap(HomeBrowseRow::items)
|
||||
.filter(BaseItem::isMovie)
|
||||
val popular = rows
|
||||
.filter { it.matches("popular", "trending", "recommended", "top pick") }
|
||||
.flatMap(HomeBrowseRow::items)
|
||||
.filter(BaseItem::isMovie)
|
||||
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie)
|
||||
|
||||
fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }
|
||||
|
||||
return buildList {
|
||||
addAll(
|
||||
listOfNotNull(
|
||||
newReleases.getOrNull(0)?.let { HomeHeroPick(it, LABEL_NEW) },
|
||||
popular.getOrNull(0)?.let { HomeHeroPick(it, LABEL_POPULAR) },
|
||||
),
|
||||
)
|
||||
addAll(
|
||||
listOfNotNull(
|
||||
newReleases.getOrNull(1)?.let { HomeHeroPick(it, LABEL_NEW) },
|
||||
popular.getOrNull(1)?.let { HomeHeroPick(it, LABEL_POPULAR) },
|
||||
),
|
||||
)
|
||||
addAll(newReleases.labelled(LABEL_NEW))
|
||||
addAll(popular.labelled(LABEL_POPULAR))
|
||||
addAll(everyMovie.labelled(LABEL_LIBRARY))
|
||||
// distinctBy keeps the first appearance, so a title that is both new and popular
|
||||
// keeps the label of the row it was drawn from first.
|
||||
}.distinctBy { it.item.id }.take(4)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun HomeMovieHero(
|
||||
movies: List<HomeHeroPick>,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentEntryFocusRequester: FocusRequester? = null,
|
||||
returnFocusItemId: String? = null,
|
||||
returnFocusRequester: FocusRequester? = null,
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
previewArtwork: ImageBitmap? = null,
|
||||
) {
|
||||
if (movies.isEmpty()) return
|
||||
|
||||
BoxWithConstraints(modifier.fillMaxWidth()) {
|
||||
val miniWidth = (maxWidth * 0.27f).coerceIn(184.dp, 326.dp)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize().padding(start = 36.dp, end = 36.dp, top = 16.dp, bottom = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
val featured = movies.first()
|
||||
var featuredModifier: Modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
if (contentEntryFocusRequester != null) {
|
||||
featuredModifier = featuredModifier.focusRequester(contentEntryFocusRequester)
|
||||
}
|
||||
if (featured.item.id == returnFocusItemId && returnFocusRequester != null) {
|
||||
featuredModifier = featuredModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
FeaturedMovieCard(
|
||||
pick = featured,
|
||||
onFocused = { onItemFocused(featured.item) },
|
||||
onClick = { onItemSelected(featured.item) },
|
||||
modifier = featuredModifier,
|
||||
previewArtwork = previewArtwork,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.width(miniWidth).fillMaxHeight(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
movies.drop(1).take(3).forEach { pick ->
|
||||
var miniModifier: Modifier = Modifier.weight(1f).fillMaxWidth()
|
||||
if (pick.item.id == returnFocusItemId && returnFocusRequester != null) {
|
||||
miniModifier = miniModifier.focusRequester(returnFocusRequester)
|
||||
}
|
||||
MiniMovieCard(
|
||||
pick = pick,
|
||||
onFocused = { onItemFocused(pick.item) },
|
||||
onClick = { onItemSelected(pick.item) },
|
||||
modifier = miniModifier,
|
||||
previewArtwork = previewArtwork,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A wash over the artwork, keyed to why the card is on the shelf rather than to its slot. */
|
||||
private fun labelTint(label: String): Color = when (label) {
|
||||
LABEL_NEW -> Color(0x667253B7)
|
||||
LABEL_POPULAR -> Color(0x66499BD5)
|
||||
else -> Color(0x66C67A42)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturedMovieCard(
|
||||
pick: HomeHeroPick,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
previewArtwork: ImageBitmap?,
|
||||
) {
|
||||
val item = pick.item
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "Featured movie, ${item.name}",
|
||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||
) { focused ->
|
||||
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
|
||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xF20A0D10),
|
||||
0.48f to Color(0xA80A0D10),
|
||||
1f to Color(0x160A0D10),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Transparent,
|
||||
0.72f to Color.Transparent,
|
||||
1f to Color(0xD9000000),
|
||||
),
|
||||
),
|
||||
)
|
||||
// The card is a fixed height and this column is centred in it, so anything
|
||||
// over budget is lost equally top and bottom — and the action, being last, went
|
||||
// first. A wrapped title costs exactly what the synopsis is worth, so the
|
||||
// synopsis is what stands down; the button is never the thing that is cut.
|
||||
var titleLines by remember(item.id) { mutableIntStateOf(1) }
|
||||
Column(
|
||||
modifier = Modifier.align(Alignment.CenterStart).fillMaxWidth(0.58f).padding(22.dp),
|
||||
) {
|
||||
Text(
|
||||
pick.label,
|
||||
color = MembyAccent,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 30.sp,
|
||||
lineHeight = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
onTextLayout = { titleLines = it.lineCount },
|
||||
)
|
||||
Spacer(Modifier.height(9.dp))
|
||||
HeroFactLine(item)
|
||||
if (titleLines == 1) {
|
||||
item.overview?.takeIf(String::isNotBlank)?.let { overview ->
|
||||
Spacer(Modifier.height(9.dp))
|
||||
Text(
|
||||
overview,
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 17.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(14.dp))
|
||||
MembyPlayChip(label = "Play", focused = focused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Year, length, certificate and score — the same wording, order and gold as the card
|
||||
* directly beneath it and the detail page it opens. The hero used to carry its own
|
||||
* formatter and print "2026 • M • 124m" over a row printing "2026 • 2h 4m".
|
||||
*/
|
||||
@Composable
|
||||
private fun HeroFactLine(item: BaseItem) {
|
||||
val facts = heroFacts(item)
|
||||
val score = ratingLabel(item)
|
||||
if (facts.isEmpty() && score == null) return
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(FactSeparator),
|
||||
color = MembyMutedText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
}
|
||||
score?.let {
|
||||
if (facts.isNotEmpty()) Text(FactSeparator, color = MembyQuietText, fontSize = 13.sp)
|
||||
Text("★ $it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MiniMovieCard(
|
||||
pick: HomeHeroPick,
|
||||
onFocused: () -> Unit,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
previewArtwork: ImageBitmap?,
|
||||
) {
|
||||
val item = pick.item
|
||||
FocusScaleContainer(
|
||||
onFocused = onFocused,
|
||||
onClick = onClick,
|
||||
contentDescription = "${pick.label} movie, ${item.name}",
|
||||
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
|
||||
) { focused ->
|
||||
Box(Modifier.fillMaxSize().background(Color(0xFF192027))) {
|
||||
HeroArtwork(item, previewArtwork, Modifier.fillMaxSize())
|
||||
Box(Modifier.fillMaxSize().background(labelTint(pick.label)))
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xE80A0D10),
|
||||
0.78f to Color(0x850A0D10),
|
||||
1f to Color(0x300A0D10),
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(Modifier.align(Alignment.CenterStart).padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(
|
||||
pick.label,
|
||||
color = if (focused) MembyAccent else MembyQuietText,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.8.sp,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 17.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
item.productionYear?.let { year ->
|
||||
Spacer(Modifier.height(3.dp))
|
||||
Text(year.toString(), color = MembyQuietText, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
if (focused) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterEnd)
|
||||
.padding(end = 16.dp)
|
||||
.size(38.dp)
|
||||
.shadow(14.dp, CircleShape)
|
||||
.clip(CircleShape)
|
||||
.background(MembyAccent)
|
||||
.border(2.dp, Color.White, CircleShape),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeroArtwork(item: BaseItem, previewArtwork: ImageBitmap?, modifier: Modifier) {
|
||||
if (previewArtwork != null) {
|
||||
Image(
|
||||
bitmap = previewArtwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
return
|
||||
}
|
||||
val repo = ServiceLocator.repository
|
||||
val density = LocalDensity.current
|
||||
val artworkWidth = with(density) { 760.dp.roundToPx() }.coerceIn(480, 1280)
|
||||
val artwork = remember(item.id, artworkWidth) {
|
||||
repo.backdropUrl(item, artworkWidth) ?: repo.primaryUrl(item, artworkWidth)
|
||||
}
|
||||
if (artwork != null) {
|
||||
AsyncImage(
|
||||
model = artwork,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier.background(
|
||||
Brush.linearGradient(
|
||||
listOf(Color(0xFF172830), Color(0xFF26343A), Color(0xFF12171B)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,6 @@ import com.ponzischeme89.memby.data.analytics.RowAnalytics
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.isMaintenanceError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,8 +17,12 @@ import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -50,15 +53,29 @@ data class HomeUiState(
|
||||
* rows behind it would be stale and unusable anyway.
|
||||
*/
|
||||
val maintenanceMessage: String? = null,
|
||||
/**
|
||||
* The gateway's update verdict. A mandatory one blocks the home screen; an optional
|
||||
* one shows a prompt the viewer can dismiss for this session.
|
||||
*/
|
||||
val update: GatewayUpdate? = null,
|
||||
) {
|
||||
val watchingAndNextUp: List<BaseItem>
|
||||
get() = (continueWatching + nextUp).distinctBy(BaseItem::id)
|
||||
|
||||
/**
|
||||
* This state with everything that is *not* a row blanked out. Paired with
|
||||
* `distinctUntilChanged`, it turns [HomeViewModel.content] into a flow that only
|
||||
* emits when something changed about what is on the rows — see the note there.
|
||||
*
|
||||
* Read rows and [loading] from this; the blanked fields are meaningless in it.
|
||||
*/
|
||||
fun contentSlice(): HomeUiState = copy(
|
||||
hasRefreshError = false,
|
||||
statusMessage = null,
|
||||
maintenanceMessage = null,
|
||||
)
|
||||
|
||||
fun statusSlice(): HomeStatus = HomeStatus(
|
||||
hasRefreshError = hasRefreshError,
|
||||
statusMessage = statusMessage,
|
||||
maintenanceMessage = maintenanceMessage,
|
||||
)
|
||||
|
||||
fun toCache() = HomeCache(
|
||||
continueWatching = continueWatching,
|
||||
nextUp = nextUp,
|
||||
@@ -84,6 +101,16 @@ data class HomeUiState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection-health slice of [HomeUiState]: what the banner and the maintenance
|
||||
* screen need, and nothing that would drag the rows into a recomposition with it.
|
||||
*/
|
||||
data class HomeStatus(
|
||||
val hasRefreshError: Boolean = false,
|
||||
val statusMessage: String? = null,
|
||||
val maintenanceMessage: String? = null,
|
||||
)
|
||||
|
||||
data class ForYouUiState(
|
||||
val rows: List<HomeRow> = emptyList(),
|
||||
val availableMinutes: Int = 0,
|
||||
@@ -95,6 +122,25 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
private val refreshMutex = Mutex()
|
||||
private val _state = MutableStateFlow(HomeUiState.from(repository.cachedHome()))
|
||||
val state: StateFlow<HomeUiState> = _state.asStateFlow()
|
||||
|
||||
// HomeScreen is a very large composable, so reading the whole of [state] there meant
|
||||
// every emission invalidated the launcher: the slow-
|
||||
// connection banner and each of the four section loads all recomposed the rows, the
|
||||
// rail and every overlay, and rebuilt the row list with them. These three narrow
|
||||
// projections let it subscribe only to what each part actually renders.
|
||||
|
||||
/** Rows and their loading flags. Does not emit for banner changes. */
|
||||
val content: StateFlow<HomeUiState> = _state
|
||||
.map(HomeUiState::contentSlice)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.contentSlice())
|
||||
|
||||
/** Connection health. Does not emit when rows change. */
|
||||
val status: StateFlow<HomeStatus> = _state
|
||||
.map(HomeUiState::statusSlice)
|
||||
.distinctUntilChanged()
|
||||
.stateIn(viewModelScope, SharingStarted.Eagerly, _state.value.statusSlice())
|
||||
|
||||
private val _focusedItem = MutableStateFlow<BaseItem?>(initialFocusedItem(_state.value))
|
||||
val focusedItem: StateFlow<BaseItem?> = _focusedItem.asStateFlow()
|
||||
private val _forYou = MutableStateFlow(ForYouUiState())
|
||||
@@ -107,20 +153,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
/** Row engagement, buffered here and uploaded in batches. */
|
||||
private val analytics = RowAnalytics()
|
||||
|
||||
/** Optional prompt the viewer waved away; forgotten when the app restarts. */
|
||||
private var dismissedUpdateVersion: String? = null
|
||||
|
||||
init {
|
||||
refreshAll()
|
||||
checkForAppUpdate()
|
||||
viewModelScope.launch {
|
||||
// A TV can leave Memby open for days. Keep checking at a deliberately slow
|
||||
// cadence so a newly published release appears without requiring a restart.
|
||||
while (true) {
|
||||
delay(UPDATE_CHECK_INTERVAL_MS)
|
||||
checkForAppUpdate()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
repository.playbackStops.collect { refreshWatching() }
|
||||
}
|
||||
@@ -134,29 +168,8 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the gateway whether this build is still allowed. Runs on every launch, so an
|
||||
* operator can retire a version without waiting for anyone to open Settings.
|
||||
*/
|
||||
fun checkForAppUpdate() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val update = repository.checkAppUpdate() ?: return@launch
|
||||
// A dismissed optional prompt stays dismissed for this session; a mandatory
|
||||
// one always reasserts itself.
|
||||
if (update.isOptional && dismissedUpdateVersion == update.version) return@launch
|
||||
_state.update { it.copy(update = update) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Dismisses an optional prompt. Mandatory updates ignore this by construction. */
|
||||
fun dismissUpdatePrompt() {
|
||||
val current = _state.value.update ?: return
|
||||
if (current.isMandatory) return
|
||||
dismissedUpdateVersion = current.version
|
||||
_state.update { it.copy(update = null) }
|
||||
}
|
||||
|
||||
fun trackRowImpression(rowId: String, rowKind: String) = analytics.rowImpression(rowId, rowKind)
|
||||
fun trackRowImpression(rowId: String, rowKind: String, visibleItemIds: List<String> = emptyList()) =
|
||||
analytics.rowImpression(rowId, rowKind, visibleItemIds)
|
||||
|
||||
fun trackRowFocused(rowId: String, rowKind: String, itemId: String) =
|
||||
analytics.rowFocused(rowId, rowKind, itemId)
|
||||
@@ -286,6 +299,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
val cached = synchronized(metadataCache) { metadataCache[item.id] }
|
||||
val focused = (cached ?: item).copy(
|
||||
membyAiringToday = item.membyAiringToday || cached?.membyAiringToday == true,
|
||||
membyRecommendationReason = item.membyRecommendationReason
|
||||
?: cached?.membyRecommendationReason,
|
||||
membyCompatibility = item.membyCompatibility ?: cached?.membyCompatibility,
|
||||
)
|
||||
_focusedItem.value = focused
|
||||
metadataJob?.cancel()
|
||||
@@ -298,7 +314,12 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
if (item.membyPlayable) {
|
||||
launch { runCatching { repository.prefetchPlayable(cached ?: item) } }
|
||||
}
|
||||
if (cached == null && !item.isSonarrSchedule) {
|
||||
// Warm the explanation and franchise siblings while the card is already
|
||||
// focused, so opening Details does not add a reason line a frame later.
|
||||
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
|
||||
launch { runCatching { repository.getRelated(focused) } }
|
||||
}
|
||||
if (cached == null && !item.isSchedule) {
|
||||
launch {
|
||||
val details = runCatching {
|
||||
repository.getItemDetails(item.id)
|
||||
@@ -464,7 +485,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
companion object {
|
||||
private const val FOCUS_METADATA_DEBOUNCE_MS = 140L
|
||||
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
|
||||
private const val UPDATE_CHECK_INTERVAL_MS = 60L * 60L * 1_000L
|
||||
|
||||
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
|
||||
state.watchingAndNextUp.firstOrNull()
|
||||
@@ -489,6 +509,7 @@ private fun List<HomeRow>.airingTodayShowKeys(): Set<String> =
|
||||
firstOrNull { it.id == "sonarr-airing-today" }
|
||||
?.items
|
||||
.orEmpty()
|
||||
.filter { it.membyAirDayLabel.equals("Today", ignoreCase = true) }
|
||||
.mapTo(mutableSetOf()) { it.name.showMatchKey() }
|
||||
.filterTo(mutableSetOf(), String::isNotEmpty)
|
||||
|
||||
@@ -497,7 +518,7 @@ private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRo
|
||||
|
||||
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
|
||||
map { item ->
|
||||
if (!item.isSonarrSchedule && item.isSeries && item.name.showMatchKey() in keys) {
|
||||
if (!item.isTvSchedule && item.isSeries && item.name.showMatchKey() in keys) {
|
||||
item.copy(membyAiringToday = true)
|
||||
} else {
|
||||
item
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,297 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.DoneAll
|
||||
import androidx.compose.material.icons.filled.FirstPage
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.RelatedContent
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.detailPositions
|
||||
import com.ponzischeme89.memby.ui.detail.detailTab
|
||||
import com.ponzischeme89.memby.ui.detail.detailTabs
|
||||
import com.ponzischeme89.memby.ui.detail.franchiseStart
|
||||
import com.ponzischeme89.memby.ui.detail.heroFacts
|
||||
import com.ponzischeme89.memby.ui.detail.playbackProgress
|
||||
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
|
||||
import com.ponzischeme89.memby.ui.detail.remainingLabel
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* A movie, or any single playable item that is not a series.
|
||||
*
|
||||
* Everything structural lives in [DetailPageScaffold]; this file only decides which tabs a
|
||||
* movie has and what goes in each one.
|
||||
*/
|
||||
@Composable
|
||||
fun MediaDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
|
||||
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
|
||||
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
|
||||
LaunchedEffect(item.id) {
|
||||
related = ServiceLocator.repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
|
||||
}
|
||||
MediaDetailContent(
|
||||
item = item,
|
||||
onPlay = onPlay,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
onTogglePlayed = onTogglePlayed,
|
||||
onOpenItem = onOpenItem,
|
||||
related = related,
|
||||
trailer = trailer,
|
||||
hideWatchedMovies = settings.hideWatchedMovies,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The layout, with everything it renders as a parameter — so it can be screenshotted and
|
||||
* previewed without a repository behind it. [related] is null while it is still coming.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MediaDetailContent(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
onTogglePlayed: (BaseItem, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
related: RelatedContent? = null,
|
||||
trailer: BaseItem? = null,
|
||||
hideWatchedMovies: Boolean = false,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
) {
|
||||
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
|
||||
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
|
||||
val visibleRelated = remember(related, hideWatchedMovies) {
|
||||
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
|
||||
}
|
||||
val badges = remember(item.id, item.mediaStreams) { mediaBadges(item) }
|
||||
val franchise = remember(item.id, item.collectionName, related?.items) {
|
||||
franchiseStart(item, related?.items.orEmpty())
|
||||
}
|
||||
val tabs = remember(item.id) { detailTabs(isSeries = false) }
|
||||
// The item arrives with whatever a home row asked for and is replaced by the full
|
||||
// record moments later. Panes say "loading" rather than "nothing here" until then.
|
||||
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
||||
|
||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||
// Every newly opened title starts as a complete hero frame. Remembering a content
|
||||
// tab also restored its focus and caused the page to reopen below the artwork.
|
||||
var tabKey by remember(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
val tabStrip = remember(item.id) { FocusRequester() }
|
||||
// One requester per pane. Sharing a single "information pane" requester between the
|
||||
// Overview and Cast & Details panes attached it to two nodes at once for the 80ms
|
||||
// AnimatedContent spends fading the outgoing pane out, and a Down press landing in that
|
||||
// window could request focus on the pane that is disappearing.
|
||||
val overviewPane = remember(item.id) { FocusRequester() }
|
||||
val castPane = remember(item.id) { FocusRequester() }
|
||||
val firstRelated = remember(item.id) { FocusRequester() }
|
||||
val relatedListState = rememberLazyListState(remembered.relatedIndex)
|
||||
val contentEntry = when (selectedTab) {
|
||||
DetailTab.MORE_LIKE_THIS -> firstRelated
|
||||
DetailTab.CAST_DETAILS -> castPane
|
||||
else -> overviewPane
|
||||
}
|
||||
|
||||
DetailPositionMemory(
|
||||
itemId = item.id,
|
||||
tabKey = tabKey,
|
||||
relatedIndex = { relatedListState.firstVisibleItemIndex },
|
||||
)
|
||||
RestoreDetailFocus(
|
||||
itemId = item.id,
|
||||
zone = DetailZone.PLAY,
|
||||
play = play,
|
||||
tabStrip = tabStrip,
|
||||
related = firstRelated,
|
||||
relatedReady = visibleRelated.isNotEmpty(),
|
||||
content = contentEntry,
|
||||
contentReady = true,
|
||||
)
|
||||
|
||||
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(confirmation) {
|
||||
if (confirmation != null) {
|
||||
delay(1_800L)
|
||||
confirmation = null
|
||||
}
|
||||
}
|
||||
|
||||
DetailPageScaffold(
|
||||
item = item,
|
||||
facts = heroFacts(item),
|
||||
badges = badges + listOfNotNull(airingBadgeLabel(item)),
|
||||
tabs = tabs,
|
||||
selectedTab = selectedTab,
|
||||
onSelectTab = { tabKey = it.key },
|
||||
playLabel = primaryActionLabel(item),
|
||||
onPlay = { onPlay(item) },
|
||||
playFocusRequester = play,
|
||||
tabFocusRequester = tabStrip,
|
||||
contentFocusRequester = contentEntry,
|
||||
modifier = modifier,
|
||||
progress = playbackProgress(item),
|
||||
progressLabel = remainingLabel(item),
|
||||
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
|
||||
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
|
||||
confirmation = confirmation,
|
||||
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
|
||||
heroActions = buildList {
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add,
|
||||
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
|
||||
active = item.isFavorite,
|
||||
onClick = {
|
||||
val desired = !item.isFavorite
|
||||
onToggleFavorite(item, desired)
|
||||
confirmation = if (desired) "Added to Favourites" else "Removed from Favourites"
|
||||
},
|
||||
),
|
||||
)
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
}
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = Icons.Default.DoneAll,
|
||||
description = if (item.userData?.played == true) "Mark unwatched" else "Mark watched",
|
||||
active = item.userData?.played == true,
|
||||
onClick = { onTogglePlayed(item, item.userData?.played != true) },
|
||||
),
|
||||
)
|
||||
// Appended because related content arrives asynchronously. Existing action
|
||||
// indices (and therefore their attached FocusRequesters) must not move.
|
||||
franchise?.takeIf { it.firstMovie.id != item.id }?.let { start ->
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = Icons.Default.FirstPage,
|
||||
description = "Open the first ${start.name} movie, ${start.firstMovie.name}",
|
||||
label = "Start with ${start.firstMovie.name}",
|
||||
onClick = { onOpenItem(start.firstMovie) },
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
) { visibleTab ->
|
||||
when (visibleTab) {
|
||||
DetailTab.OVERVIEW -> DetailOverviewPane(item, credits, overviewPane)
|
||||
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
|
||||
items = visibleRelated,
|
||||
loading = related == null,
|
||||
onSelect = onOpenItem,
|
||||
firstFocusRequester = firstRelated,
|
||||
listState = relatedListState,
|
||||
)
|
||||
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
|
||||
item = item,
|
||||
credits = credits,
|
||||
specs = specs,
|
||||
detailsLoaded = detailsLoaded,
|
||||
focusRequester = castPane,
|
||||
)
|
||||
DetailTab.EPISODES -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the page's position back to [detailPositions] as it changes.
|
||||
*
|
||||
* Kept out of the pages themselves because both need exactly this and getting it slightly
|
||||
* different in two places is how "it remembered last time" bugs start. The zone is written
|
||||
* by the scaffold's focus callbacks; this covers the two things focus does not report.
|
||||
*/
|
||||
@Composable
|
||||
internal fun DetailPositionMemory(
|
||||
itemId: String,
|
||||
tabKey: String,
|
||||
season: Int? = null,
|
||||
episodeIndex: () -> Int = { 0 },
|
||||
relatedIndex: () -> Int = { 0 },
|
||||
) {
|
||||
LaunchedEffect(itemId, tabKey, season) {
|
||||
detailPositions.update(itemId) { it.copy(tabKey = tabKey, season = season ?: it.season) }
|
||||
}
|
||||
LaunchedEffect(itemId) {
|
||||
snapshotFlow { episodeIndex() to relatedIndex() }.collect { (episode, relatedCard) ->
|
||||
detailPositions.update(itemId) {
|
||||
it.copy(episodeIndex = episode, relatedIndex = relatedCard)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts focus back where the viewer left it.
|
||||
*
|
||||
* Play is focused first regardless: it exists on the first frame, so the remote is live
|
||||
* while the rest of the page is still arriving. Only then does focus move on to the band
|
||||
* the viewer was actually in — and only if that band has something to land on, because a
|
||||
* request against an unplaced [FocusRequester] throws.
|
||||
*/
|
||||
@Composable
|
||||
internal fun RestoreDetailFocus(
|
||||
itemId: String,
|
||||
zone: DetailZone,
|
||||
play: FocusRequester,
|
||||
tabStrip: FocusRequester,
|
||||
related: FocusRequester,
|
||||
relatedReady: Boolean,
|
||||
content: FocusRequester? = null,
|
||||
contentReady: Boolean = false,
|
||||
) {
|
||||
LaunchedEffect(itemId) {
|
||||
delay(32L)
|
||||
runCatching { play.requestFocus() }
|
||||
}
|
||||
// One shot. The readiness flags flip when the network lands, and re-running then would
|
||||
// haul focus out from under a viewer who has already started moving around the page.
|
||||
var restored by remember(itemId) { mutableStateOf(zone == DetailZone.PLAY) }
|
||||
LaunchedEffect(itemId, relatedReady, contentReady) {
|
||||
if (restored) return@LaunchedEffect
|
||||
val target = when (zone) {
|
||||
DetailZone.PLAY -> null
|
||||
DetailZone.TABS -> tabStrip
|
||||
DetailZone.CONTENT -> content?.takeIf { contentReady }
|
||||
DetailZone.RELATED -> related.takeIf { relatedReady }
|
||||
} ?: return@LaunchedEffect
|
||||
// After Play, so the restore lands second and wins; a frame either way is
|
||||
// invisible, but the order is not.
|
||||
delay(96L)
|
||||
if (runCatching { target.requestFocus() }.isSuccess) restored = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.ui.theme.MembyAccent
|
||||
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
|
||||
import com.ponzischeme89.memby.ui.theme.MembyHairline
|
||||
import com.ponzischeme89.memby.ui.theme.MembyMutedText
|
||||
|
||||
/**
|
||||
* One green Play button, in two sizes.
|
||||
*
|
||||
* The app had three button languages: this one, a hand-rolled copy in the home hero with
|
||||
* the same look and different metrics, and raw `androidx.tv.material3.Button`s carrying
|
||||
* glyphs in their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours nothing
|
||||
* else on those screens uses. A remote user learns one shape and one green; drawing it
|
||||
* three ways teaches them nothing and makes every metric a separate decision.
|
||||
*
|
||||
* [MembyPlayChip] is the same surface without focus behaviour when the whole hero card is
|
||||
* already the focusable node.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MembyPlayButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
onFocused: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = false,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val scale by animateFloatAsState(if (focused) 1.055f else 1f, tween(100), label = "play-focus")
|
||||
PrimaryActionSurface(
|
||||
label = label,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
focused = focused,
|
||||
compact = compact,
|
||||
modifier = modifier
|
||||
.graphicsLayer { scaleX = scale; scaleY = scale; translationY = if (focused) -3f else 0f }
|
||||
.onFocusChanged { focused = it.isFocused; if (it.isFocused) onFocused() }
|
||||
.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MembyPlayChip(
|
||||
label: String,
|
||||
focused: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
compact: Boolean = true,
|
||||
) {
|
||||
PrimaryActionSurface(
|
||||
label = label,
|
||||
icon = Icons.Default.PlayArrow,
|
||||
focused = focused,
|
||||
compact = compact,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PrimaryActionSurface(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
focused: Boolean,
|
||||
compact: Boolean,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
val shape = RoundedCornerShape(MembyCardCorner)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.shadow(if (focused) 18.dp else 7.dp, shape)
|
||||
.clip(shape)
|
||||
.background(MembyAccent)
|
||||
.border(if (focused) 2.dp else 1.dp, if (focused) Color.White else MembyAccent, shape)
|
||||
.padding(
|
||||
horizontal = if (compact) 14.dp else 23.dp,
|
||||
vertical = if (compact) 7.dp else 12.dp,
|
||||
),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(if (compact) 18.dp else 23.dp),
|
||||
)
|
||||
Spacer(Modifier.width(if (compact) 6.dp else 8.dp))
|
||||
Text(
|
||||
label,
|
||||
color = Color.White,
|
||||
fontSize = if (compact) 13.sp else 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A selectable chip for a small set of mutually exclusive choices. The tick is a real icon
|
||||
* on the selected chip rather than a character in the label, so the chip does not change
|
||||
* width when the choice moves.
|
||||
*/
|
||||
@Composable
|
||||
internal fun MembyChoiceChip(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = RoundedCornerShape(MembyChipCorner)
|
||||
Row(
|
||||
modifier = modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> MembyAccent
|
||||
else -> Color.White.copy(alpha = 0.07f)
|
||||
},
|
||||
)
|
||||
.border(1.dp, if (selected || focused) Color.Transparent else MembyHairline, shape)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (selected) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = if (focused) Color.Black else Color.White,
|
||||
modifier = Modifier.size(15.dp),
|
||||
)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
}
|
||||
Text(
|
||||
label,
|
||||
color = when {
|
||||
focused -> Color.Black
|
||||
selected -> Color.White
|
||||
else -> MembyMutedText
|
||||
},
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Bookmark
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.NotificationsOff
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
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 androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Card
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import com.ponzischeme89.memby.data.model.NotificationPreferences
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
@Composable
|
||||
internal fun MyShowsStrip(
|
||||
shows: List<MyShow>,
|
||||
repository: EmbyRepository,
|
||||
availableWidth: Dp,
|
||||
density: String,
|
||||
navigationFocusRequester: FocusRequester,
|
||||
contentFocusRequester: FocusRequester?,
|
||||
onShowSelected: (MyShow) -> Unit,
|
||||
onContentFocused: () -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(HomeRowHeaderSpacing),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
HomeRowHeaderIcon(Icons.Default.Bookmark)
|
||||
Spacer(Modifier.width(HomeRowHeaderIconGap))
|
||||
Text(
|
||||
"My Shows",
|
||||
color = Color(0xFFF1F3F4),
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
if (shows.isNotEmpty()) {
|
||||
Text(
|
||||
shows.size.toString(),
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.padding(start = 10.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.White.copy(alpha = 0.08f))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (shows.isEmpty()) {
|
||||
Text(
|
||||
"Open any series and choose “Add to My Shows”.",
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 14.sp,
|
||||
modifier = Modifier.padding(horizontal = 36.dp, vertical = 18.dp),
|
||||
)
|
||||
} else {
|
||||
val cardsAcross = when (density) {
|
||||
"compact" -> 8
|
||||
"large" -> 5
|
||||
else -> 7
|
||||
}
|
||||
val cardWidth = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
|
||||
LazyRow(
|
||||
contentPadding = PaddingValues(horizontal = 36.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
items(shows, key = MyShow::itemId) { show ->
|
||||
MyShowCard(
|
||||
show = show,
|
||||
repository = repository,
|
||||
width = cardWidth,
|
||||
onClick = { onShowSelected(show) },
|
||||
modifier = Modifier
|
||||
.then(
|
||||
if (show.itemId == shows.first().itemId && contentFocusRequester != null) {
|
||||
Modifier.focusRequester(contentFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.focusProperties { left = navigationFocusRequester }
|
||||
.onFocusChanged { if (it.hasFocus) onContentFocused() },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MyShowCard(
|
||||
show: MyShow,
|
||||
repository: EmbyRepository,
|
||||
width: Dp,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = onClick,
|
||||
contentDescription = "${show.title}, ${myShowCardSubtitle(show)}",
|
||||
modifier = modifier.width(width),
|
||||
) { focused ->
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(width)
|
||||
.aspectRatio(2f / 3f)
|
||||
.shadow(if (focused) 7.dp else 0.dp, RoundedCornerShape(9.dp))
|
||||
.clip(RoundedCornerShape(9.dp))
|
||||
.background(Color(0xFF20252A))
|
||||
.border(
|
||||
2.dp,
|
||||
if (focused) Color.White else Color.White.copy(alpha = 0.07f),
|
||||
RoundedCornerShape(9.dp),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = repository.myShowImageUrl(show.itemId, show.imageTag),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
myShowBadge(show)?.let { (label, color) ->
|
||||
Text(
|
||||
label,
|
||||
color = Color(0xFF090B0D),
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.5.sp,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(color)
|
||||
.padding(horizontal = 7.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
show.title,
|
||||
color = if (focused) Color.White else Color(0xFFE1E5E8),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = if (focused) FontWeight.Bold else FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 7.dp).fillMaxWidth(),
|
||||
)
|
||||
Text(
|
||||
myShowCardSubtitle(show),
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun myShowBadge(show: MyShow): Pair<String, Color>? = when {
|
||||
!show.nextEpisode.isNullOrBlank() -> "UPCOMING" to Color(0xFF52B54B)
|
||||
show.sonarrStatus == "Not monitored" -> "UNMONITORED" to Color(0xFFFFB454)
|
||||
show.lifecycle == "Cancelled" -> "CANCELLED" to Color(0xFFAEB7BF)
|
||||
else -> null
|
||||
}
|
||||
|
||||
internal fun myShowCardSubtitle(show: MyShow): String {
|
||||
if (!show.nextEpisode.isNullOrBlank()) {
|
||||
val date = runCatching {
|
||||
DateTimeFormatter.ofPattern("EEE, d MMM")
|
||||
.format(Instant.parse(show.nextEpisode).atZone(ZoneId.systemDefault()))
|
||||
}.getOrNull()
|
||||
if (date != null) return "Next episode · $date"
|
||||
}
|
||||
return when {
|
||||
show.lifecycle != "Unknown" -> show.lifecycle
|
||||
show.sonarrStatus != "Not found" -> show.sonarrStatus
|
||||
else -> "Saved show"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun MyShowDetailsOverlay(
|
||||
show: MyShow,
|
||||
repository: EmbyRepository,
|
||||
removing: Boolean,
|
||||
onRemove: () -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.72f)
|
||||
.background(Color(0xFF151A1E), RoundedCornerShape(20.dp))
|
||||
.padding(34.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(30.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = repository.myShowImageUrl(show.itemId, show.imageTag, 500),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width(170.dp)
|
||||
.aspectRatio(2f / 3f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF20252A))
|
||||
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(12.dp)),
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
StatusLine("Sonarr", show.sonarrStatus)
|
||||
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
||||
StatusLine("Series status", show.lifecycle)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onRemove, enabled = !removing) {
|
||||
Text(if (removing) "Removing…" else "Remove from My Shows")
|
||||
}
|
||||
Button(onClick = onClose) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusLine(label: String, value: String) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("$label:", color = Color(0xFFAEB7BF), fontSize = 16.sp)
|
||||
Text(value, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun NotificationBell(
|
||||
unreadCount: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
Card(
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
.size(52.dp)
|
||||
.onFocusChanged { focused = it.hasFocus }
|
||||
.graphicsLayer {
|
||||
scaleX = if (focused) 1.08f else 1f
|
||||
scaleY = if (focused) 1.08f else 1f
|
||||
},
|
||||
) {
|
||||
Box(
|
||||
Modifier.fillMaxSize().background(if (focused) Color.White else Color(0xCC20262B)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Notifications,
|
||||
contentDescription = "Notifications",
|
||||
tint = if (focused) Color.Black else Color.White,
|
||||
modifier = Modifier.size(25.dp),
|
||||
)
|
||||
if (unreadCount > 0) {
|
||||
Text(
|
||||
unreadCount.coerceAtMost(99).toString(),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.background(Color(0xFFE04747), CircleShape)
|
||||
.padding(horizontal = 5.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun NotificationsOverlay(
|
||||
notifications: List<UserNotification>,
|
||||
preferences: NotificationPreferences,
|
||||
onToggleEnabled: () -> Unit,
|
||||
onToggleShowReturns: () -> Unit,
|
||||
onRead: (UserNotification) -> Unit,
|
||||
onDismissNotification: (UserNotification) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
Box(Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D))) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 72.dp, vertical = 48.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column {
|
||||
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Show-return alerts are saved for this user on every Memby TV.",
|
||||
color = Color(0xFFAEB7BF),
|
||||
fontSize = 15.sp,
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Button(onClick = onToggleEnabled) {
|
||||
Icon(
|
||||
if (preferences.enabled) Icons.Default.Notifications else Icons.Default.NotificationsOff,
|
||||
contentDescription = null,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text(if (preferences.enabled) "Alerts on" else "Alerts off")
|
||||
}
|
||||
Button(onClick = onToggleShowReturns, enabled = preferences.enabled) {
|
||||
Text(
|
||||
if (preferences.showReturnAlerts) {
|
||||
"Return alerts on"
|
||||
} else {
|
||||
"Return alerts off"
|
||||
},
|
||||
)
|
||||
}
|
||||
Button(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = null)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text("Close")
|
||||
}
|
||||
}
|
||||
}
|
||||
if (notifications.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("You’re all caught up.", color = Color(0xFFD0D6DB), fontSize = 20.sp)
|
||||
}
|
||||
} else {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
items(notifications, key = UserNotification::id) { notification ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (notification.unread) Color(0xFF202B25) else Color(0xFF171B1E),
|
||||
RoundedCornerShape(12.dp),
|
||||
)
|
||||
.padding(18.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
Box(
|
||||
Modifier.size(10.dp).background(
|
||||
if (notification.unread) Color(0xFF52B54B) else Color.Transparent,
|
||||
CircleShape,
|
||||
),
|
||||
)
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
notification.title,
|
||||
color = Color.White,
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(notification.message, color = Color(0xFFD0D6DB), fontSize = 15.sp)
|
||||
}
|
||||
if (notification.unread) {
|
||||
Button(onClick = { onRead(notification) }) { Text("Mark read") }
|
||||
}
|
||||
Button(onClick = { onDismissNotification(notification) }) { Text("Dismiss") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatMyShowDate(value: String?): String {
|
||||
if (value.isNullOrBlank()) return "Not announced"
|
||||
return runCatching {
|
||||
DateTimeFormatter.ofPattern("EEE, d MMM yyyy · h:mm a")
|
||||
.format(Instant.parse(value).atZone(ZoneId.systemDefault()))
|
||||
}.getOrDefault("Not announced")
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
internal const val QuickActionsHoldDurationMillis = 650L
|
||||
|
||||
internal enum class QuickActionDirection { UP, DOWN }
|
||||
|
||||
internal fun quickActionNextIndex(
|
||||
currentIndex: Int,
|
||||
actionCount: Int,
|
||||
direction: QuickActionDirection,
|
||||
): Int {
|
||||
if (actionCount <= 0) return 0
|
||||
val current = currentIndex.coerceIn(0, actionCount - 1)
|
||||
return when (direction) {
|
||||
QuickActionDirection.UP -> (current - 1).coerceAtLeast(0)
|
||||
QuickActionDirection.DOWN -> (current + 1).coerceAtMost(actionCount - 1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
internal enum class RowFocusDirection {
|
||||
UP,
|
||||
DOWN,
|
||||
}
|
||||
|
||||
internal data class RowFocusRequest(
|
||||
val rowId: String,
|
||||
val itemIndex: Int,
|
||||
val requestId: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Finds the next row that can actually receive card focus. Loading and empty rows are
|
||||
* deliberately skipped so one D-pad press always produces visible movement.
|
||||
*/
|
||||
internal fun adjacentFocusableRowIndex(
|
||||
itemCounts: List<Int>,
|
||||
currentIndex: Int,
|
||||
direction: RowFocusDirection,
|
||||
): Int? {
|
||||
if (currentIndex !in itemCounts.indices) return null
|
||||
val step = if (direction == RowFocusDirection.DOWN) 1 else -1
|
||||
var candidate = currentIndex + step
|
||||
while (candidate in itemCounts.indices) {
|
||||
if (itemCounts[candidate] > 0) return candidate
|
||||
candidate += step
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returning to a row restores the card last used there. On a first visit, preserve the
|
||||
* viewer's horizontal position as far as the shorter destination row permits.
|
||||
*/
|
||||
internal fun rowEntryItemIndex(
|
||||
sourceIndex: Int,
|
||||
destinationItemCount: Int,
|
||||
rememberedDestinationIndex: Int?,
|
||||
): Int {
|
||||
if (destinationItemCount <= 0) return 0
|
||||
return (rememberedDestinationIndex ?: sourceIndex).coerceIn(0, destinationItemCount - 1)
|
||||
}
|
||||
@@ -8,19 +8,34 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.BookmarkAdd
|
||||
import androidx.compose.material.icons.filled.BookmarkAdded
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Movie
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -34,7 +49,6 @@ import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -42,35 +56,57 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.RelatedContent
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
private val SeriesGreen = Color(0xFF52B54B)
|
||||
private val SeriesMuted = Color(0xFFD0D6DB)
|
||||
private val SeriesQuiet = Color(0xFFAEB7BF)
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.availableSeasons
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.defaultSeason
|
||||
import com.ponzischeme89.memby.ui.detail.detailPositions
|
||||
import com.ponzischeme89.memby.ui.detail.detailTab
|
||||
import com.ponzischeme89.memby.ui.detail.detailTabs
|
||||
import com.ponzischeme89.memby.ui.detail.episodeHeadline
|
||||
import com.ponzischeme89.memby.ui.detail.episodesForSeason
|
||||
import com.ponzischeme89.memby.ui.detail.heroFacts
|
||||
import com.ponzischeme89.memby.ui.detail.isPlayed
|
||||
import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch
|
||||
import com.ponzischeme89.memby.ui.detail.playbackProgress
|
||||
import com.ponzischeme89.memby.ui.detail.primaryActionLabel
|
||||
import com.ponzischeme89.memby.ui.detail.remainingLabel
|
||||
import com.ponzischeme89.memby.ui.detail.seasonLabel
|
||||
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import com.ponzischeme89.memby.ui.detail.unwatchedCount
|
||||
|
||||
/**
|
||||
* A series. Loads its episodes and hands them to [SeriesDetailContent], which is where the
|
||||
* layout lives — everything that decides how the screen looks is a parameter there, so it
|
||||
* can be previewed and screenshotted without a server behind it.
|
||||
*/
|
||||
@Composable
|
||||
fun SeriesDetailsOverlay(
|
||||
item: BaseItem,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
onClose: () -> Unit,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
val settings by repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
|
||||
var episodes by remember(item.id) { mutableStateOf<List<BaseItem>?>(null) }
|
||||
var loadFailed by remember(item.id) { mutableStateOf(false) }
|
||||
var selectedSectionKey by rememberSaveable(item.id) {
|
||||
mutableStateOf(SeriesDetailSection.EPISODES.key)
|
||||
}
|
||||
val selectedSection = seriesDetailSection(selectedSectionKey)
|
||||
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
|
||||
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
|
||||
|
||||
LaunchedEffect(item.id) {
|
||||
runCatching { repository.getSeriesEpisodes(item.id) }
|
||||
@@ -80,9 +116,50 @@ fun SeriesDetailsOverlay(
|
||||
episodes = emptyList()
|
||||
}
|
||||
}
|
||||
// Separate from the episode request on purpose: the two are independent, and the page
|
||||
// must not wait on "more like this" to show a show's own episodes.
|
||||
LaunchedEffect(item.id) {
|
||||
related = repository.getRelated(item)
|
||||
}
|
||||
LaunchedEffect(item.id) {
|
||||
trailer = repository.getLocalTrailer(item.id)
|
||||
}
|
||||
|
||||
SeriesDetailContent(
|
||||
item = item,
|
||||
episodes = episodes,
|
||||
loadFailed = loadFailed,
|
||||
onPlay = onPlay,
|
||||
onToggleFavorite = onToggleFavorite,
|
||||
isMyShow = isMyShow,
|
||||
onToggleMyShow = onToggleMyShow,
|
||||
related = related,
|
||||
trailer = trailer,
|
||||
hideWatchedMovies = settings.hideWatchedMovies,
|
||||
onOpenItem = onOpenItem,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
/** [episodes] is null while they are still loading, as is [related]. */
|
||||
@Composable
|
||||
internal fun SeriesDetailContent(
|
||||
item: BaseItem,
|
||||
episodes: List<BaseItem>?,
|
||||
loadFailed: Boolean,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
onToggleFavorite: (BaseItem, Boolean) -> Unit,
|
||||
isMyShow: Boolean,
|
||||
onToggleMyShow: (BaseItem, Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
related: RelatedContent? = null,
|
||||
trailer: BaseItem? = null,
|
||||
hideWatchedMovies: Boolean = false,
|
||||
onOpenItem: (BaseItem) -> Unit = {},
|
||||
) {
|
||||
val remembered = remember(item.id) { detailPositions.get(item.id) }
|
||||
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
|
||||
var selectedSeason by rememberSaveable(item.id) { mutableStateOf<Int?>(null) }
|
||||
var selectedSeason by rememberSaveable(item.id) { mutableStateOf(remembered.season) }
|
||||
LaunchedEffect(seasons) {
|
||||
if (selectedSeason !in seasons) {
|
||||
selectedSeason = defaultSeason(episodes.orEmpty())
|
||||
@@ -91,132 +168,202 @@ fun SeriesDetailsOverlay(
|
||||
val seasonEpisodes = remember(episodes, selectedSeason) {
|
||||
episodesForSeason(episodes.orEmpty(), selectedSeason)
|
||||
}
|
||||
val firstEpisode = remember { FocusRequester() }
|
||||
val seasonFocusRequesters = remember(seasons) {
|
||||
seasons.associateWith { FocusRequester() }
|
||||
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
|
||||
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
|
||||
val specs = remember(item.id, item.mediaStreams) { technicalSpecs(item) }
|
||||
val credits = remember(item.id, item.people, item.genres) { creditRows(item) }
|
||||
val visibleRelated = remember(related, hideWatchedMovies) {
|
||||
visibleWithWatchedPreference(related?.items.orEmpty(), hideWatchedMovies)
|
||||
}
|
||||
var initialSeasonFocusRequested by remember(item.id) { mutableStateOf(false) }
|
||||
LaunchedEffect(selectedSeason, seasons) {
|
||||
val requester = seasonFocusRequesters[selectedSeason] ?: return@LaunchedEffect
|
||||
if (!initialSeasonFocusRequested) {
|
||||
delay(40)
|
||||
runCatching { requester.requestFocus() }
|
||||
initialSeasonFocusRequested = true
|
||||
|
||||
// Fixed on the first frame from what the item *is*. A series has episodes, a cast and
|
||||
// technical details whether or not they have arrived yet, and a strip that waits for
|
||||
// the network is a strip that moves under the viewer's thumb.
|
||||
val tabs = remember(item.id) { detailTabs(isSeries = true) }
|
||||
val detailsLoaded = item.people.isNotEmpty() || item.mediaStreams.isNotEmpty()
|
||||
// A series opens on its cinematic hero and Overview just like a movie. Season and
|
||||
// rail positions are still remembered once the viewer enters Episodes.
|
||||
var tabKey by rememberSaveable(item.id) { mutableStateOf(DetailTab.OVERVIEW.key) }
|
||||
val selectedTab = detailTab(tabKey, tabs)
|
||||
|
||||
val play = remember(item.id) { FocusRequester() }
|
||||
val tabStrip = remember(item.id) { FocusRequester() }
|
||||
// One requester per pane: Overview, Cast & Details and the Episodes empty states all
|
||||
// shared a single "information pane" requester, which left it attached to two live
|
||||
// nodes while AnimatedContent faded the outgoing one out.
|
||||
val overviewPane = remember(item.id) { FocusRequester() }
|
||||
val castPane = remember(item.id) { FocusRequester() }
|
||||
val episodesPane = remember(item.id) { FocusRequester() }
|
||||
val firstEpisode = remember(item.id) { FocusRequester() }
|
||||
val firstRelated = remember(item.id) { FocusRequester() }
|
||||
val seasonFocusRequesters = remember(seasons) { seasons.associateWith { FocusRequester() } }
|
||||
val episodeListState = rememberLazyListState(remembered.episodeIndex)
|
||||
val relatedListState = rememberLazyListState(remembered.relatedIndex)
|
||||
|
||||
// Every one of these targets has to be attached to something on screen *right now*. A
|
||||
// `focusProperties` pointing at a requester that was never placed throws the moment the
|
||||
// viewer presses that direction — and while the episode request is in flight, on a
|
||||
// one-season show, or on any tab other than Episodes, neither the chips nor the cards
|
||||
// exist. [FocusRequester.Default] hands the press back to ordinary focus search.
|
||||
val episodesOpen = selectedTab == DetailTab.EPISODES
|
||||
val seasonStripShown = episodesOpen && seasons.size > 1
|
||||
val hasEpisodeCards = episodesOpen && seasonEpisodes.isNotEmpty()
|
||||
val selectedSeasonChip =
|
||||
if (seasonStripShown) seasonFocusRequesters[selectedSeason] else null
|
||||
val episodeEntry = selectedSeasonChip
|
||||
?: firstEpisode.takeIf { hasEpisodeCards }
|
||||
?: episodesPane
|
||||
val belowSeasons = if (hasEpisodeCards) firstEpisode else FocusRequester.Default
|
||||
val aboveEpisodes = selectedSeasonChip ?: tabStrip
|
||||
val contentEntry = when (selectedTab) {
|
||||
DetailTab.EPISODES -> episodeEntry
|
||||
DetailTab.MORE_LIKE_THIS -> firstRelated
|
||||
DetailTab.CAST_DETAILS -> castPane
|
||||
else -> overviewPane
|
||||
}
|
||||
|
||||
DetailPositionMemory(
|
||||
itemId = item.id,
|
||||
tabKey = tabKey,
|
||||
season = selectedSeason,
|
||||
episodeIndex = { episodeListState.firstVisibleItemIndex },
|
||||
relatedIndex = { relatedListState.firstVisibleItemIndex },
|
||||
)
|
||||
RestoreDetailFocus(
|
||||
itemId = item.id,
|
||||
zone = DetailZone.PLAY,
|
||||
play = play,
|
||||
tabStrip = tabStrip,
|
||||
related = firstRelated,
|
||||
relatedReady = visibleRelated.isNotEmpty(),
|
||||
content = contentEntry,
|
||||
contentReady = true,
|
||||
)
|
||||
|
||||
var confirmation by remember(item.id) { mutableStateOf<String?>(null) }
|
||||
LaunchedEffect(confirmation) {
|
||||
if (confirmation != null) {
|
||||
kotlinx.coroutines.delay(1_800L)
|
||||
confirmation = null
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0xFF090B0D)),
|
||||
) {
|
||||
BackdropLayer(item, Modifier.fillMaxSize())
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.20f),
|
||||
0.42f to Color.Black.copy(alpha = 0.52f),
|
||||
0.72f to Color(0xF5090B0D),
|
||||
1f to Color(0xFF090B0D),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
0f to Color.Black.copy(alpha = 0.72f),
|
||||
0.60f to Color.Black.copy(alpha = 0.18f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 64.dp, end = 52.dp, top = 42.dp, bottom = 34.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth(0.70f)) {
|
||||
Text(
|
||||
"SERIES",
|
||||
color = SeriesGreen,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.4.sp,
|
||||
)
|
||||
Text(
|
||||
item.name,
|
||||
color = Color.White,
|
||||
fontSize = 36.sp,
|
||||
lineHeight = 40.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val facts = listOfNotNull(
|
||||
item.productionYear?.toString(),
|
||||
seasons.size.takeIf { it > 0 }?.let { count ->
|
||||
"$count ${if (count == 1) "Season" else "Seasons"}"
|
||||
DetailPageScaffold(
|
||||
item = item,
|
||||
facts = heroFacts(item, seasons.size),
|
||||
// A series' streams describe its episodes; when Emby gives them, they are as true
|
||||
// of the show as they are of a film, and the row this page opened from already
|
||||
// badges them.
|
||||
badges = mediaBadges(item),
|
||||
tabs = tabs,
|
||||
selectedTab = selectedTab,
|
||||
onSelectTab = { tabKey = it.key },
|
||||
playLabel = primaryActionLabel(item, nextEpisode),
|
||||
onPlay = { onPlay(nextEpisode ?: item) },
|
||||
playFocusRequester = play,
|
||||
tabFocusRequester = tabStrip,
|
||||
contentFocusRequester = contentEntry,
|
||||
modifier = modifier,
|
||||
progress = nextEpisode?.let(::playbackProgress) ?: 0f,
|
||||
progressLabel = nextEpisode?.let(::remainingLabel),
|
||||
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
|
||||
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
|
||||
confirmation = confirmation,
|
||||
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
|
||||
heroActions = buildList {
|
||||
add(
|
||||
DetailHeroAction(
|
||||
icon = if (item.isFavorite) Icons.Default.Check else Icons.Default.Add,
|
||||
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
|
||||
active = item.isFavorite,
|
||||
onClick = {
|
||||
val desired = !item.isFavorite
|
||||
onToggleFavorite(item, desired)
|
||||
confirmation = if (desired) "Added to Favourites" else "Removed from Favourites"
|
||||
},
|
||||
item.officialRating,
|
||||
item.genres.take(2).joinToString(" · ").takeIf(String::isNotBlank),
|
||||
),
|
||||
)
|
||||
if (facts.isNotEmpty()) {
|
||||
Text(
|
||||
facts.joinToString(" • "),
|
||||
color = SeriesMuted,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
if (ServerConfig.isGateway) {
|
||||
add(DetailHeroAction(
|
||||
icon = if (isMyShow) Icons.Default.BookmarkAdded else Icons.Default.BookmarkAdd,
|
||||
description = if (isMyShow) "Remove from My Shows" else "Add to My Shows",
|
||||
active = isMyShow,
|
||||
onClick = { onToggleMyShow(item, !isMyShow) },
|
||||
))
|
||||
}
|
||||
trailer?.let {
|
||||
add(DetailHeroAction(Icons.Default.Movie, "Play trailer", onClick = { onPlay(it) }))
|
||||
}
|
||||
},
|
||||
) { visibleTab ->
|
||||
when (visibleTab) {
|
||||
DetailTab.OVERVIEW -> DetailOverviewPane(
|
||||
item = item,
|
||||
credits = credits,
|
||||
focusRequester = overviewPane,
|
||||
// The progress bar carries the position, so this only names the episode.
|
||||
supportingText = nextEpisode?.let { "Up next ${episodeHeadline(it)}" },
|
||||
)
|
||||
DetailTab.EPISODES -> EpisodesPane(
|
||||
episodes = episodes,
|
||||
seasonEpisodes = seasonEpisodes,
|
||||
seasons = seasons,
|
||||
selectedSeason = selectedSeason,
|
||||
remaining = remaining,
|
||||
loadFailed = loadFailed,
|
||||
seasonStripShown = seasonStripShown,
|
||||
seasonFocusRequesters = seasonFocusRequesters,
|
||||
firstEpisodeFocusRequester = firstEpisode,
|
||||
belowSeasons = belowSeasons,
|
||||
aboveEpisodes = aboveEpisodes,
|
||||
tabFocusRequester = tabStrip,
|
||||
listState = episodeListState,
|
||||
emptyFocusRequester = episodesPane,
|
||||
onSelectSeason = { selectedSeason = it },
|
||||
onPlay = onPlay,
|
||||
)
|
||||
DetailTab.MORE_LIKE_THIS -> DetailMoreLikeThisPane(
|
||||
items = visibleRelated,
|
||||
loading = related == null,
|
||||
onSelect = onOpenItem,
|
||||
firstFocusRequester = firstRelated,
|
||||
listState = relatedListState,
|
||||
)
|
||||
DetailTab.CAST_DETAILS -> DetailCastAndDetailsPane(
|
||||
item = item,
|
||||
credits = credits,
|
||||
specs = specs,
|
||||
detailsLoaded = detailsLoaded,
|
||||
focusRequester = castPane,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
item.overview?.takeIf(String::isNotBlank) ?: "No description available.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
lineHeight = 20.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Button(onClick = { onToggleFavorite(item, !item.isFavorite) }) {
|
||||
Text(if (item.isFavorite) "Remove favourite" else "Add favourite")
|
||||
}
|
||||
Button(onClick = onClose) { Text("Close") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(15.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SeriesDetailTabs(
|
||||
selected = selectedSection,
|
||||
episodeCount = episodes?.size,
|
||||
castCount = item.cast.size,
|
||||
onSelected = { selectedSectionKey = it.key },
|
||||
)
|
||||
if (selectedSection == SeriesDetailSection.EPISODES && seasons.isNotEmpty()) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(horizontal = 16.dp)
|
||||
.width(1.dp)
|
||||
.height(30.dp)
|
||||
.background(Color.White.copy(alpha = 0.12f)),
|
||||
)
|
||||
@Composable
|
||||
private fun EpisodesPane(
|
||||
episodes: List<BaseItem>?,
|
||||
seasonEpisodes: List<BaseItem>,
|
||||
seasons: List<Int>,
|
||||
selectedSeason: Int?,
|
||||
remaining: Int,
|
||||
loadFailed: Boolean,
|
||||
seasonStripShown: Boolean,
|
||||
seasonFocusRequesters: Map<Int, FocusRequester>,
|
||||
firstEpisodeFocusRequester: FocusRequester,
|
||||
belowSeasons: FocusRequester,
|
||||
aboveEpisodes: FocusRequester,
|
||||
tabFocusRequester: FocusRequester,
|
||||
listState: LazyListState,
|
||||
emptyFocusRequester: FocusRequester,
|
||||
onSelectSeason: (Int) -> Unit,
|
||||
onPlay: (BaseItem) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (seasonStripShown) {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
items(seasons, key = { it }) { season ->
|
||||
@@ -224,79 +371,52 @@ fun SeriesDetailsOverlay(
|
||||
season = season,
|
||||
selected = season == selectedSeason,
|
||||
focusRequester = seasonFocusRequesters.getValue(season),
|
||||
episodeFocusRequester = firstEpisode,
|
||||
onClick = { selectedSeason = season },
|
||||
episodeFocusRequester = belowSeasons,
|
||||
tabFocusRequester = tabFocusRequester,
|
||||
onClick = { onSelectSeason(season) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(8.dp))
|
||||
when {
|
||||
selectedSection == SeriesDetailSection.CAST -> {
|
||||
if (item.cast.isEmpty()) {
|
||||
} else if (seasons.size == 1) {
|
||||
Text(
|
||||
"Cast information is not available for this show.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
text = seasonLabel(seasons.first()),
|
||||
color = DetailMutedText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
CastRail(
|
||||
people = item.cast,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
showTitle = false,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
episodes == null -> {
|
||||
if (episodes != null && remaining > 0) {
|
||||
Text(
|
||||
"Loading seasons…",
|
||||
color = SeriesQuiet,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
text = "$remaining unwatched",
|
||||
color = DetailQuietText,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(start = 14.dp),
|
||||
)
|
||||
}
|
||||
loadFailed -> {
|
||||
Text(
|
||||
"Episodes are temporarily unavailable.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
seasons.isEmpty() -> {
|
||||
Text(
|
||||
"No episodes are available for this show.",
|
||||
color = SeriesMuted,
|
||||
fontSize = 15.sp,
|
||||
modifier = Modifier.padding(top = 24.dp),
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
start = 2.dp,
|
||||
end = 42.dp,
|
||||
top = 7.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
Spacer(Modifier.height(14.dp))
|
||||
when {
|
||||
episodes == null -> DetailFocusablePane(emptyFocusRequester) { Text("Loading episodes…", color = DetailQuietText, fontSize = 15.sp) }
|
||||
loadFailed -> DetailFocusablePane(emptyFocusRequester) { Text("Episodes are temporarily unavailable.", color = DetailQuietText, fontSize = 15.sp) }
|
||||
seasonEpisodes.isEmpty() -> DetailFocusablePane(emptyFocusRequester) { Text("No episodes are available.", color = DetailQuietText, fontSize = 15.sp) }
|
||||
else -> LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
contentPadding = PaddingValues(end = 12.dp, bottom = 18.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
items(seasonEpisodes, key = BaseItem::id) { episode ->
|
||||
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
|
||||
EpisodeCard(
|
||||
episode = episode,
|
||||
onClick = { onPlay(episode) },
|
||||
seasonFocusRequester = seasonFocusRequesters[selectedSeason]
|
||||
?: FocusRequester.Default,
|
||||
modifier = if (episode.id == seasonEpisodes.first().id) {
|
||||
Modifier
|
||||
.focusRequester(firstEpisode)
|
||||
seasonFocusRequester = aboveEpisodes,
|
||||
isFirst = index == 0,
|
||||
modifier = if (index == 0) {
|
||||
Modifier.focusRequester(firstEpisodeFocusRequester)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
@@ -305,95 +425,6 @@ fun SeriesDetailsOverlay(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal enum class SeriesDetailSection(val key: String) {
|
||||
EPISODES("episodes"),
|
||||
CAST("cast"),
|
||||
}
|
||||
|
||||
internal fun seriesDetailSection(key: String): SeriesDetailSection =
|
||||
SeriesDetailSection.entries.firstOrNull { it.key == key } ?: SeriesDetailSection.EPISODES
|
||||
|
||||
@Composable
|
||||
private fun SeriesDetailTabs(
|
||||
selected: SeriesDetailSection,
|
||||
episodeCount: Int?,
|
||||
castCount: Int,
|
||||
onSelected: (SeriesDetailSection) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
SeriesDetailTab(
|
||||
label = buildString {
|
||||
append("Episodes")
|
||||
episodeCount?.takeIf { it > 0 }?.let { append(" $it") }
|
||||
},
|
||||
selected = selected == SeriesDetailSection.EPISODES,
|
||||
onClick = { onSelected(SeriesDetailSection.EPISODES) },
|
||||
)
|
||||
SeriesDetailTab(
|
||||
label = buildString {
|
||||
append("Cast")
|
||||
castCount.takeIf { it > 0 }?.let { append(" $it") }
|
||||
},
|
||||
selected = selected == SeriesDetailSection.CAST,
|
||||
onClick = { onSelected(SeriesDetailSection.CAST) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SeriesDetailTab(
|
||||
label: String,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(8.dp)
|
||||
val background = when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen.copy(alpha = 0.18f)
|
||||
else -> Color.White.copy(alpha = 0.055f)
|
||||
}
|
||||
val textColor = if (focused) Color.Black else if (selected) Color.White else SeriesQuiet
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(background)
|
||||
.border(
|
||||
1.dp,
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen.copy(alpha = 0.72f)
|
||||
else -> Color.White.copy(alpha = 0.10f)
|
||||
},
|
||||
shape,
|
||||
)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(start = 21.dp, top = 10.dp, end = 21.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = textColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Box(
|
||||
Modifier
|
||||
.padding(top = 7.dp)
|
||||
.width(34.dp)
|
||||
.height(3.dp)
|
||||
.clip(androidx.compose.foundation.shape.RoundedCornerShape(2.dp))
|
||||
.background(if (selected && !focused) SeriesGreen else Color.Transparent),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -402,81 +433,93 @@ private fun SeasonChip(
|
||||
selected: Boolean,
|
||||
focusRequester: FocusRequester,
|
||||
episodeFocusRequester: FocusRequester,
|
||||
tabFocusRequester: FocusRequester,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(7.dp)
|
||||
val shape = RoundedCornerShape(7.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(
|
||||
when {
|
||||
focused -> Color.White
|
||||
selected -> SeriesGreen
|
||||
else -> Color(0xCC20252A)
|
||||
selected -> DetailAccent
|
||||
else -> Color.White.copy(alpha = 0.07f)
|
||||
},
|
||||
)
|
||||
.border(1.dp, Color.White.copy(alpha = if (selected) 0.28f else 0.12f), shape)
|
||||
.border(1.dp, if (selected || focused) Color.Transparent else DetailHairline, shape)
|
||||
.focusRequester(focusRequester)
|
||||
.focusProperties { down = episodeFocusRequester }
|
||||
.focusProperties {
|
||||
up = tabFocusRequester
|
||||
down = episodeFocusRequester
|
||||
}
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 17.dp, vertical = 9.dp),
|
||||
.padding(horizontal = 14.dp, vertical = 7.dp),
|
||||
) {
|
||||
Text(
|
||||
if (season == 0) "Specials" else "Season $season",
|
||||
text = seasonLabel(season),
|
||||
color = if (focused) Color.Black else Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** A minimal D-pad row: the focused episode becomes the unmistakable selection. */
|
||||
@Composable
|
||||
private fun EpisodeCard(
|
||||
episode: BaseItem,
|
||||
onClick: () -> Unit,
|
||||
seasonFocusRequester: FocusRequester,
|
||||
isFirst: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val repository = ServiceLocator.repository
|
||||
var focused by remember { mutableStateOf(false) }
|
||||
val imageUrl = remember(episode.id) {
|
||||
repository.primaryUrl(episode, 360) ?: repository.backdropUrl(episode, 360)
|
||||
}
|
||||
val shape = androidx.compose.foundation.shape.RoundedCornerShape(10.dp)
|
||||
val progress = remember(episode.userData, episode.runTimeTicks) {
|
||||
val runtime = episode.runTimeTicks ?: 0L
|
||||
if (runtime > 0L) {
|
||||
((episode.userData?.playbackPositionTicks ?: 0L).toFloat() / runtime).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
repository.primaryUrl(episode, 480) ?: repository.backdropUrl(episode, 480)
|
||||
}
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val progress = remember(episode.userData, episode.runTimeTicks) { playbackProgress(episode) }
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (focused) 1.045f else 1f,
|
||||
animationSpec = tween(110),
|
||||
targetValue = if (focused) 1.01f else 1f,
|
||||
animationSpec = tween(100),
|
||||
label = "episode-card-focus",
|
||||
)
|
||||
Column(
|
||||
Row(
|
||||
modifier = modifier
|
||||
.width(252.dp)
|
||||
.fillMaxWidth()
|
||||
.height(132.dp)
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
translationY = if (focused) -1f else 0f
|
||||
}
|
||||
.zIndex(if (focused) 1f else 0f)
|
||||
.focusProperties { up = seasonFocusRequester }
|
||||
.clip(shape)
|
||||
.background(if (focused) Color(0xFF23282C) else Color.White.copy(alpha = 0.035f))
|
||||
.border(
|
||||
width = if (focused) 2.dp else 1.dp,
|
||||
color = if (focused) Color.White.copy(alpha = 0.30f) else Color.White.copy(alpha = 0.08f),
|
||||
shape = shape,
|
||||
)
|
||||
.focusProperties {
|
||||
up = if (isFirst) seasonFocusRequester else FocusRequester.Default
|
||||
}
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
.clickable(onClick = onClick)
|
||||
.padding(bottom = 4.dp),
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF20252A)),
|
||||
.width(208.dp)
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color(0xFF15181C)),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
@@ -488,87 +531,104 @@ private fun EpisodeCard(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.border(
|
||||
width = if (focused) 3.dp else 1.dp,
|
||||
color = if (focused) Color.White else Color.White.copy(alpha = 0.10f),
|
||||
shape = shape,
|
||||
width = 1.dp,
|
||||
color = if (focused) Color.White.copy(alpha = 0.28f) else Color.White.copy(alpha = 0.10f),
|
||||
shape = RoundedCornerShape(6.dp),
|
||||
),
|
||||
)
|
||||
if (focused) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(Color.Black.copy(alpha = 0.76f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Play selected episode",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(25.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (episode.isPlayed) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Watched",
|
||||
tint = DetailAccent,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(6.dp)
|
||||
.size(16.dp),
|
||||
)
|
||||
}
|
||||
if (progress > 0f) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.height(3.dp)
|
||||
.background(Color.Black.copy(alpha = 0.65f)),
|
||||
) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth(progress)
|
||||
.height(4.dp)
|
||||
.background(SeriesGreen),
|
||||
.height(3.dp)
|
||||
.background(DetailAccent),
|
||||
)
|
||||
}
|
||||
}
|
||||
if (episode.userData?.played == true) {
|
||||
Icon(
|
||||
Icons.Default.CheckCircle,
|
||||
contentDescription = "Watched",
|
||||
tint = SeriesGreen,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(9.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 9.dp),
|
||||
) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
episode.indexNumber?.let {
|
||||
Text(
|
||||
text = listOfNotNull(
|
||||
episode.indexNumber?.let { "$it." },
|
||||
episode.name,
|
||||
).joinToString(" "),
|
||||
color = Color.White,
|
||||
text = "$it.",
|
||||
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(end = 7.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = episode.name,
|
||||
color = if (focused) Color.White else DetailText,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
episode.runtimeMinutes?.let {
|
||||
Text(
|
||||
"${it}m",
|
||||
color = SeriesQuiet,
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
text = "$it min",
|
||||
color = if (focused) Color(0xFFE4E8EA) else DetailText,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier
|
||||
.padding(start = 12.dp)
|
||||
.clip(RoundedCornerShape(5.dp))
|
||||
.background(
|
||||
if (focused) Color.White.copy(alpha = 0.10f)
|
||||
else Color.White.copy(alpha = 0.08f),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(7.dp))
|
||||
Text(
|
||||
episode.overview?.takeIf(String::isNotBlank) ?: "No episode description available.",
|
||||
color = if (focused) SeriesMuted else SeriesQuiet.copy(alpha = 0.72f),
|
||||
fontSize = 12.sp,
|
||||
lineHeight = 16.sp,
|
||||
maxLines = 2,
|
||||
text = episode.overview?.takeIf(String::isNotBlank) ?: "No plot summary available.",
|
||||
color = if (focused) Color(0xFFC4CBD0) else DetailMutedText,
|
||||
fontSize = 13.sp,
|
||||
lineHeight = 18.sp,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 5.dp),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
internal val seriesEpisodeComparator =
|
||||
compareBy<BaseItem>({ it.parentIndexNumber ?: Int.MAX_VALUE }, { it.indexNumber ?: Int.MAX_VALUE }, { it.name })
|
||||
|
||||
internal fun availableSeasons(episodes: List<BaseItem>): List<Int> =
|
||||
episodes.mapNotNull(BaseItem::parentIndexNumber).distinct().sorted()
|
||||
|
||||
internal fun episodesForSeason(episodes: List<BaseItem>, season: Int?): List<BaseItem> =
|
||||
if (season == null) emptyList()
|
||||
else episodes.filter { it.parentIndexNumber == season }.sortedWith(seriesEpisodeComparator)
|
||||
|
||||
internal fun defaultSeason(episodes: List<BaseItem>): Int? =
|
||||
episodes.firstOrNull {
|
||||
it.userData?.played != true || ((it.userData?.playbackPositionTicks ?: 0L) > 0L)
|
||||
}?.parentIndexNumber ?: availableSeasons(episodes).firstOrNull()
|
||||
|
||||
@@ -3,18 +3,15 @@ package com.ponzischeme89.memby.ui
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -25,8 +22,6 @@ 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.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
@@ -37,7 +32,6 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
@@ -45,14 +39,15 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.MaintenanceMonitor
|
||||
import com.ponzischeme89.memby.data.ServiceAlert
|
||||
@@ -62,8 +57,13 @@ private val AlertAccent = Color(0xFF52B54B)
|
||||
private val AlertTitle = Color(0xFFF2F5F7)
|
||||
private val AlertBody = Color(0xFFC3CBD2)
|
||||
|
||||
/** Height of the bar itself, before the rule and fade that blend it into the screen. */
|
||||
private val BannerHeight = 104.dp
|
||||
/**
|
||||
* Height of the bar itself, before the rule and fade that blend it into the screen.
|
||||
*
|
||||
* Kept to a strip rather than a panel because this now appears over playback as well as
|
||||
* over the launcher: whatever it says, it is covering somebody's film while it says it.
|
||||
*/
|
||||
private val BannerHeight = 48.dp
|
||||
|
||||
/**
|
||||
* Broadcast-style overscan inset. TVs crop the edges of the picture by a few percent, so
|
||||
@@ -103,12 +103,14 @@ fun ServiceAlertBanner(suppressed: Boolean, modifier: Modifier = Modifier) {
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = alert != null,
|
||||
// In from above the frame, out the same way. Slower arriving than leaving:
|
||||
// showing up should be noticed, going away should not.
|
||||
enter = slideInVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeIn(tween(260)),
|
||||
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeOut(tween(220)),
|
||||
// In from above the frame, out the same way, both unhurried: over a film the
|
||||
// arrival is the intrusion, so it eases in rather than snapping down. Still
|
||||
// slower arriving than leaving — showing up should be noticed, going away
|
||||
// should not.
|
||||
enter = slideInVertically(tween(680, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeIn(tween(520)),
|
||||
exit = slideOutVertically(tween(420, easing = FastOutSlowInEasing)) { -it } +
|
||||
fadeOut(tween(300)),
|
||||
modifier = modifier.zIndex(8f),
|
||||
) {
|
||||
lastAlert?.let { AlertBanner(it) }
|
||||
@@ -141,53 +143,52 @@ internal fun AlertBanner(
|
||||
.fillMaxWidth()
|
||||
.height(BannerHeight)
|
||||
.background(
|
||||
// Darkest at the left where the text sits, easing off to the right so
|
||||
// the artwork behind the banner still shows through.
|
||||
// Near-black, and darkest at the left where the text sits. Over a
|
||||
// film almost anything can be behind this, so the bar supplies its
|
||||
// own contrast rather than relying on the picture underneath.
|
||||
Brush.horizontalGradient(
|
||||
0f to Color(0xFF0E1418),
|
||||
0.55f to Color(0xF20E1418),
|
||||
1f to Color(0xD9121A20),
|
||||
0f to Color(0xFF04070A),
|
||||
0.55f to Color(0xF504070A),
|
||||
1f to Color(0xE0070B0F),
|
||||
),
|
||||
)
|
||||
.padding(horizontal = SafeAreaHorizontal),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
AlertPoster(posterUrl = alert.posterUrl)
|
||||
Spacer(Modifier.width(20.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
LivePip()
|
||||
AlertMark()
|
||||
Spacer(Modifier.width(14.dp))
|
||||
// The same short green rule used by the ten-minute reminder. It makes the
|
||||
// alert feel like part of the player instead of a separate notification UI.
|
||||
Box(Modifier.width(3.dp).height(26.dp).background(AlertAccent))
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Text(
|
||||
"JUST AIRED",
|
||||
alertLabel(alert.label),
|
||||
color = AlertAccent,
|
||||
fontSize = 13.sp,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 1.6.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(3.dp))
|
||||
// Keep the alert readable at TV distance without letting it overpower
|
||||
// the screen content beneath it.
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Text(
|
||||
alert.title,
|
||||
color = AlertTitle,
|
||||
fontSize = 20.sp,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
alert.message,
|
||||
color = AlertBody,
|
||||
fontSize = 15.sp,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(24.dp))
|
||||
Spacer(Modifier.width(16.dp))
|
||||
CountdownRing(fraction = { remaining.value }, secondsLeft = secondsLeft)
|
||||
}
|
||||
|
||||
@@ -196,7 +197,7 @@ internal fun AlertBanner(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(2.dp)
|
||||
.height(1.dp)
|
||||
.background(
|
||||
Brush.horizontalGradient(
|
||||
listOf(AlertAccent, AlertAccent.copy(alpha = 0.35f), Color.Transparent),
|
||||
@@ -206,14 +207,25 @@ internal fun AlertBanner(
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp)
|
||||
.height(10.dp)
|
||||
.background(
|
||||
Brush.verticalGradient(listOf(Color(0x99000000), Color.Transparent)),
|
||||
Brush.verticalGradient(listOf(Color(0x66000000), Color.Transparent)),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The eyebrow above the title. The server picks the wording so it can announce something
|
||||
* this build has no name for; a bare alert — or one from a server older than the field —
|
||||
* keeps the original episode wording, and an over-long label is cut rather than allowed
|
||||
* to push the countdown off the bar.
|
||||
*/
|
||||
internal fun alertLabel(label: String): String =
|
||||
label.trim().ifEmpty { "JUST AIRED" }.take(MaxAlertLabelChars).uppercase()
|
||||
|
||||
private const val MaxAlertLabelChars = 24
|
||||
|
||||
/**
|
||||
* A ring that empties as the banner's time runs out, with the seconds left inside it.
|
||||
*
|
||||
@@ -228,9 +240,9 @@ internal fun AlertBanner(
|
||||
*/
|
||||
@Composable
|
||||
private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||
Box(Modifier.size(46.dp), contentAlignment = Alignment.Center) {
|
||||
Box(Modifier.size(26.dp), contentAlignment = Alignment.Center) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val stroke = 3.dp.toPx()
|
||||
val stroke = 2.dp.toPx()
|
||||
val inset = stroke / 2f
|
||||
val arcSize = Size(size.width - stroke, size.height - stroke)
|
||||
drawArc(
|
||||
@@ -257,60 +269,30 @@ private fun CountdownRing(fraction: () -> Float, secondsLeft: State<Int>) {
|
||||
Text(
|
||||
secondsLeft.value.coerceAtLeast(0).toString(),
|
||||
color = AlertBody,
|
||||
fontSize = 16.sp,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Poster when the server named one; a quiet accent tile when it did not. */
|
||||
/**
|
||||
* The Emby mark, which is what every one of these banners is really speaking for —
|
||||
* a new film, a finished refresh, a server that stopped answering.
|
||||
*
|
||||
* It replaced the item poster deliberately: artwork made each alert look like a different
|
||||
* feature, and half of them (a library refresh, an outage) have no artwork to show. One
|
||||
* constant mark says "this is your server talking" in the width of a thumbnail.
|
||||
*/
|
||||
@Composable
|
||||
private fun AlertPoster(posterUrl: String?) {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(50.dp)
|
||||
.height(74.dp)
|
||||
.clip(shape)
|
||||
.background(Color(0xFF18222B)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (posterUrl != null) {
|
||||
AsyncImage(
|
||||
model = posterUrl,
|
||||
private fun AlertMark() {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.emby_logo),
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier
|
||||
.width(30.dp)
|
||||
.height(26.dp),
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
Modifier
|
||||
.size(20.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AlertAccent.copy(alpha = 0.30f)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A slow pulse — enough motion to read as "news", cheap enough for a TV GPU. */
|
||||
@Composable
|
||||
private fun LivePip() {
|
||||
val alpha = androidx.compose.animation.core.rememberInfiniteTransition(label = "alert-pip")
|
||||
.animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
tween(1_400, easing = LinearEasing),
|
||||
RepeatMode.Reverse,
|
||||
),
|
||||
label = "alert-pip-alpha",
|
||||
)
|
||||
// Deliberately not `by`: the alpha is read inside the draw lambda, so the pulse
|
||||
// repaints eight dp of circle rather than recomposing the row that holds it.
|
||||
Canvas(Modifier.size(8.dp)) {
|
||||
drawCircle(color = AlertAccent.copy(alpha = alpha.value))
|
||||
}
|
||||
}
|
||||
|
||||
// Previews render the bar directly rather than through ServiceAlertBanner: the wrapper's
|
||||
@@ -327,7 +309,38 @@ private fun ServiceAlertBannerPreview() {
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The other news the bar carries: Radarr finished importing a film. */
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerMovieAddedPreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "radarr:412:file:9001",
|
||||
title = "Mr. Smith Goes to Washington (1939)",
|
||||
message = "Mr. Smith Goes to Washington will be available in Emby shortly.",
|
||||
label = "NEW MOVIE ADDED",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** News about the service rather than the catalogue — the kind seen during a film. */
|
||||
@TvPreview
|
||||
@Composable
|
||||
private fun ServiceAlertBannerServerDownPreview() {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
AlertBanner(
|
||||
ServiceAlert(
|
||||
id = "emby:down:1785012345",
|
||||
title = "Emby has stopped communicating",
|
||||
message = "Playback may stop until it is back. Memby will say when it returns.",
|
||||
label = "SERVER NOT RESPONDING",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -344,7 +357,6 @@ private fun ServiceAlertBannerLongTitlePreview() {
|
||||
title = "A Very Long Programme Title That Will Not Fit On One Line At All",
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"aired at 10:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.get
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
|
||||
/**
|
||||
* Whether a title's logo artwork should be replaced by its plain-text name.
|
||||
*
|
||||
* Transparent Emby logos are commonly black. They disappear over a dark backdrop, so a
|
||||
* small decoded copy is inspected and the text title retained when the visible pixels are
|
||||
* overwhelmingly dark. Until the image has been inspected, text is the safe default — a
|
||||
* title that arrives a frame late is better than a title that never appears.
|
||||
*
|
||||
* Shared by the screensaver and the detail hero: both draw a title over a near-black scrim,
|
||||
* and a logo that is invisible on one is invisible on the other.
|
||||
*/
|
||||
@Composable
|
||||
internal fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
||||
if (logoUrl == null) return true
|
||||
val context = LocalContext.current
|
||||
val isDark by produceState(initialValue = true, logoUrl) {
|
||||
value = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.allowHardware(false)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching true
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
return isDark
|
||||
}
|
||||
|
||||
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
||||
var opaquePixels = 0
|
||||
var darkPixels = 0
|
||||
for (y in 0 until bitmap.height step 2) {
|
||||
for (x in 0 until bitmap.width step 2) {
|
||||
val pixel = bitmap[x, y]
|
||||
if (android.graphics.Color.alpha(pixel) < 48) continue
|
||||
opaquePixels++
|
||||
val luminance = (
|
||||
android.graphics.Color.red(pixel) * 0.2126f +
|
||||
android.graphics.Color.green(pixel) * 0.7152f +
|
||||
android.graphics.Color.blue(pixel) * 0.0722f
|
||||
)
|
||||
if (luminance < 58f) darkPixels++
|
||||
}
|
||||
}
|
||||
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
@@ -30,6 +31,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowDownward
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,20 +42,26 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusProperties
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
|
||||
import com.ponzischeme89.memby.update.UpdateChecker
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -63,7 +71,8 @@ private val UpdateBody = Color(0xFFAEB7BF)
|
||||
private val UpdateFaint = Color(0xFFA2ADB5)
|
||||
|
||||
/**
|
||||
* The update prompt, shown over the home screen.
|
||||
* The app-level update gate. AppRoot composes this instead of login, profiles, or Home,
|
||||
* so no focused media card or playback action exists behind its buttons.
|
||||
*
|
||||
* A **mandatory** update covers everything and cannot be dismissed: Back is swallowed and
|
||||
* there is one button. The operator has decided this build may no longer be used, so
|
||||
@@ -81,13 +90,53 @@ fun UpdateScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val checker = remember { UpdateChecker(context) }
|
||||
|
||||
var installing by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var waitingForInstallPermission by remember { mutableStateOf(false) }
|
||||
|
||||
fun beginInstall() {
|
||||
if (installing) return
|
||||
installing = true
|
||||
message = null
|
||||
scope.launch {
|
||||
val result = checker.downloadAndInstall(
|
||||
apkUrl = update.downloadUrl,
|
||||
token = "",
|
||||
expectedVersion = update.version,
|
||||
expectedSHA256 = update.sha256,
|
||||
expectedSizeBytes = update.sizeBytes,
|
||||
)
|
||||
installing = false
|
||||
waitingForInstallPermission =
|
||||
result.exceptionOrNull() is InstallPermissionRequiredException
|
||||
message = result.exceptionOrNull()?.message ?: "Opening the installer…"
|
||||
}
|
||||
}
|
||||
|
||||
// Android's permission screen pauses Memby. Once the viewer grants permission and
|
||||
// returns, continue automatically instead of making them discover they must press
|
||||
// Update now for a second time.
|
||||
DisposableEffect(lifecycleOwner, waitingForInstallPermission, update.version) {
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
if (event == Lifecycle.Event.ON_RESUME &&
|
||||
waitingForInstallPermission &&
|
||||
(Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
|
||||
context.packageManager.canRequestPackageInstalls())
|
||||
) {
|
||||
waitingForInstallPermission = false
|
||||
beginInstall()
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
|
||||
}
|
||||
|
||||
val primaryFocus = remember { FocusRequester() }
|
||||
val laterFocus = remember { FocusRequester() }
|
||||
LaunchedEffect(update.version) { runCatching { primaryFocus.requestFocus() } }
|
||||
|
||||
// Swallow Back entirely while an update is required. For an optional prompt, Back is
|
||||
@@ -120,13 +169,7 @@ fun UpdateScreen(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
// Opaque, not a scrim: a required update is not a dialog over usable content.
|
||||
.background(Color(0xFF0B0E11))
|
||||
// Consumes clicks so nothing behind can be reached.
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
.background(Color(0xFF0B0E11)),
|
||||
) {
|
||||
Canvas(Modifier.fillMaxSize()) {
|
||||
val centre = Offset(size.width * (0.5f + 0.06f * drift), size.height * 0.34f)
|
||||
@@ -209,7 +252,7 @@ fun UpdateScreen(
|
||||
// easy to back out of by accident.
|
||||
"Choose Update now — Memby downloads the new version, then your TV asks you " +
|
||||
"to confirm the install. If it asks permission to install apps, allow it and " +
|
||||
"the update continues.",
|
||||
"the update continues. Your profiles and sign-in stay on this TV.",
|
||||
color = UpdateFaint,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -222,21 +265,23 @@ fun UpdateScreen(
|
||||
label = if (installing) "Downloading…" else "Update now",
|
||||
primary = true,
|
||||
enabled = !installing,
|
||||
onClick = {
|
||||
if (installing) return@UpdateButton
|
||||
installing = true
|
||||
message = null
|
||||
scope.launch {
|
||||
val result = checker.downloadAndInstall(update.downloadUrl, token = "")
|
||||
installing = false
|
||||
message = result.exceptionOrNull()?.message
|
||||
?: "Opening the installer…"
|
||||
}
|
||||
onClick = ::beginInstall,
|
||||
modifier = Modifier
|
||||
.focusRequester(primaryFocus)
|
||||
.focusProperties {
|
||||
if (!update.isMandatory) right = laterFocus
|
||||
},
|
||||
modifier = Modifier.focusRequester(primaryFocus),
|
||||
)
|
||||
if (!update.isMandatory) {
|
||||
UpdateButton(label = "Later", primary = false, enabled = true, onClick = onDismiss)
|
||||
UpdateButton(
|
||||
label = "Not now",
|
||||
primary = false,
|
||||
enabled = true,
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier
|
||||
.focusRequester(laterFocus)
|
||||
.focusProperties { left = primaryFocus },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +297,13 @@ fun UpdateScreen(
|
||||
color = UpdateFaint.copy(alpha = 0.75f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
} else {
|
||||
Spacer(Modifier.height(18.dp))
|
||||
Text(
|
||||
"Press Back or choose Not now to dismiss this update.",
|
||||
color = UpdateFaint.copy(alpha = 0.82f),
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,6 +342,25 @@ private fun UpdateButton(
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
)
|
||||
.onFocusChanged { focused = it.isFocused }
|
||||
// Some Android TV launchers/remotes do not turn a Foundation click target's
|
||||
// centre key into a click consistently. Consume the activation keys here and
|
||||
// invoke once on key-up, while retaining clickable for accessibility/pointer
|
||||
// input.
|
||||
.onPreviewKeyEvent { event ->
|
||||
val native = event.nativeKeyEvent
|
||||
val activationKey =
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_DPAD_CENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_ENTER ||
|
||||
native.keyCode == android.view.KeyEvent.KEYCODE_NUMPAD_ENTER
|
||||
if (!activationKey) {
|
||||
false
|
||||
} else {
|
||||
if (enabled && native.action == android.view.KeyEvent.ACTION_UP) {
|
||||
onClick()
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
.focusable(interactionSource = remember { MutableInteractionSource() })
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 30.dp, vertical = 13.dp),
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
internal enum class UserSwitcherDirection { UP, DOWN }
|
||||
|
||||
/**
|
||||
* Profiles occupy [0, profileCount); the pinned Manage users action is always the final
|
||||
* index. Keeping this arithmetic outside Compose makes remote navigation deterministic.
|
||||
*/
|
||||
internal fun userSwitcherInitialIndex(
|
||||
profileIds: List<String>,
|
||||
activeProfileId: String?,
|
||||
): Int = profileIds.indexOf(activeProfileId).takeIf { it >= 0 } ?: 0
|
||||
|
||||
internal fun userSwitcherNextIndex(
|
||||
currentIndex: Int,
|
||||
profileCount: Int,
|
||||
direction: UserSwitcherDirection,
|
||||
): Int {
|
||||
val manageIndex = profileCount.coerceAtLeast(0)
|
||||
val current = currentIndex.coerceIn(0, manageIndex)
|
||||
return when (direction) {
|
||||
UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0)
|
||||
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(manageIndex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import kotlin.random.Random
|
||||
|
||||
internal enum class WelcomeQuoteStyle(
|
||||
val value: String,
|
||||
val label: String,
|
||||
) {
|
||||
NEUTRAL("neutral", "Neutral"),
|
||||
POSITIVE("positive", "Positive"),
|
||||
HOMICIDAL("homicidal", "Homicidal"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun from(value: String?): WelcomeQuoteStyle =
|
||||
entries.firstOrNull { it.value.equals(value, ignoreCase = true) } ?: NEUTRAL
|
||||
}
|
||||
}
|
||||
|
||||
private val WelcomeQuotes = mapOf(
|
||||
WelcomeQuoteStyle.NEUTRAL to listOf(
|
||||
"The sofa has been expecting you.",
|
||||
"Your watchlist remains impressively optimistic.",
|
||||
"Everything is ready. Decision-making is now your problem.",
|
||||
"Welcome back. The pixels have been briefed.",
|
||||
"No judgement. Even if you pick that again.",
|
||||
),
|
||||
WelcomeQuoteStyle.POSITIVE to listOf(
|
||||
"Excellent choice showing up. The rest should be easy.",
|
||||
"You bring the snacks; Memby will bring the good bits.",
|
||||
"Tonight has strong main-character energy.",
|
||||
"Your next favourite thing might be one click away.",
|
||||
"Settle in. You’ve earned the good seat.",
|
||||
),
|
||||
WelcomeQuoteStyle.HOMICIDAL to listOf(
|
||||
"Welcome back. I kept your spot. Nobody argued twice.",
|
||||
"Pick something cheerful. I’ve already hidden the evidence.",
|
||||
"The remote knows what it did.",
|
||||
"Your watchlist is safe. The witnesses, less so.",
|
||||
"Relax. Everything is under control, allegedly.",
|
||||
),
|
||||
)
|
||||
|
||||
internal fun randomWelcomeQuote(
|
||||
styleValue: String?,
|
||||
random: Random = Random.Default,
|
||||
): String {
|
||||
val quotes = WelcomeQuotes.getValue(WelcomeQuoteStyle.from(styleValue))
|
||||
return quotes[random.nextInt(quotes.size)]
|
||||
}
|
||||
|
||||
internal fun loginWelcomeMessage(
|
||||
username: String,
|
||||
styleValue: String? = WelcomeQuoteStyle.NEUTRAL.value,
|
||||
random: Random = Random.Default,
|
||||
): String {
|
||||
val name = username.trim().ifBlank { "there" }
|
||||
return "Welcome to Memby, $name. ${randomWelcomeQuote(styleValue, random)}"
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.ponzischeme89.memby.ui.detail
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -15,6 +16,15 @@ import java.util.Locale
|
||||
/** Emby stores durations and positions as 100-ns ticks. */
|
||||
private const val TICKS_PER_MINUTE = 600_000_000L
|
||||
|
||||
/**
|
||||
* The video width at which a file is called 4K, by the spec row and the card badge alike.
|
||||
*
|
||||
* UHD is 3840 wide and stays 3840 wide however hard the frame is cropped for scope, so the
|
||||
* threshold sits just under it. The badge and the `(4K)` suffix used to disagree — 3800 and
|
||||
* 3400 — and a 3600-wide file was 4K on one screen and not the other.
|
||||
*/
|
||||
const val UHD_MIN_WIDTH = 3_800
|
||||
|
||||
/** "2h 14m", "47m". Never "0m" — callers pass a positive runtime or nothing. */
|
||||
fun formatRuntime(minutes: Int): String {
|
||||
val hours = minutes / 60
|
||||
@@ -52,27 +62,100 @@ fun remainingLabel(item: BaseItem): String? {
|
||||
return "${formatPosition(left)} left"
|
||||
}
|
||||
|
||||
/** The headline row under a movie title: year, runtime, certificate, score, genres. */
|
||||
fun movieFacts(item: BaseItem): List<String> = buildList {
|
||||
/**
|
||||
* The quiet line directly under the title: year, length, certificate. Deliberately short —
|
||||
* the score sits beside the title and the genres are a credit row, so this stays at three
|
||||
* items and never has to compete for the width.
|
||||
*
|
||||
* [seasonCount] replaces the runtime for a series; pass 0 for anything else.
|
||||
*/
|
||||
fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
if (seasonCount > 0) {
|
||||
add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
} else {
|
||||
item.runtimeMinutes?.let { add(formatRuntime(it)) }
|
||||
}
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
|
||||
/** The same row for a series, where seasons replace runtime. */
|
||||
fun seriesFacts(item: BaseItem, seasonCount: Int): List<String> = buildList {
|
||||
item.productionYear?.let { add(it.toString()) }
|
||||
if (seasonCount > 0) add("$seasonCount ${if (seasonCount == 1) "Season" else "Seasons"}")
|
||||
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
|
||||
item.communityRating?.let { add("★ ${String.format(Locale.US, "%.1f", it)}") }
|
||||
item.genres.take(3).joinToString(" · ").takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
/** "8.9" for the score beside the title, or null when Emby has no community rating. */
|
||||
fun ratingLabel(item: BaseItem): String? =
|
||||
item.communityRating?.let { String.format(Locale.US, "%.1f", it) }
|
||||
|
||||
/** A label/value pair for the quiet technical area. */
|
||||
data class TechnicalSpec(val label: String, val value: String)
|
||||
|
||||
/**
|
||||
* The label/value credits block: who is in it, who made it, what it is. Kept separate from
|
||||
* [technicalSpecs] because these answer "do I want this?" and those answer "will it play?".
|
||||
*/
|
||||
fun creditRows(item: BaseItem): List<TechnicalSpec> = buildList {
|
||||
peopleNamed(item, "Actor", limit = 4)?.let { add(TechnicalSpec("Starring", it)) }
|
||||
val directors = peopleNamed(item, "Director", limit = 2)
|
||||
val creators = peopleNamed(item, "Writer", limit = 2)
|
||||
when {
|
||||
directors != null -> add(TechnicalSpec("Directed by", directors))
|
||||
creators != null -> add(TechnicalSpec("Written by", creators))
|
||||
else -> Unit
|
||||
}
|
||||
item.genres.take(3).joinToString(", ").takeIf(String::isNotBlank)
|
||||
?.let { add(TechnicalSpec("Genre", it)) }
|
||||
item.studios.map { it.name }.filter(String::isNotBlank).take(2)
|
||||
.takeIf(List<String>::isNotEmpty)
|
||||
?.let { add(TechnicalSpec("Studio", it.joinToString(", "))) }
|
||||
}
|
||||
|
||||
private fun peopleNamed(item: BaseItem, type: String, limit: Int): String? =
|
||||
item.people
|
||||
.filter { it.type.equals(type, ignoreCase = true) }
|
||||
.map { it.name }
|
||||
.filter(String::isNotBlank)
|
||||
.distinct()
|
||||
.take(limit)
|
||||
.takeIf(List<String>::isNotEmpty)
|
||||
?.joinToString(", ")
|
||||
|
||||
/**
|
||||
* The sections a detail page can show, in the order the strip lists them.
|
||||
*
|
||||
* Overview is always present and always first: it is the only one guaranteed to have
|
||||
* something in it, so it is what the page can safely open on.
|
||||
*/
|
||||
enum class DetailTab(val key: String, val label: String) {
|
||||
OVERVIEW("overview", "Overview"),
|
||||
MORE_LIKE_THIS("more-like-this", "More Like This"),
|
||||
EPISODES("episodes", "Episodes"),
|
||||
CAST_DETAILS("cast-details", "Cast & Details"),
|
||||
}
|
||||
|
||||
/**
|
||||
* The strip for one item, decided by *what the item is* — never by what has arrived from
|
||||
* the network so far.
|
||||
*
|
||||
* This used to offer only the sections that already had content, on the reasoning that an
|
||||
* empty tab is worse than a missing one. On a real TV it was worse than either: a movie
|
||||
* opens with its list-row metadata, so Cast and Details appeared a second later and shoved
|
||||
* the strip sideways under the viewer's thumb, and a series that failed to load its
|
||||
* episodes lost a tab the household knows is there. A strip that depends only on
|
||||
* [isSeries] is decided on the first frame and never moves again; a section with nothing
|
||||
* in it yet says so in its own pane, where saying so costs nobody a keypress.
|
||||
*/
|
||||
fun detailTabs(isSeries: Boolean): List<DetailTab> = buildList {
|
||||
add(DetailTab.OVERVIEW)
|
||||
if (isSeries) add(DetailTab.EPISODES)
|
||||
add(DetailTab.MORE_LIKE_THIS)
|
||||
add(DetailTab.CAST_DETAILS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a remembered tab key against what is on offer. The strip no longer changes
|
||||
* under a page, so this only has to catch a key remembered from an item of the other kind
|
||||
* — a series' Episodes tab carried over to a movie.
|
||||
*/
|
||||
fun detailTab(key: String, available: List<DetailTab>): DetailTab =
|
||||
available.firstOrNull { it.key == key } ?: DetailTab.OVERVIEW
|
||||
|
||||
/**
|
||||
* Resolution, codecs and studio — the things a viewer checks before settling in, kept out
|
||||
* of the headline because none of them decide what to watch.
|
||||
@@ -92,7 +175,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
stream.codec?.uppercase(Locale.US),
|
||||
dynamicRangeLabel(stream.videoRange, stream.videoRangeType, stream.title),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Codec", it.joinToString(" · ")))
|
||||
add(TechnicalSpec("Codec", it.joinToString(ValueSeparator)))
|
||||
}
|
||||
}
|
||||
audio?.let { stream ->
|
||||
@@ -101,7 +184,7 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
stream.channels?.let(::channelLabel),
|
||||
stream.language?.takeIf(String::isNotBlank),
|
||||
).takeIf(List<String>::isNotEmpty)?.let {
|
||||
add(TechnicalSpec("Audio", it.joinToString(" · ")))
|
||||
add(TechnicalSpec("Audio", it.joinToString(ValueSeparator)))
|
||||
}
|
||||
}
|
||||
if (subtitles > 0) {
|
||||
@@ -114,14 +197,19 @@ fun technicalSpecs(item: BaseItem): List<TechnicalSpec> {
|
||||
}
|
||||
|
||||
private fun resolutionSuffix(width: Int): String = when {
|
||||
width >= 3_400 -> " (4K)"
|
||||
width >= UHD_MIN_WIDTH -> " (4K)"
|
||||
width >= 2_500 -> " (1440p)"
|
||||
width >= 1_800 -> " (1080p)"
|
||||
width >= 1_200 -> " (720p)"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
|
||||
/**
|
||||
* "Dolby Vision", "HDR10+" or "HDR" from whatever Emby happened to fill in, or null for an
|
||||
* ordinary SDR file. Shared with the card badges so the two never name the same file
|
||||
* differently — HDR10+ used to collapse to a plain "HDR" badge.
|
||||
*/
|
||||
internal fun dynamicRangeLabel(range: String?, rangeType: String?, title: String?): String? {
|
||||
val haystack = listOfNotNull(range, rangeType, title).joinToString(" ").lowercase(Locale.US)
|
||||
return when {
|
||||
"dolby vision" in haystack || "dovi" in haystack -> "Dolby Vision"
|
||||
@@ -139,6 +227,26 @@ private fun channelLabel(channels: Int): String = when (channels) {
|
||||
else -> "${channels}ch"
|
||||
}
|
||||
|
||||
/** The canonical first film in an Emby collection, when the collection has siblings. */
|
||||
data class FranchiseStart(val name: String, val firstMovie: BaseItem)
|
||||
|
||||
fun franchiseStart(item: BaseItem, related: List<BaseItem>): FranchiseStart? {
|
||||
if (!item.isMovie) return null
|
||||
val collection = item.collectionName?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
val movies = (listOf(item) + related)
|
||||
.asSequence()
|
||||
.filter { candidate ->
|
||||
candidate.isMovie && candidate.collectionName?.trim().equals(collection, ignoreCase = true)
|
||||
}
|
||||
.distinctBy(BaseItem::id)
|
||||
.toList()
|
||||
if (movies.size < 2) return null
|
||||
val first = movies.minWithOrNull(
|
||||
compareBy<BaseItem>({ it.productionYear ?: Int.MAX_VALUE }, { it.name.lowercase(Locale.US) }),
|
||||
) ?: return null
|
||||
return FranchiseStart(collection, first)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Series structure
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -196,7 +304,7 @@ fun episodeLabel(episode: BaseItem): String? {
|
||||
/** "S2 E4 · The Crossing", falling back to whichever half is known. */
|
||||
fun episodeHeadline(episode: BaseItem): String =
|
||||
listOfNotNull(episodeLabel(episode), episode.name.takeIf(String::isNotBlank))
|
||||
.joinToString(" · ")
|
||||
.joinToString(ValueSeparator)
|
||||
|
||||
/**
|
||||
* The primary button. Series pass their next episode so the button can name it; a movie
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.ponzischeme89.memby.ui.detail
|
||||
|
||||
/**
|
||||
* Where a viewer was on a detail page, so that leaving and coming back does not undo it.
|
||||
*
|
||||
* A detail page is an overlay: closing it removes the composable entirely, which takes
|
||||
* `rememberSaveable` with it. Without somewhere outside the composition to keep this,
|
||||
* pressing Back on episode 9 of season 3 and opening the show again lands on Overview,
|
||||
* season 1, with focus on Play — every single time.
|
||||
*
|
||||
* Deliberately process-scoped rather than persisted. It is a convenience within a sitting;
|
||||
* a TV switched on the next morning should open a show where the *show* is up to, which is
|
||||
* what [defaultSeason] already decides.
|
||||
*/
|
||||
data class DetailPosition(
|
||||
val tabKey: String = DetailTab.OVERVIEW.key,
|
||||
/** Null means "not chosen yet" — [defaultSeason] still gets to pick. */
|
||||
val season: Int? = null,
|
||||
val zone: DetailZone = DetailZone.PLAY,
|
||||
val episodeIndex: Int = 0,
|
||||
val relatedIndex: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* Which band of the page held focus. Restoring the *tab* without the band is worse than
|
||||
* restoring neither: the viewer left with focus on an episode and comes back to a page
|
||||
* that looks identical but answers Down differently.
|
||||
*/
|
||||
enum class DetailZone { PLAY, TABS, CONTENT, RELATED }
|
||||
|
||||
/**
|
||||
* A small, capped, most-recently-used store of positions keyed by item id.
|
||||
*
|
||||
* Capped because a long browse would otherwise accumulate one entry per poster the viewer
|
||||
* pressed OK on, and none of them matter once they have scrolled out of memory.
|
||||
*/
|
||||
class DetailPositionStore(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) {
|
||||
private val positions = LinkedHashMap<String, DetailPosition>(maxEntries, 0.75f, true)
|
||||
|
||||
@Synchronized
|
||||
fun get(itemId: String): DetailPosition = positions[itemId] ?: DetailPosition()
|
||||
|
||||
@Synchronized
|
||||
fun update(itemId: String, transform: (DetailPosition) -> DetailPosition) {
|
||||
if (itemId.isBlank()) return
|
||||
positions[itemId] = transform(positions[itemId] ?: DetailPosition())
|
||||
while (positions.size > maxEntries) {
|
||||
val oldest = positions.keys.firstOrNull() ?: break
|
||||
positions.remove(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun clear() = positions.clear()
|
||||
|
||||
@Synchronized
|
||||
fun size(): Int = positions.size
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_MAX_ENTRIES = 16
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The one store the detail pages share. A plain global rather than a `ServiceLocator`
|
||||
* entry: it holds nothing a preview, a screenshot test or a signed-out user could misuse,
|
||||
* and every reader of it is a composable that already runs without injection.
|
||||
*/
|
||||
val detailPositions = DetailPositionStore()
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.PorterDuff
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
|
||||
/**
|
||||
* Applies a white treatment only to artwork whose visible pixels are overwhelmingly dark.
|
||||
* Transparent padding is ignored, so sparse black wordmarks are detected reliably without
|
||||
* flattening colourful or deliberately two-tone artwork.
|
||||
*/
|
||||
internal fun makeLogoVisibleOnDarkBackground(imageView: ImageView, drawable: Drawable): Boolean {
|
||||
imageView.setImageDrawable(drawable)
|
||||
imageView.clearColorFilter()
|
||||
val tintWhite = isPredominantlyDarkLogo(drawable)
|
||||
if (tintWhite) imageView.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN)
|
||||
return tintWhite
|
||||
}
|
||||
|
||||
internal fun isPredominantlyDarkLogo(drawable: Drawable): Boolean {
|
||||
val bitmap = runCatching {
|
||||
drawable.toBitmap(width = 96, height = 64, config = Bitmap.Config.ARGB_8888)
|
||||
}.getOrNull() ?: return false
|
||||
val xStep = (bitmap.width / 48).coerceAtLeast(1)
|
||||
val yStep = (bitmap.height / 32).coerceAtLeast(1)
|
||||
var visible = 0
|
||||
var dark = 0
|
||||
var bright = 0
|
||||
for (y in 0 until bitmap.height step yStep) {
|
||||
for (x in 0 until bitmap.width step xStep) {
|
||||
val pixel = bitmap.getPixel(x, y)
|
||||
if (Color.alpha(pixel) < 32) continue
|
||||
visible++
|
||||
val luminance = (
|
||||
Color.red(pixel) * 0.2126f +
|
||||
Color.green(pixel) * 0.7152f +
|
||||
Color.blue(pixel) * 0.0722f
|
||||
) / 255f
|
||||
if (luminance < 0.32f) dark++
|
||||
if (luminance > 0.72f) bright++
|
||||
}
|
||||
}
|
||||
return visible > 0 && dark * 100 >= visible * 68 && bright * 100 < visible * 12
|
||||
}
|
||||
@@ -10,6 +10,7 @@ internal data class PlaybackFailure(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val canAutoRetry: Boolean,
|
||||
val requiresTranscode: Boolean = false,
|
||||
)
|
||||
|
||||
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||
@@ -45,17 +46,26 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
||||
-> PlaybackFailure(
|
||||
title = "Video format not supported",
|
||||
detail = "This TV couldn’t decode the selected video or audio track. Try another track or a transcoded version.",
|
||||
canAutoRetry = false,
|
||||
detail = "This TV couldn’t decode the original format. Memby will request a compatible stream.",
|
||||
canAutoRetry = true,
|
||||
requiresTranscode = true,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||
-> PlaybackFailure(
|
||||
title = "Video file couldn’t be read",
|
||||
detail = "The stream format is damaged or unsupported by this TV.",
|
||||
detail = "This TV couldn’t read the original stream. Memby will request a compatible format.",
|
||||
canAutoRetry = true,
|
||||
requiresTranscode = true,
|
||||
)
|
||||
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
-> PlaybackFailure(
|
||||
title = "Video file couldn’t be read",
|
||||
detail = "The media server returned a malformed stream.",
|
||||
canAutoRetry = false,
|
||||
)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui.player
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Color
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.util.AttributeSet
|
||||
import android.util.TypedValue
|
||||
import android.view.View
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* Compact TV-safe countdown that does not depend on a continuously running animator.
|
||||
* PlayerActivity advances it only while video is actually playing, keeping the ring and
|
||||
* the pre-roll gate on the same clock.
|
||||
*/
|
||||
class PrerollCountdownView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0,
|
||||
) : View(context, attrs, defStyleAttr) {
|
||||
|
||||
private val density = resources.displayMetrics.density
|
||||
private val ringBounds = RectF()
|
||||
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.argb(72, 255, 255, 255)
|
||||
style = Paint.Style.STROKE
|
||||
strokeCap = Paint.Cap.ROUND
|
||||
strokeWidth = 5f * density
|
||||
}
|
||||
private val progressPaint = Paint(trackPaint).apply {
|
||||
color = Color.rgb(82, 190, 75)
|
||||
}
|
||||
private val numberPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
|
||||
color = Color.WHITE
|
||||
textAlign = Paint.Align.CENTER
|
||||
textSize = TypedValue.applyDimension(
|
||||
TypedValue.COMPLEX_UNIT_SP,
|
||||
30f,
|
||||
resources.displayMetrics,
|
||||
)
|
||||
typeface = android.graphics.Typeface.create("sans-serif", android.graphics.Typeface.BOLD)
|
||||
}
|
||||
|
||||
private var seconds = 7
|
||||
private var progress = 1f
|
||||
|
||||
fun setCountdown(seconds: Int, progress: Float, description: String) {
|
||||
this.seconds = seconds.coerceAtLeast(0)
|
||||
this.progress = progress.coerceIn(0f, 1f)
|
||||
contentDescription = description
|
||||
invalidate()
|
||||
}
|
||||
|
||||
override fun onDraw(canvas: Canvas) {
|
||||
super.onDraw(canvas)
|
||||
val strokeInset = trackPaint.strokeWidth / 2f
|
||||
val diameter = min(width, height).toFloat()
|
||||
val left = (width - diameter) / 2f + strokeInset
|
||||
val top = (height - diameter) / 2f + strokeInset
|
||||
ringBounds.set(
|
||||
left,
|
||||
top,
|
||||
left + diameter - trackPaint.strokeWidth,
|
||||
top + diameter - trackPaint.strokeWidth,
|
||||
)
|
||||
canvas.drawOval(ringBounds, trackPaint)
|
||||
if (progress > 0f) {
|
||||
canvas.drawArc(ringBounds, -90f, 360f * progress, false, progressPaint)
|
||||
}
|
||||
val baseline = height / 2f - (numberPaint.ascent() + numberPaint.descent()) / 2f
|
||||
canvas.drawText(seconds.toString(), width / 2f, baseline, numberPaint)
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,6 @@ import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableLongStateOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
@@ -79,8 +78,6 @@ import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.core.graphics.get
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
@@ -88,13 +85,13 @@ import androidx.tv.material3.Button
|
||||
import androidx.tv.material3.Icon
|
||||
import androidx.tv.material3.Text
|
||||
import coil.compose.AsyncImage
|
||||
import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import com.ponzischeme89.memby.R
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.useTextTitleForLogo
|
||||
import com.ponzischeme89.memby.ui.visibleWithWatchedPreference
|
||||
import com.ponzischeme89.memby.ui.settings.SettingsSheet
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -157,6 +154,7 @@ fun ScreensaverContent(
|
||||
ringColor = ringColorFromHex(settings?.ringColorHex),
|
||||
embyServerId = settings?.serverId,
|
||||
warmBackdropUrl = settings?.lastBackdropUrl,
|
||||
hideWatchedMovies = settings?.hideWatchedMovies == true,
|
||||
startupMessage = startupMessage,
|
||||
)
|
||||
}
|
||||
@@ -171,6 +169,7 @@ private fun Slideshow(
|
||||
ringColor: Color,
|
||||
embyServerId: String?,
|
||||
warmBackdropUrl: String?,
|
||||
hideWatchedMovies: Boolean,
|
||||
startupMessage: String?,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
@@ -200,12 +199,12 @@ private fun Slideshow(
|
||||
val slideProgress = remember { mutableFloatStateOf(0f) }
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
|
||||
LaunchedEffect(reloadKey) {
|
||||
LaunchedEffect(reloadKey, hideWatchedMovies) {
|
||||
loading = true
|
||||
loadError = null
|
||||
runCatching { repo.getScreensaverItems() }
|
||||
.onSuccess { fetched ->
|
||||
val queue = fetched.shuffled()
|
||||
val queue = visibleWithWatchedPreference(fetched, hideWatchedMovies).shuffled()
|
||||
// The lightweight startup request may already be on-screen. Keep that
|
||||
// exact item at the front rather than swapping through several results
|
||||
// as competing requests finish; the rest of the random queue follows it.
|
||||
@@ -224,10 +223,14 @@ private fun Slideshow(
|
||||
|
||||
// Do not wait for the full 200-item queue before showing a first real backdrop.
|
||||
// This runs alongside it and normally wins on a cold launch.
|
||||
LaunchedEffect(reloadKey) {
|
||||
LaunchedEffect(reloadKey, hideWatchedMovies) {
|
||||
runCatching { repo.getStartupBackdropMovie() }
|
||||
.onSuccess { movie ->
|
||||
if (movie != null && items.isEmpty()) {
|
||||
if (
|
||||
movie != null &&
|
||||
visibleWithWatchedPreference(listOf(movie), hideWatchedMovies).isNotEmpty() &&
|
||||
items.isEmpty()
|
||||
) {
|
||||
items = listOf(movie)
|
||||
loading = false
|
||||
}
|
||||
@@ -244,7 +247,10 @@ private fun Slideshow(
|
||||
loadingMore = true
|
||||
toast = "Finding more from your library…"
|
||||
scope.launch {
|
||||
val more = runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList())
|
||||
val more = visibleWithWatchedPreference(
|
||||
runCatching { repo.getScreensaverItems() }.getOrDefault(emptyList()),
|
||||
hideWatchedMovies,
|
||||
)
|
||||
if (more.isNotEmpty()) {
|
||||
val existing = items.mapTo(HashSet()) { it.id }
|
||||
val fresh = more.filter { it.id !in existing }
|
||||
@@ -616,7 +622,6 @@ private fun Slideshow(
|
||||
|
||||
if (settingsOpen) {
|
||||
SettingsSheet(
|
||||
editableServer = true,
|
||||
onClose = { settingsOpen = false },
|
||||
onInstallerLaunched = onExit,
|
||||
)
|
||||
@@ -863,49 +868,6 @@ private fun InfoAndActions(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transparent Emby logos are commonly black. They disappear over a dark backdrop, so
|
||||
* inspect a small decoded copy and retain the text title when its visible pixels are
|
||||
* overwhelmingly dark. Until the image has been inspected, text is the safe default.
|
||||
*/
|
||||
@Composable
|
||||
private fun useTextTitleForLogo(logoUrl: String?): Boolean {
|
||||
if (logoUrl == null) return true
|
||||
val context = LocalContext.current
|
||||
val isDark by produceState(initialValue = true, logoUrl) {
|
||||
value = runCatching {
|
||||
val result = context.imageLoader.execute(
|
||||
ImageRequest.Builder(context)
|
||||
.data(logoUrl)
|
||||
.allowHardware(false)
|
||||
.size(64, 64)
|
||||
.build(),
|
||||
) as? SuccessResult ?: return@runCatching true
|
||||
isPredominantlyDarkLogo(result.drawable.toBitmap(width = 64, height = 64))
|
||||
}.getOrDefault(true)
|
||||
}
|
||||
return isDark
|
||||
}
|
||||
|
||||
private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean {
|
||||
var opaquePixels = 0
|
||||
var darkPixels = 0
|
||||
for (y in 0 until bitmap.height step 2) {
|
||||
for (x in 0 until bitmap.width step 2) {
|
||||
val pixel = bitmap[x, y]
|
||||
if (android.graphics.Color.alpha(pixel) < 48) continue
|
||||
opaquePixels++
|
||||
val luminance = (
|
||||
android.graphics.Color.red(pixel) * 0.2126f +
|
||||
android.graphics.Color.green(pixel) * 0.7152f +
|
||||
android.graphics.Color.blue(pixel) * 0.0722f
|
||||
)
|
||||
if (luminance < 58f) darkPixels++
|
||||
}
|
||||
}
|
||||
return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusChips(item: BaseItem, isFavorite: Boolean) {
|
||||
val chips = buildList {
|
||||
|
||||
@@ -85,6 +85,7 @@ import coil.imageLoader
|
||||
import coil.request.ImageRequest
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import com.ponzischeme89.memby.ui.FocusScaleContainer
|
||||
import com.ponzischeme89.memby.ui.PosterGridCard
|
||||
|
||||
@@ -166,7 +167,7 @@ fun SearchScreen(
|
||||
state.errorMessage != null && state.results.isEmpty() -> true
|
||||
state.isDiscovery -> discoveryItems.isNotEmpty() ||
|
||||
state.suggestions.any { it.kind == SearchSuggestion.Kind.GENRE }
|
||||
else -> state.results.isNotEmpty()
|
||||
else -> state.results.isNotEmpty() || state.requestCandidates.isNotEmpty()
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) { runCatching { keyboardEntry.requestFocus() } }
|
||||
@@ -237,6 +238,7 @@ fun SearchScreen(
|
||||
},
|
||||
onItemSelected = onItemSelected,
|
||||
onRetry = viewModel::retry,
|
||||
onRequest = viewModel::request,
|
||||
onSuggestionSelected = viewModel::onQueryChanged,
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
)
|
||||
@@ -588,6 +590,7 @@ private fun ResultsPane(
|
||||
onItemFocused: (BaseItem) -> Unit,
|
||||
onItemSelected: (BaseItem) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
onSuggestionSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -644,6 +647,13 @@ private fun ResultsPane(
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
)
|
||||
!showingDiscovery && state.results.isEmpty() && state.requestCandidates.isNotEmpty() ->
|
||||
RequestOptions(
|
||||
state = state,
|
||||
resultsEntry = resultsEntry,
|
||||
keyboardReturn = keyboardReturn,
|
||||
onRequest = onRequest,
|
||||
)
|
||||
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
|
||||
else -> ResultsGrid(
|
||||
items = items,
|
||||
@@ -756,6 +766,54 @@ private fun ResultsGrid(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RequestOptions(
|
||||
state: SearchUiState,
|
||||
resultsEntry: FocusRequester,
|
||||
keyboardReturn: FocusRequester,
|
||||
onRequest: (GatewayRequestCandidate) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
"Not in your library? Request it:",
|
||||
color = Muted,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
state.requestCandidates.take(6).forEachIndexed { index, candidate ->
|
||||
val busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}"
|
||||
val label = when {
|
||||
candidate.alreadyAdded -> "${candidate.title} — already requested"
|
||||
busy -> "Requesting ${candidate.title}…"
|
||||
else -> "Request ${if (candidate.mediaType == "movie") "movie" else "show"}: " +
|
||||
candidate.title + candidate.year.takeIf { it > 0 }?.let { " ($it)" }.orEmpty()
|
||||
}
|
||||
FocusScaleContainer(
|
||||
onFocused = {},
|
||||
onClick = { onRequest(candidate) },
|
||||
contentDescription = label,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
|
||||
.focusProperties { left = keyboardReturn },
|
||||
) { focused ->
|
||||
Text(
|
||||
label,
|
||||
color = if (focused) KeyLabelFocused else KeyLabel,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(if (focused) KeyFocused else KeyIdle)
|
||||
.padding(horizontal = 18.dp, vertical = 12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
state.requestMessage?.let { Text(it, color = Muted, fontSize = 14.sp) }
|
||||
}
|
||||
}
|
||||
|
||||
private val GenreColors = listOf(
|
||||
Color(0xFFB85C38), Color(0xFF5578C8), Color(0xFF7B5AB6),
|
||||
Color(0xFF2F8F83), Color(0xFFD18A32), Color(0xFFB44E76),
|
||||
@@ -861,6 +919,7 @@ private fun SearchEmptyMessage(state: SearchUiState, showingDiscovery: Boolean)
|
||||
val message = when {
|
||||
showingDiscovery -> "Type a couple of letters to search, or pick up where the home screen left off."
|
||||
state.isLoading -> "Searching…"
|
||||
state.requestLookupLoading -> "Nothing in the library. Checking available movies and shows…"
|
||||
else -> "Nothing in this library matches that. Try fewer letters, or a different spelling."
|
||||
}
|
||||
Text(message, color = Muted, fontSize = 17.sp, modifier = Modifier.padding(top = 40.dp))
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.ponzischeme89.memby.data.EmbyRepository
|
||||
import com.ponzischeme89.memby.data.friendlyEmbyError
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.GatewayRequestCandidate
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -35,6 +36,10 @@ data class SearchUiState(
|
||||
/** True once a query has run to completion, so "no matches" is distinguishable from "not yet". */
|
||||
val hasSearched: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
val requestCandidates: List<GatewayRequestCandidate> = emptyList(),
|
||||
val requestLookupLoading: Boolean = false,
|
||||
val requestingCandidateKey: String? = null,
|
||||
val requestMessage: String? = null,
|
||||
) {
|
||||
/** The query is long enough to search but nothing came back. */
|
||||
val isEmptyResult: Boolean
|
||||
@@ -100,7 +105,14 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
/** Every keystroke, from the on-screen keyboard, a USB keyboard or voice. */
|
||||
fun onQueryChanged(query: String) {
|
||||
// The visible field updates immediately; only the *search* is debounced.
|
||||
_state.update { it.copy(query = query) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = query,
|
||||
requestCandidates = emptyList(),
|
||||
requestLookupLoading = false,
|
||||
requestMessage = null,
|
||||
)
|
||||
}
|
||||
queryFlow.value = query
|
||||
}
|
||||
|
||||
@@ -111,7 +123,13 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
fun clearQuery() {
|
||||
// Clear immediately so results from a genre tile cannot remain visible while the
|
||||
// debounced empty-query transition is pending.
|
||||
_state.update { it.copy(query = "", results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null) }
|
||||
_state.update {
|
||||
it.copy(
|
||||
query = "", results = emptyList(), isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
)
|
||||
}
|
||||
queryFlow.value = ""
|
||||
}
|
||||
|
||||
@@ -123,6 +141,38 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
viewModelScope.launch { runSearch(term) }
|
||||
}
|
||||
|
||||
fun request(candidate: GatewayRequestCandidate) {
|
||||
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
|
||||
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
|
||||
_state.update {
|
||||
it.copy(requestingCandidateKey = candidateKey, requestMessage = null)
|
||||
}
|
||||
viewModelScope.launch {
|
||||
runCatching { repository.requestMedia(candidate) }
|
||||
.onSuccess { title ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
requestingCandidateKey = null,
|
||||
requestCandidates = it.requestCandidates.map { option ->
|
||||
if (option.mediaType == candidate.mediaType &&
|
||||
option.foreignId == candidate.foreignId
|
||||
) option.copy(alreadyAdded = true) else option
|
||||
},
|
||||
requestMessage = "${title.ifBlank { candidate.title }} was requested.",
|
||||
)
|
||||
}
|
||||
}
|
||||
.onFailure { error ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
requestingCandidateKey = null,
|
||||
requestMessage = friendlyEmbyError(error),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Genre chips for the empty state, taken from items the home screen already loaded.
|
||||
* Nothing is fetched: if home has no data yet, the chips simply do not appear.
|
||||
@@ -149,7 +199,11 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
// Back to the discovery state, but the previous results are dropped rather
|
||||
// than left behind a shorter query they no longer match.
|
||||
_state.update {
|
||||
it.copy(results = emptyList(), isLoading = false, hasSearched = false, errorMessage = null)
|
||||
it.copy(
|
||||
results = emptyList(), isLoading = false, hasSearched = false,
|
||||
errorMessage = null, requestCandidates = emptyList(),
|
||||
requestLookupLoading = false, requestMessage = null,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -158,6 +212,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update {
|
||||
it.copy(results = cached, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
if (cached.isEmpty()) loadRequestCandidates(term)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -166,13 +221,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
_state.update { it.copy(isLoading = true, errorMessage = null) }
|
||||
runCatching { repository.search(term) }
|
||||
.onSuccess { items ->
|
||||
val ranked = rankSearchResults(term, items)
|
||||
// Gateway payloads carry the backend ranker's score. Preserve that
|
||||
// ordering exactly; direct-to-Emby mode keeps the local textual fallback.
|
||||
val ranked = if (items.any { it.membyRecommendationScore != null }) {
|
||||
items
|
||||
} else {
|
||||
rankSearchResults(term, items)
|
||||
}
|
||||
cache[term] = ranked
|
||||
rememberQuery(term)
|
||||
viewModelScope.launch { repository.recordSearch(term) }
|
||||
_state.update {
|
||||
it.copy(results = ranked, isLoading = false, hasSearched = true, errorMessage = null)
|
||||
}
|
||||
if (ranked.isEmpty()) loadRequestCandidates(term)
|
||||
}
|
||||
.onFailure { error ->
|
||||
// A cancelled search is the normal case while typing, not a failure.
|
||||
@@ -183,6 +245,20 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadRequestCandidates(term: String) {
|
||||
_state.update { it.copy(requestLookupLoading = true) }
|
||||
val candidates = runCatching { repository.lookupMediaRequests(term) }
|
||||
.getOrElse { error ->
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
emptyList()
|
||||
}
|
||||
if (_state.value.query.trim() == term) {
|
||||
_state.update {
|
||||
it.copy(requestCandidates = candidates, requestLookupLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rememberQuery(term: String) {
|
||||
recentQueries.removeAll { it.equals(term, ignoreCase = true) }
|
||||
recentQueries.addFirst(term)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
package com.ponzischeme89.memby.ui.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* The one vocabulary of colour, shape and punctuation shared by the launcher and the detail
|
||||
* pages.
|
||||
*
|
||||
* These two surfaces sit next to each other the moment a detail page opens from a home row,
|
||||
* and they had drifted into four near-blacks, four greens, two secondary-text greys and
|
||||
* eight corner radii. Nothing here is new design — it is the values that were already
|
||||
* winning, named once so a change lands on both screens at the same time.
|
||||
*/
|
||||
|
||||
/** The near-black every full-screen surface is drawn on. */
|
||||
val MembySurface = Color(0xFF090B0D)
|
||||
|
||||
/** One step up, for panels and sheets that need to read as raised off [MembySurface]. */
|
||||
val MembySurfaceRaised = Color(0xFF101418)
|
||||
|
||||
/** Emby's green. The only accent in the app. */
|
||||
val MembyAccent = Color(0xFF52B54B)
|
||||
|
||||
/** Primary body copy. Not pure white — that vibrates on a TV panel at this size. */
|
||||
val MembyOnSurface = Color(0xFFE2E5E8)
|
||||
|
||||
/**
|
||||
* Secondary and tertiary copy, raised for TV viewing distance: quiet still reads as
|
||||
* secondary without falling into low-contrast grey-on-black.
|
||||
*/
|
||||
val MembyMutedText = Color(0xFFD0D6DB)
|
||||
val MembyQuietText = Color(0xFFAEB7BF)
|
||||
|
||||
/** Hairline rules and unfocused borders. */
|
||||
val MembyHairline = Color(0x28FFFFFF)
|
||||
|
||||
/** The community score, wherever it is rendered as its own run of text. */
|
||||
val MembyScore = Color(0xFFF5C518)
|
||||
|
||||
// --- Shape ---------------------------------------------------------------------------
|
||||
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
|
||||
// than inventing a fourth.
|
||||
|
||||
/** Chips, badges and small controls. */
|
||||
val MembyChipCorner = 8.dp
|
||||
|
||||
/** Cards: posters, episode rows, buttons. */
|
||||
val MembyCardCorner = 10.dp
|
||||
|
||||
/** Full panels: heroes, overlays, confirmation toasts. */
|
||||
val MembyPanelCorner = 14.dp
|
||||
|
||||
// --- Punctuation ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Between one fact and the next — year, runtime, certificate. One separator everywhere,
|
||||
* because the same line is drawn by the home hero, the metadata panel and the detail page.
|
||||
*/
|
||||
const val FactSeparator = " • "
|
||||
|
||||
/** Within a single fact that happens to hold a list: genres, codecs, audio attributes. */
|
||||
const val ValueSeparator = " · "
|
||||
@@ -9,11 +9,14 @@ import androidx.tv.material3.LocalTextStyle
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.darkColorScheme
|
||||
|
||||
// The scheme is the same near-blacks the screens actually paint (see DesignTokens.kt), so
|
||||
// a component that falls back to a theme colour lands on the surface it is sitting on
|
||||
// rather than one shade beside it.
|
||||
private val EmbyColors = darkColorScheme(
|
||||
primary = androidx.compose.ui.graphics.Color(0xFF52B54B),
|
||||
primary = MembyAccent,
|
||||
onPrimary = androidx.compose.ui.graphics.Color.White,
|
||||
surface = androidx.compose.ui.graphics.Color(0xFF101418),
|
||||
background = androidx.compose.ui.graphics.Color(0xFF0B0E11),
|
||||
surface = MembySurfaceRaised,
|
||||
background = MembySurface,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.ponzischeme89.memby.update
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayUpdate
|
||||
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* App-level update client, deliberately independent of profiles and saved sessions.
|
||||
*
|
||||
* The gateway endpoint is public and the Retrofit client below never receives a bearer
|
||||
* token. This makes an update check safe before sign-in and prevents update failures from
|
||||
* invalidating, replacing, or otherwise coupling themselves to a viewer's credentials.
|
||||
*/
|
||||
class ServerUpdateService private constructor(
|
||||
private val checkRemote: (suspend () -> GatewayUpdate)?,
|
||||
) {
|
||||
suspend fun check(): Result<GatewayUpdate?> {
|
||||
val remote = checkRemote ?: return Result.success(null)
|
||||
return try {
|
||||
Result.success(remote().takeIf { it.isActionable })
|
||||
} catch (cancelled: CancellationException) {
|
||||
throw cancelled
|
||||
} catch (failure: Throwable) {
|
||||
Result.failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun create(gatewayUrl: String?): ServerUpdateService {
|
||||
val api = gatewayUrl?.let { GatewayServiceFactory.create(it) { null } }
|
||||
return ServerUpdateService(api?.let { { it.updateStatus() } })
|
||||
}
|
||||
|
||||
internal fun forTest(check: suspend () -> GatewayUpdate): ServerUpdateService =
|
||||
ServerUpdateService(check)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package com.ponzischeme89.memby.update
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.pm.PackageInfoCompat
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -12,6 +15,8 @@ import kotlinx.serialization.json.Json
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Result of a "check for updates" against the configured Gitea release. */
|
||||
@@ -156,7 +161,13 @@ class UpdateChecker(private val context: Context) {
|
||||
* needs the "install unknown apps" permission; if it's missing we send the user
|
||||
* to that settings screen and return a message asking them to retry.
|
||||
*/
|
||||
suspend fun downloadAndInstall(apkUrl: String, token: String): Result<Unit> =
|
||||
suspend fun downloadAndInstall(
|
||||
apkUrl: String,
|
||||
token: String,
|
||||
expectedVersion: String = "",
|
||||
expectedSHA256: String = "",
|
||||
expectedSizeBytes: Long = 0,
|
||||
): Result<Unit> =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Gate on the install-unknown-apps permission before spending a download.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
|
||||
@@ -170,21 +181,75 @@ class UpdateChecker(private val context: Context) {
|
||||
context.startActivity(intent)
|
||||
}
|
||||
return@withContext Result.failure(
|
||||
IllegalStateException("Allow Memby to install apps, then check again.")
|
||||
InstallPermissionRequiredException(
|
||||
"Allow Memby to install apps. The update will continue when you return.",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val file = File(context.cacheDir, "memby-update.apk")
|
||||
val updateDir = File(context.cacheDir, "updates").apply { mkdirs() }
|
||||
val partial = File(updateDir, "memby-update.part")
|
||||
val file = File(updateDir, "memby-update.apk")
|
||||
partial.delete()
|
||||
val req = Request.Builder().url(apkUrl).apply {
|
||||
if (token.isNotBlank()) header("Authorization", "token ${token.trim()}")
|
||||
}.build()
|
||||
http.newCall(req).execute().use { resp ->
|
||||
if (!resp.isSuccessful) error("Download failed (${resp.code}).")
|
||||
val body = resp.body ?: error("Empty download.")
|
||||
file.outputStream().use { out -> body.byteStream().copyTo(out) }
|
||||
val declaredSize = body.contentLength()
|
||||
if (declaredSize > MAX_APK_BYTES) error("The update is unexpectedly large.")
|
||||
if (expectedSizeBytes > 0 && declaredSize >= 0 &&
|
||||
declaredSize != expectedSizeBytes
|
||||
) {
|
||||
error("The update size does not match the published release.")
|
||||
}
|
||||
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
var written = 0L
|
||||
try {
|
||||
FileOutputStream(partial).use { out ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
written += read
|
||||
if (written > MAX_APK_BYTES) {
|
||||
error("The update is unexpectedly large.")
|
||||
}
|
||||
digest.update(buffer, 0, read)
|
||||
out.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
out.fd.sync()
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
partial.delete()
|
||||
throw error
|
||||
}
|
||||
if (written == 0L || (expectedSizeBytes > 0 && written != expectedSizeBytes)) {
|
||||
partial.delete()
|
||||
error("The update download was incomplete.")
|
||||
}
|
||||
val actualSHA256 = digest.digest().joinToString("") {
|
||||
"%02x".format(it.toInt() and 0xff)
|
||||
}
|
||||
if (expectedSHA256.isNotBlank() &&
|
||||
!actualSHA256.equals(expectedSHA256.trim(), ignoreCase = true)
|
||||
) {
|
||||
partial.delete()
|
||||
error("The update failed its integrity check.")
|
||||
}
|
||||
}
|
||||
|
||||
verifyApk(partial, expectedVersion)
|
||||
file.delete()
|
||||
if (!partial.renameTo(file)) {
|
||||
partial.delete()
|
||||
error("The verified update could not be prepared.")
|
||||
}
|
||||
val uri = FileProvider.getUriForFile(
|
||||
context, "${context.packageName}.fileprovider", file,
|
||||
)
|
||||
@@ -196,6 +261,51 @@ class UpdateChecker(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun verifyApk(file: File, expectedVersion: String) {
|
||||
val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
PackageManager.GET_SIGNING_CERTIFICATES
|
||||
} else {
|
||||
PackageManager.GET_SIGNATURES
|
||||
}
|
||||
val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, flags)
|
||||
?: error("The downloaded file is not a valid Android app.")
|
||||
if (archive.packageName != context.packageName) {
|
||||
error("The update belongs to a different app.")
|
||||
}
|
||||
if (expectedVersion.isNotBlank() &&
|
||||
normalizeVersion(archive.versionName.orEmpty()) != normalizeVersion(expectedVersion)
|
||||
) {
|
||||
error("The downloaded app version does not match the published release.")
|
||||
}
|
||||
|
||||
val installed = context.packageManager.getPackageInfo(context.packageName, flags)
|
||||
if (PackageInfoCompat.getLongVersionCode(archive) <=
|
||||
PackageInfoCompat.getLongVersionCode(installed)
|
||||
) {
|
||||
error("Android requires an update with a higher version code.")
|
||||
}
|
||||
if (signerDigests(archive) != signerDigests(installed) ||
|
||||
signerDigests(archive).isEmpty()
|
||||
) {
|
||||
error("The update was not signed by Memby’s trusted release key.")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun signerDigests(info: PackageInfo): Set<String> {
|
||||
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
info.signingInfo?.apkContentsSigners.orEmpty()
|
||||
} else {
|
||||
info.signatures.orEmpty()
|
||||
}
|
||||
return signatures.mapTo(linkedSetOf()) { signature ->
|
||||
MessageDigest.getInstance("SHA-256")
|
||||
.digest(signature.toByteArray())
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
}
|
||||
}
|
||||
|
||||
/** True when [remote] parses to a strictly higher version than [installed]. */
|
||||
private fun isNewer(remote: String, installed: String): Boolean {
|
||||
val r = parseVersion(remote)
|
||||
@@ -212,8 +322,14 @@ class UpdateChecker(private val context: Context) {
|
||||
normalizeVersion(v).split('.', '-', ' ', '+').mapNotNull { it.toIntOrNull() }
|
||||
|
||||
private fun normalizeVersion(v: String): String = v.trim().trimStart('v', 'V')
|
||||
|
||||
companion object {
|
||||
private const val MAX_APK_BYTES = 250L * 1024L * 1024L
|
||||
}
|
||||
}
|
||||
|
||||
class InstallPermissionRequiredException(message: String) : IllegalStateException(message)
|
||||
|
||||
/**
|
||||
* A `.json` update URL means "static manifest"; anything else is treated as a Gitea host.
|
||||
* Chosen by URL shape rather than a mode switch: one fewer setting to get wrong on a TV
|
||||
|
||||
@@ -3,20 +3,19 @@ package com.ponzischeme89.memby.update
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.ponzischeme89.memby.ui.screensaver.ScreensaverActivity
|
||||
import com.ponzischeme89.memby.ui.MainActivity
|
||||
|
||||
/**
|
||||
* Reopens the launcher entry point when this package is replaced in place.
|
||||
* Reopens the normal app entry point when this package is replaced in place.
|
||||
*
|
||||
* Replacing an APK kills its process, including an active Dream's render process. On
|
||||
* TV that can leave the old Dream surface black. Android sends this broadcast to the
|
||||
* newly installed package, giving us a chance to present a real UI instead.
|
||||
* The normal entry point runs the public update check and then reuses the existing saved
|
||||
* session. Nothing in this recovery path clears app data, profiles, or credentials.
|
||||
*/
|
||||
class UpdateRecoveryReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != Intent.ACTION_MY_PACKAGE_REPLACED) return
|
||||
|
||||
val launch = ScreensaverActivity.restartAfterUpdateIntent(context).apply {
|
||||
val launch = Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(
|
||||
Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP or
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="90"
|
||||
android:endColor="#F2000000"
|
||||
android:startColor="#00000000"
|
||||
android:centerColor="#28000000"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#B8000000"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#A607090B"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<solid android:color="#FF20252A" />
|
||||
<corners android:radius="10dp" />
|
||||
<stroke android:width="1dp" android:color="#30FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:endColor="#00000000"
|
||||
android:startColor="#EB07090B"
|
||||
android:type="linear" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="11dp" />
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:startColor="#FF223139"
|
||||
android:centerColor="#FF182126"
|
||||
android:endColor="#FF0D1114" />
|
||||
</shape>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="11dp" />
|
||||
<solid android:color="#00000000" />
|
||||
<stroke android:width="1dp" android:color="#38FFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<corners android:radius="11dp" />
|
||||
<gradient
|
||||
android:angle="0"
|
||||
android:startColor="#F2070A0C"
|
||||
android:centerColor="#B8070A0C"
|
||||
android:endColor="#33070A0C" />
|
||||
</shape>
|
||||
@@ -2,6 +2,8 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="12dp" />
|
||||
|
||||
<gradient
|
||||
android:angle="315"
|
||||
android:centerColor="#FF0D1114"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<corners android:radius="12dp" />
|
||||
<solid android:color="#00000000" />
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="#4DFFFFFF" />
|
||||
</shape>
|
||||
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Near-black and barely there: this rolls up over somebody's film, so it reads as a
|
||||
broadcast lower third rather than as a dialog. -->
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#E60A0D10" />
|
||||
<corners android:radius="4dp" />
|
||||
</shape>
|
||||
@@ -18,6 +18,18 @@
|
||||
app:surface_type="surface_view"
|
||||
app:use_controller="true" />
|
||||
|
||||
<!-- Channel + programme identity. It is owned by the activity rather than the
|
||||
controller, so it can appear briefly without opening the transport controls. -->
|
||||
<include layout="@layout/player_playback_identity" />
|
||||
|
||||
<!-- The ten-minute cue. Declared before the next-up banner so that banner covers it
|
||||
if the two ever coincide: what comes next matters more than how long is left. -->
|
||||
<include layout="@layout/player_time_remaining" />
|
||||
|
||||
<!-- Finale context has priority over the ordinary start cue and is physically stacked
|
||||
above it. Both remain non-focusable lower thirds. -->
|
||||
<include layout="@layout/player_season_finale" />
|
||||
|
||||
<!-- Above the video, below the loading overlay: a slide that is still starting has
|
||||
nothing to say about what comes next. -->
|
||||
<include layout="@layout/player_next_up_banner" />
|
||||
@@ -29,6 +41,17 @@
|
||||
<!-- Cast is metadata-only and never pauses or rebuilds the player. -->
|
||||
<include layout="@layout/player_cast_overlay" />
|
||||
|
||||
<!-- Service alerts, the same bar the launcher shows. Declared before the loading and
|
||||
error overlays so those cover it: a notice about the library is not what someone
|
||||
staring at a failed stream needs. Never focusable, so it cannot take the remote
|
||||
away from the transport controls. -->
|
||||
<androidx.compose.ui.platform.ComposeView
|
||||
android:id="@+id/player_service_alerts"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:focusable="false" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/playback_loading"
|
||||
android:layout_width="match_parent"
|
||||
@@ -66,7 +89,8 @@
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- One cheap overlay, no second ExoPlayer. The programme prepares underneath it. -->
|
||||
<!-- The existing PlayerView is temporarily hosted inside this overlay, then returned
|
||||
here at full-screen size after the countdown. No second player or stream is used. -->
|
||||
<include layout="@layout/player_preroll" />
|
||||
|
||||
<!-- Deliberately outside PlayerView's controller hierarchy: a fatal error must remain
|
||||
|
||||
@@ -14,24 +14,31 @@
|
||||
android:background="@android:color/transparent" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="210dp"
|
||||
android:layout_gravity="top"
|
||||
android:background="@drawable/player_osd_top_gradient" />
|
||||
android:layout_width="720dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:background="@drawable/player_osd_left_gradient" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="300dp"
|
||||
android:layout_height="230dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="@drawable/player_osd_bottom_gradient" />
|
||||
|
||||
<!-- Pause is a deliberate browsing moment, rather than just the ordinary OSD frozen
|
||||
over a frame. It carries the movie context while the transport row below remains
|
||||
the single, familiar place to resume or seek. -->
|
||||
<include layout="@layout/player_pause_overlay" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_now_playing_group"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="140dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="30dp"
|
||||
android:gravity="center_vertical"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
@@ -60,6 +67,8 @@
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:maxWidth="600dp"
|
||||
android:includeFontPadding="true"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_pause_overlay"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<View
|
||||
android:layout_width="820dp"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_pause_scrim" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="760dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="start|center_vertical"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginBottom="62dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_pause_poster"
|
||||
android:layout_width="164dp"
|
||||
android:layout_height="246dp"
|
||||
android:background="@drawable/player_pause_poster_background"
|
||||
android:clipToOutline="true"
|
||||
android:contentDescription="@string/player_pause_poster"
|
||||
android:outlineProvider="background"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="30dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:letterSpacing="0.18"
|
||||
android:text="@string/player_paused"
|
||||
android:textColor="#FF6BCB63"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_pause_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="34sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_pause_overview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:ellipsize="end"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:maxLines="5"
|
||||
android:textColor="#DDE7EBEE"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/player_pause_resume_hint"
|
||||
android:textColor="#B8FFFFFF"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- A short, non-focusable station ident shown over the first five seconds of content. -->
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:id="@+id/player_playback_identity"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="54dp"
|
||||
android:layout_gravity="start|top"
|
||||
android:layout_marginStart="48dp"
|
||||
android:layout_marginTop="34dp"
|
||||
android:alpha="0"
|
||||
android:focusable="false"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:visibility="gone">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="42dp"
|
||||
android:layout_height="38dp"
|
||||
android:contentDescription="@string/player_preroll_brand_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_playback_identity_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="14dp"
|
||||
android:ellipsize="end"
|
||||
android:gravity="start|center_vertical"
|
||||
android:maxLines="1"
|
||||
android:maxWidth="520dp"
|
||||
android:shadowColor="#E0000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="2"
|
||||
android:shadowRadius="5"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="25sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
@@ -5,114 +5,152 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="#FF050708"
|
||||
android:clickable="true"
|
||||
android:clipChildren="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="64dp"
|
||||
android:paddingTop="54dp"
|
||||
android:paddingEnd="64dp"
|
||||
android:paddingBottom="54dp">
|
||||
|
||||
<!-- Reserved for the eventual pre-roll media asset. Keeping this a plain View is
|
||||
virtually free and lets the real programme buffer beneath the overlay. -->
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="52dp"
|
||||
android:layout_weight="1.62"
|
||||
android:background="@drawable/player_preroll_video_background">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="74dp"
|
||||
android:layout_height="64dp"
|
||||
android:layout_gravity="start|bottom"
|
||||
android:layout_marginStart="28dp"
|
||||
android:layout_marginBottom="25dp"
|
||||
android:alpha="0.24"
|
||||
android:contentDescription="@null"
|
||||
android:id="@+id/player_preroll_brand"
|
||||
android:layout_width="58dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_gravity="start|top"
|
||||
android:layout_marginStart="44dp"
|
||||
android:layout_marginTop="28dp"
|
||||
android:contentDescription="@string/player_preroll_brand_logo"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_countdown"
|
||||
android:layout_width="wrap_content"
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:clipChildren="false"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="44dp"
|
||||
android:paddingTop="84dp"
|
||||
android:paddingEnd="44dp"
|
||||
android:paddingBottom="22dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="224dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/player_preroll_video_host"
|
||||
android:layout_width="398dp"
|
||||
android:layout_height="224dp"
|
||||
android:layout_marginEnd="38dp"
|
||||
android:background="@drawable/player_preroll_video_background"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="@drawable/player_preroll_video_frame"
|
||||
android:outlineProvider="background">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_countdown_container"
|
||||
android:layout_width="80dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end|bottom"
|
||||
android:layout_marginEnd="28dp"
|
||||
android:layout_marginBottom="28dp"
|
||||
android:background="@drawable/player_status_background"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:text="@string/player_preroll_countdown_initial"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="15sp"
|
||||
android:layout_marginEnd="18dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:gravity="center_horizontal"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="5dp"
|
||||
android:text="@string/player_preroll_starting_in"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#CCFFFFFF"
|
||||
android:textSize="9sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<com.ponzischeme89.memby.ui.player.PrerollCountdownView
|
||||
android:id="@+id/player_preroll_countdown"
|
||||
android:layout_width="66dp"
|
||||
android:layout_height="66dp"
|
||||
android:contentDescription="@string/player_preroll_countdown_initial" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_schedule_panel"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="NOW"
|
||||
android:textColor="#FF64C85A"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_now_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Coming up"
|
||||
android:layout_marginTop="7dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="2"
|
||||
android:text="Northbound · The Crossing"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="30sp"
|
||||
android:textSize="26sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_now_metadata"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:text="From your Sonarr calendar"
|
||||
android:textColor="#FF8E979D"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="S02E04 · 48 mins"
|
||||
android:textColor="#FFD0D6DB"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_now_overview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="3"
|
||||
android:text="The crew follows a signal beyond the last charted crossing and finds the shore waiting for them."
|
||||
android:textColor="#FFB1BAC1"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_today_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="25dp"
|
||||
android:text="AIRING TODAY"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF55B94D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_today"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_week_label"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="22dp"
|
||||
android:text="THIS WEEK"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF8E979D"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/player_preroll_week"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="4dp"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="UPCOMING"
|
||||
android:textColor="#FF64C85A"
|
||||
android:textSize="11sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<GridLayout
|
||||
android:id="@+id/player_preroll_calendar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="170dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:columnCount="4"
|
||||
android:rowCount="2"
|
||||
android:useDefaultMargins="false" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="80dp"
|
||||
android:background="@drawable/player_preroll_card_background"
|
||||
android:clipToOutline="true"
|
||||
android:outlineProvider="background">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_preroll_card_artwork"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_preroll_card_scrim" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="10dp"
|
||||
android:paddingTop="8dp"
|
||||
android:paddingEnd="9dp"
|
||||
android:paddingBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_card_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAllCaps="true"
|
||||
android:textColor="#FF64C85A"
|
||||
android:textSize="8sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_card_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="3dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_preroll_card_detail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="#FFD0D6DB"
|
||||
android:textSize="9sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/player_preroll_card_frame" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Finale context is a separate, higher-priority lower third. It sits above the normal
|
||||
start/time cue so both can be read together and never takes focus from playback. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/player_season_finale"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="52dp"
|
||||
android:layout_marginBottom="120dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/time_remaining_cue_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingEnd="18dp">
|
||||
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="32dp"
|
||||
android:background="#FFFFB454" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="27dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_season_finale_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:letterSpacing="0.16"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:text="@string/player_season_finale_label"
|
||||
android:textColor="#FFFFC778"
|
||||
android:textSize="10sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_season_finale_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingTop="9dp"
|
||||
android:paddingBottom="9dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="Signal Hill · Season 2 finale" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The ten-minute lower third. A single non-focusable strip pinned to the lower left,
|
||||
inside the TV overscan margin, that rolls up from below its own baseline and rolls
|
||||
back down again. It never takes the remote and never covers the transport controls. -->
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/player_time_remaining"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|start"
|
||||
android:layout_marginStart="52dp"
|
||||
android:layout_marginBottom="72dp"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:focusable="false"
|
||||
android:visibility="gone">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/time_remaining_cue_background"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="18dp">
|
||||
|
||||
<!-- Keep the cue recognisably Memby even when it appears independently of the
|
||||
transport OSD. The accent rule still distinguishes ordinary timing cues. -->
|
||||
<View
|
||||
android:layout_width="3dp"
|
||||
android:layout_height="26dp"
|
||||
android:background="#FF69CD61" />
|
||||
|
||||
<ImageView
|
||||
android:layout_width="27dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="10dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:src="@drawable/emby_logo" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_time_remaining_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:letterSpacing="0.16"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:text="@string/player_time_remaining_label"
|
||||
android:textColor="#99FFFFFF"
|
||||
android:textSize="10sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/player_time_remaining_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:paddingTop="7dp"
|
||||
android:paddingBottom="7dp"
|
||||
android:textColor="#FFFFFFFF"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
tools:text="10 mins" />
|
||||
</LinearLayout>
|
||||
</FrameLayout>
|
||||
@@ -14,6 +14,10 @@
|
||||
<item quantity="other">Reconnecting in %1$d seconds…</item>
|
||||
</plurals>
|
||||
<string name="player_now_playing">NOW PLAYING</string>
|
||||
<string name="player_paused">PAUSED</string>
|
||||
<string name="player_pause_poster">Movie poster</string>
|
||||
<string name="player_pause_resume_hint">Press OK to resume</string>
|
||||
<string name="player_pause_overview_fallback">No description is available for this title.</string>
|
||||
<string name="player_position_separator">/</string>
|
||||
<string name="player_loading_duration">Loading duration…</string>
|
||||
<string name="player_live">Live</string>
|
||||
@@ -29,12 +33,24 @@
|
||||
<string name="player_text_size">TEXT SIZE</string>
|
||||
<string name="player_back_to_close">BACK · CLOSE</string>
|
||||
<string name="player_ends_at">Ends at %1$s</string>
|
||||
<string name="player_preroll_countdown_initial">Starting in 5 seconds…</string>
|
||||
<string name="player_preroll_countdown_initial">Starting in 7 seconds…</string>
|
||||
<string name="player_preroll_starting_in">Starting in</string>
|
||||
<string name="player_preroll_brand_logo">Memby logo</string>
|
||||
<plurals name="player_preroll_countdown">
|
||||
<item quantity="one">Starting in %1$d second…</item>
|
||||
<item quantity="other">Starting in %1$d seconds…</item>
|
||||
</plurals>
|
||||
<string name="player_preroll_starting">Starting now…</string>
|
||||
<string name="player_time_remaining_label">TIME REMAINING</string>
|
||||
<string name="player_season_finale_label">SEASON FINALE</string>
|
||||
<string name="player_season_finale_value">%1$s · Season %2$d finale</string>
|
||||
<string name="player_finishes_in_label">FINISHES IN</string>
|
||||
<string name="player_resume_time_left_label">TIME LEFT</string>
|
||||
<string name="player_finishes_in_value">%1$s (%2$s)</string>
|
||||
<plurals name="player_time_remaining_cue">
|
||||
<item quantity="one">%1$d min</item>
|
||||
<item quantity="other">%1$d mins</item>
|
||||
</plurals>
|
||||
<string name="next_up_label">NEXT UP</string>
|
||||
<string name="next_up_play_now">Play now</string>
|
||||
<string name="next_up_dismiss">Dismiss</string>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.gatewayCapabilityTokens
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DevicePlaybackCapabilitiesTest {
|
||||
@Test
|
||||
fun detailedH264AndHevcDecoderSupportIsReportedToTheGateway() {
|
||||
val capabilities = DevicePlaybackCapabilities(
|
||||
h264 = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("baseline", "main", "high"),
|
||||
mainLevel = 52,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
),
|
||||
hevc = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("main", "main10"),
|
||||
mainLevel = 153,
|
||||
tenBitLevel = 153,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
hdr10 = true,
|
||||
),
|
||||
)
|
||||
val tokens = capabilities.gatewayCapabilityTokens()
|
||||
|
||||
assertTrue("video_h264_profile_high" in tokens)
|
||||
assertTrue("video_h264_level_52" in tokens)
|
||||
assertTrue("video_h264_max_3840x2160" in tokens)
|
||||
assertTrue("video_hevc_decode" in tokens)
|
||||
assertTrue("video_hevc_profile_main10" in tokens)
|
||||
assertTrue("video_hevc_main10_level_153" in tokens)
|
||||
assertTrue("video_hevc_hdr10" in tokens)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun h264OnlyDeviceDoesNotClaimHevc() {
|
||||
val tokens = DevicePlaybackCapabilities().gatewayCapabilityTokens()
|
||||
|
||||
assertTrue("video_h264_decode" in tokens)
|
||||
assertTrue("video_hevc_decode" !in tokens)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.GatewayHome
|
||||
import com.ponzischeme89.memby.data.model.GatewayDevices
|
||||
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlayback
|
||||
import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
|
||||
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
|
||||
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
|
||||
import com.ponzischeme89.memby.data.model.GatewayFeatures
|
||||
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -19,6 +23,17 @@ import org.junit.Test
|
||||
* side, this fails before a TV ever sees it.
|
||||
*/
|
||||
class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes signed in devices without an allowance`() {
|
||||
val response = json.decodeFromString<GatewayDevices>(
|
||||
"""{"devices":[{"deviceId":"tv-1","deviceName":"Living room","clientVersion":"0.2.4","lastSeenAt":"2026-08-02T10:00:00Z","current":true}]}""",
|
||||
)
|
||||
|
||||
assertEquals(1, response.devices.size)
|
||||
assertEquals("Living room", response.devices.single().deviceName)
|
||||
assertTrue(response.devices.single().current)
|
||||
}
|
||||
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@@ -38,6 +53,40 @@ class GatewayPayloadTest {
|
||||
assertEquals(1, status.serverProtocol)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes live feature policy and recovery state`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""{"featureSchemaVersion":1,"featureRevision":7,"safeMode":true,"features":{"sonarr_preroll":false}}""",
|
||||
)
|
||||
val policy = json.decodeFromString<GatewayFeatures>(
|
||||
"""{"schemaVersion":1,"revision":7,"safeMode":true,"canRollback":true,"features":[{"key":"sonarr_preroll","name":"Sonarr upcoming preroll","enabled":false,"source":"safe_mode","compatible":true}]}""",
|
||||
)
|
||||
|
||||
assertEquals(7L, status.featureRevision)
|
||||
assertEquals(false, status.features["sonarr_preroll"])
|
||||
assertTrue(policy.safeMode)
|
||||
assertTrue(policy.canRollback)
|
||||
assertEquals("safe_mode", policy.features.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes recommendation onboarding ratings and emby items`() {
|
||||
val onboarding = json.decodeFromString<RecommendationOnboarding>(
|
||||
"""{
|
||||
"completed":false,
|
||||
"ratings":{"movie-1":5},
|
||||
"items":[
|
||||
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
|
||||
{"Id":"series-1","Name":"Severance","Type":"Series"}
|
||||
]
|
||||
}""",
|
||||
)
|
||||
|
||||
assertEquals(false, onboarding.completed)
|
||||
assertEquals(5, onboarding.ratings["movie-1"])
|
||||
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a home payload with emby-shaped items`() {
|
||||
val payload = """
|
||||
@@ -119,12 +168,12 @@ class GatewayPayloadTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the informational Sonarr schedule row`() {
|
||||
fun `decodes the informational TV schedule row`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows": [{
|
||||
"id":"sonarr-airing-today",
|
||||
"title":"Shows airing today",
|
||||
"title":"Shows airing in the next 5 days",
|
||||
"kind":"schedule",
|
||||
"items":[{
|
||||
"Id":"sonarr:7:42",
|
||||
@@ -134,7 +183,8 @@ class GatewayPayloadTest {
|
||||
"MembySource":"sonarr",
|
||||
"MembyEpisodeTitle":"The Crossing",
|
||||
"MembyEpisodeCode":"S02E04",
|
||||
"MembyAirLabel":"Airs today at 8:00 PM",
|
||||
"MembyAirDayLabel":"Tomorrow",
|
||||
"MembyAirLabel":"Tomorrow: 8:00 PM",
|
||||
"MembyAvailability":"downloading",
|
||||
"MembyAvailabilityText":"Downloading",
|
||||
"MembyPlayable":false
|
||||
@@ -150,12 +200,48 @@ class GatewayPayloadTest {
|
||||
|
||||
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
|
||||
|
||||
assertTrue(item.isSonarrSchedule)
|
||||
assertTrue(item.isTvSchedule)
|
||||
assertEquals("S02E04", item.membyEpisodeCode)
|
||||
assertEquals("Tomorrow", item.membyAirDayLabel)
|
||||
assertEquals("Downloading", item.membyAvailabilityText)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the informational Radarr digital release row`() {
|
||||
val payload = """
|
||||
{
|
||||
"rows":[{
|
||||
"id":"radarr-upcoming-movies",
|
||||
"title":"Upcoming Movie releases",
|
||||
"kind":"movie-schedule",
|
||||
"items":[{
|
||||
"Id":"radarr:7",
|
||||
"Name":"Arrival",
|
||||
"Type":"MembyRadarrMovie",
|
||||
"ImageTags":{"Primary":"radarr"},
|
||||
"MembySource":"radarr",
|
||||
"MembyAirsAt":"2026-08-01T00:00:00+12:00",
|
||||
"MembyAirDayLabel":"Saturday",
|
||||
"MembyAirLabel":"Digital release Saturday",
|
||||
"MembyAvailability":"upcoming",
|
||||
"MembyAvailabilityText":"Upcoming digital release",
|
||||
"MembyPlayable":false
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
|
||||
val item = row.items.single()
|
||||
|
||||
assertEquals("movie-schedule", row.kind)
|
||||
assertTrue(item.isMovieSchedule)
|
||||
assertTrue(item.isSchedule)
|
||||
assertEquals("Digital release Saturday", item.membyAirLabel)
|
||||
assertEquals(false, item.membyPlayable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a home payload without rows still decodes`() {
|
||||
// The gateway omits recommendation rows while they are still building, and an
|
||||
@@ -186,13 +272,26 @@ class GatewayPayloadTest {
|
||||
@Test
|
||||
fun `decodes a playback response`() {
|
||||
val playback = json.decodeFromString<GatewayPlayback>(
|
||||
"""{"itemId":"9","title":"Severance – Pilot","url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
|
||||
"""{"itemId":"9","title":"Severance – Pilot","overview":"Mark returns to the severed floor.","seriesName":"Severance","episodeCode":"S01E01","runtimeMs":3420000,"prerollEnabled":false,"prerollDurationMs":4000,"url":"https://emby.example/Videos/9/stream?static=true","resumePositionMs":42000}""",
|
||||
)
|
||||
assertEquals("9", playback.itemId)
|
||||
assertEquals(42_000L, playback.resumePositionMs)
|
||||
assertEquals("S01E01", playback.episodeCode)
|
||||
assertEquals(3_420_000L, playback.runtimeMs)
|
||||
assertEquals(false, playback.prerollEnabled)
|
||||
assertEquals(4_000L, playback.prerollDurationMs)
|
||||
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes a one-time auto-follow acknowledgement`() {
|
||||
val response = json.decodeFromString<GatewayPlaybackReportResponse>(
|
||||
"""{"autoFollowedShowTitle":"Severance"}""",
|
||||
)
|
||||
|
||||
assertEquals("Severance", response.autoFollowedShowTitle)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes the next-episode response the player counts down to`() {
|
||||
val next = json.decodeFromString<GatewayNextEpisode>(
|
||||
@@ -235,13 +334,4 @@ class GatewayPayloadTest {
|
||||
assertNull(next.item.episodeCode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `device allowance response becomes a specific sign-in error`() {
|
||||
val error = parseDeviceLimit(
|
||||
"""{"error":"device_limit_reached","activeClients":3,"maxClientsPerUser":3}""",
|
||||
)
|
||||
|
||||
assertEquals(3, error?.activeClients)
|
||||
assertEquals(3, error?.maxClients)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,12 +30,14 @@ class GatewayUpdateTest {
|
||||
@Test
|
||||
fun `an optional verdict is dismissable`() {
|
||||
val update = json.decodeFromString<GatewayUpdate>(
|
||||
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk"}""",
|
||||
"""{"status":"optional","version":"0.1.54","downloadUrl":"https://nas/memby.apk","sha256":"abc","sizeBytes":123}""",
|
||||
)
|
||||
|
||||
assertTrue(update.isOptional)
|
||||
assertFalse(update.isMandatory)
|
||||
assertTrue(update.isActionable)
|
||||
assertEquals("abc", update.sha256)
|
||||
assertEquals(123L, update.sizeBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MediaSourceInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackDeliveryTest {
|
||||
@Test
|
||||
fun directPlayUsesOriginalOnlyWhenServerMarksItSupported() {
|
||||
val delivery = selectPlaybackDelivery(
|
||||
MediaSourceInfo(
|
||||
supportsDirectPlay = true,
|
||||
directStreamUrl = "/Videos/item/stream.mkv",
|
||||
transcodingUrl = "/Videos/item/master.m3u8",
|
||||
),
|
||||
)
|
||||
|
||||
assertNull(delivery.url)
|
||||
assertEquals("DirectPlay", delivery.playMethod)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun negotiatedDirectStreamIsUsedForContainerCompatibility() {
|
||||
val delivery = selectPlaybackDelivery(
|
||||
MediaSourceInfo(
|
||||
supportsDirectPlay = false,
|
||||
supportsDirectStream = true,
|
||||
directStreamUrl = "/Videos/item/stream.mp4",
|
||||
transcodingUrl = "/Videos/item/master.m3u8",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("/Videos/item/stream.mp4", delivery.url)
|
||||
assertEquals("DirectStream", delivery.playMethod)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forcedFallbackUsesTranscodeEvenWhenDirectPlayWasAdvertised() {
|
||||
val delivery = selectPlaybackDelivery(
|
||||
MediaSourceInfo(
|
||||
supportsDirectPlay = true,
|
||||
transcodingUrl = "/Videos/item/master.m3u8",
|
||||
),
|
||||
forceTranscode = true,
|
||||
)
|
||||
|
||||
assertEquals("/Videos/item/master.m3u8", delivery.url)
|
||||
assertEquals("Transcode", delivery.playMethod)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import com.ponzischeme89.memby.data.model.DeviceProfile
|
||||
import com.ponzischeme89.memby.data.playback.DevicePlaybackCapabilities
|
||||
import com.ponzischeme89.memby.data.playback.VideoDecoderCapabilities
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PlaybackReportMathTest {
|
||||
@@ -26,4 +30,45 @@ class PlaybackReportMathTest {
|
||||
assertEquals("Encode", profiles["pgssub"])
|
||||
assertEquals("Encode", profiles["dvdsub"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun directPlayProfileAdvertisesHevcOnlyForCapableTvs() {
|
||||
val baseline = DeviceProfile.embyAndroidTv().directPlayProfiles.single()
|
||||
val capable = DeviceProfile.embyAndroidTv(supportsHevc = true).directPlayProfiles.single()
|
||||
|
||||
assertEquals("h264", baseline.videoCodec)
|
||||
assertEquals("h264,hevc", capable.videoCodec)
|
||||
assertEquals("aac,mp3", capable.audioCodec)
|
||||
assertFalse(capable.videoCodec.contains("av1"))
|
||||
assertFalse(capable.audioCodec.contains("eac3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detailedProfileAllowsVideoStreamCopyWithinDecoderLimits() {
|
||||
val profile = DeviceProfile.embyAndroidTv(
|
||||
DevicePlaybackCapabilities(
|
||||
h264 = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("baseline", "main", "high"),
|
||||
mainLevel = 52,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
),
|
||||
hevc = VideoDecoderCapabilities(
|
||||
supported = true,
|
||||
profiles = setOf("main", "main10"),
|
||||
mainLevel = 153,
|
||||
tenBitLevel = 153,
|
||||
maxWidth = 3840,
|
||||
maxHeight = 2160,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("h264,hevc", profile.directPlayProfiles.single().videoCodec)
|
||||
assertEquals("h264,hevc", profile.transcodingProfiles.single().videoCodec)
|
||||
assertTrue(profile.codecProfiles.any { it.codec == "h264" && it.conditions.any { c -> c.value == "52" } })
|
||||
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.value == "153" } })
|
||||
assertTrue(profile.codecProfiles.any { it.codec == "hevc" && it.conditions.any { c -> c.property == "Width" } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,17 @@ class ProfileSettingsTest {
|
||||
assertEquals(30, family.forYouMinutes)
|
||||
assertEquals(false, family.hasOpenedForYou)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `welcome quote style belongs to each profile`() {
|
||||
val matt = profile.copy(welcomeQuoteStyle = "homicidal")
|
||||
val family = profile.copy(
|
||||
id = "server::family",
|
||||
userId = "family",
|
||||
welcomeQuoteStyle = "positive",
|
||||
)
|
||||
|
||||
assertEquals("homicidal", matt.welcomeQuoteStyle)
|
||||
assertEquals("positive", family.welcomeQuoteStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,6 +73,19 @@ class RowAnalyticsTest {
|
||||
assertEquals(listOf("favorites", "recommended"), impressions.map { it.rowId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `visible posters receive deduplicated item impressions`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
|
||||
collector.rowImpression("recommended", "MOVIES", listOf("one", "two"))
|
||||
|
||||
val itemImpressions = collector.drain().filter {
|
||||
it.event == RowAnalytics.EVENT_IMPRESSION && it.itemId.isNotEmpty()
|
||||
}
|
||||
assertEquals(listOf("one", "two"), itemImpressions.map { it.itemId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `focusing a row that was never reported still records the impression`() {
|
||||
val collector = analytics(FakeClock())
|
||||
|
||||
@@ -85,5 +85,28 @@ class ServiceAlertTest {
|
||||
assertEquals("sonarr:7:42:aired", alert.id)
|
||||
assertEquals("sonarr:7:42", alert.itemId)
|
||||
assertEquals("sonarr", alert.imageTag)
|
||||
// A gateway that predates server-worded banners sends no label at all.
|
||||
assertEquals("", alert.label)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `status decodes a radarr import alert with its own wording`() {
|
||||
val status = json.decodeFromString<GatewayServiceStatus>(
|
||||
"""
|
||||
{"maintenance":false,"message":"","alerts":[{
|
||||
"id":"radarr:412:file:9001","kind":"radarr-import","label":"NEW MOVIE ADDED",
|
||||
"title":"Mr. Smith Goes to Washington (1939)",
|
||||
"message":"Mr. Smith Goes to Washington will be available in Emby shortly.",
|
||||
"itemId":"radarr:412","imageTag":"radarr","airedAt":"2026-07-31T19:04:00Z"
|
||||
}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
val alert = status.alerts.single()
|
||||
assertEquals("radarr:412:file:9001", alert.id)
|
||||
assertEquals("NEW MOVIE ADDED", alert.label)
|
||||
// The image proxy serves Radarr covers under this pair, so the banner has a
|
||||
// poster before Emby has scanned the film in.
|
||||
assertEquals("radarr:412", alert.itemId)
|
||||
assertEquals("radarr", alert.imageTag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
class SessionValidationTest {
|
||||
@Test
|
||||
fun `server restart preserves saved session`() {
|
||||
assertTrue(shouldPreserveSessionAfterValidationFailure(IOException("offline")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `maintenance response preserves saved session`() {
|
||||
assertTrue(shouldPreserveSessionAfterValidationFailure(httpFailure(503)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `explicit unauthorized response may invalidate saved session`() {
|
||||
assertFalse(shouldPreserveSessionAfterValidationFailure(httpFailure(401)))
|
||||
}
|
||||
|
||||
private fun httpFailure(code: Int): HttpException =
|
||||
HttpException(Response.error<Any>(code, "error".toResponseBody()))
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.ponzischeme89.memby.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class SettingsStoreProfileRemovalTest {
|
||||
@Test
|
||||
fun `ten minute reminder preference is persisted`() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = SettingsStore(context)
|
||||
|
||||
store.setShowTenMinuteReminder(false)
|
||||
|
||||
assertFalse(store.snapshot().showTenMinuteReminder)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removing profiles preserves other users and clears only an active session`() = runBlocking {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
val store = SettingsStore(context)
|
||||
|
||||
store.saveSession("https://example.test", "token-a", "user-a", "Alex", "server")
|
||||
val alex = store.snapshot().profiles.single()
|
||||
store.saveSession("https://example.test", "token-b", "user-b", "Bailey", "server")
|
||||
val bailey = store.snapshot().profiles.single { it.userId == "user-b" }
|
||||
|
||||
store.removeProfile(alex.id)
|
||||
val afterInactiveRemoval = store.snapshot()
|
||||
assertEquals(listOf(bailey.id), afterInactiveRemoval.profiles.map { it.id })
|
||||
assertTrue(afterInactiveRemoval.isSignedIn)
|
||||
assertEquals("user-b", afterInactiveRemoval.userId)
|
||||
|
||||
store.removeProfile(bailey.id)
|
||||
val afterActiveRemoval = store.snapshot()
|
||||
assertTrue(afterActiveRemoval.profiles.isEmpty())
|
||||
assertFalse(afterActiveRemoval.isSignedIn)
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import com.ponzischeme89.memby.data.HomeSnapshot
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AiringTodayTagsTest {
|
||||
@Test
|
||||
fun `tags matching recommended series from today's Sonarr schedule`() {
|
||||
fun `tags matching recommended series from today's TV schedule`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
@@ -22,6 +23,7 @@ class AiringTodayTagsTest {
|
||||
name = "The Bear",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -56,6 +58,7 @@ class AiringTodayTagsTest {
|
||||
name = "Marvel's DAREDEVIL",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -85,6 +88,7 @@ class AiringTodayTagsTest {
|
||||
name = "Fargo",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -98,4 +102,80 @@ class AiringTodayTagsTest {
|
||||
|
||||
assertFalse(home.withAiringTodayTags().rows.last().items.single().membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `only today's scheduled show is propagated to other rows`() {
|
||||
val home = HomeSnapshot(
|
||||
rows = listOf(
|
||||
HomeRow(
|
||||
id = "sonarr-airing-today",
|
||||
title = "Shows airing in the next 5 days",
|
||||
kind = "schedule",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "sonarr:1:1",
|
||||
name = "Today Show",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Today",
|
||||
),
|
||||
BaseItem(
|
||||
id = "sonarr:2:1",
|
||||
name = "Tomorrow Show",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = "Tomorrow",
|
||||
),
|
||||
),
|
||||
),
|
||||
HomeRow(
|
||||
id = "recommended",
|
||||
title = "Recommended",
|
||||
items = listOf(
|
||||
BaseItem(id = "series-1", name = "Today Show", type = "Series"),
|
||||
BaseItem(id = "series-2", name = "Tomorrow Show", type = "Series"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val recommendations = home.withAiringTodayTags().rows.last().items
|
||||
assertTrue(recommendations[0].membyAiringToday)
|
||||
assertFalse(recommendations[1].membyAiringToday)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `schedule poster badges use each server-authored day`() {
|
||||
fun scheduled(day: String) = BaseItem(
|
||||
id = day,
|
||||
name = "Show",
|
||||
type = "MembySonarrEpisode",
|
||||
membySource = "sonarr",
|
||||
membyAirDayLabel = day,
|
||||
membyAiringToday = true,
|
||||
)
|
||||
|
||||
assertEquals("TODAY", airingBadgeLabel(scheduled("Today")))
|
||||
assertEquals("TOMORROW", airingBadgeLabel(scheduled("Tomorrow")))
|
||||
assertEquals("FRIDAY", airingBadgeLabel(scheduled("Friday")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Radarr movie schedule uses its server-authored release day badge`() {
|
||||
val movie = BaseItem(
|
||||
id = "radarr:7",
|
||||
name = "Arrival",
|
||||
type = "MembyRadarrMovie",
|
||||
membySource = "radarr",
|
||||
membyAirDayLabel = "Saturday",
|
||||
)
|
||||
|
||||
assertEquals("SATURDAY", airingBadgeLabel(movie))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `upcoming schedule status does not claim the show airs today`() {
|
||||
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
|
||||
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.ui.detail.franchiseStart
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class DetailFranchiseTest {
|
||||
private fun movie(id: String, year: Int, collection: String? = "The Saga") = BaseItem(
|
||||
id = id,
|
||||
name = "Movie $id",
|
||||
type = "Movie",
|
||||
productionYear = year,
|
||||
collectionName = collection,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `earliest movie in the same collection starts the franchise`() {
|
||||
val current = movie("three", 2022)
|
||||
val start = franchiseStart(
|
||||
current,
|
||||
listOf(movie("two", 2018), movie("one", 2014), movie("other", 2001, "Other")),
|
||||
)
|
||||
|
||||
assertEquals("The Saga", start?.name)
|
||||
assertEquals("one", start?.firstMovie?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a collection name without a sibling is not presented as a franchise`() {
|
||||
assertNull(franchiseStart(movie("only", 2020), emptyList()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.ui.detail.DetailPosition
|
||||
import com.ponzischeme89.memby.ui.detail.DetailPositionStore
|
||||
import com.ponzischeme89.memby.ui.detail.DetailTab
|
||||
import com.ponzischeme89.memby.ui.detail.DetailZone
|
||||
import com.ponzischeme89.memby.ui.detail.detailTab
|
||||
import com.ponzischeme89.memby.ui.detail.detailTabs
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The two rules the detail pages depend on and cannot check on a device: the tab strip is
|
||||
* decided by what the item *is*, and a page's position survives being closed.
|
||||
*/
|
||||
class DetailNavigationTest {
|
||||
|
||||
@Test
|
||||
fun `a movie always offers the same three tabs`() {
|
||||
assertEquals(
|
||||
listOf(DetailTab.OVERVIEW, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
|
||||
detailTabs(isSeries = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a series always offers episodes`() {
|
||||
assertEquals(
|
||||
listOf(DetailTab.OVERVIEW, DetailTab.EPISODES, DetailTab.MORE_LIKE_THIS, DetailTab.CAST_DETAILS),
|
||||
detailTabs(isSeries = true),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The regression this replaced: the strip used to be built from what had loaded, so a
|
||||
* movie opened with one tab and grew two more when its metadata arrived — moving the
|
||||
* strip under whatever the viewer was already pressing.
|
||||
*/
|
||||
@Test
|
||||
fun `the strip does not depend on loaded metadata`() {
|
||||
assertEquals(detailTabs(isSeries = false), detailTabs(isSeries = false))
|
||||
assertEquals(detailTabs(isSeries = true), detailTabs(isSeries = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an episodes key remembered from a series falls back on a movie`() {
|
||||
assertEquals(
|
||||
DetailTab.OVERVIEW,
|
||||
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = false)),
|
||||
)
|
||||
assertEquals(
|
||||
DetailTab.EPISODES,
|
||||
detailTab(DetailTab.EPISODES.key, detailTabs(isSeries = true)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown key falls back to overview`() {
|
||||
assertEquals(DetailTab.OVERVIEW, detailTab("nonsense", detailTabs(isSeries = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a page reopens where it was left`() {
|
||||
val store = DetailPositionStore()
|
||||
store.update("show-1") {
|
||||
it.copy(tabKey = DetailTab.EPISODES.key, season = 3, zone = DetailZone.CONTENT)
|
||||
}
|
||||
store.update("show-1") { it.copy(episodeIndex = 6) }
|
||||
|
||||
assertEquals(
|
||||
DetailPosition(
|
||||
tabKey = DetailTab.EPISODES.key,
|
||||
season = 3,
|
||||
zone = DetailZone.CONTENT,
|
||||
episodeIndex = 6,
|
||||
),
|
||||
store.get("show-1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unvisited page opens on overview with focus on play`() {
|
||||
val position = DetailPositionStore().get("never-opened")
|
||||
|
||||
assertEquals(DetailTab.OVERVIEW.key, position.tabKey)
|
||||
assertEquals(DetailZone.PLAY, position.zone)
|
||||
assertEquals(null, position.season)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `play focus pins the complete hero while lower zones release scrolling`() {
|
||||
assertEquals(0, detailHeroScrollTarget(DetailZone.PLAY))
|
||||
assertEquals(null, detailHeroScrollTarget(DetailZone.TABS))
|
||||
assertEquals(null, detailHeroScrollTarget(DetailZone.CONTENT))
|
||||
assertEquals(null, detailHeroScrollTarget(DetailZone.RELATED))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the store is capped and keeps the most recently used`() {
|
||||
val store = DetailPositionStore(maxEntries = 3)
|
||||
listOf("a", "b", "c").forEach { id ->
|
||||
store.update(id) { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
|
||||
}
|
||||
// Touching "a" makes it the newest, so "b" is what falls out.
|
||||
store.get("a")
|
||||
store.update("d") { it.copy(tabKey = DetailTab.MORE_LIKE_THIS.key) }
|
||||
|
||||
assertEquals(3, store.size())
|
||||
assertEquals(DetailTab.CAST_DETAILS.key, store.get("a").tabKey)
|
||||
assertEquals(DetailTab.OVERVIEW.key, store.get("b").tabKey)
|
||||
assertEquals(DetailTab.MORE_LIKE_THIS.key, store.get("d").tabKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank item id is never stored`() {
|
||||
val store = DetailPositionStore()
|
||||
store.update("") { it.copy(tabKey = DetailTab.CAST_DETAILS.key) }
|
||||
|
||||
assertEquals(0, store.size())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.RelatedContent
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.EmbyPerson
|
||||
import com.ponzischeme89.memby.data.model.MediaStream
|
||||
import com.ponzischeme89.memby.data.model.Studio
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.ui.detail.creditRows
|
||||
import com.ponzischeme89.memby.ui.detail.technicalSpecs
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
/**
|
||||
* Renders the movie and series detail pages to PNGs under `build/screenshots/`.
|
||||
*
|
||||
* ```powershell
|
||||
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
|
||||
* ```
|
||||
*
|
||||
* There is no network here, so every artwork URL resolves to null and the pages render on
|
||||
* their own scrims. That is the point: it is the check that the poster column, the fact row
|
||||
* and the episode strip hold their shape when Emby has no backdrop and no poster to give —
|
||||
* the worst case, and the one a real library hits often enough to matter.
|
||||
*
|
||||
* Like [ServiceAlertBannerScreenshotTest], this is a `*ScreenshotTest.kt` file and is
|
||||
* allowed the Robolectric dependency; what these pages *say* lives in pure functions in
|
||||
* `ui/detail/DetailFacts.kt` and is covered by [SeriesDetailsTest] in plain JUnit.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class DetailPageScreenshotTest {
|
||||
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
// The hero asks the repository for its artwork URLs; without a token it answers
|
||||
// null, which is exactly the fallback case being captured.
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie page`() {
|
||||
capture("df_detail-movie") {
|
||||
MediaDetailContent(
|
||||
item = movie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Part-watched: the progress bar and "Resume" wording only appear in this state. */
|
||||
@Test
|
||||
fun `movie page part watched`() {
|
||||
capture("df_detail-movie-resumable") {
|
||||
MediaDetailContent(
|
||||
item = movie.copy(
|
||||
userData = UserItemData(playbackPositionTicks = 47L * 600_000_000L),
|
||||
),
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series page`() {
|
||||
capture("df_detail-series") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The first frame, before the episode request lands. The header must already be whole. */
|
||||
@Test
|
||||
fun `series page loading`() {
|
||||
capture("df_detail-series-loading") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = null,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = true,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The other panes. Clicking a tab is the same path a remote takes — the strip selects
|
||||
* on focus — so this also proves the panes swap without disturbing the header.
|
||||
*/
|
||||
@Test
|
||||
fun `series episodes tab`() {
|
||||
captureTab("df_detail-series-episodes", "Episodes") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series cast tab`() {
|
||||
captureTab("df_detail-series-cast-details", "Cast & Details") {
|
||||
SeriesDetailContent(
|
||||
item = series,
|
||||
episodes = episodes,
|
||||
loadFailed = false,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
isMyShow = false,
|
||||
onToggleMyShow = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `movie details tab`() {
|
||||
captureTab("df_detail-movie-more-like-this", "More Like This") {
|
||||
MediaDetailContent(
|
||||
item = movie,
|
||||
onPlay = {},
|
||||
onToggleFavorite = { _, _ -> },
|
||||
onTogglePlayed = { _, _ -> },
|
||||
related = related,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tab panes at the geometry the scaffold gives them on a 540dp TV — the slot the
|
||||
* audit found clipping every technical spec off the bottom of Cast & Details, which is
|
||||
* the whole reason that tab exists. A tab does not scroll, so everything it has to say
|
||||
* has to be inside this box.
|
||||
*/
|
||||
@Test
|
||||
fun `cast and details pane at its real slot geometry`() {
|
||||
capturePane("df_detail-pane-cast-details") {
|
||||
DetailCastAndDetailsPane(
|
||||
item = movie,
|
||||
credits = creditRows(movie),
|
||||
specs = technicalSpecs(movie),
|
||||
detailsLoaded = true,
|
||||
focusRequester = FocusRequester(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `overview pane at its real slot geometry`() {
|
||||
capturePane("df_detail-pane-overview") {
|
||||
DetailOverviewPane(
|
||||
item = series,
|
||||
credits = creditRows(series),
|
||||
focusRequester = FocusRequester(),
|
||||
supportingText = "Up next S1 E3 · The Long Count",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun capturePane(name: String, pane: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = DetailSideGutter, vertical = 16.dp)
|
||||
.height(detailPaneHeight(540.dp)),
|
||||
) { pane() }
|
||||
}
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
private fun capture(name: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) { content() }
|
||||
}
|
||||
compose.onNodeWithText(tab).performClick()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
/**
|
||||
* What the gateway returns for a warm profile: the reason strip above the tabs and the
|
||||
* carousel under the page. Both are the point of these captures now — they are the two
|
||||
* bands that decide whether the page still fits on one screen.
|
||||
*/
|
||||
private val related = RelatedContent(
|
||||
reasons = listOf(
|
||||
"Because you watch Thriller",
|
||||
"You've watched Aria Vance before",
|
||||
"Well rated (8.4)",
|
||||
),
|
||||
items = List(8) { index ->
|
||||
BaseItem(
|
||||
id = "related-$index",
|
||||
name = listOf(
|
||||
"The Quiet Meridian",
|
||||
"Harbour Lights",
|
||||
"Nine Days of Rain",
|
||||
"The Cartographer's Wife",
|
||||
"Winterline",
|
||||
"A Country of Small Rivers",
|
||||
"The Last Broadcast",
|
||||
"Northbound",
|
||||
)[index],
|
||||
type = "Movie",
|
||||
productionYear = 2019 + index % 5,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
private val cast = listOf(
|
||||
person("Aria Vance", "Detective Iris Kell"),
|
||||
person("Marcus Oyelaran", "Samuel Reed"),
|
||||
person("Nina Kowalczyk", "Dr. Halvorsen"),
|
||||
person("Tomas Brandt", "The Cartographer"),
|
||||
person("Ines Ferreira", "Captain Ruiz"),
|
||||
person("Daniel Cho", "Weatherman"),
|
||||
)
|
||||
|
||||
private val streams = listOf(
|
||||
MediaStream(
|
||||
type = "Video",
|
||||
codec = "hevc",
|
||||
width = 3840,
|
||||
height = 2160,
|
||||
videoRange = "HDR",
|
||||
videoRangeType = "HDR10",
|
||||
),
|
||||
MediaStream(type = "Audio", codec = "eac3", channels = 6, language = "eng", title = "Dolby Atmos"),
|
||||
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
|
||||
MediaStream(type = "Subtitle", codec = "subrip", language = "fra"),
|
||||
)
|
||||
|
||||
private val movie = BaseItem(
|
||||
id = "movie-1",
|
||||
name = "The Longest Northbound Winter",
|
||||
type = "Movie",
|
||||
overview = "A cartographer chasing a river that no longer exists finds the last " +
|
||||
"village on the map still waiting for him, and has to decide whether telling " +
|
||||
"them the truth is a kindness. Shot over four winters in a valley that floods " +
|
||||
"every spring, and assembled from what survived.",
|
||||
taglines = listOf("Some maps are promises."),
|
||||
productionYear = 2024,
|
||||
officialRating = "PG-13",
|
||||
communityRating = 8.4,
|
||||
genres = listOf("Drama", "Adventure", "Mystery"),
|
||||
runTimeTicks = 134L * 600_000_000L,
|
||||
studios = listOf(Studio(name = "Northlight Pictures"), Studio(name = "Kestrel")),
|
||||
mediaStreams = streams,
|
||||
people = cast,
|
||||
)
|
||||
|
||||
private val series = BaseItem(
|
||||
id = "series-1",
|
||||
name = "Signal Hill",
|
||||
type = "Series",
|
||||
overview = "A coastal radio station keeps receiving a broadcast that has not been " +
|
||||
"transmitted yet. Six weeks before the storm, the night operator starts writing " +
|
||||
"down what she hears.",
|
||||
taglines = listOf("Listen closely."),
|
||||
productionYear = 2022,
|
||||
officialRating = "TV-MA",
|
||||
communityRating = 8.9,
|
||||
genres = listOf("Thriller", "Drama"),
|
||||
studios = listOf(Studio(name = "Harbour Line")),
|
||||
mediaStreams = streams,
|
||||
people = cast,
|
||||
)
|
||||
|
||||
private val episodes = listOf(
|
||||
episode(1, 1, "Carrier Wave", played = true),
|
||||
episode(1, 2, "Dead Air", played = true),
|
||||
episode(1, 3, "The Long Count", position = 12L * 600_000_000L),
|
||||
episode(1, 4, "Nightingale"),
|
||||
episode(1, 5, "Six Weeks Out"),
|
||||
episode(2, 1, "Landfall"),
|
||||
)
|
||||
|
||||
private fun episode(
|
||||
season: Int,
|
||||
number: Int,
|
||||
title: String,
|
||||
played: Boolean = false,
|
||||
position: Long = 0L,
|
||||
) = BaseItem(
|
||||
id = "s${season}e$number",
|
||||
name = title,
|
||||
type = "Episode",
|
||||
seriesName = "Signal Hill",
|
||||
parentIndexNumber = season,
|
||||
indexNumber = number,
|
||||
runTimeTicks = 48L * 600_000_000L,
|
||||
overview = "The night shift picks up a voice reading tomorrow's shipping forecast, " +
|
||||
"and the log book from 1974 says the same thing happened before.",
|
||||
userData = UserItemData(played = played, playbackPositionTicks = position),
|
||||
)
|
||||
|
||||
private fun person(name: String, role: String) = EmbyPerson(
|
||||
id = name.filter(Char::isLetter),
|
||||
name = name,
|
||||
role = role,
|
||||
type = "Actor",
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
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.shape.RoundedCornerShape
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class HomeMovieHeroScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home hero with popular and new releases`() {
|
||||
capture("df_home-movie-hero", movies)
|
||||
}
|
||||
|
||||
/**
|
||||
* The case the audit reproduced: with a title long enough to wrap, the card's content
|
||||
* column overflowed its fixed height and the green Play chip — the last thing in the
|
||||
* column — was clipped away entirely. It has to survive here.
|
||||
*/
|
||||
@Test
|
||||
fun `home hero with a wrapping title`() {
|
||||
capture(
|
||||
"df_home-movie-hero-long-title",
|
||||
listOf(
|
||||
HomeHeroPick(
|
||||
movie(
|
||||
"The Longest Northbound Winter",
|
||||
2026,
|
||||
"A cartographer chasing a river that no longer exists finds the last " +
|
||||
"village on the map still waiting for him.",
|
||||
8.4,
|
||||
),
|
||||
"NEW RELEASE",
|
||||
),
|
||||
) + movies.drop(1),
|
||||
)
|
||||
}
|
||||
|
||||
private fun capture(name: String, movies: List<HomeHeroPick>) {
|
||||
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
|
||||
.use(BitmapFactory::decodeStream)
|
||||
.asImageBitmap()
|
||||
val railFocus = FocusRequester()
|
||||
|
||||
compose.setContent {
|
||||
PreviewSurface(alignment = Alignment.TopStart) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
TvNavigationRail(
|
||||
selected = BrowseDestination.HOME,
|
||||
expanded = false,
|
||||
navigationFocusRequester = railFocus,
|
||||
onRailFocusChanged = {},
|
||||
onDestinationSelected = {},
|
||||
)
|
||||
Column(Modifier.weight(1f).fillMaxHeight()) {
|
||||
HomeMovieHero(
|
||||
movies = movies,
|
||||
navigationFocusRequester = railFocus,
|
||||
onItemFocused = {},
|
||||
onItemSelected = {},
|
||||
modifier = Modifier.height(homeHeaderHeight(540.dp, showHero = true)),
|
||||
previewArtwork = artwork,
|
||||
)
|
||||
Text(
|
||||
"Recently added movies",
|
||||
color = Color(0xFFF1F3F4),
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier.padding(start = 36.dp, top = 6.dp, bottom = 9.dp),
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 36.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
repeat(5) { index ->
|
||||
Column(Modifier.weight(1f)) {
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(16f / 9f)
|
||||
.clip(RoundedCornerShape(7.dp))
|
||||
.background(
|
||||
listOf(
|
||||
Color(0xFF27343D), Color(0xFF28302D),
|
||||
Color(0xFF352C37), Color(0xFF392F28), Color(0xFF25343A),
|
||||
)[index],
|
||||
),
|
||||
)
|
||||
Text(
|
||||
movies[index % movies.size].item.name,
|
||||
color = Color.White,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
Text(
|
||||
"2026 • 2h 4m",
|
||||
color = Color(0xFF8F9AA3),
|
||||
fontSize = 11.sp,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onNodeWithText("Play").fetchSemanticsNode()
|
||||
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
|
||||
}
|
||||
|
||||
private val movies = listOf(
|
||||
HomeHeroPick(
|
||||
movie(
|
||||
"The Last Horizon",
|
||||
2026,
|
||||
"Beyond the mapped worlds, one explorer finds an ocean that remembers every visitor.",
|
||||
8.7,
|
||||
),
|
||||
"NEW RELEASE",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
|
||||
"POPULAR",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("Northstar Run", 2025, "The final supply ship takes an impossible route.", 7.9),
|
||||
"NEW RELEASE",
|
||||
),
|
||||
HomeHeroPick(
|
||||
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
|
||||
"FROM YOUR LIBRARY",
|
||||
),
|
||||
)
|
||||
|
||||
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
|
||||
id = name.lowercase().replace(' ', '-'),
|
||||
name = name,
|
||||
type = "Movie",
|
||||
overview = overview,
|
||||
productionYear = year,
|
||||
officialRating = "M",
|
||||
communityRating = rating,
|
||||
runTimeTicks = 124L * 600_000_000L,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class HomeMovieHeroTest {
|
||||
@Test
|
||||
fun `home hero gives way to focused row metadata`() {
|
||||
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = null))
|
||||
assertTrue(shouldShowHomeMovieHero(hasMovies = true, focusedRowId = HOME_HERO_ROW_ID))
|
||||
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, focusedRowId = "continue"))
|
||||
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, focusedRowId = null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `home hero leaves room for a complete shelf on a 540dp tv`() {
|
||||
val heroHeight = homeHeaderHeight(540.dp, showHero = true)
|
||||
|
||||
assertEquals(248.4f, heroHeight.value, 0.01f)
|
||||
assertTrue(540.dp - heroHeight >= 288.dp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hero alternates new releases and popular movies`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
|
||||
row("popular", "Popular Movies", "popular-1", "popular-2"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("new-1", "popular-1", "new-2", "popular-2"),
|
||||
selectHomeHeroMovies(rows).map { it.item.id },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The caption used to be the card's slot, so the third card was always "TRENDING"
|
||||
* whatever it was. It now names the row the title was actually drawn from.
|
||||
*/
|
||||
@Test
|
||||
fun `hero labels each pick by the row it came from`() {
|
||||
val rows = listOf(
|
||||
row("latest-movies", "Recently Added Movies", "new-1"),
|
||||
row("popular", "Popular Movies", "popular-1"),
|
||||
row("comedy", "Comedy", "other-1"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf("NEW RELEASE", "POPULAR", "FROM YOUR LIBRARY"),
|
||||
selectHomeHeroMovies(rows).map { it.label },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hero removes repeated movies across source rows`() {
|
||||
val rows = listOf(
|
||||
row("latest", "New releases", "shared", "new-2"),
|
||||
row("recommended", "Recommended", "shared", "popular-2", "popular-3"),
|
||||
)
|
||||
|
||||
assertEquals(4, selectHomeHeroMovies(rows).size)
|
||||
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
|
||||
}
|
||||
|
||||
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
|
||||
id = id,
|
||||
title = title,
|
||||
items = ids.map { BaseItem(id = it, name = it, type = "Movie") },
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "",
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,33 @@ class MediaBadgesTest {
|
||||
)
|
||||
}
|
||||
|
||||
/** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */
|
||||
@Test
|
||||
fun `names HDR10+ rather than collapsing it to HDR`() {
|
||||
val item = BaseItem(
|
||||
id = "movie",
|
||||
mediaStreams = listOf(
|
||||
MediaStream(type = "Video", width = 3840, videoRangeType = "HDR10+"),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(listOf("4K", "HDR10+"), mediaBadges(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* The badge and the spec row's `(4K)` suffix used to disagree — 3800 against 3400 —
|
||||
* so a 3600-wide file was 4K on the detail page and not on the card.
|
||||
*/
|
||||
@Test
|
||||
fun `uses the same 4K threshold as the technical spec row`() {
|
||||
fun badgesAt(width: Int) = mediaBadges(
|
||||
BaseItem(id = "m", mediaStreams = listOf(MediaStream(type = "Video", width = width))),
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), badgesAt(3_600))
|
||||
assertEquals(listOf("4K"), badgesAt(3_840))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `returns no badges when stream metadata is unavailable`() {
|
||||
assertEquals(emptyList<String>(), mediaBadges(BaseItem(id = "unknown")))
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.MyShow
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class MyShowsTest {
|
||||
@Test
|
||||
fun missingNextEpisodeHasFriendlyCopy() {
|
||||
assertEquals("Not announced", formatMyShowDate(null))
|
||||
assertEquals("Not announced", formatMyShowDate("not-a-date"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cardSubtitlePrioritisesUsefulShowState() {
|
||||
assertEquals(
|
||||
"Cancelled",
|
||||
myShowCardSubtitle(MyShow(itemId = "1", title = "Ended", lifecycle = "Cancelled")),
|
||||
)
|
||||
assertEquals(
|
||||
"Not monitored",
|
||||
myShowCardSubtitle(MyShow(itemId = "2", title = "Paused", sonarrStatus = "Not monitored")),
|
||||
)
|
||||
assertEquals(
|
||||
"Saved show",
|
||||
myShowCardSubtitle(MyShow(itemId = "3", title = "Unknown")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class QuickActionsNavigationTest {
|
||||
@Test
|
||||
fun `quick actions require an intentional hold`() {
|
||||
assertTrue(QuickActionsHoldDurationMillis >= 600L)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `down reaches every action and stops at the final action`() {
|
||||
assertEquals(1, quickActionNextIndex(0, 4, QuickActionDirection.DOWN))
|
||||
assertEquals(3, quickActionNextIndex(3, 4, QuickActionDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up returns toward the safe first action and stops there`() {
|
||||
assertEquals(2, quickActionNextIndex(3, 4, QuickActionDirection.UP))
|
||||
assertEquals(0, quickActionNextIndex(0, 4, QuickActionDirection.UP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty menus remain bounded`() {
|
||||
assertEquals(0, quickActionNextIndex(5, 0, QuickActionDirection.DOWN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ResponsiveRowSizingTest {
|
||||
@Test
|
||||
fun metadataPanelKeepsReadableLineLengthsAcrossTvWidths() {
|
||||
assertEquals(537.6f, metadataPanelContentWidth(640.dp, compact = true).value, 0.01f)
|
||||
assertEquals(720f, metadataPanelContentWidth(1920.dp, compact = false).value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun standardPosterRowFitsSevenCompleteCards() {
|
||||
val width = responsiveRowCardWidth(
|
||||
availableWidth = 1280.dp,
|
||||
preferredCardsAcross = 7,
|
||||
minWidth = 102.dp,
|
||||
maxWidth = 218.dp,
|
||||
)
|
||||
|
||||
val occupiedWidth = width * 7 + 16.dp * 6 + 36.dp * 2
|
||||
assertEquals(1280f, occupiedWidth.value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun narrowRowReducesCardCountInsteadOfClippingCards() {
|
||||
val width = responsiveRowCardWidth(
|
||||
availableWidth = 640.dp,
|
||||
preferredCardsAcross = 7,
|
||||
minWidth = 102.dp,
|
||||
maxWidth = 218.dp,
|
||||
)
|
||||
|
||||
// Four 130dp cards, three gaps and both insets fill this viewport exactly.
|
||||
assertEquals(130f, width.value, 0.01f)
|
||||
assertEquals(640f, (width * 4 + 16.dp * 3 + 36.dp * 2).value, 0.01f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wideRowAddsCardsRatherThanExceedingMaximumWidth() {
|
||||
val width = responsiveRowCardWidth(
|
||||
availableWidth = 2560.dp,
|
||||
preferredCardsAcross = 4,
|
||||
minWidth = 164.dp,
|
||||
maxWidth = 360.dp,
|
||||
)
|
||||
|
||||
assertTrue(width <= 360.dp)
|
||||
assertEquals(341.71f, width.value, 0.01f)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class RowNavigationTest {
|
||||
@Test
|
||||
fun `vertical movement skips rows without focusable cards`() {
|
||||
val counts = listOf(6, 0, 0, 4)
|
||||
|
||||
assertEquals(
|
||||
3,
|
||||
adjacentFocusableRowIndex(counts, 0, RowFocusDirection.DOWN),
|
||||
)
|
||||
assertEquals(
|
||||
0,
|
||||
adjacentFocusableRowIndex(counts, 3, RowFocusDirection.UP),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical movement stops at the page boundary`() {
|
||||
val counts = listOf(3, 2)
|
||||
|
||||
assertNull(adjacentFocusableRowIndex(counts, 0, RowFocusDirection.UP))
|
||||
assertNull(adjacentFocusableRowIndex(counts, 1, RowFocusDirection.DOWN))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `vertical movement can traverse beyond the second row`() {
|
||||
val counts = listOf(6, 6, 6, 6, 6)
|
||||
val visited = mutableListOf(0)
|
||||
var current = 0
|
||||
|
||||
while (true) {
|
||||
current = adjacentFocusableRowIndex(
|
||||
counts,
|
||||
current,
|
||||
RowFocusDirection.DOWN,
|
||||
) ?: break
|
||||
visited += current
|
||||
}
|
||||
|
||||
assertEquals(listOf(0, 1, 2, 3, 4), visited)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `first visit keeps horizontal position and clamps to a shorter row`() {
|
||||
assertEquals(4, rowEntryItemIndex(4, destinationItemCount = 8, null))
|
||||
assertEquals(2, rowEntryItemIndex(7, destinationItemCount = 3, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `return visit restores the destination row position`() {
|
||||
assertEquals(
|
||||
1,
|
||||
rowEntryItemIndex(
|
||||
sourceIndex = 5,
|
||||
destinationItemCount = 8,
|
||||
rememberedDestinationIndex = 1,
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
2,
|
||||
rowEntryItemIndex(
|
||||
sourceIndex = 1,
|
||||
destinationItemCount = 3,
|
||||
rememberedDestinationIndex = 20,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import com.ponzischeme89.memby.ui.detail.availableSeasons
|
||||
import com.ponzischeme89.memby.ui.detail.defaultSeason
|
||||
import com.ponzischeme89.memby.ui.detail.episodesForSeason
|
||||
import com.ponzischeme89.memby.ui.detail.seasonLabel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -55,9 +59,8 @@ class SeriesDetailsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `detail tabs default safely to episodes`() {
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("episodes"))
|
||||
assertEquals(SeriesDetailSection.CAST, seriesDetailSection("cast"))
|
||||
assertEquals(SeriesDetailSection.EPISODES, seriesDetailSection("future-section"))
|
||||
fun `season zero is labelled specials`() {
|
||||
assertEquals("Specials", seasonLabel(0))
|
||||
assertEquals("Season 3", seasonLabel(3))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.ponzischeme89.memby.data.HomeCache
|
||||
import com.ponzischeme89.memby.data.Settings
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.HomeRow
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -15,6 +16,28 @@ import org.junit.Test
|
||||
* the server invented, and surviving a cold start from cache.
|
||||
*/
|
||||
class ServerHomeRowsTest {
|
||||
@Test
|
||||
fun `profile row preferences hide pin and order server rows`() {
|
||||
val serverRows = listOf(
|
||||
HomeRow("continue", "Continue", "continue", emptyList()),
|
||||
HomeRow("recommended", "Recommended", "recommendation", emptyList()),
|
||||
HomeRow("latest-movies", "Latest", "latest", emptyList()),
|
||||
HomeRow("seasonal", "Seasonal", "recommendation", emptyList()),
|
||||
)
|
||||
val settings = Settings(
|
||||
homeRowOrder = "seasonal\nrecommended\ncontinue",
|
||||
homePinnedRows = "recommended",
|
||||
homeHiddenRows = "latest-movies",
|
||||
)
|
||||
|
||||
val result = serverHomeRows(
|
||||
HomeUiState(rows = serverRows, loading = emptySet()),
|
||||
settings,
|
||||
)
|
||||
|
||||
assertEquals(listOf("recommended", "seasonal", "continue"), result.map { it.id })
|
||||
}
|
||||
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@@ -73,10 +96,7 @@ class ServerHomeRowsTest {
|
||||
|
||||
@Test
|
||||
fun `successful login welcome uses authenticated username`() {
|
||||
assertEquals(
|
||||
"You're now logged in as Matt. Welcome to Memby!",
|
||||
loginWelcomeMessage(" Matt "),
|
||||
)
|
||||
assertTrue(loginWelcomeMessage(" Matt ").startsWith("Welcome to Memby, Matt. "))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -189,7 +209,61 @@ class ServerHomeRowsTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Sonarr schedule rows use show cards and cannot be hidden by old preferences`() {
|
||||
fun `home and library destinations have distinct shelves`() {
|
||||
val state = HomeUiState(
|
||||
rows = serverRows + listOf(
|
||||
row("curated:drama-shows", "shows", "show"),
|
||||
row("curated:movies:genre:drama", "movies", "movie"),
|
||||
),
|
||||
loading = emptySet(),
|
||||
)
|
||||
|
||||
val home = homeRowsFor(BrowseDestination.HOME, state, Settings())
|
||||
val shows = homeRowsFor(BrowseDestination.SHOWS, state, Settings())
|
||||
val movies = homeRowsFor(BrowseDestination.MOVIES, state, Settings())
|
||||
|
||||
assertTrue(home.none { it.id.startsWith("curated:") })
|
||||
assertTrue(shows.any { it.id == "curated:drama-shows" })
|
||||
assertTrue(movies.any { it.id == "curated:movies:genre:drama" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discovery shelves do not repeat a continued series or played title`() {
|
||||
val continued = BaseItem(id = "episode", type = "Episode", seriesId = "hard-man")
|
||||
val repeatedSeries = BaseItem(id = "hard-man", type = "Series")
|
||||
val playedMovie = BaseItem(
|
||||
id = "played",
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = true),
|
||||
)
|
||||
val fresh = (1..4).map { BaseItem(id = "fresh-$it", type = "Series") }
|
||||
val rows = deduplicateBrowseRows(
|
||||
listOf(
|
||||
HomeBrowseRow(
|
||||
id = "continue",
|
||||
title = "Continue",
|
||||
items = listOf(continued),
|
||||
kind = MediaRowKind.CONTINUE,
|
||||
emptyMessage = "empty",
|
||||
),
|
||||
HomeBrowseRow(
|
||||
id = "curated:drama-shows",
|
||||
title = "Drama",
|
||||
items = listOf(repeatedSeries, playedMovie) + fresh,
|
||||
kind = MediaRowKind.SHOWS,
|
||||
emptyMessage = "empty",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
fresh.map { it.id },
|
||||
rows.last().items.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `TV schedule rows use show cards and cannot be hidden by old preferences`() {
|
||||
val schedule = row("sonarr-airing-today", "schedule", "sonarr:7:42")
|
||||
|
||||
val rows = serverHomeRows(
|
||||
@@ -199,7 +273,24 @@ class ServerHomeRowsTest {
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(MediaRowKind.SHOWS, rows.single().kind)
|
||||
assertEquals("No monitored shows are airing today", rows.single().emptyMessage)
|
||||
assertEquals("No monitored shows are airing in the next 5 days", rows.single().emptyMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Radarr schedule rows use movie cards and explain an empty digital window`() {
|
||||
val schedule = row("radarr-upcoming-movies", "movie-schedule", "radarr:7")
|
||||
|
||||
val rows = serverHomeRows(
|
||||
HomeUiState(rows = listOf(schedule), loading = emptySet()),
|
||||
Settings(homeSections = ""),
|
||||
)
|
||||
|
||||
assertEquals(1, rows.size)
|
||||
assertEquals(MediaRowKind.MOVIES, rows.single().kind)
|
||||
assertEquals(
|
||||
"No monitored movies have a digital release in the next 5 days",
|
||||
rows.single().emptyMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
@@ -25,8 +32,8 @@ import org.robolectric.annotation.GraphicsMode
|
||||
* composable genuinely cannot be done in plain JUnit, so Robolectric is confined to files
|
||||
* named `*ScreenshotTest.kt`. Logic tests stay pure; keep them that way.
|
||||
*
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn on the launcher's
|
||||
* own background, flush to the top edge exactly as `MainActivity` places it.
|
||||
* Qualifiers describe a 1080p TV: 960x540dp at xhdpi. The bar is drawn over a realistic
|
||||
* cinematic playback still, flush to the top edge exactly as `PlayerActivity` places it.
|
||||
*
|
||||
* [AlertBanner] is rendered directly rather than [ServiceAlertBanner]: the wrapper's whole
|
||||
* job is the drop-in from above, and a still frame of an animation says nothing. Posters
|
||||
@@ -49,7 +56,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -62,7 +68,54 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:8:43:aired",
|
||||
title = "The Long Dark",
|
||||
message = "S01E09 — Winterlight aired at 8:30 PM and is downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A Radarr import, which is the case with a server-supplied eyebrow: the longest
|
||||
* label the bar is expected to carry has to leave the countdown ring room.
|
||||
*/
|
||||
@Test
|
||||
fun `new movie added`() {
|
||||
capture(
|
||||
"alert-banner-movie-added",
|
||||
ServiceAlert(
|
||||
id = "radarr:412:file:9001",
|
||||
title = "Mr. Smith Goes to Washington (1939)",
|
||||
message = "Mr. Smith Goes to Washington will be available in Emby shortly.",
|
||||
label = "NEW MOVIE ADDED",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/** The service itself talking: a finished refresh, with nothing to illustrate it. */
|
||||
@Test
|
||||
fun `library updated`() {
|
||||
capture(
|
||||
"alert-banner-library-updated",
|
||||
ServiceAlert(
|
||||
id = "library:1785012345",
|
||||
title = "24 titles added or updated",
|
||||
message = "Memby has finished refreshing — it is on the home screen now.",
|
||||
label = "LIBRARY UPDATED",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert most likely to be read over a film, and the one whose wording has to work
|
||||
* with the picture already stalled behind it.
|
||||
*/
|
||||
@Test
|
||||
fun `server not responding`() {
|
||||
capture(
|
||||
"alert-banner-server-down",
|
||||
ServiceAlert(
|
||||
id = "emby:down:1785012345",
|
||||
title = "Emby has stopped communicating",
|
||||
message = "Playback may stop until it is back. Memby will say when it returns.",
|
||||
label = "SERVER NOT RESPONDING",
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -78,7 +131,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
message = "S11E03 — The One Where Absolutely Everything Happens At Once " +
|
||||
"And Then Some More Happens After That aired at 10:30 PM and is " +
|
||||
"downloading now.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -92,7 +144,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:3:12:aired",
|
||||
title = "Dune",
|
||||
message = "S01E01 aired and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -110,7 +161,6 @@ class ServiceAlertBannerScreenshotTest {
|
||||
id = "sonarr:7:42:aired",
|
||||
title = "Northbound",
|
||||
message = "S02E04 — The Crossing aired at 9:00 PM and will be in Emby soon.",
|
||||
posterUrl = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -125,7 +175,16 @@ class ServiceAlertBannerScreenshotTest {
|
||||
|
||||
@Composable
|
||||
private fun AlertBannerOnHomeBackground(alert: ServiceAlert) {
|
||||
PreviewSurface(alignment = Alignment.TopCenter) {
|
||||
val playbackStill = requireNotNull(
|
||||
javaClass.getResourceAsStream("/playback_alert_preview_still.png"),
|
||||
).use(BitmapFactory::decodeStream).asImageBitmap()
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
|
||||
Image(
|
||||
bitmap = playbackStill,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
AlertBanner(alert)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class UserSwitcherNavigationTest {
|
||||
@Test
|
||||
fun `active profile receives initial focus`() {
|
||||
assertEquals(
|
||||
1,
|
||||
userSwitcherInitialIndex(listOf("one", "two", "three"), "two"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `three profiles lead to pinned manage action`() {
|
||||
val profileCount = 3
|
||||
|
||||
assertEquals(
|
||||
3,
|
||||
userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN),
|
||||
)
|
||||
assertEquals(
|
||||
3,
|
||||
userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `up and down navigation stay inside the switcher`() {
|
||||
assertEquals(0, userSwitcherNextIndex(0, 3, UserSwitcherDirection.UP))
|
||||
assertEquals(2, userSwitcherNextIndex(3, 3, UserSwitcherDirection.UP))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `manage remains reachable when no profiles exist`() {
|
||||
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
|
||||
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.test.junit4.createComposeRule
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.tv.material3.Text
|
||||
import com.github.takahirom.roborazzi.captureRoboImage
|
||||
import com.ponzischeme89.memby.ServiceLocator
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
|
||||
class WatchedVisibilityScreenshotTest {
|
||||
@get:Rule
|
||||
val compose = createComposeRule()
|
||||
|
||||
@Before
|
||||
fun locator() {
|
||||
ServiceLocator.init(ApplicationProvider.getApplicationContext())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `browse cards hide watched movies and count watched episodes`() {
|
||||
val visible = applyWatchedVisibility(listOf(row), enabled = true).single()
|
||||
|
||||
compose.setContent {
|
||||
PreviewSurface {
|
||||
Column(Modifier.fillMaxSize().padding(48.dp)) {
|
||||
Text("Because you like great stories", color = Color.White, fontSize = 22.sp)
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 18.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(18.dp),
|
||||
) {
|
||||
visible.items.forEach { item ->
|
||||
PortraitMediaCard(
|
||||
item = item,
|
||||
availableWidth = 820.dp,
|
||||
showSecondaryMetadata = true,
|
||||
onFocused = {},
|
||||
onClick = {},
|
||||
onLongClick = {},
|
||||
density = "large",
|
||||
showWatchedEpisodeCount = visible.showWatchedEpisodeCount,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.onRoot().captureRoboImage("build/screenshots/watched-visibility-row.png")
|
||||
}
|
||||
|
||||
private val row = HomeBrowseRow(
|
||||
id = "stories",
|
||||
title = "Stories",
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "Nothing here",
|
||||
items = listOf(
|
||||
BaseItem(
|
||||
id = "seen",
|
||||
name = "Already Seen",
|
||||
type = "Movie",
|
||||
userData = UserItemData(played = true),
|
||||
),
|
||||
BaseItem(
|
||||
id = "signal",
|
||||
name = "Signal House",
|
||||
type = "Series",
|
||||
recursiveItemCount = 10,
|
||||
userData = UserItemData(unplayedItemCount = 2),
|
||||
),
|
||||
BaseItem(
|
||||
id = "winter",
|
||||
name = "A Long Winter",
|
||||
type = "Series",
|
||||
recursiveItemCount = 24,
|
||||
userData = UserItemData(unplayedItemCount = 19),
|
||||
),
|
||||
BaseItem(id = "new", name = "Not Watched Yet", type = "Movie", productionYear = 2026),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.ponzischeme89.memby.ui
|
||||
|
||||
import com.ponzischeme89.memby.data.model.BaseItem
|
||||
import com.ponzischeme89.memby.data.model.UserItemData
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class WatchedVisibilityTest {
|
||||
@Test
|
||||
fun `preference is disabled by default`() {
|
||||
assertFalse(com.ponzischeme89.memby.data.Settings().hideWatchedMovies)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled preference hides only completed movies`() {
|
||||
val watchedMovie = item("movie", "Movie", played = true)
|
||||
val unwatchedMovie = item("new-movie", "Movie")
|
||||
val watchedSeries = item("series", "Series", played = true)
|
||||
val row = row(watchedMovie, unwatchedMovie, watchedSeries)
|
||||
|
||||
val filtered = applyWatchedVisibility(listOf(row), enabled = true).single()
|
||||
|
||||
assertEquals(listOf("new-movie", "series"), filtered.items.map(BaseItem::id))
|
||||
assertTrue(filtered.showWatchedEpisodeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `disabled preference preserves row content`() {
|
||||
val row = row(item("movie", "Movie", played = true))
|
||||
|
||||
val unchanged = applyWatchedVisibility(listOf(row), enabled = false).single()
|
||||
|
||||
assertEquals(listOf("movie"), unchanged.items.map(BaseItem::id))
|
||||
assertFalse(unchanged.showWatchedEpisodeCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `series progress uses aggregate episode counts`() {
|
||||
val series = BaseItem(
|
||||
id = "series",
|
||||
type = "Series",
|
||||
recursiveItemCount = 10,
|
||||
userData = UserItemData(unplayedItemCount = 2),
|
||||
)
|
||||
|
||||
assertEquals("8 of 10 watched", watchedEpisodeCountLabel(series))
|
||||
assertNull(watchedEpisodeCountLabel(item("movie", "Movie")))
|
||||
}
|
||||
|
||||
private fun item(id: String, type: String, played: Boolean = false) = BaseItem(
|
||||
id = id,
|
||||
type = type,
|
||||
userData = UserItemData(played = played),
|
||||
)
|
||||
|
||||
private fun row(vararg items: BaseItem) = HomeBrowseRow(
|
||||
id = "row",
|
||||
title = "Row",
|
||||
items = items.toList(),
|
||||
kind = MediaRowKind.MOVIES,
|
||||
emptyMessage = "Empty",
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user