Publish current app and server

This commit is contained in:
ponzischeme89
2026-08-02 22:10:19 +12:00
parent a265636139
commit 1ed180c739
203 changed files with 23933 additions and 2788 deletions
+30 -5
View File
@@ -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
+1
View File
@@ -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.
+248 -11
View File
@@ -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
View File
@@ -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 (250420dp) 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.
+340
View File
@@ -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.
+38
View File
@@ -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.
+26 -6
View File
@@ -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
View File
@@ -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
+43 -5
View File
@@ -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
+2 -2
View File
@@ -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 = "",
)
@@ -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("Youre 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. Youve earned the good seat.",
),
WelcomeQuoteStyle.HOMICIDAL to listOf(
"Welcome back. I kept your spot. Nobody argued twice.",
"Pick something cheerful. Ive 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 couldnt decode the selected video or audio track. Try another track or a transcoded version.",
canAutoRetry = false,
detail = "This TV couldnt 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 couldnt be read",
detail = "The stream format is damaged or unsupported by this TV.",
detail = "This TV couldnt 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 couldnt 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 Membys 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>
+25 -1
View File
@@ -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>
+118 -80
View File
@@ -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>
+17 -1
View File
@@ -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