App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
+19 -1
View File
@@ -14,9 +14,13 @@ POSTGRES_PASSWORD=7dfc3eb07108013c7bea9787457397551b5eb9059adac51aea4741f4ec649b
# Fixed NAS host port used by the mserver.sublogue.com reverse proxy.
MEMBY_PORT=32768
# INFO is recommended. DEBUG also logs successful health, status and artwork requests.
# INFO is recommended. DEBUG also logs successful health, status and artwork requests,
# playback progress reports and search terms.
MEMBY_LOG_LEVEL=INFO
MEMBY_LOG_BUFFER_CAPACITY=5000
# console (default) is one aligned line per event, timestamp first and no `time=` key.
# logfmt restores slog's own key=value line; json is for a log collector.
MEMBY_LOG_FORMAT=console
MEMBY_GOMEMLIMIT=384MiB
MEMBY_SERVER_MEMORY_LIMIT=512m
@@ -74,6 +78,20 @@ MEMBY_RADARR_WEBHOOK_TOKEN=bfa059594adeadf9105c27481a5fd758
# import still hears about it. 0 turns the banners off.
MEMBY_RADARR_ALERT_WINDOW=3h
# Optional Bazarr integration, which is what lets a viewer fetch a subtitle from the
# player for a title the library has none for. Bazarr writes the file beside the media
# file, so Emby serves the result and Memby stores nothing — leaving these unset simply
# means the option is never offered. The API key is in Bazarr under Settings > General.
MEMBY_BAZARR_URL=http://10.0.0.2:6767
MEMBY_BAZARR_API_KEY=e079ab79c1e32b5cf648d079f4421e11
# How long Bazarr's movie/series/episode listings are cached. They exist only to turn an
# Emby item into the Radarr/Sonarr id Bazarr keys on.
MEMBY_BAZARR_TTL=5m
# A manual search queries live subtitle providers and is legitimately slow, so it gets a
# longer timeout than the rest of the upstreams. Too short reads to a viewer as "no
# subtitles found" rather than as a timeout.
MEMBY_BAZARR_TIMEOUT=45s
# 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.
+3
View File
@@ -25,3 +25,6 @@ local.properties
/dist/out/
*.jks
*.keystore
# The installed APK a deploy script pulls off a television to compare against.
/.tmp-memby-installed-base.apk
+55
View File
@@ -0,0 +1,55 @@
# AGENTS.md
Guidance for coding agents working in this repository. **`CLAUDE.md` is the full brief** —
what Memby is, how it is built and deployed, and why the architecture is the shape it is.
Read it first. This file carries the rules that are easiest to break without noticing.
## Language: New Zealand English
**Everything a person reads is New Zealand English. No American spellings.**
- `-ise` / `-isation`, never `-ize` / `-ization` — personalise, synchronisation, organise,
recognise, prioritise.
- `-our`, never `-or` — colour, favourite, behaviour, honour, neighbour.
- `-re`, never `-er` — centre, theatre, metre.
- **licence** the noun, *license* the verb. **programme** for a broadcast, *program* for
software.
- grey, catalogue, cancelled, labelled, travelling, dialogue, defence, mould.
This covers on-screen copy in Kotlin and `app/src/main/res/values/strings.xml`,
`CHANGELOG.md` (the TV renders it twice — Settings → About, and the what's-new panel after
an update), the admin console at `server/internal/api/admin.html`, the release landing page
in `dist/template/`, every string the gateway sends the client to display — row titles,
alert `label`s, the preference and feature catalogues, error messages — and this
repository's own prose and comments.
### What is exempt, and why
**Identifiers and anything on a wire keep the spelling they already have.** Renaming them
breaks the wire or the build:
- Emby's API — `favorites`, `IsFavorite`, `SearchTerm`.
- Android and Compose — `Color`, `fontSize`, `TheaterComedy`, `RecognizerIntent`.
- Go and Kotlin — `synchronized`, and the literal `"request canceled"` in `images.go`,
which is matched against `net/http`'s own error text.
- SPDX and licence names — "GNU General Public License", `GPL-2.0-only`.
- CSS properties — `color`, `text-transform`, `box-sizing`.
The rule is about words a person reads, never tokens a machine matches.
### Where the two meet
The boundary sits at the render. The favourites row is the worked example: the id and the
row `kind` stay `favorites` on both sides of the wire, and `personalisedFavouritesTitle`
puts **"Favourites"** on the screen. When a new string is both a key and a label, spell the
display half and leave the key alone.
A quick sweep before finishing a change that touches copy:
```bash
grep -rnEio '\b([a-z]{3,}iz(e|es|ed|ing|ation)|behaviors?|colors?|favorites?|honor|theater|canceled|labeled|catalog|gray)\b' \
app/src/main server/internal dist CHANGELOG.md
```
Expect hits — most are the exemptions above. Read each one and ask whether a viewer or an
operator ever sees it.
+54
View File
@@ -0,0 +1,54 @@
## 0.2.26 — 2026-08-05
- Improved: Ratings are more accurate and easier to read.
- Fixed: Continue Watching now keeps the right order as you watch.
- Fixed: Film and show pages no longer display incorrectly.
- Fixed: The loading screen no longer appears when you skip forward or back.
## 0.2.25 — 2026-08-05
- Added: Left and right on the remote skip back and forward while you watch.
- Added: You can choose whether one press skips 10, 20 or 30 seconds.
- Added: You can search for and add subtitles without leaving what you are watching.
- Added: A cast button in the player shows who is in the scene.
- Added: Episode lists now show when each episode aired.
- Improved: Ratings now show the IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb logos.
## 0.2.24 — 2026-08-05
- Fixed: The centre button on the remote pauses straight away instead of taking two presses.
- Fixed: Reinstalling Memby on a television replaces it in your devices list instead of adding another one.
- Fixed: Removing a television from Devices now clears it from Emby as well.
- Improved: The subtitle menu is quicker to read and easier to move around.
## 0.2.23 — 2026-08-04
- Added: Turning subtitles off, and the language you pick, are saved to your account.
- Added: Tags showing whether a show is continuing, has ended, or is still in cinemas.
- Improved: Settings is simpler, darker and easier to read.
- Fixed: The Play button on the Home screen is no longer squashed by long titles.
## 0.2.22 — 2026-08-04
- Fixed: Updates no longer fail with a warning about an untrusted release key.
- Fixed: Updates now install on televisions where they previously did nothing.
- Added: Memby asks for permission to install updates while you are setting up.
- Added: Instructions for televisions that have no install permission screen.
- Improved: You are now told clearly when an update fails.
## 0.2.20 — 2026-08-04
- Added: A warning appears when Memby cannot reach your Emby server.
- Added: Your settings are saved to your account and restored on any television you sign into.
- Added: Title logos, auto-play and the ten-minute reminder are saved for each person.
## 0.2.17 — 2026-08-03
- Added: Episodes in Continue Watching now open their own page.
- Improved: Resume buttons show which episode you are up to.
## 0.2.16 — 2026-08-03
- Added: You can rename or sign out televisions from Settings → Devices.
- Added: Optional ratings on film pages.
- Added: A version history in About, so you can see what changed.
- Improved: The navigation rail stays visible while you are in Settings.
## 0.2.0 — 2026-05-01
- Added: Instant search with an on-screen keyboard.
- Added: Alerts for newly added titles, library updates and server outages.
- Added: Personalised "Why you might enjoy it" suggestions on film and show pages.
- Improved: Film and series pages now share one layout.
- Improved: Home screen rows can change without updating the app.
+833 -27
View File
@@ -11,8 +11,19 @@ labelled "Memby Screensaver"), which was the project's original purpose and stil
the same APK.
User-facing name is always **Memby**: `app_name`/`screensaver_name`/`developer_name` in
`res/values/strings.xml`, the `MediaBrowser Client="Memby"` auth header Emby shows in its
devices list, and on-screen copy.
`res/values/strings.xml`, and on-screen copy.
**Except on the wire to Emby.** The `X-Emby-Authorization` header identifies the client as
**`MbyATV`**, not Memby — that header travels to whatever Emby does with its own logs, and
the product name has no business being what identifies a client to somebody else. It is
sent from two places and they must agree, or one television signing in both ways appears as
two clients: `MEMBY_CLIENT_NAME` in the gateway (`internal/config`) and the literal in
`EmbyServiceFactory`'s auth interceptor on the direct path. The `Version=` beside it is the
**television's app version**, which on the gateway path means `Credentials.ClientVersion`
threaded from the session's `X-Memby-Version`; it was a hardcoded `"1.0"`, so every device
in Emby's dashboard read as the same build and there was no way to tell which set was
behind. A request the gateway makes for itself (library sync, health probe, device cleanup)
carries no session, and reports the gateway's own `buildinfo.Version()` instead.
`Emby*` class names (`EmbyRepository`, `EmbyApi`, `EmbyServiceFactory`, `EmbyModels`) are
kept on purpose: those types model *Emby's* API, and renaming them would make the code
@@ -115,6 +126,30 @@ pass `-Device host:port` when Android rotates the wireless-debugging port.
URL is a static manifest, anything else is a Gitea host. `resolveApkUrl` lets a manifest
use a relative `apkUrl`. Both are unit-tested in `UpdateSourceTest`.
**`CHANGELOG.md` is the version history the TV shows.** It is read into
`BuildConfig.CHANGELOG_TEXT` at build time the way `LICENSE` and `NOTICE` are, parsed by
the pure `parseChangelog` in `ui/settings/VersionHistory.kt`, and rendered by Settings →
About as one collapsible release per entry. So a release edits one file and the history
stays readable offline. Keep the `## <version> — <date>` / `- bullet` shape; anything else
in the file is skipped as prose, and a bullet wrapped onto a second line is rejoined.
**The same file is what a TV shows after it updates itself.** `ui/whatsnew/` puts the
running build's changelog entry over the launcher once, and `Settings.whatsNewSeenVersion`
(device state, deliberately not a synced preference — what is new is a property of the APK
on *this* set) records that it has been. `whatsNewDecision` is the pure rule and holds the
three cases that must not show a panel: a version already recorded, a **fresh install**
(no record and nobody signed in — everything is new to that TV, so it is marked seen during
setup instead), and a build the changelog does not describe, which is marked seen rather
than shown as an empty panel. Signed out *with* a record is neither: the notes belong over
the launcher, so that launch waits. Things to preserve — it is an overlay composed after
`HomeScreen`, not a branch of `AppRoot`'s `when`, so the cached rows are already drawn
behind it and nothing about it can delay startup; the version is recorded on dismissal,
so a set switched off mid-panel is told again rather than never; and the Continue button
points every direction back at itself, or one press of Down walks into rows the viewer
cannot see behind the scrim. `maxChangesFor` derives the bullet count from the screen for
the same reason detail panes derive their height — a panel that overruns a 720p set has no
scrollbar to aim at and hides its own button.
**Forced updates are server-controlled.** `server/internal/appupdate` decides `none` /
`optional` / `mandatory` from the client's `X-Memby-Version` header against an
operator-set policy (admin page → App updates). `HomeViewModel.checkForAppUpdate` runs on
@@ -164,6 +199,33 @@ the container is down. Specifics worth knowing:
`HomeViewModel.updateUserData` must map over `rows` too or an optimistic favourite won't
show on a recommendation card; and `loadBatchHome` keeps the previous rows when a
response arrives with none, because the gateway omits recommendations while they build.
- **Continue Watching and Next Up are one row**, merged by `api/continue_watching.go`.
They answered the same question — "what am I in the middle of?" — and splitting them
meant a show moved between rows the moment an episode ended, which is exactly when
somebody most wants the next one. Merging is not concatenation: both lists are ordered
by recency, so the row is a *merge of two sorted lists*, never a sort of their union —
Emby's order within each is the useful part. That needs a time per card, and a Next Up
episode has none of its own (it is unwatched), so `recentlyPlayedSeries` asks for the
household's recent plays and each episode is placed by when its **series** was last
watched. Things to preserve: a series in both is represented by its resume item, since
somebody eleven minutes into an episode wants that episode; an undated card never
displaces a dated one and falls back to the resume half, so a failed lookup degrades to
the order the launcher had when they were two rows rather than to nonsense; and the
rule exists twice, in `data/ContinueWatching.kt` for the direct path, pinned by
deliberately parallel tests (`ContinueWatchingTest`, `continue_watching_test.go`) —
with no gateway there is nobody to ask, and the row must not differ depending on
whether the container is up. The client still folds a `nextup` row it is handed into
Continue Watching (`foldNextUpIntoContinue`), because the home cache written by the
previous build is what a TV draws before its first refresh lands.
- **Continue Watching is not ranked** (`progressRow` in `api/ranking.go`).
Every other row goes through `personalizeTitles`; this one keeps Emby's order, which is
most-recently-watched first and the entire reason the row is useful. Ranking it by
taste is not a neutral reshuffle: an episode's row payload carries no studios, cast or
collection, its `Type` has no affinity evidence behind it, and its runtime fits a
session profile built from features badly — so films sorted to the front, episodes
sorted past the visible cards, and a show somebody had just watched an episode of read
as having disappeared. The diversity caps and the exploration shuffle in `WeightedRank`
compound it. `TestProgressRowsKeepEmbyOrder` pins it.
- `HomeCache.rows` persists them for cold start. New fields there need defaults — an
existing install decodes a cache written by the previous build.
- Image URLs are built by the private `imageUrl()` helper. Coil fetches plain URLs with no
@@ -188,6 +250,32 @@ household, so watched/favourite/resume state must never be cached there and stil
from Emby live. A full import mark-and-sweeps on `synced_at`; incremental uses
`MinDateLastSaved` with a minute of overlap.
**External ratings** (MDBList) are bought by the day, not by the request, so the design
question is never "how fast can we fetch" but "how few times must we ever ask". The answer
is that a title is fetched once and kept: `external_media_ratings` holds the raw provider
response permanently, `ratingsNeedRefresh` renews a scored title after 30 days and an
empty answer after 3 (a new release genuinely gains scores), and a stored value is *always*
served immediately — a refresh happens behind the viewer, never in front of one. Redis
still fronts it, but only to save the Postgres read. Things to preserve:
- **A row carries its own ratings.** `decorateItemRatings` injects `MembyRatings` into the
item JSON of home rows, search, related and `/v1/items/{id}`, so a card draws its scores
as the row appears rather than when D-pad focus reaches it. It attaches **only what is
already stored** — one indexed read for a whole launcher, and never an external request
on the request path. `/v1/items/{id}/ratings` still exists for a title nobody has looked
up yet, and the client (`ItemRatingsStrip`) falls back to it on focus.
- **Identity is the expensive half, so it is remembered.** MDBList is keyed by tmdb/imdb
id, which Emby only reveals in a `ProviderIds` lookup — a request per card. `ProviderIds`
is therefore in the library import's `syncFields`, and `item_rating_refs` records what
each Emby item turned out to be as televisions navigate, so the index fills in without
waiting on a full re-import. `ratingKeyFor` holds the rule the live lookup uses: films
and shows only, an episode is rated as its series, tmdb wins over imdb.
- **The warmer is fed by navigation and bounded three ways.** Rows report the titles they
could not decorate; the queue is bounded and deliberately *lossy* (a dropped title is
offered again the next time somebody scrolls past it), fetches are paced, and
`claimRatingsBudget` caps the day — a 429 stops it for an hour. A household's allowance
is spent on titles it actually looks at, in the order it looks at them.
**Maintenance mode** gates the whole `/v1` subtree (that's why `Routes()` builds a
separate `v1` mux) with a 503 carrying `maintenance: true`. `/healthz`, `/readyz` and
`/admin` sit outside it deliberately. State lives in Postgres and is cached in memory,
@@ -210,7 +298,7 @@ never cost a Sonarr request. **Events**, which are published into one shared Red
(`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:
still hear the news. Four 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
@@ -222,6 +310,16 @@ still hear the news. Three publishers today:
`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.
- `AnnounceDeployment`, same file, published by `deploy-server.ps1` through
`POST /admin/api/deployment-alert` — "Memby server in deployment mode". **Where it is
published from is the design**: the deploying script calls the gateway it is about to
replace, using the admin token from the *deployed* `.env`, before the image is built.
That build is the several minutes during which the old gateway still answers and every
open TV polls `/v1/status` at least once. Announcing at the swap would be too late twice
over — nothing is left to publish with once the stack is down, and Redis runs with
`--save "" --appendonly no` and no volume, so the swap discards anything published but
not yet collected. It is best-effort on both sides: a first deployment has no previous
token to announce with, and a deployment must never fail over a banner.
- `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
@@ -264,6 +362,35 @@ every alert look like a different feature. The wire still carries `itemId`/`imag
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.
**Emby outage bar.** An alert is news; this is *state*, and both are needed. `internal/api/
emby_health.go` caches what the reachability probe found and `/v1/status` publishes it as
`emby: {monitored, reachable, since, checkedAt, retrySeconds}`; `ui/EmbyOutageBanner.kt`
renders it as a persistent red strip across the top, on the launcher *and* over playback,
counting down to the next attempt. The case it exists for is a television switched on
twenty minutes into an outage: it was never told anything, the film will not start, and
the alert that announced it has long since fallen out of its window. Things to preserve:
- **`monitored` is load-bearing.** `MEMBY_EMBY_HEALTH_INTERVAL=0` turns the probe off, and
a client that trusted `reachable` alone would then show a permanent red bar on a server
that is working. The server sends `reachable: true` in that case as well, but do not
remove either half.
- **The bar's threshold (`embyOutageThreshold`, 2) is lower than the alert's**
(`embyFailureThreshold`, 3) on purpose. The announcement is a one-shot that cannot be
taken back, so it waits to be sure; the bar clears itself the moment Emby answers, so
being early costs a minute of red rather than a false claim left standing.
- **An outage already on screen keeps its countdown.** The status poll runs six times per
retry, so recomputing the deadline each time would reset the number every ten seconds
and it would never reach zero. `nextOutageState` is the pure rule and is unit-tested.
It counts in `SystemClock.elapsedRealtime`, not wall clock — a TV correcting its clock
mid-outage must not throw the countdown.
- **The news bar yields the strip while it is up** (`ServiceAlertBanner(suppressed = … ||
outage != null)`), in `MainActivity` and in `PlayerActivity` alike. They occupy the same
place and one of them says it better.
- **The direct path has its own probe.** With no gateway there is nobody to ask, so
`MaintenanceMonitor.launchDirectEmbyProbe` pings `System/Info/Public` on the same minute
— unauthenticated on purpose, since a probe needing a token would report a stale session
as a server outage.
**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.
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` →
@@ -292,10 +419,87 @@ on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` sw
failures, because telemetry must never surface on a TV. Aggregates are read at query time
in `store.RowStats`; raw events are pruned after 90 days.
**Admin interface** is `server/internal/api/admin.html`, a single embedded page (no build
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.
**Server logging** answers "who did what, from which television, on which build". Three
pieces make that true and each is easy to undo:
- `internal/logging` writes an **aligned console line** — timestamp, level, message,
fields — because a labelled `time=` in front of the message is noise in every viewer
that already has a timestamp column. Fields are ordered by `fieldRank`: identity
(`component`, `user`, `device`, `client`) first so it can be read as a column, the
constant `version` and the `error` last. `MEMBY_LOG_FORMAT` switches to `logfmt` or
`json`; the ring buffer the admin page reads is fed the same records in every format.
- `internal/api/logcontext.go` carries a **`*requestIdentity` in the request context**.
`withLogging` creates it from the route and the client headers; `authed` fills in the
viewer and television once the session resolves; both the handler's own events
(`s.loggerFor(ctx)`) and the closing request line read it. It is a pointer precisely so
the outer middleware sees what an inner layer learned — `r.WithContext` in the handler
would not reach it. Prefer `s.loggerFor(ctx)` over `s.log` anywhere a request is in
scope, or the line lands with no idea whose it was.
- `componentFor(path)` is **the part of the app a call came from, derived from the
route** rather than declared by the client: the TV would have to thread a surface name
through every repository method, and this way an old APK is attributed correctly too.
Keep it a pure function with a case per area, and add to the test when a route lands.
The events that matter are logged as events, not inferred from request lines: sign-in
(and rejection), sign-out, device removed/renamed, `playback requested` / `started` /
`stopped` (with `watched=`), `next episode resolved`, `update offered`, media requests,
maintenance and feature changes, library syncs. Playback reports carry only an item id,
so `playbackTitles` (bounded, in-memory, lossy on restart) remembers what
`/v1/items/{id}/playback` called the thing, which is what lets a stop be logged by name.
Ten-second progress reports and per-keystroke searches are DEBUG on purpose.
`internal/buildinfo/VERSION` is embedded and appears on every line, on `/healthz` and in
the admin rail — bump it with a meaningful server change; nothing else identifies which
tree a container was deployed from.
**Admin interface** is `server/internal/api/admin/` — a shell, a stylesheet, a shared
runtime and one fragment per page, all embedded and composed by `admin_console.go` at
start-up into finished bytes per URL. No build step and no CDN: a strict no-dependency
console is still the whole point, and serving a page is still a write of a `[]byte`.
Guarded by `MEMBY_ADMIN_TOKEN`; unset means every `/admin` route 404s. It polls
`/admin/api/status` every 30s and stops entirely on a hidden tab.
It was one HTML file holding every screen at once, all but one of them hidden, which is why
opening `/admin/logs` also sent the accounts settings editor and the feature grid, and why
each page read as a pile of unrelated controls. Things to preserve:
- **`adminNav` is the only place a page is declared.** The rail, the page titles, the set of
legal `/admin/<page>` URLs and the render loop all read it, so a page cannot be in the
menu and 404, or be reachable and unnamed. Adding one is an entry there plus
`admin/pages/<id>.html` and `<id>.js`; a fragment with no entry panics at start-up rather
than sitting there looking maintained.
- **Hidden pages are addressed by a route that carries something else in the path.**
`/admin/accounts/{userID}` renders the `account` fragment; `/admin/account` is a 404 and
`cleanInstallerDestination` refuses it, because a sign-in returning there would land on a
page about nobody. That route sends the login form back to `/admin/accounts` instead.
- **The page fragments contain no inline styles.** Everything is drawn from the component
vocabulary in `admin.css` — card, tile, field, check, tag, chip, list, table, glyph. A
screen that needs a look of its own is a missing component, not a licence for a style
attribute; the previous page had four hundred lines of CSS and still reached for
`style="..."` on every second element.
- **Six tones, and three of them mean nothing.** Green is the verdict colour, amber is look
at this, red is wrong — and beside them are info (blue), note (violet) and data (teal),
which carry no judgement at all. They are what lets a page say a person is a different
kind of thing from a television without every coloured element on screen reading as a
warning. A tone is passed, never derived: the same idea wears the same colour on every
page it appears on (the library is teal, a person violet, a television blue), which is
most of what makes twelve screens read as one console. A verdict tag also carries a dot in
its own colour, because tone alone is no signal to somebody who cannot separate the green
from the amber.
- **Icons live in `core.js` and nowhere else** — one stroked path on a 24×24 grid each, the
same shape the rail's marks take. A fragment asks for one with `data-icon` (plus
`data-icon-tone`) on any element and `Admin.decorate` fills it in once, when the page has
parsed; anything a poll redraws asks with `ui.glyph`, or the mark is wiped on the first
refresh. An unknown name draws nothing rather than a broken box: a mark is decoration, and
a typo in one must never be what an operator notices about a page.
- **`core.js` owns the transport, the error banner and the refresh loop**; a page registers
`Admin.onStatus` (called with each status poll) or `Admin.onRefresh` (its own request
alongside it). `Admin.settled`/`fill`/`check` are the one rule that must not be dropped:
never redraw markup the operator is working inside, or every poll takes a half-typed
field or an open select away mid-edit.
- **Nothing on the overview page is editable.** It answers "is anything wrong" and links to
the page that can do something about it. A screen that both summarises and changes state
is where an accidental click lives.
**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
@@ -330,12 +534,140 @@ fields to a home query is a startup-cost regression — extend the detail call i
Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks`,
`resumePositionMs`).
**Synced settings.** A viewer's settings live on the *server* and follow the person to
whichever television they sign into; an operator can also read and push them per user from
the admin console's accounts page. Three pieces:
- **The vocabulary is `internal/api/preferences.go`**, a `preferenceCatalogue` in the same
shape as `featureCatalogue`, and it is the only place that decides what a legal value is.
The admin console renders its editor straight from it (it rides along on
`/admin/api/accounts`), so a new setting is one catalogue entry plus the matching key on
the TV. The store holds the document opaquely — adding a setting is never a migration.
`normalizePreferences` returns a *complete* document with unknown keys dropped and
illegal values replaced, which is what stands between a hand-edited admin request and a
launcher that cannot draw a row; it is the piece worth testing hard.
- **The revision is the delivery mechanism.** `user_preferences` carries one, `/v1/status`
carries the current value, and `PreferencesSync` fetches the document only when it
differs from what this TV holds. That is why an operator's push arrives within a poll
without a second connection, and why the poll stays one integer for every open TV.
Writes take an advisory lock and a revision check because every television in the house
writes this row; a 409 returns the winner's document in the body, and the client
**adopts rather than retries** — the other writer is usually the operator.
`store.ForceRevision` is how the admin push deliberately wins that race.
- **`PreferencesSync.lastSynced` is what stops a feedback loop.** It records the document
both ends agreed on, and a push happens only when the local state differs from *it* —
so adopting a pull, which writes to DataStore and re-emits the settings flow, never
looks like a local edit. Keep that invariant or the two ends will push each other
forever.
**Every revision is kept, and the operator can put one back.** `user_preference_revisions`
holds the whole document per revision with who wrote it, written in the *same transaction*
as the document itself, so there is no state in which a revision exists and nothing records
where it came from. `internal/api/admin_preferences.go` serves it at
`/admin/accounts/{userID}/settings` — a hidden page, addressed by a route carrying the
person in the path like the account page it is reached from. Things to preserve:
- **A restore is a forward write, never a rewind.** It goes out as the *next* revision
carrying an old document, with `restored_from` recording where it came from. The revision
is the entire delivery mechanism — televisions compare numbers — so one that went
backwards would leave every set in the house believing it was already up to date while
holding what the operator had just replaced. It is also what makes a restore undoable:
the version it replaced is still a row below it.
- **The restored document is re-normalised.** A revision written before a setting existed
has nothing to say about it, and one written before its options changed may hold a value
the server would now reject — restoring verbatim would put that on a television.
- **What a row *says* is `preferenceChanges`**, a pure function driven entirely by the
catalogue, so a setting added tomorrow is described without touching it. It compares the
rendered *labels* rather than the values: two documents that read identically have not
changed anything an operator can see, and a television pushing back the document it
already held is an ordinary event that must not fill the table with rows nobody made. The
oldest revision held is labelled `initial` rather than diffed against the defaults, which
would claim decisions nobody made.
- **`user_preference_acks` is the receipt the revision does not have.** The status poll
tells every open TV the number; being told is not having adopted, so an ack is written
when a set *fetches* the document (and when its own write is accepted, or its 409 hands
it a winner it adopts). That is what separates "the bedroom TV never fetched it" from
"it fetched it and something has since overwritten it". History and acks are pruned
together at write time by `preferenceHistoryLimit`, except that a device's most recent
ack is never pruned — a set switched off for a year is exactly the one worth describing
as "on revision 12" rather than as one that has never checked in.
**Upgrading an existing install is schema 2** (`SettingsMigrationLogic`,
`CURRENT_SETTINGS_SCHEMA`). Nothing is lost: the flat keys already hold every value and are
what the app renders from, so a viewer sees their settings unchanged on first launch, and
`preferencesRevision` starts at 0 — which means the first sync *pushes* what the TV has
rather than pulling defaults down over it. The one thing that needed a migration step is
the three toggles that moved from device-wide to per-profile: an existing install has them
in the flat keys and in no profile, so they decode as the defaults, and the first profile
switch would have copied those defaults back over the flat keys (`applyProfile` writes
profile → flat) and then synced the result up as a deliberate choice. Step 1→2 folds the
device-wide value into every stored profile, which is exact rather than approximate —
while it was device-wide, that value really was in force for all of them. It cannot detect
its own work (the encoder omits default values, so a profile that chose `true` is
byte-identical to one that never chose), so the schema version is the only thing making it
run once; a test pins that.
What syncs is a person's choices; what does not is anything identifying a *television* —
device name, update source and token, the screensaver's rotation and ring colour.
`showTitleLogo` / `autoPlayNextEpisode` / `showTenMinuteReminder` moved from device-wide to
per-profile as part of this, because a device-wide value would push whoever signed in last
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all seventeen
keys and the revision in **one** edit — DataStore rewrites the whole file per edit, and the
revision landing apart from the values it describes would leave a TV permanently believing
it was up to date while holding something else.
**Multi-profile session state.** `SettingsStore` stores a list of `EmbyProfile` (server,
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()`.
**`deviceId` is the television's identity**, in Emby's devices list and in Settings →
Devices alike: the gateway holds one session per `(user, device_id)` and Emby keys its own
device record on the same value, so a set that signs in with an id either list has seen
replaces its entry rather than adding one. It therefore has to outlive the app's own
storage, which on these sets it does not — every APK is sideloaded, an install that will
not go over the old one is done by hand as an uninstall and reinstall, and the DataStore's
`ReplaceFileCorruptionHandler` empties the file after a process killed mid-write. So
`deviceIdFor` derives it from `ANDROID_ID` (hashed, so the platform id is never sent
anywhere) rather than generating a UUID, and falls back to a random id only for the values
that identify nothing — null, blank, all zeroes, or the one a batch of early devices
shared, where two televisions would otherwise become one. An id already stored is kept:
changing it is the duplicate this avoids, which is why installs predating this keep theirs
and converge only when they are next reinstalled.
Removing a device is two deletions, not one. `handleDeleteDevice` (and the admin console's
equivalent) revokes the gateway session and then calls `retireEmbyDevice`, because logging
out only invalidates the token — Emby keeps the device row in its dashboard until the row
itself is deleted, so a TV removed from one list would stay visible in the other. It is
best-effort on purpose: the session is already gone, which is what ends that TV's access,
and it needs the sync credentials since the record belongs to the server rather than to
the viewer.
**A television that changes its device id supersedes its old one.** Sessions are unique per
`(user, device_id)`, so a second row for one set can only mean the id itself moved — a
reinstall on a build that generated a random one, or an install predating the derived id.
Left alone each of those keeps a session row, an Emby device record and a build history of
its own, and one set in a living room reads as three. `store.supersedeDevices` runs inside
`CreateSession`'s transaction, deletes the same user's other rows carrying the same device
*name*, and hands them back so `retireSupersededDevices` can take the cached session, the
build history and the Emby record with them. Two things hold it up: the match is on the
name because it is the only evidence there is — the token, the session and the Emby record
are all new — and `supersedeName` (pure, tested) refuses a blank name and
`store.DefaultDeviceName`, the compatibility placeholder an unnamed build sends, or the
second unnamed set in a household would sign the first out on every launch. It happens
after the sign-in has succeeded, because tidying a set's previous life must never be what
stops it getting in.
**Build history is per device id, not per session** (`device_versions`). A session row
carries only the version in force right now and is overwritten by the next call reporting a
different one, so on its own "what has this set been running" is one value deep. It is
written from two places and needs both: `handleLogin` for a fresh sign-in, and
`captureClientIdentity` for a set that updated *itself* and will therefore never sign in
again — guarded there on the version actually having moved, since every authenticated
request reaches that path. It is keyed on the device rather than the viewer because the
history belongs to the television, and it is deleted wherever a device row is.
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
@@ -393,17 +725,218 @@ Playback from the dream `finish()`es first and starts `PlayerActivity` on a dela
thread post to avoid the "activity behind the dream" race.
**In-app updates.** `UpdateChecker` polls a user-configured **Gitea** release
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads the
APK and hands it to the system installer via `FileProvider`. Because replacing the APK kills
a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads and
verifies the APK, then commits it to a **`PackageInstaller` session**. Because replacing the
APK kills a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
`MY_PACKAGE_REPLACED` and relaunches `MainActivity` with
`EXTRA_LAUNCH_UPDATED_SLIDESHOW`.
The session is not an implementation detail — it is why the updater works on a television.
The phone idiom, an `ACTION_VIEW` intent at the APK's content URI, fails three ways here: the
implicit intent is subject to package visibility on Android 11+, several TV builds expose no
activity for the package-archive MIME type at all, and nothing reports what the installer
then did. Sets that hit it had to be reinstalled by hand to move a version. So:
- **`verifyApk` must distinguish "wrong key" from "could not read the key".** It compares
the downloaded APK's signers against the installed app's, and the trap is that
`getPackageArchiveInfo` leaves `signingInfo` null on several Android versions where
`getPackageInfo` fills it in. Asking only for `GET_SIGNING_CERTIFICATES` therefore
produced an empty set for the archive, which the check reported as *"not signed by
Memby's trusted release key"* — on correctly signed APKs, every release, leaving a manual
reinstall as the only way to move a version. So: both flags on both sides, prefer
`apkContentsSigners` and fall back to `signatures`, and let `signerVerdict` (pure,
unit-tested) return `UNVERIFIABLE` rather than `MISMATCH`. An unverifiable read proceeds:
the APK has already been matched against the published SHA-256, package name and version,
and Android enforces signature identity at install time regardless — a real mismatch comes
back as `STATUS_FAILURE_CONFLICT` with a message saying to reinstall.
- **The outcome is a broadcast, not a return value.** `downloadAndInstall` succeeding means
the session was *committed*; `InstallResultReceiver` receives what happened and publishes
it on `AppInstall.messages`, which every screen that can start an install collects. A
mandatory update is the reason this matters: the screen cannot be dismissed, so "Opening
the installer…" with nothing following it is a dead end with no explanation.
`installStatusMessage` is the pure wording rule and is unit-tested — a TV has no logcat
and no support channel, so that sentence is the whole diagnosis.
- **`STATUS_PENDING_USER_ACTION` is the normal path, not a failure.** The system hands back
an intent for its own confirmation screen and the receiver must launch it.
- **The permission is asked for during setup, not at update time.** Every TV here is
sideloaded, in practice through Downloader — which means *Downloader* holds Android's
per-app install permission and Memby never does. `ui/InstallPermissionScreen.kt` sits
between `FirstRunScreen` and `SetupScreen` on a fresh install, and is deliberately
**skippable**: a permission that only matters later must never block a new install, and on
a TV with no permission screen there would be nothing the viewer could do to satisfy it.
The numbered steps are the substance — Android's own screen is an unexplained list of app
names with switches, reached by a remote.
- **The operator can push that step to TVs already in service**, which is the half that
fixes the existing fleet rather than only new installs. It is an ordinary entry in the
gateway's `featureCatalogue` (`install_permission_prompt`), so it rides the status poll
and the admin console renders its toggle with no extra work. Three conditions guard it and
all three matter: the client must declare `install_permission_v1` (an older app can never
be sent a screen it does not have), the operator must have it on, and the permission must
actually be missing — which is what makes it self-clearing, since granting it removes the
only reason it appears. `MaintenanceMonitor.installPermissionPrompt` defaults to **false**:
a missing field must not conjure a screen.
- **The permission dead end is stated, not retried.** Many TV builds do not implement
`ACTION_MANAGE_UNKNOWN_APP_SOURCES`; when starting it fails, the message names the path
through the TV's own settings instead. Before this the failure was swallowed and the
screen promised to continue "when you return" from a screen that never opened.
- Stale sessions are abandoned before a new one is created (they hold a staged APK and count
against the per-app limit), and `UPDATE_PACKAGES_WITHOUT_USER_ACTION` lets later updates
apply silently once Memby is its own installer of record — never depended on, since the
system falls back to asking.
**Playback** uses Emby's direct stream (`/Videos/{id}/stream?static=true`) — no
`PlaybackInfo`/transcode negotiation, so exotic codecs may fail. The
`media3-exoplayer-hls` dependency is already present for when that's added. Progress is
reported back to Emby via `reportPlaybackStarted/Progress/Stopped`.
**Which subtitle comes on is the gateway's decision**, not the television's. The server is
what calls `PlaybackInfo` and enumerates the streams, so `selectSubtitle` in
`internal/api/subtitles.go` picks one from the viewer's synced settings
(`subtitlesEnabled`, `subtitleLanguage`) and `/v1/items/{id}/playback` and `/next` return it
as `selectedSubtitleId`. Turning subtitles off, or choosing Italian, in the player's overlay
writes those two settings (`SettingsStore.setSubtitlePreference`), so the choice follows the
person to every set rather than staying in the room it was made in. Things to preserve:
- **The rule exists twice on purpose.** `selectSubtitleId` in `data/SubtitleSupport.kt` is
the direct path's copy, and the two are pinned by deliberately parallel tests
(`SubtitlePreferenceTest`, `subtitles_test.go`) — with no gateway there is nobody to ask,
and a viewer must not get different subtitles depending on whether the container is up.
The language alias table is duplicated for the same reason: Emby writes three-letter codes,
media3 reports two, and both ends have to agree on what "Italian" means.
- **A chosen language that the title does not have falls back to a *forced* track and
nothing else.** Falling through to the default would put English on screen for somebody
who asked for Italian; forced subtitles translate what is foreign to the film's own audio
and are wanted either way.
- **`subtitlesEnabled: false` disables the text track explicitly.** Declining to select one
is not enough — media3 turns on a default-flagged track by itself, so "off" has to be said.
- **`selectedSubtitleId` is matched on `Format.id`**, which is the id the sidecar's
`SubtitleConfiguration` was built with. It can legitimately fail to match a container's
embedded track, which is why `preferredTextTrack` falls back to running the same rule over
the player's real tracks rather than giving up.
**A subtitle the library does not have is fetched through Bazarr**, from the player, in
`server/internal/bazarr` and `internal/api/subtitle_download.go`. The whole feature rests on
one property of Bazarr: it writes the file *beside the media file*. So the gateway stores
nothing, serves nothing and never holds a provider credential — it asks Bazarr to fetch,
calls `emby.RefreshItem` so Emby notices, waits `embyRefreshSettleDelay`, and re-reads the
streams; the new track then arrives down the ordinary `PlaybackInfo` path, which is why
`playableSubtitle` needed no new shape and `selectSubtitle` works on it with no special
case. Things to preserve:
- **The hard part is identity, not the download.** Bazarr keys on the *arr's id
(`radarrid`, Sonarr's `episodeid`) and Emby knows neither, so `bazarrMovieFor` /
`bazarrSeriesFor` / `bazarrEpisodeFor` match by title, year and episode number. They are
pure and tested hard because a mismatch writes one film's subtitle next to another.
Episodes match on *numbers*, never titles — the two disagree often enough (translations,
differently named two-parters) to reject correct matches — and season 0 is specials, a
real season, not "no season".
- **The provider row is opaque.** `subtitle` is a provider-specific token that must be
handed back verbatim on the download call; it round-trips through the television
untouched rather than living in a server-side cache, so a viewer reading the list by
remote cannot have their choice expire underneath them.
- **`subtitleDownloadAvailable` rides the playback response**, not `/v1/status`: the
drop-up is the only thing that asks and it already holds that response, where the status
poll is made by every open TV every ten seconds. It is `bazarr != nil` **and** the
`subtitle_download` feature, and the client default is **false** — a missing field must
never conjure a row that leads to a request the backend cannot answer.
- **Client-side it is a second screen, not a third section.** The drop-up is 344dp by about
a third of a 720p screen; stacking a track list, the size chips and search results
squeezed the tracks to one visible row. `SubtitleDownloadState.expanded` swaps
`player_subtitle_main_section` out, and Back steps out of the download half before it
closes the menu — one press per level.
- **`subtitleRequestInFlight` is a flag, not `job?.isActive`.** `lifecycleScope` uses the
immediate main dispatcher, so a coroutine body runs synchronously up to its first
suspension — before `subtitleSearchJob =` has been assigned. Reading the job would see
the previous one on exactly the redraw meant to disable the rows.
- **A download resets `subtitleAutoSelectionAttempted`.** The new track only exists in a
freshly built media item; without the reset the sidecar is attached and nothing turns it
on.
**Changing it mid-film is a drop-up**, `player_subtitle_overlay.xml`, anchored over the
subtitle button it opens from rather than the full-height panel it used to be. It sits on
somebody's film, so there is no scrim and the panel is near-black: the option under focus is
the only fill on it (accent green), the choice in force is a quiet grey plate with a green
label, and every other row has no background at all. The margins are measured off the
transport row — 44dp end, 112dp bottom to clear its 72dp strip and the controls' 28dp
padding — and the top margin is what caps the track list before its `ScrollView` (which
takes the overflow via `layout_weight`) starts scrolling instead of growing up the screen.
`bindSubtitleMenu` in `ui/player/SubtitleMenu.kt` fills it from plain
`SubtitleMenuEntry` lists so `SubtitleMenuScreenshotTest` can render the real menu with no
player, server or decoder → `build/screenshots/subtitles-menu/`; deciding *what* the entries
are stays in `PlayerActivity`, the only thing that can read media3's tracks. Only the first
open animates — redrawing after a choice would slide the menu again under someone still
working down it.
**Who is that? is a button, not a menu item.** The cast panel has its own `player_cast`
control beside the subtitle button in `memby_player_controls.xml`, because the question is
asked mid-scene and one that has to survive a dialog and four menu rows is one nobody asks
twice. It is deliberately *not* also in `showTrackMenu`'s list: one thing reachable two ways
is one thing whose two entry points drift apart. Things to preserve:
- **The panel is a fade, not a card.** `player_cast_scrim` carries it up from the bottom
edge so the scene stays legible above the names — which is the reason somebody opened it.
The 48dp side inset matches the transport row, so opening it does not shift the column the
title and controls are read in.
- **The heading is the title, not the word "Cast".** The accent eyebrow above already says
what the panel is; repeating the button just pressed costs the line that could confirm
what is being watched.
- **Initials sit behind every portrait** (`castInitials`, pure and tested). Emby has no
photo for a good part of a typical cast, and a row of identical grey rectangles says
nothing about which name is which. They are behind rather than instead of the image, so
nothing has to decide in advance whether artwork will arrive.
- **The focus ring is the `foreground`**, drawn over the artwork, and the portrait takes
`duplicateParentState` because the *card* is what is focusable. A remote has no hover: the
ring and the scale are the only thing saying which face is selected.
- `bindCastPanel` in `ui/player/CastPanel.kt` takes a `CastPanelState` and an injected image
loader, so `CastPanelScreenshotTest` renders the real cards with no player, server or
network → `build/screenshots/cast-panel/`. `loaded` is separate from an empty list because
"still fetching" and "no cast recorded" are different things to be told.
**Time to first frame** is the number playback is judged by, and a *resume* is the worst
case: it is a seek, and a seek over HTTP is several more requests before a single frame is
decoded. Four things exist to hold it down, and each is easy to give back:
- **`ui/player/PlayerEngine.kt` builds the player**, apart from the activity, because
construction is on the critical path of every launch. It pulls media bytes through
`HttpStack` rather than media3's own HttpURLConnection client, so the header, index and
offset requests a resume makes reuse one connection instead of repeating the handshake
three times; and it enables constant-bitrate seeking, so a container with no usable seek
table computes the offset instead of reading its way there. Both are borrowed from
[Wholphin](https://github.com/damontecres/Wholphin), a Jellyfin TV client under the same
GPL-2.0 licence.
- **`PlayerActivity.onCreate` is ordered as critical path then decoration**, with the
comment saying so. Build the player, hand it the stream, *then* wire the overlays. This is
safe because media3 posts its callbacks to the main thread and none can arrive until
`onCreate` returns. The service-alert `ComposeView` mounts on the first call to
`hidePlaybackLoading` and the cast lookup runs from `startPlaybackSession`: both used to
run during `onCreate`, spending Compose's first composition and an Emby request at exactly
the moment the decoder wanted the main thread and the connection pool.
- **A resume opens the player before the stream is resolved.** `PlaybackRequest` carries
what the launcher already knew from the card, `PlayerActivity` resolves the stream while
the activity, its layout and its decoder are starting, and `adoptPlayable` takes on
whatever the server settled (for a series, which episode). A cold start deliberately still
resolves first: its wait is already spent inside the pre-roll, which cannot begin until
there is a stream playing behind it, and whether there is a pre-roll at all is part of the
same answer. `MainActivity` keeps two states for this — `launchingItem` is the gate that
stops a second Play press stacking a second player, `resolvingItem` is the loading screen
and belongs only to the route that waits.
- **`ui/player/PlaybackTrace.kt` says where the time went.** "Playback is slow" is not
actionable; `event=first_frame … activity=…(+…) player=… stream=… prepared=… ready=…
first_frame=…` is. Marks are cumulative from the Play press, a repeated stage keeps the
first time it was reached, and a stage that never happened is absent rather than zero.
`PlaybackTraceSections.kt` names the same two spans for a systrace so `:benchmark` can
measure what the log can only report — see "Benchmarks" below.
Measured on a Chromecast with Google TV against the NAS gateway, the shape is:
`prepare()` → first frame is **over 90%** of a resume, the stream negotiation is ~120 ms
and the app's own startup ~365 ms. Within that, the largest single term is **cold versus
warm connection to Emby** — the same file, same seek, was 4177 ms on the first playback of
a session and 2004 ms on the second. Artwork and API traffic go to the *gateway* host, so
the pool has nothing open to *Emby* when the first playback starts, and that first resume
pays DNS, TCP, TLS and Emby's file open. Pre-warming that connection is the open
opportunity. Two things that look like causes and are not: the subtitle auto-selection
costs 20200 ms, not seconds, and the seek itself is about 900 ms.
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from
@@ -418,6 +951,40 @@ inside the running player instead of relaunching the activity, so `itemId`/`play
/`stopReported` must all be reset together or the outgoing episode is never reported
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
**Skipping is Left and Right, and it does not open anything.** `ui/player/SeekControls.kt`
holds the arithmetic and the wording; `PlayerActivity.dispatchKeyEvent` owns the keys and
`player_seek_indicator.xml` is the centred chip that says what just happened. How far one
press moves is `Settings.seekIntervalSeconds` — 10, 20 or 30, a synced per-profile setting
like the subtitle ones, with the same vocabulary in `data/SeekPreference.kt` and in the
gateway's catalogue. Things to preserve:
- **A press moves a target, not the playhead.** `SeekPreview` accumulates and the seek is
committed `SEEK_COMMIT_DELAY_MS` after the last press, because a seek over HTTP is
several requests before a frame is decoded — four quick presses must be one seek of two
minutes, not four the viewer sits through in turn. It also has to accumulate against the
*previous target* rather than the live position, or the film running underneath swallows
part of every press after the first.
- **The keys are only taken while the transport is hidden** (`seekControlsActive`, the
same gate shape as `centrePausesPlayback`). With the controls up, Left and Right belong
to whatever holds focus, and taking them would leave the subtitle and cast buttons
unreachable. A stream that is not seekable falls through to media3 instead: nothing
errors and nothing claims to have skipped.
- **Only discrete presses count.** A held key repeats at the platform's rate, which is fast
enough to throw somebody minutes down a film they meant to nudge — the repeats are
consumed rather than acted on, so letting go does not open the transport either.
- **A pending skip is committed in `onStop`** and dropped by `resetSeekControls` when the
episode underneath changes, or the position reported to Emby — and so where the title
resumes from — is one the viewer had already skipped past.
- **The buffering a skip causes belongs to the skip** (`seekBuffering`), so the loading
overlay is withheld while the seek lands and the OSD is held up in its place — the viewer
asked to move, and "+30s · 1:12:40" over their film is the answer to that where "Opening
Memby…" reads as a failure. It also un-wedged the keys: `seekControlsActive` refuses to
act while the overlay is up, so the overlay a skip raised swallowed the next press of the
same key. Two bounds keep it honest — `showPlaybackLoading` clears the flag, so a retry,
an error or the next episode is never withheld on a skip's account, and
`SEEK_LOADING_GRACE_MS` puts the overlay up after all if the seek is still buffering
6 seconds later, because past that it is not a skip landing, it is a film that stopped.
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
tag `EmbyClientPerf`. `benchmark/` is a `com.android.test` macrobenchmark module targeting
the release variants the `androidx.baselineprofile` plugin generates, so its numbers are
@@ -425,6 +992,28 @@ real rather than debug-influenced. `HomeBenchmark` deliberately measures cold st
`CompilationMode.None()` and `Partial()` — because the only way to know the baseline
profile is earning its keep is to see both numbers.
`PlaybackBenchmark` measures time to first frame off the `Memby.playback*` trace spans,
with `resumeFromContinueWatching` and `coldStartFromHomeHero` as a pair — a resume differs
from a cold start in one way that matters, so it takes both to say whether a change helped
seeking or helped everything. Unlike the rest of the module it **needs a signed-in TV and a
reachable Emby**, since most of what it measures is network and decoder; its numbers only
compare against other runs on the same TV, server and title. It exists because a handful of
hand-timed launches could not settle anything — identical runs of one file varied by 70%.
Things to preserve: browsing happens in `setupBlock` so only the Play press is timed;
`openResumableDetailPage` *searches* the Continue Watching row for a Resume button rather
than assuming a position, because the row reorders as the household watches, and skips the
run rather than silently measuring a cold start and calling it a resume. The section names
are duplicated between `PlaybackTraceSections` and the benchmark because a `com.android.test`
module cannot link against the app — `PlaybackTraceNamesTest` is what stops a rename turning
the benchmark into one that finds no slices and cheerfully reports zero.
Two task names to get right, both of which fail confusingly rather than obviously. To check
it compiles use `:benchmark:compileBenchmarkReleaseKotlin` — `:benchmark:assemble` triggers
baseline-profile generation against a connected device. To *run* a benchmark use
`:benchmark:connectedBenchmarkReleaseAndroidTest`, not `connectedCheck`, which also runs the
`nonMinifiedRelease` variant and measures everything a second time. And `JAVA_HOME` must
point at the JBR, as for any direct Gradle invocation here.
**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
@@ -440,11 +1029,37 @@ R8 warnings do not fail the build — check the task output after changing depen
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`.
Emby API, the gateway API, Coil's artwork loader and the video stream itself 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. It
matters again for a resume, which opens the same file three times over before the first
frame. Don't construct a bare `OkHttpClient.Builder()` — derive from `HttpStack.base`. The
stream's derived client raises the read timeout and sets **no call timeout**, which would
cap the length of a film.
## Language
**Everything a person reads is New Zealand English.** No American spellings: `-ise`/
`-isation` (personalise, synchronisation, organise), `-our` (colour, favourite, behaviour),
`-re` (centre, theatre), **licence** the noun and *license* the verb, **programme** for a
broadcast, and grey, catalogue, cancelled, labelled, travelling. This covers on-screen copy
in Kotlin and `res/values/strings.xml`, `CHANGELOG.md` (which the TV renders twice — Settings
→ About and the what's-new panel), the admin console, the release landing page in
`dist/template/`, every string the gateway sends the client to display (row titles, alert
`label`s, the preference and feature catalogues, error messages), and this repository's own
prose and comments.
**It does not apply to identifiers or anything on a wire.** Emby's API is American
(`favorites`, `IsFavorite`), so are Android and Compose (`Color`, `fontSize`,
`TheaterComedy`, `RecognizerIntent`), Go and Kotlin (`synchronized`, and the literal
`"request canceled"` in `images.go`, which matches `net/http`'s own error text), SPDX and
GPL names ("GNU General Public License"), and CSS. Renaming any of those breaks the wire or
the build. The rule is about words a person reads, never tokens a machine matches.
The boundary sits at the render, and the favourites row is the worked example: its id and
`kind` stay `favorites` on both sides, `personalisedFavouritesTitle` puts **"Favourites"** on
the screen. When a new string is both, spell the display half and leave the key alone.
## UI conventions
@@ -476,7 +1091,7 @@ than introducing a separate type, which is what lets `homeRowsFor` keep taking a
**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
`MembyQuietText`, `MembyRatingsSurface` behind the ratings strip, 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`'
@@ -492,11 +1107,37 @@ with the same look and different metrics, and raw `androidx.tv.material3.Button`
glyphs typed into their labels ("▶ Resume", "✓ 30 min"), which picked up theme colours
nothing around them uses.
**Settings is black, flat, and says one thing once.** `ui/settings/SettingsSheet.kt` had four
stacked surfaces to show two switches — the page, the rail, a titled section card, and the
rows inside it — and the card's title repeated the page header, which repeated the rail item
already highlighted beside it. It is now a black canvas with the rail separated by a single
right-edge hairline, and `SettingsGroup` lays rows flat with `SettingDivider` between them:
no card, no icon chip, no section heading. `SettingsGroup(label = …)` exists only for a page
with genuinely two groups (About), and is a quiet caption rather than a second heading. Three
things to keep: the *controls* are untouched (`StatusToggle`, `SettingsChoiceChip`, the badge
pill) because they are what makes the screen read as Memby and they look better on black than
they did on a card; the row under focus is the only lit surface on the page, so nothing else
may grow a background; and everything shares a 16dp left inset — header, rows, dividers,
notices — because with the card gone that inset is the only thing holding the column
together. Copy is plain-language and second person ("Ten minutes left", "Hide films you have
seen"), not feature names.
**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.
`heroFacts`, `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.
**A score is only ever shown by the ratings strip.** Emby's `CommunityRating` used to be
drawn as a gold `★ 8.4` on the detail fact row, the home card metadata panel and the home
hero, beside the strip that was already showing IMDb and Rotten Tomatoes — two ratings in
one panel, one of which named no provider at all. The client no longer renders it anywhere
outside the screensaver (which has no strip), and `EmbyRepository.getRatings` no longer
falls back to it either: standing it in for a real source meant a card claiming a TMDb score
TMDb had never been asked for. A title MDBList cannot answer for now shows no strip, which
is the honest answer. Scores are formatted by the gateway alone (`formatRatingScore`), by
the scale they are measured on — a fractional scale always keeps its decimal, so IMDb 7
goes out as `7.0` rather than a bare `7` beside somebody else's `8.2`.
**Home rows all lead with the same header**: `HomeRowHeaderIcon` + `HomeRowHeaderIconGap`,
`HomeRowHeaderSpacing`. A header that skips the icon chip starts its title 38dp left of
@@ -505,10 +1146,33 @@ 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.
and falls back to every movie in the response, so it routinely lied. Only the **minis** carry
that caption now: they have no fact line, so the label is the only reason the card gives,
where on the featured card it sat above a line already printing the year and cost the height
that broke the button.
**Play is measured before the words.** The featured card is a fixed height, and a Column
gives each child what the ones before it left — so the chip, being last, was handed the
remainder after a two-line title and rendered as a green sliver with its label squeezed out.
Compressed, not clipped, which is why it read as malformed rather than missing. The text now
sits in a `weight(1f, fill = false)` child, and weighted children are measured from what is
left over: the spacer and the chip take their natural size first and the prose gives way.
Keep that inversion. The `titleLines == 1` rule that stands the synopsis down is still worth
having — it means the give usually costs nothing visible — but it is a tidiness, not the
guarantee. `HomeMovieHeroScreenshotTest` renders the wrapping-title case for exactly this.
**The hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)` takes a
count of local days and rotates the starting point of each candidate list; `MainActivity`
keys its `remember` on `rememberHomeHeroDay()`, which sleeps until the next local midnight
rather than polling. Three properties are load-bearing and unit-tested. It is a *rotation*,
not a shuffle: the server's ranking is still the order, so what it thinks is worth leading
with comes round again and yesterday's hero is one place down rather than somewhere
arbitrary. The same day always yields the same four cards — the launcher rebuilds on every
home refresh and focus change, and a hero that re-picked each time would churn under
someone walking past. And the day is *local*: "resets at midnight" means the viewer's
midnight, which is why the zone offset is a parameter to the pure `localEpochDay` /
`millisUntilNextLocalDay` rather than read inside them. `Math.floorDiv`/`floorMod` for longs
arrived in API 24 and this app ships to 23, so that arithmetic is written out by hand.
**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
@@ -561,6 +1225,21 @@ series one; they differ only in which tabs they offer. The tabs are Overview, Ep
`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.
- **Moving between the three bands is stated as intent, not as one destination.** Hero →
strip → pane is the page's whole navigation and it must never be dead, so the scaffold
routes those presses through `Modifier.onVerticalNavigation` and `focusFirstAvailable`,
which takes a *list* — the selected tab, then the band as a focus group, then the pane —
and moves to the first that is actually placed. Down out of the hero is on the hero as a
whole rather than on the row of buttons, because whatever in there holds focus the press
means the same thing; a band with nothing in it yet (an episode page whose seasons have
not arrived) is passed through rather than stopped at. Two things to keep. It is
`onKeyEvent`, not the preview, so a control that means something of its own by Up or Down
keeps it, and an unhandled press still falls through to Compose's own focus search. And
the **content pane keeps `focusProperties { up = … }`** rather than a key handler: panes
navigate vertically inside themselves and override that property where they do
(`EpisodeCard` sets `up = FocusRequester.Default` on every card but the first), which a
blanket handler would take away. The tab strip anchors that requester to the first tab
when the selected one is not in the list, so the property always names something real.
- 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.
@@ -578,11 +1257,129 @@ series one; they differ only in which tabs they offer. The tabs are Overview, Ep
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.
- **Nothing about related titles is allowed to fail.** It is asked for on *focus*
(`HomeViewModel.focusItem` warms it while the card is highlighted), so it is the most
frequently made request on the launcher and was by some way the loudest thing in the
gateway's error log. `RelatedTo` now degrades at every step instead: a taste profile that
cannot be built costs the reasons and not the carousel (`Why` falls back to catalogue
facts, `FilterUnseen` keeps everything), a failed or empty `Similar` lookup falls through
to `genreNeighbours` — the imported catalogue, in this title's genres, best rated first,
which is also the only half that works while Emby is the thing that is down — and
`relatedSubject` reads the item itself from `library_items` when Emby will not answer for
it. The only error the engine still returns is the viewer having navigated on, and the
handler answers that with silence rather than a logged 502. Two supporting rules: an
empty carousel is cached for `relatedEmptyTTL` rather than the item lifetime, because a
ten-minute answer must not outlive the minute of trouble that produced it; and
client-side `getRelated` is single-flighted on the repository's own scope, so the
cancelled focus prefetch neither aborts the request the detail page is about to want nor
caches its own failure as an answer.
- `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.
**"Estimated finish: 18 August"** is `data/SeriesPace.kt`, a quiet line under the series
hero's progress bar. It is derived entirely from the episode list the detail page already
holds — Emby's per-episode `UserData.Played` and `LastPlayedDate` — which is the whole
design: there is no new storage, nothing to invalidate, and **no second implementation on
the direct path**, unlike the subtitle and Continue Watching rules. It is per viewer and
per series by construction, since that user data is Emby's and is keyed that way, and it
recalculates for free because finishing an episode, marking one watched, a history sync
from another client and a newly imported episode all change that list and nothing else.
Things to preserve:
- **Most of the function is about refusing to answer.** A wrong date is worse than none, so
every guard returns null: fewer than three completions (two only when they are on
separate days and within a week of each other), nothing watched in a month, one or zero
episodes left, a horizon past a year. The load-bearing one is that the window must span
**at least two distinct local days** — three episodes in one evening is a sitting, not a
rate, and reading a daily pace off it is exactly how the opening of a binge promises a
finish this week.
- **The window ends at the present and stops at the last break** (`sinceLastBreak`), rather
than averaging a viewer's whole history with the show. Somebody who took a year over
season one and is now watching nightly is watching nightly; including the silence
predicts a finish years out.
- **Estimating and wording are separate functions.** `estimateSeriesPace` answers in
numbers and `seriesPaceLabel` turns them into a sentence, so a "finish this weekend" row
or a completion reminder can use the first without inheriting the second. Near dates are
named and far ones rounded to weeks or months — a pace measured over a fortnight cannot
honestly pick a day four months out.
- **"Catch up" is not a synonym for "finish".** `BaseItem.isOngoingSeries` prefers Sonarr's
lifecycle where the gateway attached one and falls back to Emby's `Status`, which is the
only source the direct path has; both absent means "finish", the weaker claim. `Status`
is in `fieldsDetail` on both paths for this, and the gateway's item cache key moved to
`item:v5:` so entries written before it cannot hide the field.
- **Quiet is the property a unit test cannot check**, so `SeriesPaceScreenshotTest` renders
the line on a real hero → `build/screenshots/series-pace/`. It covers both spacing cases
(with a progress bar above it and without), both verbs, and — the one worth keeping — the
empty case, which must leave the hero identical with nothing holding the line's space.
Its history is built relative to the clock rather than pinned, because a fixture with a
fixed date would fall out of the recency window and capture the empty case by accident.
- **The date is formatted here, not by the platform.** `formatPaceDate` names the month
from its own table so a set configured in US English cannot start printing "August 18"
into New Zealand copy, and the civil-calendar arithmetic is hand-rolled because
`java.time` needs API 26 and this app ships to 23 — the same reason `data/LocalDays.kt`
writes out `floorDiv`. That file is where the local-day arithmetic now lives; it was
private to `HomeMovieHero` while the daily hero rotation was its only caller.
**An episode has its own page.** `ui/EpisodeDetailsOverlay.kt`, reached whenever
`item.isEpisode` — which is what Continue Watching hands over. It is the same
`DetailPageScaffold` as the other two with two substitutions, both of them scaffold
parameters rather than a second layout:
- The logo belongs to the *series*, so the hero takes an `eyebrow` ("SEASON 3 · EPISODE 4")
and a `subtitle` (the episode's own title) under it, plus a `title` override for the
no-logo fallback — without that override the fallback heading printed the episode's name
a second time.
- `strip` replaces the tab strip in the band under the hero, keeping the same
`DetailStripHeight`, the same fold and the same focus contract (it is handed the requester
above it, the one below it, and the callback that pins the page). An episode gets the
**season scroller**: every season the library holds, the current one flagged WATCHING,
earlier ones dimmed and ticked. The rules are pure and pinned in `EpisodeDetailTest` —
seasons *before* the current one count as watched however patchy they are, seasons after
never do however much has been sampled, and specials (season 0) are exempt from the
"behind you" rule entirely because a tick on an unwatched special is a claim the page must
not make. `seriesProgressLabel` counts numbered seasons only for the same reason.
- The pane under it is the selected season's episodes, opened on the episode the page is
about and flagged THIS EPISODE. It reuses `EpisodeCard` from the series page rather than
copying it, or the two screens drift on what a watched episode looks like.
- Screenshots are `EpisodeDetailScreenshotTest` → `build/screenshots/episode-detail/`.
**A schedule card opens the show, and says why.** The "Shows airing in the next 5 days"
row is informational — its cards are episodes that have not aired, `MembyPlayable: false`,
so pressing one used to do nothing at all. It now opens the *series* page, with the air
time restated on it (`ui/detail/AiringNotice.kt` → the accent band in `DetailPageScaffold`,
where the recommendation reason would otherwise sit). Four things hold it together:
- **The link is server-side.** `MembySeriesItemId` is resolved in `api/sonarr.go` by
matching the Sonarr title (and year, which wins when both a remake and its original are
in the library) against `store.SeriesRefs`. A show Sonarr follows but Emby has never
imported carries none, and its card stays inert rather than opening an empty page.
- **The notice belongs to the route, not to the show.** `MainActivity.detailsAiringNotice`
is set only by that row's `onItemSelected` and cleared everywhere else a page opens —
including "More like this", which is why walking Back does not restore it. The same
series reached from Favourites or search must never claim a schedule.
- **Its wording is the gateway's**, copied off the card (`membyAirLabel` and friends). The
TV never derives an air time from a timestamp, so the page cannot contradict the card
that was just pressed.
- **The page opens on a stub** (`scheduleSeriesStub`) and fills in from
`HomeViewModel.focusItem`, the same swap `FocusedDetailsOverlay` already does — waiting on
an item request before anything appears is what would make the row feel broken. The
episode's own overview and artwork are deliberately dropped: they belong to the episode.
**One lifecycle word, one colour, three rows.** A schedule card and a My Shows card both
wear a tag saying whether the show is still being made or the film has actually come out —
CONTINUING, ENDED, IN CINEMAS — and `LifecycleBadge` in `HomeComponents.kt` is the single
place that colours them, so the same word never means two things on one launcher. Green is
still going, red is over, blue is not out yet, amber is in cinemas. Two things worth
keeping: the *wording* is the gateway's (`MembyLifecycleText`, from `api/lifecycle.go`) and
only the *slug* is a lookup key, so an *arr status a build predates still reads correctly
instead of falling back to a slug; and a card with no lifecycle wears no tag rather than an
invented one — an older gateway, a cached row, or a show *arr has no status for. The
availability badge above it answers a different question (has the household's copy
downloaded), which is why they occupy opposite corners. `myShowBadge` puts CANCELLED ahead
of everything else on a followed show: nothing else on that card matters as much.
**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.
@@ -591,12 +1388,21 @@ parameters are previewable — the same property that makes them unit-testable.
previewing the still inner composable over an animated wrapper (`AlertBanner`, not
`ServiceAlertBanner`): a frozen frame of a slide-in shows nothing useful.
**Screenshots.** `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables
to PNGs under `app/build/screenshots/` via Roborazzi + Robolectric, at TV 1080p qualifiers
**Screenshots.** `OnboardingScreenshotTest` covers everything a new television shows before
the launcher — first run, the install-permission step in both its states, and sign-in empty,
filled, rejected, connecting and adding-a-viewer — into `build/screenshots/onboarding/`. It
is the sequence nobody sees twice and the one that decides whether that TV can ever update
itself, so being able to look at it without reinstalling on hardware matters more here than
anywhere else. It is also why `SignInContent` and `InstallPermissionContent` are stateless:
`SetupScreen` keeps the authentication, the content takes parameters.
`app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables
to PNGs under `app/build/screenshots/<feature-name>/` via Roborazzi + Robolectric, at TV 1080p qualifiers
— the way to look at a layout without a TV to hand. This is the *only* Android dependency
allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests
stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these
are artifacts to look at, not checked-in goldens, and a screenshot test that silently
captures nothing is worse than none. AGP's own `com.android.compose.screenshot` plugin was
captures nothing is worse than none. Give each feature its own kebab-case folder and keep
all variants of that feature together. AGP's own `com.android.compose.screenshot` plugin was
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
checking that upstream.
+11 -3
View File
@@ -10,9 +10,11 @@ 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:
Portions of Memby's Android playback capability probing, server-side
device-profile generation, and ExoPlayer construction (constant-bitrate
seeking and sourcing media over the application's own OkHttp client, in
app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt) are
adapted from Wholphin, an Android TV client licensed under GNU GPL version 2:
https://github.com/damontecres/Wholphin
@@ -36,3 +38,9 @@ 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.
The marks shown beside each score in the ratings strip
(app/src/main/res/drawable-nodpi/ic_rating_*.png) identify the rating provider
a score came from. IMDb, Rotten Tomatoes, Metacritic, Letterboxd and TMDb are
trademarks of their respective owners, used here only to attribute those
scores. Memby is not affiliated with, endorsed by, or certified by any of them.
+11 -1
View File
@@ -32,6 +32,9 @@ fun buildConfigString(value: String): String =
.replace("\r\n", "\\n")
.replace("\n", "\\n") + "\""
// The About page's version history. Kept as one checked-in document rather than a Kotlin
// list so a release only edits CHANGELOG.md, and the TV shows the history offline.
val changelogText = rootProject.file("CHANGELOG.md").readText()
val gplLicenseText = rootProject.file("LICENSE").readText()
val projectNoticeText =
rootProject.file("NOTICE").readText()
@@ -39,7 +42,7 @@ val projectNoticeText =
// 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.2.10"
val defaultVersionName = "0.2.26"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -81,6 +84,7 @@ android {
buildConfigField("String", "SOURCE_CODE_URL", buildConfigString(membySourceUrl))
buildConfigField("String", "GPL_LICENSE_TEXT", buildConfigString(gplLicenseText))
buildConfigField("String", "PROJECT_NOTICE_TEXT", buildConfigString(projectNoticeText))
buildConfigField("String", "CHANGELOG_TEXT", buildConfigString(changelogText))
}
// Release signing. Android identifies an app by (applicationId, signing key), so
@@ -189,6 +193,9 @@ dependencies {
// 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")
// Names the playback launch phases in a systrace so :benchmark can measure them.
// Free when tracing is off, which is every build a viewer ever runs.
implementation("androidx.tracing:tracing-ktx:1.2.0")
// Compose (versions from BOM)
implementation("androidx.compose.ui:ui")
@@ -216,6 +223,9 @@ dependencies {
implementation("androidx.media3:media3-exoplayer:1.5.1")
implementation("androidx.media3:media3-exoplayer-hls:1.5.1")
implementation("androidx.media3:media3-ui:1.5.1")
// Lets the player pull its bytes through the app's one OkHttp stack instead of
// media3's own HttpURLConnection client — see ui/player/PlayerEngine.kt.
implementation("androidx.media3:media3-datasource-okhttp:1.5.1")
debugImplementation("androidx.compose.ui:ui-tooling")
+19 -10
View File
@@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Needed to hand a downloaded APK to the system installer (in-app updates). -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Lets a self-update apply without a confirmation screen once Memby is its own
installer of record (Android 12+). Ignored before that, and never required: the
install session falls back to asking. -->
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
<!-- Package visibility (Android 11+). Without these, resolveActivity() returns null and
"Play in Emby" / "Screensaver settings" silently do nothing. -->
@@ -13,6 +17,12 @@
<!-- EmbyAppLauncher hands an item to an installed Emby client. -->
<package android:name="com.mb.android" />
<package android:name="tv.emby.embyatv" />
<!-- The per-app "install unknown apps" screen. Many TV builds do not implement it;
declaring it here is what lets the updater tell the difference between "the
viewer has not granted this yet" and "this TV has nowhere to grant it". -->
<intent>
<action android:name="android.settings.MANAGE_UNKNOWN_APP_SOURCES" />
</intent>
<!-- openScreensaverSettings probes the TV's own settings screens, best first. -->
<intent>
<action android:name="android.settings.DREAM_SETTINGS" />
@@ -111,16 +121,15 @@
android:resource="@xml/emby_dream" />
</service>
<!-- Serves the downloaded update APK to the system package installer. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- Outcome of a self-update's install session. Not exported: the only sender is
the system, through a PendingIntent this app created. -->
<receiver
android:name=".update.InstallResultReceiver"
android:exported="false">
<intent-filter>
<action android:name="com.ponzischeme89.memby.INSTALL_RESULT" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -4,6 +4,7 @@ import android.annotation.SuppressLint
import android.content.Context
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.MaintenanceMonitor
import com.ponzischeme89.memby.data.PreferencesSync
import com.ponzischeme89.memby.data.SettingsStore
/**
@@ -22,10 +23,22 @@ object ServiceLocator {
lateinit var maintenance: MaintenanceMonitor
private set
/**
* Held rather than discarded because it is a long-lived collector, not a service
* anything calls: it starts working when it is constructed and must not be collected
* while a viewer's settings are half-synced.
*/
lateinit var preferencesSync: PreferencesSync
private set
fun init(context: Context) {
if (::repository.isInitialized) return
settings = SettingsStore(context.applicationContext)
repository = EmbyRepository(settings)
maintenance = MaintenanceMonitor(repository, settings)
// Takes the revision channel from the status poll rather than polling itself: the
// app already asks the gateway a question every ten seconds, and settings do not
// deserve a second connection.
preferencesSync = PreferencesSync(repository, settings, maintenance.preferencesRevision)
}
}
@@ -0,0 +1,86 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
/**
* The direct-to-Emby copy of the gateway's `mergeContinueWatching`.
*
* The rule exists twice on purpose, like the subtitle rule does: with no gateway there is
* nobody to ask, and Continue Watching must not hold different cards depending on whether
* the container is up. `ContinueWatchingTest` and `continue_watching_test.go` are
* deliberately parallel change one and change the other.
*
* Resume items and Next Up episodes are one row because they answer one question. Both
* lists arrive most-recently-watched first, so this is a merge of two sorted lists and
* never a sort of their union: Emby's order within each is the useful part. What places a
* Next Up episode is when its *series* was last watched, since the episode itself is
* unwatched and carries no date of its own.
*/
internal fun mergeContinueWatching(
resume: List<BaseItem>,
nextUp: List<BaseItem>,
seriesLastPlayed: Map<String, String>,
): List<BaseItem> {
val seenItems = mutableSetOf<String>()
val seenSeries = mutableSetOf<String>()
val inProgress = resume.map { item ->
seenItems += item.id
item.continueSeriesKey()?.let { seenSeries += it }
item to item.continuePlayedAt(seriesLastPlayed)
}
// A series being watched right now is represented by the episode it is part-way
// through, not by the one after it.
val upNext = nextUp
.filter { item ->
val series = item.continueSeriesKey()
item.id !in seenItems && (series == null || seenSeries.add(series))
}
.map { item -> item to item.continuePlayedAt(seriesLastPlayed) }
val merged = ArrayList<BaseItem>(inProgress.size + upNext.size)
var left = 0
var right = 0
while (left < inProgress.size && right < upNext.size) {
val (nextItem, nextAt) = upNext[right]
val (currentItem, currentAt) = inProgress[left]
// An undated card never displaces a dated one, and falls back to the resume half:
// that is the list which is definitely in progress.
if (nextAt != null && (currentAt == null || nextAt > currentAt)) {
merged += nextItem
right++
} else {
merged += currentItem
left++
}
}
while (left < inProgress.size) merged += inProgress[left++].first
while (right < upNext.size) merged += upNext[right++].first
return merged
}
private fun BaseItem.continueSeriesKey(): String? = seriesId?.takeIf(String::isNotBlank)
private fun BaseItem.continuePlayedAt(seriesLastPlayed: Map<String, String>): String? =
normalizePlayedAt(userData?.lastPlayedDate)
?: continueSeriesKey()?.let { normalizePlayedAt(seriesLastPlayed[it]) }
/**
* Emby writes UTC timestamps with .NET's seven fractional digits
* (`2026-08-06T21:04:05.1234567Z`), so trimming to whole seconds makes them fixed-width
* and directly comparable as strings `java.time` is not available at this minSdk.
* Anything that is not a recognisable timestamp is undated rather than mis-ordered.
*/
internal fun normalizePlayedAt(value: String?): String? {
val trimmed = value?.trim().orEmpty()
if (trimmed.length < 19) return null
val stamp = trimmed.take(19)
val shaped = stamp[4] == '-' && stamp[7] == '-' && stamp[10] == 'T' &&
stamp[13] == ':' && stamp[16] == ':'
if (!shaped) return null
val digits = stamp.filterIndexed { index, _ -> index !in setOf(4, 7, 10, 13, 16) }
if (!digits.all(Char::isDigit)) return null
// Emby's "never played" sentinel. A real date it is not, and treating it as one would
// sort a card by a year nobody watched anything in.
if (stamp.startsWith("0001-01-01")) return null
return stamp
}
@@ -11,6 +11,8 @@ 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.GatewaySubtitleCandidate
import com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.PlaybackReport
@@ -28,6 +30,7 @@ import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
@@ -67,6 +70,8 @@ data class NextEpisode(
val url: String,
val resumePositionMs: Long = 0L,
val subtitles: List<PlayableSubtitle> = emptyList(),
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
@@ -80,6 +85,13 @@ data class Playable(
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val subtitles: List<PlayableSubtitle> = emptyList(),
/**
* Which subtitle track to turn on, decided from the viewer's synced settings. On the
* gateway path the server decides it, because that is what asks Emby which tracks
* exist; on the direct path the same rule runs here. Blank means "none suitable".
*/
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
@@ -88,6 +100,67 @@ data class Playable(
val runtimeMs: Long = 0L,
val prerollEnabled: Boolean = true,
val prerollDurationMs: Long = 6_500L,
/**
* Whether the backend can fetch a subtitle this title does not have. Only the gateway
* can - the direct path has nobody to ask - and it stays false when an operator has
* turned the feature off, so the player never offers a row that cannot do anything.
*/
val subtitleDownloadAvailable: Boolean = false,
)
/**
* What a subtitle search came back with.
*
* An empty list is an ordinary answer, not a failure - the providers may simply have
* nothing for this release - so [message] is what the drop-up prints in its place. It
* comes from the gateway when the gateway answered, because "nothing was found" and
* "Memby could not work out which title this is" are different things to be told.
*/
data class SubtitleSearch(
val results: List<GatewaySubtitleCandidate> = emptyList(),
val message: String = "",
)
/**
* A downloaded subtitle, with the item's tracks as Emby reports them after being told to
* look again. The player swaps its media item to these rather than re-resolving the
* stream, so the new track comes on without leaving the film.
*/
data class SubtitleDownload(
val message: String,
val subtitles: List<PlayableSubtitle>,
val selectedSubtitleId: String,
val mediaSourceId: String,
val playSessionId: String,
val url: String,
)
/**
* Everything needed to resolve a stream, plus everything the player needs to dress its
* loading screen while that resolution is still in flight.
*
* It exists so that pressing Play can open the player *before* the server has answered.
* Starting an activity, inflating its layout and initialising a video decoder all cost
* real time on a television, and there is no reason for them to queue up behind a network
* round trip that they do not depend on. Everything here is already known to the launcher
* from the card the viewer was looking at.
*
* [resumePositionMs] is the client's last known resume point, which is what the card
* displayed; the server's answer remains authoritative for where playback actually starts.
* It is carried because the pre-roll decision cannot wait for that answer see
* `shouldShowPreroll`.
*/
@Serializable
data class PlaybackRequest(
val itemId: String,
val itemType: String = "",
val title: String = "",
val isSeries: Boolean = false,
val resumePositionMs: Long = 0L,
val logoUrl: String? = null,
val overview: String? = null,
val episodeCode: String? = null,
val runtimeMs: Long = 0L,
)
class EmbyRepository(private val settings: SettingsStore) {
@@ -122,6 +195,7 @@ class EmbyRepository(private val settings: SettingsStore) {
private val relatedMutex = Mutex()
private val relatedCache =
LinkedHashMap<String, CachedRelated>(RELATED_CACHE_SIZE, 0.75f, true)
private val relatedInFlight = mutableMapOf<String, Deferred<RelatedContent?>>()
fun cachedHome(): HomeCache? = settings.homeCache(snapshot)
@@ -412,25 +486,73 @@ class EmbyRepository(private val settings: SettingsStore) {
)
}
/** Unfinished movies and episodes for the current Emby user. */
/**
* Everything in progress: unfinished movies and episodes, interleaved with the episode
* that follows each show somebody has just finished.
*
* They are one row see [mergeContinueWatching]. The gateway does this merge itself;
* this is the direct path's copy, and the three requests run together because an
* episode's place in the row depends on when its series was last watched.
*/
suspend fun getContinueWatching(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).continueWatching
val userId = snapshot.userId ?: error("Not connected")
return requireApi().getResumeItems(
userId,
mapOf(
"Recursive" to "true",
"MediaTypes" to "Video",
"Limit" to limit.toString(),
"Fields" to "RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableUserData" to "true",
),
).items
return coroutineScope {
val resume = async {
requireApi().getResumeItems(
userId,
mapOf(
"Recursive" to "true",
"MediaTypes" to "Video",
"Limit" to limit.toString(),
"Fields" to "RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableUserData" to "true",
),
).items
}
val nextUp = async { runCatching { getNextUp(limit) }.getOrDefault(emptyList()) }
// What orders the Next Up half. A failure here is not a failed row: the merge
// falls back to the resume items first.
val played = async { runCatching { recentlyPlayedSeries() }.getOrDefault(emptyMap()) }
mergeContinueWatching(resume.await(), nextUp.await(), played.await())
}
}
/** Episodes the server recommends playing next, excluding resumable duplicates in the UI. */
/**
* When anything in each series was last played, keyed by series id. A Next Up episode
* is unwatched and so carries no date of its own; this is what places it in the row.
*/
private suspend fun recentlyPlayedSeries(): Map<String, String> {
val userId = snapshot.userId ?: error("Not connected")
val played = requireApi().getItems(
userId,
mapOf(
"IncludeItemTypes" to "Episode",
"Recursive" to "true",
"Filters" to "IsPlayed",
"SortBy" to "DatePlayed",
"SortOrder" to "Descending",
"Limit" to CONTINUE_PLAY_LOOKBACK.toString(),
"Fields" to "SeriesId",
"EnableImages" to "false",
"EnableUserData" to "true",
"EnableTotalRecordCount" to "false",
),
).items
// Emby answers most-recently-played first, so the first entry seen for a series is
// the one that counts.
val recent = LinkedHashMap<String, String>()
for (item in played) {
val seriesId = item.seriesId?.takeIf(String::isNotBlank) ?: continue
val at = normalizePlayedAt(item.userData?.lastPlayedDate) ?: continue
recent.putIfAbsent(seriesId, at)
}
return recent
}
/** Episodes Emby recommends playing next. Merged into Continue Watching for display. */
suspend fun getNextUp(limit: Int = 24): List<BaseItem> {
if (ServerConfig.isGateway) return getHome(limit).nextUp
val userId = snapshot.userId ?: error("Not connected")
@@ -438,7 +560,7 @@ class EmbyRepository(private val settings: SettingsStore) {
mapOf(
"UserId" to userId,
"Limit" to limit.toString(),
"Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"Fields" to "Overview,ProductionYear,RunTimeTicks,SeriesId,SeriesName,PrimaryImageAspectRatio",
"ImageTypeLimit" to "1",
"EnableImageTypes" to "Backdrop,Primary,Logo",
"EnableTotalRecordCount" to "false",
@@ -542,11 +664,19 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireGateway().recommendationPreferences()
}
suspend fun saveRecommendationRatings(ratings: Map<String, Int>) {
suspend fun saveRecommendationRatings(
ratings: Map<String, Int>,
actors: List<String> = emptyList(),
actresses: List<String> = emptyList(),
directors: List<String> = emptyList(),
) {
if (!ServerConfig.isGateway) return
requireGateway().saveRecommendationPreferences(
com.ponzischeme89.memby.data.model.RecommendationPreferences(
ratings = ratings.filterValues { it in 1..5 },
actors = actors,
actresses = actresses,
directors = directors,
),
)
}
@@ -600,6 +730,40 @@ class EmbyRepository(private val settings: SettingsStore) {
suspend fun serverFeatures(): GatewayFeatures = requireGateway().features()
/** This viewer's server-held settings. */
suspend fun userPreferences(): com.ponzischeme89.memby.data.model.GatewayPreferences =
requireGateway().preferences()
/**
* Writes them back, at the revision this TV believes it is editing.
*
* A 409 is not an error here and not a reason to retry: somebody else changed the
* document, and on this endpoint that is nearly always the operator pushing settings
* from the admin console. Their copy comes back in the conflict body, so it is
* returned as an ordinary result and the caller adopts it. Retrying would undo them.
*/
suspend fun saveUserPreferences(
revision: Long,
preferences: kotlinx.serialization.json.JsonObject,
): com.ponzischeme89.memby.data.model.GatewayPreferences = try {
requireGateway().savePreferences(
com.ponzischeme89.memby.data.model.GatewayPreferencesRequest(revision, preferences),
)
} catch (conflict: HttpException) {
if (conflict.code() != 409) throw conflict
parsePreferencesConflict(conflict) ?: throw conflict
}
/**
* Whether Emby answers, on the direct path where there is no gateway to ask.
*
* `System/Info/Public` needs no credentials and returns a few bytes, which is what
* makes it usable as a repeated probe anything under `Users/{id}` would fail for a
* stale token and be reported as an outage that is really a sign-in problem.
*/
suspend fun pingEmby(): Boolean =
runCatching { requireApi().systemInfoPublic().close() }.isSuccess
/** Full item metadata, requested only after focus settles on an item. */
suspend fun getItemDetails(itemId: String): BaseItem {
if (ServerConfig.isGateway) return requireGateway().item(itemId)
@@ -607,22 +771,40 @@ class EmbyRepository(private val settings: SettingsStore) {
return requireApi().getItem(
userId = userId,
itemId = itemId,
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,PrimaryImageAspectRatio,CollectionName",
fields = "Overview,Genres,MediaStreams,People,ProductionYear,OfficialRating,CommunityRating,RunTimeTicks,SeriesId,SeriesName,Status,ParentIndexNumber,IndexNumber,PrimaryImageAspectRatio,CollectionName",
)
}
/**
* Optional movie ratings authored entirely by the gateway. Direct Emby mode has no
* MDBList configuration, and any gateway failure is deliberately indistinguishable
* from a movie with no external ratings.
*/
suspend fun getMovieRatings(
itemId: String,
): List<com.ponzischeme89.memby.data.model.GatewayMovieRating> {
if (!ServerConfig.isGateway || itemId.isBlank()) return emptyList()
return runCatching { requireGateway().movieRatings(itemId).ratings }
.getOrDefault(emptyList())
.filter { it.name.isNotBlank() && it.score.isNotBlank() && it.scale.isNotBlank() }
private val ratingsCache = java.util.concurrent.ConcurrentHashMap<String, List<com.ponzischeme89.memby.data.model.MediaRating>>()
/** Optional ratings that never block essential metadata. The gateway owns a durable
* cache; this process cache makes repeated focus/detail visits free on the TV. */
suspend fun getRatings(item: BaseItem): List<com.ponzischeme89.memby.data.model.MediaRating> {
// Rows and detail payloads now carry whatever the gateway already had stored, so
// the common case costs no request at all. Seed the process cache with it: the
// same title reached from search or a related rail then needs nothing either.
item.membyRatings.takeIf { it.isNotEmpty() }?.let { carried ->
ratingsCache[item.id] = carried
return carried
}
ratingsCache[item.id]?.let { return it }
// Emby's own community rating is deliberately not a fallback. It arrives with no
// provider behind it, so standing it in for a real one meant a card claiming a
// TMDb score TMDb had never been asked for — and the strip exists to say where a
// number came from. A title MDBList cannot answer for shows no strip at all.
val result = if (ServerConfig.isGateway && item.id.isNotBlank()) {
runCatching { requireGateway().movieRatings(item.id).ratings }.getOrDefault(emptyList())
} else emptyList()
// Do not make an empty result permanent: the gateway fills its cache behind the
// viewer, so a title it could not decorate now can answer a moment later.
if (result.isNotEmpty()) ratingsCache[item.id] = result
return result
}
@Deprecated("Use getRatings(BaseItem)")
suspend fun getMovieRatings(itemId: String): List<com.ponzischeme89.memby.data.model.MediaRating> {
val item = runCatching { getItemDetails(itemId) }.getOrNull() ?: return emptyList()
return getRatings(item)
}
/** Whether this episode closes its season; failures are non-fatal playback metadata. */
@@ -660,56 +842,79 @@ class EmbyRepository(private val settings: SettingsStore) {
* 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.
*
* Two things keep this from being noisy. It is **single-flighted on the repository's
* own scope**, because the caller is usually [HomeViewModel.focusItem] warming the
* page while the card is focused: that job is cancelled the moment the D-pad moves
* on, and a request cancelled at the socket is one the gateway logs as a failure and
* one nobody keeps the answer of so the shared request outlives the caller that
* started it, and opening Details a moment later awaits it rather than asking again.
* And **only a real answer is cached**; a failure is not, or one bad minute would
* leave the page unable to explain itself for the next ten.
*/
suspend fun getRelated(item: BaseItem, limit: Int = RELATED_LIMIT): RelatedContent {
if (item.id.isBlank()) return RelatedContent()
val now = System.currentTimeMillis()
relatedMutex.withLock {
val inFlight = relatedMutex.withLock {
relatedCache[item.id]
?.takeIf { it.expiresAtMs > now }
?.let { return it.content }
relatedCache.remove(item.id)
relatedInFlight[item.id] ?: newRelatedRequest(item, limit)
}
return inFlight.await() ?: RelatedContent()
}
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()
private fun newRelatedRequest(item: BaseItem, limit: Int): Deferred<RelatedContent?> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
val loaded = runCatching { loadRelated(item, limit) }.getOrNull()
?: return@async null
relatedMutex.withLock {
relatedCache[item.id] = CachedRelated(
content = loaded.copy(items = loaded.items.take(limit)),
expiresAtMs = System.currentTimeMillis() + RELATED_CACHE_TTL_MS,
)
while (relatedCache.size > RELATED_CACHE_SIZE) {
relatedCache.entries.iterator().run {
next()
remove()
}
}
relatedCache.getValue(item.id).content
}
} finally {
relatedMutex.withLock { relatedInFlight.remove(item.id) }
}
return relatedCache.getValue(item.id).content
}
relatedInFlight[item.id] = request
request.start()
return request
}
private suspend fun loadRelated(item: BaseItem, limit: Int): RelatedContent {
if (ServerConfig.isGateway) {
val response = requireGateway().related(item.id)
return RelatedContent(
reasons = response.reasons.filter(String::isNotBlank),
items = response.items.filter { it.id != item.id },
)
}
val userId = snapshot.userId ?: error("Not connected")
return 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 },
)
}
/**
@@ -735,7 +940,7 @@ class EmbyRepository(private val settings: SettingsStore) {
seriesId,
mapOf(
"UserId" to userId,
"Fields" to "Overview,RunTimeTicks,SeriesName,PrimaryImageAspectRatio",
"Fields" to "Overview,RunTimeTicks,SeriesName,PremiereDate,PrimaryImageAspectRatio",
"EnableUserData" to "true",
"EnableImages" to "true",
"EnableImageTypes" to "Primary,Thumb,Backdrop",
@@ -873,6 +1078,7 @@ class EmbyRepository(private val settings: SettingsStore) {
*/
suspend fun resolvePlayable(item: BaseItem): Playable {
require(item.membyPlayable) { "This item is informational and cannot be played" }
val playback = playbackRequest(item)
val now = System.currentTimeMillis()
val (cached, request) = playableMutex.withLock {
val ready = playableCache[item.id]?.takeIf { it.expiresAtMs > now }?.playable
@@ -880,12 +1086,81 @@ class EmbyRepository(private val settings: SettingsStore) {
ready to null
} else {
playableCache.remove(item.id)
null to (playableInFlight[item.id] ?: newPlayableRequest(item))
null to (playableInFlight[item.id] ?: newPlayableRequest(playback))
}
}
return cached ?: requireNotNull(request).await()
}
/**
* The launch-time description of [item] what the launcher already knows, in the form
* the player and [resolvePlayableForLaunch] both take. Built here because the artwork
* and episode-code helpers it needs are the repository's.
*/
fun playbackRequest(item: BaseItem): PlaybackRequest = PlaybackRequest(
itemId = item.id,
itemType = item.type,
title = item.name,
isSeries = item.isSeries,
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
overview = item.overview,
episodeCode = episodeCode(item),
runtimeMs = item.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
)
/**
* Returns a prefetched stream if one is sitting ready for [itemId], without suspending
* and without starting a request. This is what lets the launcher tell instantly whether
* pressing Play can hand the player a URL or must let it resolve one for itself, so
* that decision never costs a frame of its own.
*/
fun readyPlayableForLaunch(itemId: String): Playable? {
// tryLock rather than a blocking wait, and failing to take it is a legitimate
// answer: the lock is held exactly when a resolution is in flight, and the caller's
// fallback — letting the player await it — is what should happen then anyway.
if (!playableMutex.tryLock()) return null
try {
val entry = playableCache[itemId] ?: return null
if (!isFreshPlayablePrefetch(entry.resolvedAtMs, System.currentTimeMillis())) return null
// Consumed: negotiated session state must never be handed out twice.
playableCache.remove(itemId)
return entry.playable
} finally {
playableMutex.unlock()
}
}
/**
* Resolves playback for an actual Play action. A prefetched PlaybackInfo response owns
* negotiated session state, so it is safe only for the brief focus-to-click interval
* it was created to cover. Consume that result once; an older or already-consumed
* prefetch is replaced with a fresh server request.
*/
suspend fun resolvePlayableForLaunch(item: BaseItem): Playable {
require(item.membyPlayable) { "This item is informational and cannot be played" }
return resolvePlayableForLaunch(playbackRequest(item))
}
suspend fun resolvePlayableForLaunch(request: PlaybackRequest): Playable {
require(request.itemId.isNotBlank()) { "A media item is required to start playback" }
val now = System.currentTimeMillis()
val (cached, inFlight) = playableMutex.withLock {
val entry = playableCache.remove(request.itemId)
val ready = entry
?.takeIf { isFreshPlayablePrefetch(it.resolvedAtMs, now) }
?.playable
ready to if (ready == null) playableInFlight[request.itemId] else null
}
if (cached != null) return cached
if (inFlight != null) {
return inFlight.await().also {
playableMutex.withLock { playableCache.remove(request.itemId) }
}
}
return resolvePlayableUncached(request)
}
/** Resolves the likely stream after focus settles, without opening or buffering it. */
suspend fun prefetchPlayable(item: BaseItem) {
if (!item.membyPlayable) return
@@ -915,6 +1190,8 @@ class EmbyRepository(private val settings: SettingsStore) {
url = discovery.url ?: buildStreamUrl(itemId),
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
subtitlesEnabled = snapshot.subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
@@ -936,9 +1213,12 @@ class EmbyRepository(private val settings: SettingsStore) {
// up to one progress interval behind and would visibly jump the viewer back.
resumePositionMs = resumePositionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
)
}
@@ -962,12 +1242,15 @@ class EmbyRepository(private val settings: SettingsStore) {
url = playback.url,
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = playback.subtitles,
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
mediaSourceId = playback.mediaSourceId,
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
overview = playback.overview.ifBlank { null },
episodeCode = playback.episodeCode.ifBlank { null },
runtimeMs = playback.runtimeMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
)
}
val discovery = directPlayback(
@@ -982,19 +1265,24 @@ class EmbyRepository(private val settings: SettingsStore) {
url = discovery.url ?: buildStreamUrl(session.itemId),
resumePositionMs = positionMs.coerceAtLeast(0L),
subtitles = discovery.subtitles,
// The viewer just chose this track by hand and Emby is burning it into the
// stream, so there is no text track left for the player to select.
subtitlesEnabled = true,
selectedSubtitleId = subtitleIndex.toString(),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
)
}
private fun newPlayableRequest(item: BaseItem): Deferred<Playable> {
private fun newPlayableRequest(item: PlaybackRequest): Deferred<Playable> {
val request = scope.async(start = CoroutineStart.LAZY) {
try {
resolvePlayableUncached(item).also { playable ->
playableMutex.withLock {
playableCache[item.id] = CachedPlayable(
playableCache[item.itemId] = CachedPlayable(
playable = playable,
resolvedAtMs = System.currentTimeMillis(),
expiresAtMs = System.currentTimeMillis() + PLAYABLE_CACHE_TTL_MS,
)
while (playableCache.size > PLAYABLE_CACHE_SIZE) {
@@ -1006,47 +1294,49 @@ class EmbyRepository(private val settings: SettingsStore) {
}
}
} finally {
playableMutex.withLock { playableInFlight.remove(item.id) }
playableMutex.withLock { playableInFlight.remove(item.itemId) }
}
}
playableInFlight[item.id] = request
playableInFlight[item.itemId] = request
request.start()
return request
}
private suspend fun resolvePlayableUncached(item: BaseItem): Playable {
private suspend fun resolvePlayableUncached(item: PlaybackRequest): Playable {
if (ServerConfig.isGateway) {
// Episode selection for a series is the gateway's job now.
val playback = requireGateway().playback(
itemId = item.id,
itemType = item.type,
title = item.name,
itemId = item.itemId,
itemType = item.itemType,
title = item.title,
resumePositionMs = item.resumePositionMs,
)
return Playable(
itemId = playback.itemId,
title = playback.title.ifBlank { item.name },
title = playback.title.ifBlank { item.title },
url = playback.url,
resumePositionMs = playback.resumePositionMs,
logoUrl = logoUrl(item),
logoUrl = item.logoUrl,
subtitles = playback.subtitles,
subtitlesEnabled = playback.subtitlesEnabled,
selectedSubtitleId = playback.selectedSubtitleId,
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,
episodeCode = playback.episodeCode.ifBlank { item.episodeCode },
runtimeMs = playback.runtimeMs.takeIf { it > 0L } ?: item.runtimeMs,
prerollEnabled = playback.prerollEnabled,
prerollDurationMs = playback.prerollDurationMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
)
}
if (item.isSeries) {
val userId = snapshot.userId ?: error("Not connected")
val episode = firstNextUpEpisode(userId, item.id) ?: firstEpisode(userId, item.id)
requireNotNull(episode) { "No episodes found for ${item.name}" }
val episode = firstNextUpEpisode(userId, item.itemId) ?: firstEpisode(userId, item.itemId)
requireNotNull(episode) { "No episodes found for ${item.title}" }
val title = buildString {
append(item.name)
append(item.title)
episode.name.takeIf { it.isNotBlank() }?.let { append(" $it") }
}
val discovery = directPlayback(episode.id, episode.resumePositionMs)
@@ -1055,8 +1345,10 @@ class EmbyRepository(private val settings: SettingsStore) {
title = title,
url = discovery.url ?: buildStreamUrl(episode.id),
resumePositionMs = episode.resumePositionMs,
logoUrl = logoUrl(item),
logoUrl = item.logoUrl,
subtitles = discovery.subtitles,
subtitlesEnabled = snapshot.subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId(discovery.subtitles),
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
@@ -1065,23 +1357,28 @@ class EmbyRepository(private val settings: SettingsStore) {
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
)
}
val discovery = directPlayback(item.id, item.resumePositionMs)
val discovery = directPlayback(item.itemId, item.resumePositionMs)
return Playable(
itemId = item.id,
title = item.name,
url = discovery.url ?: buildStreamUrl(item.id),
itemId = item.itemId,
title = item.title,
url = discovery.url ?: buildStreamUrl(item.itemId),
resumePositionMs = item.resumePositionMs,
logoUrl = logoUrl(item),
logoUrl = item.logoUrl,
subtitles = discovery.subtitles,
subtitlesEnabled = snapshot.subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId(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,
episodeCode = item.episodeCode,
runtimeMs = item.runtimeMs,
)
}
/** Drop negotiated playback state when a catalogue refresh can change episode selection. */
suspend fun invalidatePlaybackPrefetch() = clearPlayableCache()
private suspend fun clearPlayableCache() {
playableMutex.withLock {
playableCache.clear()
@@ -1095,8 +1392,11 @@ class EmbyRepository(private val settings: SettingsStore) {
seriesEpisodesCache.clear()
}
relatedMutex.withLock {
// Reasons are personal: another profile must never inherit this one's.
// Reasons are personal: another profile must never inherit this one's, and a
// request already in the air was made with the outgoing profile's session.
relatedCache.clear()
relatedInFlight.values.forEach { it.cancel() }
relatedInFlight.clear()
}
}
@@ -1161,10 +1461,60 @@ class EmbyRepository(private val settings: SettingsStore) {
}.getOrNull()
}
/**
* Ask the gateway for subtitles this title does not have.
*
* Gateway-only, and deliberately so: fetching a subtitle means talking to Bazarr, which
* means holding its API key, and a television is the wrong place for that. With no
* gateway the player simply never offers the option, which is what
* [Playable.subtitleDownloadAvailable] already tells it.
*
* A failure comes back as an empty result with a sentence saying so rather than as an
* exception. This is a live query against subtitle providers slow, and allowed to
* find nothing so "it did not work" is an ordinary answer the drop-up has to render
* either way, and there is nothing useful for a caller to do with a throw.
*/
suspend fun searchSubtitles(itemId: String, language: String? = null): SubtitleSearch {
if (itemId.isBlank() || !ServerConfig.isGateway) {
return SubtitleSearch(emptyList(), "Subtitle downloads are not available.")
}
return runCatching {
val response = requireGateway().searchSubtitles(itemId, language?.takeIf { it.isNotBlank() })
SubtitleSearch(response.results, response.message)
}.getOrElse { error -> SubtitleSearch(emptyList(), friendlyEmbyError(error)) }
}
/**
* Fetch one of those subtitles. Success carries the item's tracks re-read from Emby
* after it was told to look again, so the player can put the new track on without a
* second round trip; null means it did not arrive.
*/
suspend fun downloadSubtitle(
itemId: String,
candidate: GatewaySubtitleCandidate,
): SubtitleDownload? {
if (itemId.isBlank() || !ServerConfig.isGateway || candidate.token.isBlank()) return null
return runCatching {
val response = requireGateway().downloadSubtitle(
itemId,
GatewaySubtitleDownloadRequest(candidate),
)
SubtitleDownload(
message = response.message,
subtitles = response.subtitles,
selectedSubtitleId = response.selectedSubtitleId,
mediaSourceId = response.mediaSourceId,
playSessionId = response.playSessionId,
url = response.url,
)
}.getOrNull()
}
private suspend fun gatewayNextEpisode(itemId: String, seriesId: String?): NextEpisode {
val response = requireGateway().nextEpisode(itemId, seriesId.orEmpty())
return nextEpisodeOf(
response.item, response.url, response.resumePositionMs, response.subtitles,
response.subtitlesEnabled, response.selectedSubtitleId,
response.mediaSourceId, response.playSessionId, response.playMethod,
)
}
@@ -1191,6 +1541,7 @@ class EmbyRepository(private val settings: SettingsStore) {
val discovery = directPlayback(next.id, next.resumePositionMs)
return nextEpisodeOf(
next, discovery.url ?: buildStreamUrl(next.id), next.resumePositionMs, discovery.subtitles,
snapshot.subtitlesEnabled, selectedSubtitleId(discovery.subtitles),
discovery.mediaSourceId, discovery.playSessionId, discovery.playMethod,
)
}
@@ -1200,6 +1551,8 @@ class EmbyRepository(private val settings: SettingsStore) {
url: String,
resumePositionMs: Long,
subtitles: List<PlayableSubtitle>,
subtitlesEnabled: Boolean,
selectedSubtitleId: String,
mediaSourceId: String,
playSessionId: String,
playMethod: String,
@@ -1212,11 +1565,29 @@ class EmbyRepository(private val settings: SettingsStore) {
url = url,
resumePositionMs = resumePositionMs,
subtitles = subtitles,
subtitlesEnabled = subtitlesEnabled,
selectedSubtitleId = selectedSubtitleId,
mediaSourceId = mediaSourceId,
playSessionId = playSessionId,
playMethod = playMethod,
)
/**
* The track this viewer's settings choose out of [subtitles], on the direct path.
*
* In gateway mode the server answers this - it is the thing that asks Emby what tracks
* exist, and answering it there is what lets one decision follow a person between
* televisions. With no gateway there is nobody to ask, so the same rule
* ([selectSubtitleId]) runs here against the same synced settings, and a viewer gets
* the same subtitles either way.
*/
private fun selectedSubtitleId(subtitles: List<PlayableSubtitle>): String =
selectSubtitleId(
candidates = subtitles.map(PlayableSubtitle::asCandidate),
enabled = snapshot.subtitlesEnabled,
language = snapshot.subtitleLanguage,
).orEmpty()
private suspend fun directPlayback(
itemId: String,
positionMs: Long,
@@ -1513,6 +1884,7 @@ private fun episodeCode(item: BaseItem): String? {
private data class CachedPlayable(
val playable: Playable,
val resolvedAtMs: Long,
val expiresAtMs: Long,
)
@@ -1541,6 +1913,13 @@ private data class CachedRelated(
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
// How far back to look for the play that places a Next Up episode in Continue Watching.
// This is a household's recent viewing, not its history: a series nobody has touched in
// this many plays is not competing for the front of the row anyway. Mirrors the gateway's
// continuePlayLookback.
private const val CONTINUE_PLAY_LOOKBACK = 120
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
@@ -1553,6 +1932,9 @@ private const val RELATED_CACHE_TTL_MS = 10L * 60L * 1_000L
internal fun millisecondsToTicks(milliseconds: Long): Long =
milliseconds.coerceAtLeast(0L) * 10_000L
internal fun isFreshPlayablePrefetch(resolvedAtMs: Long, nowMs: Long): Boolean =
resolvedAtMs <= nowMs && nowMs - resolvedAtMs <= PLAYABLE_PREFETCH_MAX_AGE_MS
private val BaseItem.resumePositionMs: Long
get() = ((userData?.playbackPositionTicks ?: 0L) / 10_000L).coerceAtLeast(0L)
@@ -1596,6 +1978,19 @@ internal fun parseMaintenanceMessage(body: String): String? {
}.getOrNull()
}
/**
* The server's copy of a viewer's settings, carried in the body of a 409 so adopting it
* costs no second request. Returns null if the body is not one, in which case the caller
* lets the original failure stand rather than inventing a document.
*/
private fun parsePreferencesConflict(
conflict: HttpException,
): com.ponzischeme89.memby.data.model.GatewayPreferences? = runCatching {
val body = conflict.response()?.errorBody()?.string().orEmpty()
if (body.isBlank()) return@runCatching null
errorBodyJson.decodeFromString<com.ponzischeme89.memby.data.model.GatewayPreferences>(body)
}.getOrNull()
/** True when this failure is the gateway reporting a deliberate outage. */
fun isMaintenanceError(t: Throwable): Boolean = t is HttpException && t.code() == 503
@@ -0,0 +1,40 @@
package com.ponzischeme89.memby.data
/**
* Local-day arithmetic, shared by everything that has to answer "what day is it here?".
*
* It lived in `HomeMovieHero` while the daily hero rotation was the only caller; the series
* pace estimate counts in local days for the same reason and must not carry a second copy.
*/
internal const val DAY_MS = 24L * 60L * 60L * 1000L
/**
* The day the television is living in, counted in local days since the epoch.
*
* Local rather than UTC, because "resets at midnight" means the viewer's midnight and not
* Greenwich's. The zone offset is a parameter rather than something read in here so the
* arithmetic stays pure and a test can put itself either side of the date line.
*/
internal fun localEpochDay(nowMs: Long, zoneOffsetMs: Int): Long =
floorDiv(nowMs + zoneOffsetMs, DAY_MS)
/**
* How long until the local day rolls over. Never zero and never negative: this drives a
* timer, and a zero would spin it. Exactly at midnight the answer is a whole day, because
* the day that has just begun is not the one being waited for.
*/
internal fun millisUntilNextLocalDay(nowMs: Long, zoneOffsetMs: Int): Long {
val local = nowMs + zoneOffsetMs
return DAY_MS - (local - floorDiv(local, DAY_MS) * DAY_MS)
}
// Math.floorDiv/floorMod for longs arrived in API 24 and this app still ships to 23, so
// the two lines they would have saved are written out instead.
internal fun floorDiv(value: Long, divisor: Long): Long {
val quotient = value / divisor
return if (value % divisor != 0L && (value xor divisor) < 0L) quotient - 1L else quotient
}
internal fun floorMod(value: Long, divisor: Long): Long =
value - floorDiv(value, divisor) * divisor
@@ -19,6 +19,22 @@ import kotlinx.coroutines.launch
data class MaintenanceNotice(val message: String)
data class CompatibilityNotice(val message: String)
/**
* Emby is not answering, and something is still trying.
*
* Distinct from [MaintenanceNotice], which is Memby being taken down deliberately: this is
* the media server behind it going quiet. It matters to the viewer for one specific
* reason video direct-plays from Emby, so during an outage a film stops with no
* explanation at all, while Memby itself stays up and can say why.
*
* [nextAttemptAtMillis] is elapsed-realtime, not wall clock, so the countdown cannot be
* thrown by a TV correcting its clock over the network mid-outage.
*/
data class EmbyOutage(
val nextAttemptAtMillis: Long,
val retryIntervalSeconds: Int,
)
/**
* 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
@@ -66,6 +82,30 @@ class MaintenanceMonitor(
private val _alert = MutableStateFlow<ServiceAlert?>(null)
val alert: StateFlow<ServiceAlert?> = _alert.asStateFlow()
private val _embyOutage = MutableStateFlow<EmbyOutage?>(null)
/** Null whenever Emby is answering, or when nothing is watching it. */
val embyOutage: StateFlow<EmbyOutage?> = _embyOutage.asStateFlow()
private val _preferencesRevision = MutableStateFlow(0L)
private val _installPermissionPrompt = MutableStateFlow(false)
/**
* The viewer's server-held settings revision, as of the last successful poll. This is
* how an operator's push reaches a television: the number changes, [PreferencesSync]
* notices and fetches the document. Carrying the revision rather than the settings
* themselves keeps a poll that runs every ten seconds on every open TV to one integer.
*/
val preferencesRevision: StateFlow<Long> = _preferencesRevision.asStateFlow()
/**
* Whether the operator wants TVs that cannot install their own updates to be asked for
* the permission. Pushed through the same poll as everything else, and false whenever
* the server has not said otherwise a screen that appears because a field was missing
* would be the wrong way round.
*/
val installPermissionPrompt: StateFlow<Boolean> = _installPermissionPrompt.asStateFlow()
private val seenAlertIds = mutableSetOf<String>()
private var seenAlertsLoaded = false
private var shownAlertId: String? = null
@@ -73,6 +113,7 @@ class MaintenanceMonitor(
init {
scope.launchStatusLoop()
scope.launchDirectEmbyProbe()
}
/**
@@ -114,6 +155,11 @@ class MaintenanceMonitor(
if (!ServerConfig.isGateway || !session.isSignedIn) {
_notice.value = null
_compatibility.value = null
// The direct-path probe owns the outage bar when there is no gateway;
// clearing it here would fight that loop for the same flow.
if (ServerConfig.isGateway) _embyOutage.value = null
_preferencesRevision.value = 0
_installPermissionPrompt.value = false
dismissAlert()
return@collectLatest
}
@@ -139,6 +185,13 @@ class MaintenanceMonitor(
} else {
null
}
_preferencesRevision.value = status.preferencesRevision
_installPermissionPrompt.value =
status.features[INSTALL_PERMISSION_FEATURE] == true
// Emby's state is reported even during maintenance: an
// operator taking Memby down while Emby is also unreachable
// should not have that fact disappear from the poll.
_embyOutage.value = outageFrom(status.emby)
if (status.maintenance) {
// The maintenance screen owns the display; anything
// cheerful in front of it would only be confusing.
@@ -152,6 +205,9 @@ class MaintenanceMonitor(
repository.invalidateSession()
_notice.value = null
_compatibility.value = null
_embyOutage.value = null
_preferencesRevision.value = 0
_installPermissionPrompt.value = false
dismissAlert()
return@collectLatest
}
@@ -162,6 +218,63 @@ class MaintenanceMonitor(
}
}
/**
* Turns the gateway's reading into a countdown this TV can render.
*
* The next attempt is computed from *this device's* elapsed clock rather than from the
* server's timestamps, because the two are not the same clock and the difference would
* show up as a countdown that jumps. `checkedAt` is deliberately unused for that
* reason: it says when the probe ran on the gateway, which is only a lower bound on
* when this poll heard about it.
*/
private fun outageFrom(health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth): EmbyOutage? =
nextOutageState(health, _embyOutage.value, elapsedRealtime())
/**
* The direct path's own probe. With no gateway there is nobody to ask, so the app asks
* Emby itself on the same minute the gateway would have used, so the bar reads
* identically either way.
*
* It runs only while signed in and only on the direct path, and only in the
* foreground: a background loop hitting a server every minute for a screen nobody is
* looking at is exactly what the status poll already refuses to do.
*/
private fun CoroutineScope.launchDirectEmbyProbe() = launch {
if (ServerConfig.isGateway) return@launch
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
repository.settingsFlow.collectLatest { session ->
if (!session.isSignedIn) {
_embyOutage.value = null
return@collectLatest
}
var failures = 0
while (isActive) {
if (repository.pingEmby()) {
failures = 0
_embyOutage.value = null
} else {
failures++
// Same threshold the gateway applies, and for the same reason:
// one failed request is a hiccup, and a red bar for it is worse
// than a moment of silence. Once past it the countdown is re-armed
// on every attempt, because the next attempt really is a minute
// from now.
if (failures >= DIRECT_OUTAGE_THRESHOLD) {
_embyOutage.value = EmbyOutage(
nextAttemptAtMillis =
elapsedRealtime() + DEFAULT_RETRY_SECONDS * 1000L,
retryIntervalSeconds = DEFAULT_RETRY_SECONDS,
)
}
}
delay(DEFAULT_RETRY_SECONDS * 1000L)
}
}
}
}
private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()
private suspend fun offerNextAlert(alerts: List<GatewayAlert>) {
if (!seenAlertsLoaded) {
seenAlertIds += runCatching { settings.seenAlertIds() }.getOrDefault(emptySet())
@@ -192,6 +305,19 @@ class MaintenanceMonitor(
companion object {
internal const val POLL_INTERVAL_MS = 10_000L
/** Matches `featureInstallPermission` in the gateway's feature catalogue. */
internal const val INSTALL_PERMISSION_FEATURE = "install_permission_prompt"
/**
* How often Emby is retried during an outage. It matches the gateway's own
* default (MEMBY_EMBY_HEALTH_INTERVAL), so the countdown on the bar means the
* same thing whether the probe is running here or there.
*/
internal const val DEFAULT_RETRY_SECONDS = 60
/** Consecutive direct-path failures before the bar appears. */
internal const val DIRECT_OUTAGE_THRESHOLD = 2
/**
* How long one banner stays on screen. The banner draws a ring counting this
* down, so the two must agree take the duration from here rather than
@@ -201,6 +327,33 @@ class MaintenanceMonitor(
}
}
/**
* The outage state to publish after a status poll, given the one already showing.
*
* The rule worth keeping is that an outage already on screen keeps its countdown until it
* actually runs out. The status poll is six times faster than the retry it is describing,
* so recomputing the deadline every time would reset the number every ten seconds and the
* viewer would watch a counter that never reaches zero.
*
* Pure, and takes [nowMillis] as a parameter, so the two properties that matter a live
* countdown survives a poll, an expired one is re-armed can be tested without waiting a
* minute for each.
*/
internal fun nextOutageState(
health: com.ponzischeme89.memby.data.model.GatewayEmbyHealth,
existing: EmbyOutage?,
nowMillis: Long,
): EmbyOutage? {
if (!health.isOutage) return null
if (existing != null && existing.nextAttemptAtMillis > nowMillis) return existing
val retrySeconds = health.retrySeconds.takeIf { it > 0 }
?: MaintenanceMonitor.DEFAULT_RETRY_SECONDS
return EmbyOutage(
nextAttemptAtMillis = nowMillis + retrySeconds * 1000L,
retryIntervalSeconds = retrySeconds,
)
}
/**
* Whether an alert held for a screen that never appeared should be given up on.
*
@@ -0,0 +1,155 @@
package com.ponzischeme89.memby.data
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* Keeps this television's settings and the viewer's server-held document the same thing.
*
* Three events move settings, and all three go through here:
*
* - **The viewer changes something on this TV.** The local write already happened
* nothing in the UI waits on the network and the new document is pushed up after it.
* - **The viewer changes something on another TV, or an operator pushes from the admin
* console.** The revision on the status poll goes up, this notices and pulls.
* - **A profile signs in somewhere for the first time.** The server's copy wins if it has
* one; otherwise this TV's settings become the starting document.
*
* The failure mode being designed against is a *loop*: a pull that looks like a local
* change and triggers a push, which bumps the revision, which looks like a remote change.
* [lastSynced] is what breaks it it records the exact document both ends agreed on, and
* a push happens only when the local one differs from it. Adopting a pull sets it, so the
* write that follows never looks like news.
*
* Everything here fails silently and retries on the next event. Settings are not worth an
* error message on a television, and the state they are in local values intact, server
* copy intact is a correct one to be left in until the next poll.
*/
class PreferencesSync(
private val repository: EmbyRepository,
private val settings: SettingsStore,
private val remoteRevision: StateFlow<Long>,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
) {
/**
* The document last known to be identical on both ends, per profile. A local state
* matching this is not a change and must not be pushed.
*/
private var lastSynced: Pair<String, UserPreferences>? = null
/** One sync at a time: a pull and a push racing would decide the revision twice. */
private val mutex = Mutex()
init {
scope.launch { run() }
}
private suspend fun run() {
// Nothing to sync for a TV nobody is looking at, and the status poll this rides
// beside stops in the background too, so the revision would go stale anyway.
ProcessLifecycleOwner.get().lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
combine(
repository.settingsFlow,
remoteRevision,
) { session, revision -> session to revision }
// The launcher rewrites the home cache constantly and it is part of
// Settings; without this, every refresh would look like a settings change
// and be considered for a push.
.map { (session, revision) -> SyncTrigger(session, revision) }
.distinctUntilChanged()
// Plain collect, not collectLatest: a reconcile that is cancelled halfway
// could leave [lastSynced] describing a document that was never written.
// These are short and serialised by the mutex, so waiting is cheaper than
// reasoning about a half-applied sync.
.collect(::reconcile)
}
}
/** The only parts of the session a sync depends on. */
private data class SyncTrigger(
val profileKey: String,
val signedIn: Boolean,
val localRevision: Long,
val remoteRevision: Long,
val local: UserPreferences,
) {
constructor(session: Settings, remoteRevision: Long) : this(
profileKey = "${session.userId.orEmpty()}@${session.serverUrl.orEmpty()}",
signedIn = session.isSignedIn,
localRevision = session.preferencesRevision,
remoteRevision = remoteRevision,
local = session.toUserPreferences(),
)
}
private suspend fun reconcile(trigger: SyncTrigger) {
if (!ServerConfig.isGateway || !trigger.signedIn) return
mutex.withLock {
val agreed = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
// Never synced on this TV, or the server has moved on without us. Pull first:
// the server's copy is the shared truth, and a push here would overwrite a
// change made on another television with this one's defaults.
if (agreed == null || trigger.remoteRevision > trigger.localRevision) {
if (pull(trigger)) return
}
// Only a genuine local edit gets pushed. Anything else is either the document
// just adopted or an unrelated part of Settings changing.
val current = lastSynced?.takeIf { it.first == trigger.profileKey }?.second
if (current != null && current != trigger.local) push(trigger)
}
}
/**
* Takes the server's copy. Returns true when the local document was replaced, so the
* caller knows not to treat the write it just made as a local edit.
*/
private suspend fun pull(trigger: SyncTrigger): Boolean {
val remote = runCatching { repository.userPreferences() }.getOrNull() ?: return false
// Revision 0 means the server has never been told anything about this viewer. The
// settings already on this television are then the best starting point there is,
// so they are pushed up rather than replaced by catalogue defaults.
if (remote.revision == 0L) {
lastSynced = trigger.profileKey to UserPreferences()
push(trigger)
return true
}
val decoded = decodeUserPreferences(remote.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded
// Written even when the values match, because the revision must advance: without
// it the next poll sees the same gap and pulls again, every ten seconds, forever.
runCatching { settings.applyRemotePreferences(decoded, remote.revision) }
.onFailure { lastSynced = null }
return true
}
private suspend fun push(trigger: SyncTrigger) {
val stored = runCatching {
repository.saveUserPreferences(trigger.localRevision, trigger.local.encode())
}.getOrNull() ?: return
// A 409 comes back here as an ordinary result carrying somebody else's document —
// an operator's push, nearly always. Adopting it is the correct outcome: this TV
// was editing a revision that no longer exists, and retrying would revert them.
val decoded = decodeUserPreferences(stored.preferences, fallback = trigger.local)
lastSynced = trigger.profileKey to decoded
if (decoded == trigger.local) {
runCatching { settings.setPreferencesRevision(stored.revision) }
} else {
runCatching { settings.applyRemotePreferences(decoded, stored.revision) }
.onFailure { lastSynced = null }
}
}
}
@@ -0,0 +1,21 @@
package com.ponzischeme89.memby.data
/**
* How far one press of Left or Right moves the film.
*
* The vocabulary lives beside the settings it is stored in rather than in the player,
* because three things read it the store, the settings row and the player and the
* gateway's own catalogue (`seekIntervalSeconds` in `internal/api/preferences.go`) holds
* the matching list. Keep the two in step: a value this build does not recognise is
* normalised to the default rather than honoured, so an operator pushing an interval a
* television has never heard of costs that set the default and never an unexplained skip.
*/
/** The intervals a viewer may choose between, in the order the settings row offers them. */
val SEEK_INTERVAL_SECONDS: List<Int> = listOf(10, 20, 30)
/** Ten seconds is the smallest offered, and the one a mis-press costs least to undo. */
const val DEFAULT_SEEK_INTERVAL_SECONDS: Int = 10
fun normalizeSeekIntervalSeconds(seconds: Int): Int =
if (seconds in SEEK_INTERVAL_SECONDS) seconds else DEFAULT_SEEK_INTERVAL_SECONDS
@@ -0,0 +1,258 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import kotlin.math.ceil
/**
* When a viewer is likely to finish the episodes of a series they already have.
*
* Everything here is derived from the episode list the detail page already holds Emby's
* `UserData.Played` and `UserData.LastPlayedDate`, one per episode so there is no new
* storage, nothing to invalidate, and no second implementation on the direct path. It
* recalculates for free: finishing an episode, marking one watched, a history sync from
* another Emby client and a newly imported episode all change that list and nothing else.
* Because the state is Emby's per-user data, it is per viewer and per series by
* construction, including for two people sharing one television.
*
* It is deliberately apart from the UI. [estimateSeriesPace] answers with numbers and
* [seriesPaceLabel] turns them into a sentence, so a "finish this weekend" row or a
* completion reminder can use the first without inheriting the second's wording.
*
* The honest answer is often *nothing*. Every guard below returns null rather than a
* confident-looking date: a viewer told they will finish on a day they will not is worse
* off than one told nothing at all.
*/
data class SeriesPaceEstimate(
/** Unwatched episodes the household actually holds. Never fewer than two. */
val remainingEpisodes: Int,
/** The measured rate over the recent window, in episodes per local day. */
val episodesPerDay: Double,
/** Local days from today until the finish. 0 is today, 1 tomorrow. */
val daysAway: Int,
/** The finish, in local days since the epoch — the form the calendar wording needs. */
val finishEpochDay: Long,
/**
* Whether the show is still being made. It changes the verb, because "finish" is a
* claim about a series and only "catch up" is true of one that is still running.
*/
val catchUp: Boolean,
)
// The most recent completions worth measuring. Old enough behaviour is not this viewer's
// current pace — somebody who took a year over season one and is now watching nightly is
// watching nightly.
private const val PACE_WINDOW_EPISODES = 10
private const val PACE_WINDOW_DAYS = 30L
/**
* A gap this long ends the window. Somebody returning to a show after a fortnight away is
* establishing a new pace, and averaging the silence in would predict a finish years out.
*/
private const val PACE_BREAK_DAYS = 14L
/** Nothing watched in this long and there is no current pace to project at all. */
private const val PACE_STALE_DAYS = 30L
private const val PACE_MIN_EPISODES = 3
/**
* Two completions are enough only when they are on separate days *and* close enough
* together to read as a rhythm rather than as two unrelated evenings.
*/
private const val PACE_PAIR_MAX_SPAN_DAYS = 7L
/** Beyond a year the arithmetic is still valid and the answer is still useless. */
private const val PACE_MAX_HORIZON_DAYS = 365L
/**
* The estimate, or null when there is nothing trustworthy to say.
*
* [episodes] is the whole series as the library holds it which is what makes the answer
* "the episodes available to you", since Emby lists nothing it has not imported. [ongoing]
* says the show is still in production; see [SeriesPaceEstimate.catchUp].
*/
fun estimateSeriesPace(
episodes: List<BaseItem>,
nowMs: Long,
zoneOffsetMs: Int,
ongoing: Boolean = false,
): SeriesPaceEstimate? {
if (episodes.isEmpty()) return null
// Specials are bonus material, not the thing being caught up on: counting them would
// put a finish date beyond the last real episode for a viewer who is never going to
// watch the making-of. The same rule `nextEpisodeToWatch` follows — unless specials
// are all the library has, in which case they are the show.
val numbered = episodes.filter { (it.parentIndexNumber ?: 0) > 0 }
val considered = if (numbered.isNotEmpty()) numbered else episodes
// A part-watched episode is not a completion: it counts as remaining and contributes
// nothing to the pace, which is exactly what "has not crossed the threshold" means.
val remaining = considered.count { it.userData?.played != true }
// One episode left needs no date, and none left needs no estimate.
if (remaining <= 1) return null
val completions = considered
.filter { it.userData?.played == true }
.mapNotNull { playedAtMillis(it.userData?.lastPlayedDate) }
.sorted()
if (completions.isEmpty()) return null
val mostRecent = completions.last()
// A stamp in the future is a clock that has not been set, not a viewing habit.
if (mostRecent - nowMs > DAY_MS) return null
if (nowMs - mostRecent > PACE_STALE_DAYS * DAY_MS) return null
val recent = completions
.takeLast(PACE_WINDOW_EPISODES)
.filter { mostRecent - it <= PACE_WINDOW_DAYS * DAY_MS }
val window = sinceLastBreak(recent)
val days = window.map { localEpochDay(it, zoneOffsetMs) }
// Everything known happening on one day is one sitting, and a sitting has no daily
// rate: three episodes on a Sunday would read as three a day and promise a finish
// this week. This is the guard that stops the opening of a binge projecting one.
val distinctDays = days.distinct().size
if (distinctDays < 2) return null
val spanDays = days.last() - days.first() + 1
if (window.size < PACE_MIN_EPISODES && spanDays > PACE_PAIR_MAX_SPAN_DAYS) return null
// Episodes per day over the whole span, inclusive of both ends — the count of viewing
// days is what a rate is measured against, so a binge of three on Saturday and three
// on Sunday is three a day rather than six.
val perDay = window.size.toDouble() / spanDays.toDouble()
if (perDay <= 0.0) return null
val daysNeeded = ceil(remaining / perDay).toLong()
if (daysNeeded > PACE_MAX_HORIZON_DAYS) return null
// The first of those days is today: at one a day with three left, the third is two
// days from now, not three.
val daysAway = (daysNeeded - 1L).coerceAtLeast(0L)
return SeriesPaceEstimate(
remainingEpisodes = remaining,
episodesPerDay = perDay,
daysAway = daysAway.toInt(),
finishEpochDay = localEpochDay(nowMs, zoneOffsetMs) + daysAway,
catchUp = ongoing,
)
}
/**
* The trailing run of [completions] with no [PACE_BREAK_DAYS] gap in it.
*
* Walking back from the most recent rather than forward from the oldest is the point: what
* is wanted is the pace the viewer is on *now*, so the window ends at the present and stops
* at whatever break precedes it.
*/
private fun sinceLastBreak(completions: List<Long>): List<Long> {
if (completions.size <= 1) return completions
var start = completions.size - 1
while (start > 0 && completions[start] - completions[start - 1] < PACE_BREAK_DAYS * DAY_MS) {
start--
}
return completions.subList(start, completions.size)
}
/**
* The sentence the detail page shows, or null for an absent estimate.
*
* Near dates are named, because "18 August" is something a viewer can hold a weekend up
* against. Far ones are rounded to weeks or months instead: a pace measured over a
* fortnight cannot honestly pick a day four months out, and printing one anyway is false
* precision dressed as helpfulness.
*/
fun seriesPaceLabel(estimate: SeriesPaceEstimate?): String? {
if (estimate == null) return null
val verb = if (estimate.catchUp) "catch up" else "finish"
return when {
estimate.daysAway == 0 -> "You'll likely $verb today"
estimate.daysAway == 1 -> "You'll likely $verb tomorrow"
estimate.daysAway <= 30 -> {
val date = formatPaceDate(estimate.finishEpochDay)
if (estimate.catchUp) "You'll catch up around $date" else "Estimated finish: $date"
}
else -> {
val tail = if (estimate.catchUp) "to catch up" else "remaining"
"At your current pace: about ${roughDuration(estimate.daysAway)} $tail"
}
}
}
/** "6 weeks", "3 months" — the coarse end of the wording, never a day count. */
private fun roughDuration(days: Int): String {
val months = (days + 15) / 30
if (days >= 70 && months >= 2) return "$months months"
val weeks = (days + 3) / 7
return if (weeks == 1) "1 week" else "$weeks weeks"
}
/**
* "18 August". Day first and no ordinal suffix, which is how a date is written here, and
* with the month named from this table rather than from the platform's locale a set
* configured in US English must not start printing "August 18" into New Zealand copy.
*
* No year: this is only reached inside a month, where the next 18 August is unambiguous.
*/
internal fun formatPaceDate(epochDay: Long): String {
val (_, month, day) = civilFromEpochDay(epochDay)
return "$day ${MONTH_NAMES[month - 1]}"
}
private val MONTH_NAMES = listOf(
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
)
/**
* Year, month and day from a count of days since 1970-01-01, by the usual civil-calendar
* algorithm. Hand-rolled because `java.time` needs API 26 and this app ships to 23, and
* because a `Calendar` would drag the device's default zone into a pure function that has
* already been handed the offset it needs.
*/
private fun civilFromEpochDay(epochDay: Long): Triple<Int, Int, Int> {
// Shift the epoch to 0000-03-01 so leap days land at the end of the cycle.
val shifted = epochDay + 719_468L
val era = floorDiv(shifted, 146_097L)
val dayOfEra = shifted - era * 146_097L
val yearOfEra = (dayOfEra - dayOfEra / 1460L + dayOfEra / 36_524L - dayOfEra / 146_096L) / 365L
val year = yearOfEra + era * 400L
val dayOfYear = dayOfEra - (365L * yearOfEra + yearOfEra / 4L - yearOfEra / 100L)
val monthPrime = (5L * dayOfYear + 2L) / 153L
val day = (dayOfYear - (153L * monthPrime + 2L) / 5L + 1L).toInt()
val month = (if (monthPrime < 10L) monthPrime + 3L else monthPrime - 9L).toInt()
return Triple((if (month <= 2) year + 1L else year).toInt(), month, day)
}
/**
* Emby's UTC timestamp as epoch milliseconds, or null for anything unrecognisable.
*
* [normalizePlayedAt] already rejects the malformed and the "never played" sentinel and
* hands back a fixed-width `yyyy-MM-ddTHH:mm:ss`; all that is left is to read the digits.
* Doing it by hand rather than with `SimpleDateFormat` keeps the function pure and free of
* that class's thread-safety problem, which the analytics buffer has to work around.
*/
internal fun playedAtMillis(value: String?): Long? {
val stamp = normalizePlayedAt(value) ?: return null
val year = stamp.substring(0, 4).toIntOrNull() ?: return null
val month = stamp.substring(5, 7).toIntOrNull() ?: return null
val day = stamp.substring(8, 10).toIntOrNull() ?: return null
val hour = stamp.substring(11, 13).toIntOrNull() ?: return null
val minute = stamp.substring(14, 16).toIntOrNull() ?: return null
val second = stamp.substring(17, 19).toIntOrNull() ?: return null
if (month !in 1..12 || day !in 1..31) return null
return epochDayFromCivil(year, month, day) * DAY_MS +
(hour * 3_600L + minute * 60L + second) * 1_000L
}
/** The inverse of [civilFromEpochDay], by the same algorithm. */
private fun epochDayFromCivil(year: Int, month: Int, day: Int): Long {
val y = (if (month <= 2) year - 1 else year).toLong()
val era = floorDiv(y, 400L)
val yearOfEra = y - era * 400L
val monthPrime = if (month > 2) month - 3 else month + 9
val dayOfYear = (153L * monthPrime + 2L) / 5L + day - 1L
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
return era * 146_097L + dayOfEra - 719_468L
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.data
import android.content.Context
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
@@ -9,6 +10,7 @@ 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.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
@@ -27,8 +29,250 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.decodeFromJsonElement
import java.security.MessageDigest
import java.util.UUID
private val settingsJson = Json {
// A newer build may have written fields this build does not know about (for example
// after a rollback). Unknown fields must not make every saved profile disappear.
ignoreUnknownKeys = true
coerceInputValues = true
}
private const val CURRENT_SETTINGS_SCHEMA = 2
private val settingsSchemaKey = intPreferencesKey("settings_schema_version")
private val migrationProfilesKey = stringPreferencesKey("profiles")
private val migrationProfilesRecoveryKey = stringPreferencesKey("profiles_recovery_v0")
/** The known-bad `ANDROID_ID` a batch of old devices all shipped with. */
private const val SHARED_ANDROID_ID = "9774d56d682e549c"
/**
* Derives this television's device id from `ANDROID_ID`, hashed so the platform
* identifier itself is never sent to a server, and prefixed so the value reads as ours in
* Emby's devices list.
*
* Falls back to a random id when the platform has nothing usable null on a device that
* has not finished setting up, blank, or the one value a batch of early devices all
* reported, which would make every one of them the same television. A random id is worse
* only in that it does not survive a reinstall; sharing one identity between two sets
* would be wrong rather than merely inconvenient.
*/
internal fun deviceIdFor(androidId: String?): String {
val trimmed = androidId?.trim().orEmpty()
if (trimmed.length < 16 || trimmed == SHARED_ANDROID_ID || trimmed.all { it == '0' }) {
return UUID.randomUUID().toString()
}
val digest = MessageDigest.getInstance("SHA-256")
.digest("memby:$trimmed".toByteArray(Charsets.UTF_8))
return "memby-" + digest.take(10).joinToString("") { "%02x".format(it) }
}
private data class DecodedProfiles(
val profiles: List<EmbyProfile>,
val hadErrors: Boolean,
)
/**
* Decode profiles independently. One damaged or future-incompatible entry must not hide
* every other account on the TV, which decoding the complete list in one call would do.
*/
private fun decodeProfilesSafely(value: String?): DecodedProfiles {
if (value.isNullOrBlank()) return DecodedProfiles(emptyList(), false)
return runCatching {
val elements = settingsJson.parseToJsonElement(value) as? JsonArray
?: return@runCatching DecodedProfiles(emptyList(), true)
val decoded = elements.mapNotNull { element ->
runCatching { settingsJson.decodeFromJsonElement<EmbyProfile>(element) }
.getOrNull()
?.takeIf {
it.serverUrl.isNotBlank() && it.token.isNotBlank() &&
it.userId.isNotBlank() && it.username.isNotBlank()
}
}
DecodedProfiles(decoded.distinctBy(EmbyProfile::id), decoded.size != elements.size)
}.getOrElse { DecodedProfiles(emptyList(), true) }
}
/** Versioned and idempotent transformations applied before DataStore emits its first value. */
internal object SettingsMigrationLogic {
fun migrateToCurrent(source: Preferences): Preferences {
val migrated = source.toMutablePreferences()
var version = runCatching { source[settingsSchemaKey] ?: 0 }.getOrDefault(0)
if (version > CURRENT_SETTINGS_SCHEMA) return source // safe downgrade
while (version < CURRENT_SETTINGS_SCHEMA) {
// Each version is a separate step so future changes can be appended without
// changing an already-shipped transformation.
when (version) {
0 -> migrateFromUnversioned(migrated)
1 -> migrateDeviceTogglesIntoProfiles(migrated)
}
version += 1
migrated[settingsSchemaKey] = version
}
return migrated
}
private fun migrateFromUnversioned(preferences: MutablePreferences) {
val rawProfiles = preferences[migrationProfilesKey]
val decoded = decodeProfilesSafely(rawProfiles)
if (decoded.hadErrors && !rawProfiles.isNullOrBlank()) {
// Keep the original payload for a later release to recover more from. The app
// uses the repaired value below, so this backup can never block startup.
preferences[migrationProfilesRecoveryKey] = rawProfiles
}
val legacy = legacyProfileForMigration(preferences)
val profiles = decoded.profiles.toMutableList()
if (legacy != null) {
val existingIndex = profiles.indexOfFirst {
it.userId == legacy.userId && it.serverUrl.trimEnd('/') == legacy.serverUrl.trimEnd('/')
}
if (existingIndex < 0) {
profiles += legacy
} else {
// Flat keys describe the currently authenticated session and are the best
// source for credentials; retain the per-profile UI choices from the blob.
val existing = profiles[existingIndex]
profiles[existingIndex] = existing.copy(
id = legacy.id,
serverUrl = legacy.serverUrl,
token = legacy.token,
userId = legacy.userId,
username = legacy.username,
serverId = legacy.serverId,
)
}
}
if (rawProfiles != null || profiles.isNotEmpty()) {
val stripped = profiles.map { profile ->
profile.homeCacheJson?.takeIf(String::isNotBlank)?.let { cache ->
val cacheKey = stringPreferencesKey("home_cache::${profile.userId}@${profile.serverUrl}")
if (preferences[cacheKey] == null) preferences[cacheKey] = cache
}
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
}
preferences[migrationProfilesKey] = settingsJson.encodeToString(stripped)
}
}
/**
* Folds the three toggles that used to be device-wide into every stored profile.
*
* `showTitleLogo`, `autoPlayNextEpisode` and `showTenMinuteReminder` became
* per-profile when settings moved to the server. On an install that predates that,
* the flat keys hold the viewer's real choices while the saved profiles carry none
* so they decode as the class defaults, all three on. Nothing looks wrong at first,
* because the flat keys are what the app renders from. But [applyProfile] copies a
* profile's values *into* those flat keys, so the first profile switch after upgrading
* would silently switch all three back on, and the sync would then push that to the
* server as a deliberate choice.
*
* Copying one device-wide value into every profile is exactly right rather than a
* compromise: while it was device-wide, that value genuinely was in force for all of
* them.
*
* Overwriting whatever a profile carries is safe because at schema 1 it cannot carry
* anything the three fields did not exist on [EmbyProfile] then. It is also the only
* option available: the encoder omits default values, so a stored profile that chose
* `true` is byte-identical to one that never chose at all. The schema version is what
* makes this run exactly once.
*/
private fun migrateDeviceTogglesIntoProfiles(preferences: MutablePreferences) {
val profiles = decodeProfilesSafely(preferences[migrationProfilesKey]).profiles
if (profiles.isEmpty()) return
val titleLogo = preferences[booleanPreferencesKey("show_title_logo")] ?: true
val autoPlayNext = preferences[booleanPreferencesKey("auto_play_next_episode")] ?: true
val tenMinutes = preferences[booleanPreferencesKey("show_ten_minute_reminder")] ?: true
// All three at their defaults means there is nothing a viewer chose to preserve,
// and rewriting the blob would be a whole-file fsync for no change.
if (titleLogo && autoPlayNext && tenMinutes) return
preferences[migrationProfilesKey] = settingsJson.encodeToString(
profiles.map {
it.copy(
showTitleLogo = titleLogo,
autoPlayNextEpisode = autoPlayNext,
showTenMinuteReminder = tenMinutes,
)
},
)
}
private fun legacyProfileForMigration(preferences: Preferences): EmbyProfile? {
val serverUrl = preferences[stringPreferencesKey("server_url")]?.takeIf(String::isNotBlank)
?: return null
val token = preferences[stringPreferencesKey("token")]?.takeIf(String::isNotBlank)
?: return null
val userId = preferences[stringPreferencesKey("user_id")]?.takeIf(String::isNotBlank)
?: return null
val username = preferences[stringPreferencesKey("username")]
?.takeIf(String::isNotBlank) ?: "Memby user"
return EmbyProfile(
id = "${serverUrl.trimEnd('/')}::$userId",
serverUrl = serverUrl,
token = token,
userId = userId,
username = username,
serverId = preferences[stringPreferencesKey("server_id")],
homeCacheJson = preferences[
stringPreferencesKey("home_cache::$userId@$serverUrl")
] ?: preferences[stringPreferencesKey("home_cache")],
forYouMinutes = preferences[intPreferencesKey("for_you_minutes")] ?: 0,
hasOpenedForYou = preferences[booleanPreferencesKey("has_opened_for_you")] ?: false,
welcomeQuoteStyle = preferences[stringPreferencesKey("welcome_quote_style")]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
homeSections = preferences[stringPreferencesKey("home_sections")]
?: Settings.DEFAULT_HOME_SECTIONS,
homeCardDensity = preferences[stringPreferencesKey("home_card_density")]
?: Settings.DEFAULT_HOME_CARD_DENSITY,
homeArtworkStyle = preferences[stringPreferencesKey("home_artwork_style")]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[
booleanPreferencesKey("show_home_card_metadata")
] ?: true,
showRatingsStrip = preferences[booleanPreferencesKey("show_ratings_strip")] ?: true,
hideWatchedMovies = preferences[booleanPreferencesKey("hide_watched_movies")] ?: false,
homeRowOrder = preferences[stringPreferencesKey("home_row_order")].orEmpty(),
homePinnedRows = preferences[stringPreferencesKey("home_pinned_rows")].orEmpty(),
homeHiddenRows = preferences[stringPreferencesKey("home_hidden_rows")].orEmpty(),
preferencesRevision = preferences[longPreferencesKey("preferences_revision")] ?: 0,
// These were device-wide before settings moved to the server, so the values
// this install already had become the first profile's. Defaulting them here
// instead would silently turn three settings back on for everyone upgrading.
showTitleLogo = preferences[booleanPreferencesKey("show_title_logo")] ?: true,
autoPlayNextEpisode = preferences[
booleanPreferencesKey("auto_play_next_episode")
] ?: true,
showTenMinuteReminder = preferences[
booleanPreferencesKey("show_ten_minute_reminder")
] ?: true,
)
}
}
/** The built-in launcher rows a viewer may choose between. */
internal val VALID_HOME_SECTIONS = setOf("continue", "favorites", "latest")
private class SettingsPreferencesMigration : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean =
runCatching { (currentData[settingsSchemaKey] ?: 0) < CURRENT_SETTINGS_SCHEMA }
.getOrDefault(true)
override suspend fun migrate(currentData: Preferences): Preferences =
// Migration code must never turn into a permanent startup/edit failure. All known
// data remains readable on the current code path, and leaving the version alone
// lets the next process start retry a transiently failed migration.
runCatching { SettingsMigrationLogic.migrateToCurrent(currentData) }
.getOrElse { currentData }
override suspend fun cleanUp() = Unit
}
/**
* 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
@@ -42,6 +286,7 @@ import java.util.UUID
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(
name = "emby_settings",
corruptionHandler = ReplaceFileCorruptionHandler { emptyPreferences() },
produceMigrations = { listOf(SettingsPreferencesMigration()) },
)
/** Persisted connection state. */
@@ -65,6 +310,13 @@ data class Settings(
val autoPlayNextEpisode: Boolean = true,
// Show the compact lower-third when playback crosses ten minutes remaining.
val showTenMinuteReminder: Boolean = true,
// Turn a subtitle track on automatically, and which language wins when one is chosen.
// Per-profile and synced: subtitles are a personal choice, not a property of the room.
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
// How far one press of Left or Right moves the film. Per-profile and synced for the
// same reason subtitles are: it is a habit, not a property of the room.
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -79,6 +331,8 @@ data class Settings(
/** Card image selection on browse rows: automatic, poster, or backdrop. */
val homeArtworkStyle: String = DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
/** Show third-party ratings on browse and detail surfaces. Profile-specific. */
val showRatingsStrip: 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. */
@@ -95,6 +349,19 @@ data class Settings(
* and remains authoritative for anyone not listed here.
*/
val onboardedUserIds: Set<String> = emptySet(),
/**
* The app version whose release notes this television has already shown. Deliberately
* device state rather than a synced preference: what is new is a property of the APK
* sitting on this set, and a viewer who signs into a second TV that is still a version
* behind has not seen that build's notes.
*/
val whatsNewSeenVersion: String? = null,
/**
* Revision of the active profile's settings as last agreed with the gateway. Zero
* means this TV has never synced them, which is what tells [PreferencesSync] to push
* what is here rather than wait to be told.
*/
val preferencesRevision: Long = 0,
val profiles: List<EmbyProfile> = emptyList(),
) {
val isSignedIn: Boolean
@@ -136,10 +403,20 @@ data class EmbyProfile(
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val homeRowOrder: String = "",
val homePinnedRows: String = "",
val homeHiddenRows: String = "",
/** Per profile, because two people on one TV have separate documents on the server. */
val preferencesRevision: Long = 0,
/** Playback and presentation choices, which sync alongside the home ones. */
val showTitleLogo: Boolean = true,
val autoPlayNextEpisode: Boolean = true,
val showTenMinuteReminder: Boolean = true,
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
)
class SettingsStore(private val context: Context) {
@@ -174,6 +451,9 @@ class SettingsStore(private val context: Context) {
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 SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -183,6 +463,7 @@ class SettingsStore(private val context: Context) {
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 SHOW_RATINGS_STRIP = booleanPreferencesKey("show_ratings_strip")
val HIDE_WATCHED_MOVIES = booleanPreferencesKey("hide_watched_movies")
val HOME_ROW_ORDER = stringPreferencesKey("home_row_order")
val HOME_PINNED_ROWS = stringPreferencesKey("home_pinned_rows")
@@ -191,6 +472,8 @@ class SettingsStore(private val context: Context) {
val PROFILES = stringPreferencesKey("profiles")
val SEEN_ALERTS = stringPreferencesKey("seen_alert_ids")
val ONBOARDED_USERS = stringSetPreferencesKey("onboarded_user_ids")
val WHATS_NEW_VERSION = stringPreferencesKey("whats_new_seen_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
}
/**
@@ -266,16 +549,137 @@ class SettingsStore(private val context: Context) {
private fun decodeAlertIds(raw: String?): List<String> =
raw?.split('\n')?.filter { it.isNotBlank() }.orEmpty()
// These three used to be device-wide. They are per-profile now for the same reason
// they sync: they describe how a *person* wants Memby to behave, so two viewers
// sharing a television must be able to disagree about them — and a device-wide value
// would mean whoever signed in last pushed their choice into everyone's account.
suspend fun setShowTitleLogo(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TITLE_LOGO] = enabled }
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TITLE_LOGO] = enabled
updateActiveProfile(preferences) { it.copy(showTitleLogo = enabled) }
}
}
suspend fun setAutoPlayNextEpisode(enabled: Boolean) {
context.dataStore.edit { it[Keys.AUTO_PLAY_NEXT] = enabled }
context.dataStore.edit { preferences ->
preferences[Keys.AUTO_PLAY_NEXT] = enabled
updateActiveProfile(preferences) { it.copy(autoPlayNextEpisode = enabled) }
}
}
suspend fun setShowTenMinuteReminder(enabled: Boolean) {
context.dataStore.edit { it[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled }
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = enabled
updateActiveProfile(preferences) { it.copy(showTenMinuteReminder = enabled) }
}
}
/**
* Records what the viewer just did in the player's subtitle menu.
*
* Both halves move together because the menu only ever produces one of two answers:
* "off", or a specific track and a track that has no language of its own must not
* quietly overwrite the language somebody chose earlier, which is why a blank language
* leaves that half alone.
*/
suspend fun setSubtitlePreference(enabled: Boolean, language: String?) {
val normalized = normalizeSubtitleLanguage(language)
context.dataStore.edit { preferences ->
preferences[Keys.SUBTITLES_ENABLED] = enabled
if (normalized.isNotEmpty()) preferences[Keys.SUBTITLE_LANGUAGE] = normalized
updateActiveProfile(preferences) {
it.copy(
subtitlesEnabled = enabled,
subtitleLanguage = if (normalized.isEmpty()) it.subtitleLanguage else normalized,
)
}
}
}
/** Normalised on the way in, so a bad value can never reach the player as a step size. */
suspend fun setSeekIntervalSeconds(seconds: Int) {
val interval = normalizeSeekIntervalSeconds(seconds)
context.dataStore.edit { preferences ->
preferences[Keys.SEEK_INTERVAL_SECONDS] = interval
updateActiveProfile(preferences) { it.copy(seekIntervalSeconds = interval) }
}
}
/**
* Adopts the server's document for the signed-in viewer, in one write.
*
* One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
* edit, and this touches seventeen keys plus the profiles blob doing it through the
* individual setters would be seventeen rewrites for one sync, on a TV that has just
* started up.
*
* The revision is stored in the same edit as the values it describes. If they could
* come apart, a TV could believe it was up to date while holding something else, and
* nothing would ever correct it: the status poll only compares numbers.
*/
suspend fun applyRemotePreferences(preferences: UserPreferences, revision: Long) {
val sections = preferences.homeSections
.filter { it in VALID_HOME_SECTIONS }
.distinct()
.ifEmpty { listOf("favorites") }
.joinToString(",")
context.dataStore.edit { store ->
store[Keys.HOME_SECTIONS] = sections
store[Keys.HOME_CARD_DENSITY] = preferences.homeCardDensity
store[Keys.HOME_ARTWORK_STYLE] = preferences.homeArtworkStyle
store[Keys.SHOW_HOME_CARD_METADATA] = preferences.showHomeCardMetadata
store[Keys.SHOW_RATINGS_STRIP] = preferences.showRatingsStrip
store[Keys.HIDE_WATCHED_MOVIES] = preferences.hideWatchedMovies
store[Keys.SHOW_TITLE_LOGO] = preferences.showTitleLogo
store[Keys.WELCOME_QUOTE_STYLE] = preferences.welcomeQuoteStyle
store[Keys.AUTO_PLAY_NEXT] = preferences.autoPlayNextEpisode
store[Keys.SHOW_TEN_MINUTE_REMINDER] = preferences.showTenMinuteReminder
store[Keys.SUBTITLES_ENABLED] = preferences.subtitlesEnabled
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
store[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
store[Keys.HOME_HIDDEN_ROWS] = preferences.homeHiddenRows.joinToString("\n")
store[Keys.PREFERENCES_REVISION] = revision
updateActiveProfile(store) {
it.copy(
homeSections = sections,
homeCardDensity = preferences.homeCardDensity,
homeArtworkStyle = preferences.homeArtworkStyle,
showHomeCardMetadata = preferences.showHomeCardMetadata,
showRatingsStrip = preferences.showRatingsStrip,
hideWatchedMovies = preferences.hideWatchedMovies,
showTitleLogo = preferences.showTitleLogo,
welcomeQuoteStyle = preferences.welcomeQuoteStyle,
autoPlayNextEpisode = preferences.autoPlayNextEpisode,
showTenMinuteReminder = preferences.showTenMinuteReminder,
subtitlesEnabled = preferences.subtitlesEnabled,
subtitleLanguage = preferences.subtitleLanguage,
seekIntervalSeconds =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
forYouMinutes = preferences.forYouMinutes,
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
homeHiddenRows = preferences.homeHiddenRows.joinToString("\n"),
preferencesRevision = revision,
)
}
}
}
/**
* Records the revision a successful push was stored under, without touching values.
* Separate from [applyRemotePreferences] because after a push this TV already holds
* what the server does rewriting the values would be a redundant file rewrite and,
* worse, could clobber an edit made in the moment the request was in flight.
*/
suspend fun setPreferencesRevision(revision: Long) {
context.dataStore.edit { preferences ->
preferences[Keys.PREFERENCES_REVISION] = revision
updateActiveProfile(preferences) { it.copy(preferencesRevision = revision) }
}
}
suspend fun setRingColor(hex: String) {
@@ -287,7 +691,7 @@ class SettingsStore(private val context: Context) {
}
suspend fun setHomeSections(sections: List<String>) {
val valid = sections.filter { it in setOf("continue", "favorites", "latest") }.distinct()
val valid = sections.filter { it in VALID_HOME_SECTIONS }.distinct()
val selected = valid.ifEmpty { listOf("favorites") }.joinToString(",")
context.dataStore.edit { preferences ->
preferences[Keys.HOME_SECTIONS] = selected
@@ -320,6 +724,13 @@ class SettingsStore(private val context: Context) {
}
}
suspend fun setShowRatingsStrip(show: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.SHOW_RATINGS_STRIP] = show
updateActiveProfile(preferences) { it.copy(showRatingsStrip = show) }
}
}
suspend fun setHideWatchedMovies(hide: Boolean) {
context.dataStore.edit { preferences ->
preferences[Keys.HIDE_WATCHED_MOVIES] = hide
@@ -378,7 +789,7 @@ class SettingsStore(private val context: Context) {
* 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)
val encodedCache = settingsJson.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
@@ -445,6 +856,20 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Records the version whose release notes have been shown on this television, so the
* "what's new" panel appears exactly once per update. Written on dismissal, and also
* written silently on a fresh install so the first sign-in is not greeted by notes for
* a build the viewer has never been without.
*/
suspend fun markWhatsNewSeen(version: String) {
val trimmed = version.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
preferences[Keys.WHATS_NEW_VERSION] = trimmed
}
}
suspend fun markForYouOpened() {
context.dataStore.edit { preferences ->
preferences[Keys.HAS_OPENED_FOR_YOU] = true
@@ -489,7 +914,7 @@ class SettingsStore(private val context: Context) {
}
if (profile.homeCacheJson == null) profile else profile.copy(homeCacheJson = null)
}
preferences[Keys.PROFILES] = Json.encodeToString(stripped)
preferences[Keys.PROFILES] = settingsJson.encodeToString(stripped)
}
/**
@@ -514,7 +939,7 @@ class SettingsStore(private val context: Context) {
}
private fun decodeHomeCache(json: String): HomeCache? =
runCatching { Json.decodeFromString<HomeCache>(json) }.getOrNull()
runCatching { settingsJson.decodeFromString<HomeCache>(json) }.getOrNull()
/**
* Reads DataStore directly instead of taking the replayed flow value. This matters
@@ -523,11 +948,32 @@ class SettingsStore(private val context: Context) {
suspend fun snapshot(): Settings = settingsFrom(context.dataStore.data.first())
.also { latestSettings = it }
/** Returns the stable device id, generating and persisting one on first use. */
/**
* Returns the stable device id, deriving and persisting one on first use.
*
* This id *is* the television's identity: Emby's devices list and Settings Devices
* both key on it, and signing in with one Emby has seen replaces that entry rather
* than adding a second. So it has to outlive the app's own storage, which on these
* sets it does not every APK is sideloaded, an install that will not go over the
* old one is done by hand as an uninstall and reinstall, and the DataStore's
* corruption handler replaces the file outright after a process killed mid-write.
* A random id turned each of those into a second television, one per version, that
* had to be removed by hand. `ANDROID_ID` survives all of them and is scoped to this
* app's signing key, so the same set signs back in over its own entry.
*
* An id already stored is kept: it is a working identity, and replacing it is the
* duplicate this exists to avoid.
*/
suspend fun ensureDeviceId(): String {
val existing = context.dataStore.data.first()[Keys.DEVICE_ID]
if (!existing.isNullOrBlank()) return existing
val generated = UUID.randomUUID().toString()
val androidId = runCatching {
android.provider.Settings.Secure.getString(
context.contentResolver,
android.provider.Settings.Secure.ANDROID_ID,
)
}.getOrNull()
val generated = deviceIdFor(androidId)
context.dataStore.edit { it[Keys.DEVICE_ID] = generated }
return generated
}
@@ -554,10 +1000,21 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = previous?.homeArtworkStyle
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = previous?.showHomeCardMetadata ?: true,
showRatingsStrip = previous?.showRatingsStrip ?: true,
hideWatchedMovies = previous?.hideWatchedMovies ?: false,
homeRowOrder = previous?.homeRowOrder.orEmpty(),
homePinnedRows = previous?.homePinnedRows.orEmpty(),
homeHiddenRows = previous?.homeHiddenRows.orEmpty(),
// Carried over so a re-sign-in on a TV that already knows this profile
// does not look like a first sync and push stale values back up.
preferencesRevision = previous?.preferencesRevision ?: 0,
showTitleLogo = previous?.showTitleLogo ?: true,
autoPlayNextEpisode = previous?.autoPlayNextEpisode ?: true,
showTenMinuteReminder = previous?.showTenMinuteReminder ?: true,
subtitlesEnabled = previous?.subtitlesEnabled ?: true,
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = previous?.seekIntervalSeconds
?: DEFAULT_SEEK_INTERVAL_SECONDS,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -635,10 +1092,20 @@ class SettingsStore(private val context: Context) {
preferences.remove(Keys.HOME_CARD_DENSITY)
preferences.remove(Keys.HOME_ARTWORK_STYLE)
preferences.remove(Keys.SHOW_HOME_CARD_METADATA)
preferences.remove(Keys.SHOW_RATINGS_STRIP)
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.SHOW_TITLE_LOGO)
preferences.remove(Keys.AUTO_PLAY_NEXT)
preferences.remove(Keys.SHOW_TEN_MINUTE_REMINDER)
preferences.remove(Keys.SUBTITLES_ENABLED)
preferences.remove(Keys.SUBTITLE_LANGUAGE)
preferences.remove(Keys.SEEK_INTERVAL_SECONDS)
// Cleared with the rest: a revision left behind would make the next profile on
// this TV believe it had already synced settings it has never seen.
preferences.remove(Keys.PREFERENCES_REVISION)
preferences.remove(Keys.USERNAME)
}
@@ -675,10 +1142,19 @@ class SettingsStore(private val context: Context) {
preferences[Keys.HOME_CARD_DENSITY] = profile.homeCardDensity
preferences[Keys.HOME_ARTWORK_STYLE] = profile.homeArtworkStyle
preferences[Keys.SHOW_HOME_CARD_METADATA] = profile.showHomeCardMetadata
preferences[Keys.SHOW_RATINGS_STRIP] = profile.showRatingsStrip
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[Keys.SHOW_TITLE_LOGO] = profile.showTitleLogo
preferences[Keys.AUTO_PLAY_NEXT] = profile.autoPlayNextEpisode
preferences[Keys.SHOW_TEN_MINUTE_REMINDER] = profile.showTenMinuteReminder
preferences[Keys.SUBTITLES_ENABLED] = profile.subtitlesEnabled
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
preferences[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -705,15 +1181,25 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: 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(),
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
showTitleLogo = preferences[Keys.SHOW_TITLE_LOGO] ?: true,
autoPlayNextEpisode = preferences[Keys.AUTO_PLAY_NEXT] ?: true,
showTenMinuteReminder = preferences[Keys.SHOW_TEN_MINUTE_REMINDER] ?: true,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
)
}
private fun decodeProfiles(value: String?): List<EmbyProfile> =
value?.let { runCatching { Json.decodeFromString<List<EmbyProfile>>(it) }.getOrNull() }.orEmpty()
decodeProfilesSafely(value).profiles
private fun settingsFrom(preferences: Preferences): Settings {
val storedProfiles = decodeProfiles(preferences[Keys.PROFILES])
@@ -737,7 +1223,12 @@ class SettingsStore(private val context: Context) {
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,
subtitlesEnabled = preferences[Keys.SUBTITLES_ENABLED] ?: true,
subtitleLanguage = preferences[Keys.SUBTITLE_LANGUAGE] ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
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 = activeHomeCache(preferences),
@@ -747,6 +1238,7 @@ class SettingsStore(private val context: Context) {
homeArtworkStyle = preferences[Keys.HOME_ARTWORK_STYLE]
?: Settings.DEFAULT_HOME_ARTWORK_STYLE,
showHomeCardMetadata = preferences[Keys.SHOW_HOME_CARD_METADATA] ?: true,
showRatingsStrip = preferences[Keys.SHOW_RATINGS_STRIP] ?: true,
hideWatchedMovies = preferences[Keys.HIDE_WATCHED_MOVIES] ?: false,
homeRowOrder = preferences[Keys.HOME_ROW_ORDER].orEmpty(),
homePinnedRows = preferences[Keys.HOME_PINNED_ROWS].orEmpty(),
@@ -754,6 +1246,8 @@ class SettingsStore(private val context: Context) {
welcomeQuoteStyle = preferences[Keys.WELCOME_QUOTE_STYLE]
?: Settings.DEFAULT_WELCOME_QUOTE_STYLE,
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
profiles = profiles,
)
}
@@ -770,6 +1264,11 @@ class SettingsStore(private val context: Context) {
@Serializable
data class HomeCache(
val continueWatching: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
/**
* Only ever read now, never written: a cache from the build that still had two rows is
* what a TV draws before its first refresh lands, so `HomeUiState.from` folds these
* into [continueWatching] rather than losing them for that first frame.
*/
val nextUp: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val favorites: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
val latestMovies: List<com.ponzischeme89.memby.data.model.BaseItem> = emptyList(),
@@ -55,6 +55,131 @@ internal fun subtitleTracks(
)
}.distinctBy { it.id }
/**
* "No preference": keep the flag-driven behaviour somebody who has never opened the
* subtitle menu should still get.
*/
const val SUBTITLE_LANGUAGE_AUTO = "auto"
/**
* ISO 639-1 codes are the vocabulary, because that is what media3 reports for a track after
* its own normalisation. The aliases are the three-letter forms Emby actually writes into a
* stream's language field for several languages, two of them for the same thing.
*
* The same table exists in the gateway (`internal/api/subtitles.go`), which is what makes
* the two paths agree on what "Italian" means. Add a language to one and add it to both.
*/
private val subtitleLanguageAliases: Map<String, String> = buildMap {
fun language(code: String, vararg aliases: String) {
put(code, code)
aliases.forEach { put(it, code) }
}
language("en", "eng")
language("it", "ita")
language("es", "spa", "esp")
language("fr", "fre", "fra")
language("de", "ger", "deu")
language("pt", "por")
language("nl", "dut", "nld")
language("sv", "swe")
language("da", "dan")
language("no", "nor", "nob", "nno")
language("fi", "fin")
language("pl", "pol")
language("cs", "cze", "ces")
language("hu", "hun")
language("ro", "rum", "ron")
language("el", "gre", "ell")
language("ru", "rus")
language("uk", "ukr")
language("tr", "tur")
language("ar", "ara")
language("he", "heb", "iw")
language("hi", "hin")
language("ja", "jpn")
language("ko", "kor")
language("zh", "chi", "zho", "cmn", "yue")
language("th", "tha")
language("vi", "vie")
}
/**
* Folds a track's language onto that vocabulary. An unrecognised code survives as itself
* rather than becoming empty, so an exact match on a language the table has no entry for
* still works.
*/
fun normalizeSubtitleLanguage(raw: String?): String {
val value = raw?.trim()?.lowercase().orEmpty()
if (value.isEmpty()) return ""
val base = value.takeWhile { it != '-' && it != '_' }
return subtitleLanguageAliases[base] ?: base
}
/** The parts of a subtitle track [selectSubtitleId] needs, whatever produced it. */
data class SubtitleCandidate(
val id: String,
val language: String?,
val isDefault: Boolean = false,
val isForced: Boolean = false,
val isHearingImpaired: Boolean = false,
)
/**
* Which track to turn on, or null for none. The rules mirror `selectSubtitle` in the
* gateway exactly, because the direct-to-Emby path has no gateway to ask and a viewer must
* not get different subtitles depending on whether the container is up:
*
* - Off means off.
* - A chosen language wins, and within it a plain full track beats a forced or
* hearing-impaired one somebody who picked Italian wants the dialogue, not the
* signs-only track that happens to come first.
* - A chosen language the title does not have falls back to a *forced* track only. Forced
* subtitles translate what is foreign to the film's own audio, so they are wanted either
* way; falling through to English instead would put a language nobody asked for on screen.
* - With no language chosen: forced, then default, then the first track which is what an
* install that has never touched the setting keeps.
*/
fun selectSubtitleId(
candidates: List<SubtitleCandidate>,
enabled: Boolean,
language: String,
): String? {
if (!enabled || candidates.isEmpty()) return null
val preferred = normalizeSubtitleLanguage(language)
if (preferred.isNotEmpty() && preferred != SUBTITLE_LANGUAGE_AUTO) {
bestSubtitleInLanguage(candidates, preferred)?.let { return it }
return candidates.firstOrNull { it.isForced }?.id
}
return candidates.firstOrNull { it.isForced }?.id
?: candidates.firstOrNull { it.isDefault }?.id
?: candidates.first().id
}
// Forced sorts last within a language precisely because it is not a substitute for the full
// track somebody asked for.
private fun bestSubtitleInLanguage(
candidates: List<SubtitleCandidate>,
language: String,
): String? = candidates
.filter { normalizeSubtitleLanguage(it.language) == language }
.maxByOrNull {
when {
it.isForced -> 1
it.isHearingImpaired -> 2
it.isDefault -> 5
else -> 4
}
}
?.id
internal fun PlayableSubtitle.asCandidate(): SubtitleCandidate = SubtitleCandidate(
id = id,
language = language,
isDefault = isDefault,
isForced = isForced,
isHearingImpaired = isHearingImpaired,
)
private fun subtitleExtension(codec: String?, url: String): String =
when (codec?.trim()?.lowercase()) {
"subrip" -> "srt"
@@ -0,0 +1,164 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
/**
* The settings that belong to a *person* rather than to a television, in the shape both
* ends of the sync agree on.
*
* What is here and what is not is the whole design. These follow a viewer to any set they
* sign into and are the ones an operator can push. Deliberately absent: the device name,
* the update source and token, the screensaver's rotation interval and ring colour, and
* the last backdrop those describe the box in the living room, and carrying them across
* would rename someone's other television the moment they signed in on it.
*
* The class is a plain data holder with no Android or DataStore dependency so the
* conversions either side of it stay unit-testable.
*/
data class UserPreferences(
val homeSections: List<String> = DEFAULT_SECTIONS,
val homeCardDensity: String = Settings.DEFAULT_HOME_CARD_DENSITY,
val homeArtworkStyle: String = Settings.DEFAULT_HOME_ARTWORK_STYLE,
val showHomeCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val showTitleLogo: Boolean = true,
val welcomeQuoteStyle: String = Settings.DEFAULT_WELCOME_QUOTE_STYLE,
val autoPlayNextEpisode: Boolean = true,
val showTenMinuteReminder: Boolean = true,
/**
* Whether a subtitle track is turned on automatically, and which language wins when it
* is. These sync for the same reason the rest do turning subtitles off in the bedroom
* should not leave them on in the living room and the gateway is what applies them,
* since it is the thing that asks Emby which tracks exist.
*/
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
/** How far Left and Right move the film, in seconds. One of [SEEK_INTERVAL_SECONDS]. */
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
val homeHiddenRows: List<String> = emptyList(),
) {
companion object {
val DEFAULT_SECTIONS: List<String> = Settings.DEFAULT_HOME_SECTIONS.split(",")
}
}
/**
* What this television currently believes, ready to be pushed up.
*
* Reads the flat active-profile keys rather than the profile list: those are the values
* every screen actually renders from, so this is the state a viewer would recognise.
*/
fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
homeSections = homeSections.decodeCommaList(),
homeCardDensity = homeCardDensity,
homeArtworkStyle = homeArtworkStyle,
showHomeCardMetadata = showHomeCardMetadata,
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
showTitleLogo = showTitleLogo,
welcomeQuoteStyle = welcomeQuoteStyle,
autoPlayNextEpisode = autoPlayNextEpisode,
showTenMinuteReminder = showTenMinuteReminder,
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
homeHiddenRows = homeHiddenRows.decodeLineList(),
)
internal fun String.decodeCommaList(): List<String> =
split(',').map(String::trim).filter(String::isNotEmpty)
internal fun String.decodeLineList(): List<String> =
split('\n').map(String::trim).filter(String::isNotEmpty)
/**
* Decodes the server's document, keeping this build's value for anything it does not
* recognise or cannot read.
*
* [fallback] is the current local state rather than the class defaults, and that
* distinction matters: a server response missing a key must leave that setting alone, not
* silently reset it. That is what makes it safe for the gateway to grow a setting before
* every television in the house has the release that knows about it.
*/
fun decodeUserPreferences(
json: JsonObject,
fallback: UserPreferences = UserPreferences(),
): UserPreferences = UserPreferences(
homeSections = json.stringList("homeSections", fallback.homeSections)
.ifEmpty { fallback.homeSections },
homeCardDensity = json.string("homeCardDensity", fallback.homeCardDensity),
homeArtworkStyle = json.string("homeArtworkStyle", fallback.homeArtworkStyle),
showHomeCardMetadata = json.boolean("showHomeCardMetadata", fallback.showHomeCardMetadata),
showRatingsStrip = json.boolean("showRatingsStrip", fallback.showRatingsStrip),
hideWatchedMovies = json.boolean("hideWatchedMovies", fallback.hideWatchedMovies),
showTitleLogo = json.boolean("showTitleLogo", fallback.showTitleLogo),
welcomeQuoteStyle = json.string("welcomeQuoteStyle", fallback.welcomeQuoteStyle),
autoPlayNextEpisode = json.boolean("autoPlayNextEpisode", fallback.autoPlayNextEpisode),
showTenMinuteReminder = json.boolean("showTenMinuteReminder", fallback.showTenMinuteReminder),
subtitlesEnabled = json.boolean("subtitlesEnabled", fallback.subtitlesEnabled),
subtitleLanguage = json.string("subtitleLanguage", fallback.subtitleLanguage),
// Normalised rather than trusted: the catalogue on the gateway may offer an interval
// this build has no vocabulary for, and a skip of an unknown length is worse than the
// default. A key the response does not carry leaves this television's value alone.
seekIntervalSeconds = normalizeSeekIntervalSeconds(
json.int("seekIntervalSeconds", fallback.seekIntervalSeconds),
),
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
homeHiddenRows = json.stringList("homeHiddenRows", fallback.homeHiddenRows),
)
/** The document as the gateway expects it. The server normalises whatever arrives. */
fun UserPreferences.encode(): JsonObject = buildJsonObject {
putJsonArray("homeSections") { homeSections.forEach { add(JsonPrimitive(it)) } }
put("homeCardDensity", homeCardDensity)
put("homeArtworkStyle", homeArtworkStyle)
put("showHomeCardMetadata", showHomeCardMetadata)
put("showRatingsStrip", showRatingsStrip)
put("hideWatchedMovies", hideWatchedMovies)
put("showTitleLogo", showTitleLogo)
put("welcomeQuoteStyle", welcomeQuoteStyle)
put("autoPlayNextEpisode", autoPlayNextEpisode)
put("showTenMinuteReminder", showTenMinuteReminder)
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
put("seekIntervalSeconds", seekIntervalSeconds)
put("forYouMinutes", forYouMinutes)
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homeHiddenRows") { homeHiddenRows.forEach { add(JsonPrimitive(it)) } }
}
private fun JsonObject.string(key: String, fallback: String): String =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content ?: fallback
// A JSON string is not a boolean here: "false" arriving as text must fall back rather than
// be read as some truthy value, which is why isString is checked before the conversion.
private fun JsonObject.boolean(key: String, fallback: Boolean): Boolean =
(this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.booleanOrNull ?: fallback
private fun JsonObject.int(key: String, fallback: Int): Int =
(this[key] as? JsonPrimitive)?.takeUnless { it.isString }?.intOrNull ?: fallback
private fun JsonObject.stringList(key: String, fallback: List<String>): List<String> {
val array = this[key] as? JsonArray ?: return fallback
return array.mapNotNull { element ->
(element as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim()?.takeIf(String::isNotEmpty)
}.distinct()
}
@@ -37,6 +37,8 @@ data class UserItemData(
@SerialName("Played") val played: Boolean = false,
@SerialName("PlaybackPositionTicks") val playbackPositionTicks: Long = 0,
@SerialName("UnplayedItemCount") val unplayedItemCount: Int? = null,
/** When this item was last played. What orders the merged Continue Watching row. */
@SerialName("LastPlayedDate") val lastPlayedDate: String? = null,
)
@Serializable
@@ -314,6 +316,10 @@ data class BaseItem(
@SerialName("Overview") val overview: String? = null,
@SerialName("Taglines") val taglines: List<String> = emptyList(),
@SerialName("ProductionYear") val productionYear: Int? = null,
// The day a title first aired or was released, as Emby's ISO-8601 string. An episode's
// is the one date that distinguishes it from its neighbours in a list, so it is asked
// for by name in every episode query — it is not a default field.
@SerialName("PremiereDate") val premiereDate: String? = null,
@SerialName("OfficialRating") val officialRating: String? = null,
@SerialName("CommunityRating") val communityRating: Double? = null,
@SerialName("Studios") val studios: List<Studio> = emptyList(),
@@ -328,6 +334,12 @@ data class BaseItem(
@SerialName("ImageTags") val imageTags: Map<String, String> = emptyMap(),
@SerialName("SeriesId") val seriesId: String? = null,
@SerialName("SeriesName") val seriesName: String? = null,
/**
* Emby's production status for a series "Continuing" or "Ended". Only the detail
* call asks for it; a row item carries none, which is why [isOngoingSeries] treats
* absence as "not known to be running" rather than guessing either way.
*/
@SerialName("Status") val status: String? = null,
// Emby returns these on episodes without being asked, so they cost no extra Fields.
@SerialName("IndexNumber") val indexNumber: Int? = null,
@SerialName("ParentIndexNumber") val parentIndexNumber: Int? = null,
@@ -346,7 +358,16 @@ data class BaseItem(
@SerialName("MembyAirLabel") val membyAirLabel: String? = null,
@SerialName("MembyAvailability") val membyAvailability: String? = null,
@SerialName("MembyAvailabilityText") val membyAvailabilityText: String? = null,
// Sonarr's or Radarr's own lifecycle for the title — whether more episodes are coming,
// whether the film has actually been released. Distinct from availability, which is
// about the household's copy. The slug is what the badge colours by; the text is the
// gateway's wording, so a status this build predates still reads correctly.
@SerialName("MembyLifecycle") val membyLifecycle: String? = null,
@SerialName("MembyLifecycleText") val membyLifecycleText: String? = null,
@SerialName("MembyPlayable") val membyPlayable: Boolean = true,
// The Emby series a schedule card stands for, when the library holds it. Absent for a
// show Sonarr follows but Emby has never imported, so the card stays informational.
@SerialName("MembySeriesItemId") val membySeriesItemId: String? = null,
// 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.
@@ -360,6 +381,11 @@ data class BaseItem(
@SerialName("MembyRecommendationReasonCodes")
val membyRecommendationReasonCodes: List<String> = emptyList(),
@SerialName("MembyExploration") val membyExploration: Boolean = false,
// External ratings the gateway already had stored for this title. They ride on the
// card so a row can draw its scores as it appears rather than when focus reaches it;
// an item the gateway has never looked up carries none and the dedicated ratings
// request still fills it in. Defaulted, so a cached home payload decodes unchanged.
@SerialName("MembyRatings") val membyRatings: List<MediaRating> = emptyList(),
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
@@ -368,6 +394,18 @@ data class BaseItem(
val isTvSchedule: Boolean get() = membySource == "sonarr"
val isMovieSchedule: Boolean get() = membySource == "radarr"
val isSchedule: Boolean get() = isTvSchedule || isMovieSchedule
/**
* Whether more episodes are expected. Sonarr's answer wins where the gateway attached
* one, since it knows about a season announced but not yet imported; Emby's own
* [status] is the fallback and the only source the direct path has. Neither present
* means no, which keeps the pace estimate saying "finish" the weaker claim.
*/
val isOngoingSeries: Boolean
get() = when {
membyLifecycle != null -> membyLifecycle.equals("continuing", ignoreCase = true)
else -> status.equals("Continuing", ignoreCase = true)
}
val cast: List<EmbyPerson> get() = people.filter(EmbyPerson::isCastMember)
/** "S2 · E5" when the season is known, "E5" when only the episode is, else null. */
@@ -67,7 +67,13 @@ data class HomeRow(
data class GatewayHome(
/** Server-composed rows, in display order. */
val rows: List<HomeRow> = emptyList(),
/** In progress, including the episode after one just finished — they are one row. */
val continueWatching: List<BaseItem> = emptyList(),
/**
* Still sent, and still merged into [continueWatching] by the gateway. It remains on
* the wire for televisions running a build that predates the merge, which compose a
* Next Up row of their own from it.
*/
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
@@ -118,6 +124,67 @@ data class GatewayServiceStatus(
val featureRevision: Long = 0,
val safeMode: Boolean = false,
val features: Map<String, Boolean> = emptyMap(),
/**
* Whether *Emby* is answering, as opposed to whether the gateway is. Absent from a
* server older than this field, which is why it defaults to a healthy, unmonitored
* reading rather than to null an app that cannot tell must not claim an outage.
*/
val emby: GatewayEmbyHealth = GatewayEmbyHealth(),
/**
* Revision of this viewer's server-held settings. The app compares it with what it
* has and fetches the document only when they differ, so an operator's push arrives
* on the poll the app is already making.
*/
val preferencesRevision: Long = 0,
)
/**
* The gateway's live reading of its own connection to Emby.
*
* [monitored] is the load-bearing field: with the probe switched off nothing updates
* [reachable], so a client that ignored this would show a permanent, wrong red bar. The
* server also sends `reachable = true` in that case, but the app should not depend on both
* halves of a defence.
*/
@Serializable
data class GatewayEmbyHealth(
val monitored: Boolean = false,
val reachable: Boolean = true,
/** When the current state began, RFC3339. */
val since: String = "",
/** When the last probe ran, RFC3339. The retry countdown is measured from here. */
val checkedAt: String = "",
val retrySeconds: Int = 0,
) {
/** An outage worth telling the viewer about: monitored, and currently failing. */
val isOutage: Boolean get() = monitored && !reachable
}
/**
* A viewer's settings as the gateway holds them, so they follow the person to whichever
* television they sit in front of.
*
* [preferences] stays a [JsonObject] on the wire rather than a typed class on purpose: a
* server that has learned a new setting must not make this response undecodable on an app
* that has not. [com.ponzischeme89.memby.data.UserPreferences] is where it becomes typed,
* and everything it does not recognise is carried through untouched.
*/
@Serializable
data class GatewayPreferences(
val schemaVersion: Int = 0,
val revision: Long = 0,
val updatedAt: String = "",
/** "device" or "admin" — who wrote it last. */
val source: String = "",
val preferences: kotlinx.serialization.json.JsonObject =
kotlinx.serialization.json.JsonObject(emptyMap()),
)
/** A write of [GatewayPreferences]. [revision] is the one being edited, for conflict detection. */
@Serializable
data class GatewayPreferencesRequest(
val revision: Long,
val preferences: kotlinx.serialization.json.JsonObject,
)
@Serializable
@@ -168,10 +235,9 @@ data class GatewayRows(
val rows: List<HomeRow> = emptyList(),
)
/** Server-formatted external movie rating. The TV renders these fields verbatim and
* never needs to know which providers are enabled or how their scales work. */
/** Normalised third-party rating shared by cards, banners, and detail pages. */
@Serializable
data class GatewayMovieRating(
data class MediaRating(
val source: String = "",
val name: String = "",
val score: String = "",
@@ -180,19 +246,40 @@ data class GatewayMovieRating(
@Serializable
data class GatewayMovieRatings(
val ratings: List<GatewayMovieRating> = emptyList(),
val ratings: List<MediaRating> = emptyList(),
)
@Deprecated("Use MediaRating")
typealias GatewayMovieRating = MediaRating
@Serializable
data class RecommendationOnboarding(
val completed: Boolean = false,
// Defaults true for compatibility with gateways released before prompting became
// server-controlled; current gateways always send the field explicitly.
val prompted: Boolean = true,
val ratings: Map<String, Int> = emptyMap(),
val items: List<BaseItem> = emptyList(),
val movies: List<BaseItem> = emptyList(),
val shows: List<BaseItem> = emptyList(),
val actors: List<RecommendationPerson> = emptyList(),
val actresses: List<RecommendationPerson> = emptyList(),
val directors: List<RecommendationPerson> = emptyList(),
)
@Serializable
data class RecommendationPreferences(
val ratings: Map<String, Int> = emptyMap(),
val actors: List<String> = emptyList(),
val actresses: List<String> = emptyList(),
val directors: List<String> = emptyList(),
)
@Serializable
data class RecommendationPerson(
val id: String = "",
val name: String = "",
val imageTag: String = "",
)
@Serializable
@@ -277,9 +364,64 @@ data class GatewayPlayback(
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
// Which of those tracks the gateway decided to turn on, from the viewer's synced
// settings. Absent on an older gateway, which is what the defaults describe: subtitles
// on, nothing chosen, so the television falls back to its own flag-driven pick.
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
// Whether this gateway can fetch a subtitle the title does not have. It rides on the
// playback response rather than the status poll because the drop-up is the only thing
// that asks and it already holds this. Absent on an older gateway, and the default
// must stay false: a missing field must never conjure a row that cannot do anything.
val subtitleDownloadAvailable: Boolean = false,
)
/** One subtitle a viewer can choose to download, as the gateway offers it. */
@Serializable
data class GatewaySubtitleCandidate(
// Bazarr's opaque provider handle. It round-trips untouched — nothing on this side
// parses it, and reconstructing it from the other fields would break the download.
val token: String = "",
val language: String = "",
val languageLabel: String = "",
val provider: String = "",
val score: Int = 0,
val forced: Boolean = false,
val hearingImpaired: Boolean = false,
val originalFormat: Boolean = false,
// What the row prints. Composed by the gateway so an app that predates a new wording
// still renders it correctly, the same reason alert labels are the gateway's.
val label: String = "",
)
@Serializable
data class GatewaySubtitleSearch(
val results: List<GatewaySubtitleCandidate> = emptyList(),
// Why there are none, when there are none. "Nothing was found" and "Memby could not
// work out which title this is" are different answers and a viewer deserves to know
// which one they got.
val message: String = "",
)
@Serializable
data class GatewaySubtitleDownloadRequest(val candidate: GatewaySubtitleCandidate)
/**
* The result of a download: the item's tracks re-read from Emby after it was told to look
* again, so the player can swap its media item and turn the new track on without a second
* round trip.
*/
@Serializable
data class GatewaySubtitleDownload(
val message: String = "",
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val url: String = "",
)
/**
@@ -293,6 +435,8 @@ data class GatewayNextEpisode(
val url: String,
val resumePositionMs: Long = 0,
val subtitles: List<com.ponzischeme89.memby.data.PlayableSubtitle> = emptyList(),
val subtitlesEnabled: Boolean = true,
val selectedSubtitleId: String = "",
val mediaSourceId: String = "",
val playSessionId: String = "",
val playMethod: String = "DirectPlay",
@@ -0,0 +1,35 @@
package com.ponzischeme89.memby.data.model
fun MediaRating.wordmark(): String = when (source.lowercase()) {
"tomatoes" -> "RT"
"audience" -> "RT Audience"
"metacritic" -> "Metacritic"
"letterboxd" -> "Letterboxd"
"mal" -> "MAL"
"anilist" -> "AniList"
"anidb" -> "AniDB"
"kitsu" -> "Kitsu"
"imdb" -> "IMDb"
"tmdb" -> "TMDb"
"trakt" -> "Trakt"
else -> name.trim()
}
fun MediaRating.formattedScore(): String = when (source.lowercase()) {
"tomatoes", "audience", "trakt", "anilist", "kitsu" -> "$score%"
else -> score
}
fun List<MediaRating>.displayable(): List<MediaRating> = filter { rating ->
rating.wordmark().isNotBlank() && rating.score.toDoubleOrNull()?.let { it > 0.0 } == true
}.distinctBy { it.source.lowercase() }
fun ratingDisplayLimit(widthDp: Int): Int = when {
widthDp < 120 -> 1
widthDp < 220 -> 2
widthDp < 360 -> 3
else -> Int.MAX_VALUE
}
fun ratingsStripVisible(enabled: Boolean, ratings: List<MediaRating>): Boolean =
enabled && ratings.displayable().isNotEmpty()
@@ -20,6 +20,14 @@ interface EmbyApi {
@POST("Users/AuthenticateByName")
suspend fun authenticate(@Body body: AuthRequest): AuthResult
/**
* The reachability probe on the direct path. Unauthenticated and tiny by design: a
* probe that needed a token would report a stale session as a server outage, and the
* response is discarded only whether it arrived is the answer.
*/
@GET("System/Info/Public")
suspend fun systemInfoPublic(): okhttp3.ResponseBody
@GET("Users/{userId}/Items")
suspend fun getItems(
@Path("userId") userId: String,
@@ -61,8 +61,11 @@ private class EmbyAuthInterceptor(
override fun intercept(chain: Interceptor.Chain): Response {
val deviceId = deviceIdProvider()
// Version comes from the build, so Emby's device list shows which release a TV
// is actually running.
val authHeader = "MediaBrowser Client=\"Memby\", " +
// is actually running. The client name is deliberately not the app's own — this
// header leaves the house with whatever Emby does with its logs — and must match
// what the gateway sends (MEMBY_CLIENT_NAME), or one television signing in both
// ways would appear as two clients.
val authHeader = "MediaBrowser Client=\"MbyATV\", " +
"Device=\"Android TV\", DeviceId=\"$deviceId\", Version=\"${BuildConfig.VERSION_NAME}\""
val builder = chain.request().newBuilder()
@@ -141,6 +141,19 @@ interface GatewayApi {
@GET("v1/features")
suspend fun features(): GatewayFeatures
/** This viewer's settings as the server holds them, for whichever TV they sit at. */
@GET("v1/preferences")
suspend fun preferences(): com.ponzischeme89.memby.data.model.GatewayPreferences
/**
* Writes them back. A 409 means somebody else usually the operator got there
* first; the response body is their document, so the caller adopts rather than retries.
*/
@PUT("v1/preferences")
suspend fun savePreferences(
@Body body: com.ponzischeme89.memby.data.model.GatewayPreferencesRequest,
): com.ponzischeme89.memby.data.model.GatewayPreferences
@GET("v1/items/{id}")
suspend fun item(@Path("id") itemId: String): BaseItem
@@ -173,6 +186,24 @@ interface GatewayApi {
@Query("forceTranscode") forceTranscode: Boolean = false,
): GatewayPlayback
/**
* Ask the subtitle service for tracks this title does not have. This is a live query
* against subtitle providers, so it is slow by nature seconds, not milliseconds.
* 404 when the gateway has no subtitle service or the operator has turned it off.
*/
@GET("v1/items/{id}/subtitles/search")
suspend fun searchSubtitles(
@Path("id") itemId: String,
@Query("language") language: String? = null,
): com.ponzischeme89.memby.data.model.GatewaySubtitleSearch
/** Fetch one of those tracks. The gateway waits for Emby to notice the new file. */
@POST("v1/items/{id}/subtitles/download")
suspend fun downloadSubtitle(
@Path("id") itemId: String,
@Body body: com.ponzischeme89.memby.data.model.GatewaySubtitleDownloadRequest,
): com.ponzischeme89.memby.data.model.GatewaySubtitleDownload
/** 404 when nothing follows this item: a movie, or a series finale. */
@GET("v1/items/{id}/next")
suspend fun nextEpisode(
@@ -74,6 +74,9 @@ internal val MEMBY_CAPABILITIES = listOf(
"live_feature_refresh_v1",
"sonarr_preroll_v1",
"auto_my_shows_v1",
// Declares that this build can show the install-permission step. An older app never
// receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -55,6 +55,11 @@ 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.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -74,12 +79,12 @@ 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.data.model.GatewayMovieRating
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.AiringNotice
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
@@ -88,7 +93,6 @@ 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
@@ -102,7 +106,14 @@ internal val DetailMutedText = MembyMutedText
internal val DetailQuietText = MembyQuietText
internal val DetailHairline = MembyHairline
internal val DetailSideGutter = 58.dp
private val DetailTabHeight = 66.dp
/**
* The band anchored under the hero: the tab strip on a movie or series page, the season
* scroller on an episode's. One height for both, because the fold it defines is the same
* fold, and a page whose content starts at a different place depending on what it is about
* reads as two designs.
*/
internal val DetailStripHeight = 66.dp
/**
* How much of the content pane is left showing under the tab strip.
@@ -125,6 +136,44 @@ private val DetailFoldPeek = 34.dp
internal fun detailPaneHeight(viewportHeight: Dp): Dp =
(viewportHeight - 132.dp).coerceIn(250.dp, 420.dp)
/**
* Hands focus to the first target that is actually on screen, and says whether any took it.
*
* `focusProperties { down = }` names exactly one node and throws when that node is not
* attached which on these pages is an ordinary state rather than an error. An episode
* page has no season chips until the seasons arrive, a season scrolled out of its LazyRow
* is not composed, a tab can be selected before its pane holds anything focusable. Down
* has to do the obvious thing in every one of those cases, so the direction keys name a
* *list* of places to try and fall through to the next rather than going dead under
* somebody's thumb.
*/
internal fun focusFirstAvailable(vararg targets: FocusRequester?): Boolean {
targets.forEach { target ->
if (target != null && runCatching { target.requestFocus() }.isSuccess) return true
}
return false
}
/**
* Vertical navigation stated as intent rather than as a destination.
*
* Deliberately `onKeyEvent` and not the preview: a press is offered to whatever holds
* focus first, so a control that means something of its own by Up or Down keeps it, and
* only an otherwise unhandled press is routed. Returning false leaves Compose's own focus
* search to try, which is the right last resort. Left and Right are never touched.
*/
internal fun Modifier.onVerticalNavigation(
up: (() -> Boolean)? = null,
down: (() -> Boolean)? = null,
): Modifier = onKeyEvent { event ->
if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
when (event.key) {
Key.DirectionUp -> up?.invoke() ?: false
Key.DirectionDown -> down?.invoke() ?: false
else -> false
}
}
internal data class DetailHeroAction(
val icon: ImageVector,
val description: String,
@@ -179,19 +228,58 @@ 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,
tabs: List<DetailTab> = emptyList(),
selectedTab: DetailTab = DetailTab.OVERVIEW,
onSelectTab: (DetailTab) -> Unit = {},
/**
* Replaces the tab strip in the band under the hero. An episode page has one thing to
* navigate the seasons so it supplies its own scroller rather than pretending to be
* a set of tabs; the frame, the fold and the focus contract stay identical either way.
* It is handed the requester to hand focus back to above it, the one to send focus to
* below it, and the callback that pins the page to the strip.
*/
strip: (@Composable (
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onFocused: () -> Unit,
) -> Unit)? = null,
eyebrow: String? = null,
subtitle: String? = null,
/**
* The heading when the item has no logo to show. An episode's page is *about* the
* episode but *headed* by the show, so it passes the series name here without it the
* fallback prints the episode's title twice, once as the heading and once under the
* number.
*/
title: String? = null,
progress: Float = 0f,
progressLabel: String? = null,
/**
* "Estimated finish: 18 August" how the viewer's own pace projects onto what is left
* of a series. It sits with the progress information rather than beside it, and is
* deliberately quiet: it is a nicety on a page whose job is Play.
*
* Its own line rather than an addition to [progressLabel], because the progress bar
* appears only for a part-watched episode and the estimate is at its most useful for
* somebody who finished one last night and has not started the next.
*/
paceLabel: String? = null,
reasons: List<String> = emptyList(),
ratings: List<GatewayMovieRating> = emptyList(),
/**
* Set only when the page was opened from the "Shows airing" row, and it takes the
* accent line the recommendation reason would otherwise have: someone who arrived by
* pressing "Thursday, 9pm" came for the schedule, not for why the engine likes the
* show. Never focusable, like the reason it replaces.
*/
airingNotice: AiringNotice? = null,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
heroActions: List<DetailHeroAction> = emptyList(),
confirmation: String? = null,
pageListState: LazyListState = remember(item.id) { LazyListState() },
@@ -212,6 +300,17 @@ internal fun DetailPageScaffold(
} else {
playFocusRequester
}
// The band and the pane as a whole, so a press can reach them when the one node they
// would rather land on — the selected tab, the season being watched, the first
// episode card — is not composed on this frame.
val stripEntryRequester = remember(item.id) { FocusRequester() }
val contentEntryRequester = remember(item.id) { FocusRequester() }
// Down out of the hero: the strip, and failing that the content under it. A band with
// nothing in it yet is a thing to pass through, not a thing to stop at.
val enterStripFromHero = {
focusFirstAvailable(tabFocusRequester, stripEntryRequester, contentFocusRequester, contentEntryRequester)
}
val enterContent = { focusFirstAvailable(contentFocusRequester, contentEntryRequester) }
fun reveal(index: Int, offset: Int = 0) {
scope.launch { pageListState.animateScrollToItem(index, offset) }
}
@@ -243,7 +342,7 @@ internal fun DetailPageScaffold(
// 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 heroHeight = (maxHeight - DetailStripHeight - DetailFoldPeek).coerceAtLeast(340.dp)
val paneHeight = detailPaneHeight(maxHeight)
LazyColumn(
state = pageListState,
@@ -254,14 +353,20 @@ internal fun DetailPageScaffold(
item = item,
facts = facts,
badges = badges,
eyebrow = eyebrow,
subtitle = subtitle,
title = title ?: item.name,
playLabel = playLabel,
onPlay = onPlay,
playFocusRequester = playFocusRequester,
tabFocusRequester = tabFocusRequester,
onNavigateDown = enterStripFromHero,
progress = progress,
progressLabel = progressLabel,
paceLabel = paceLabel,
reasons = reasons,
airingNotice = airingNotice,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
actions = heroActions,
actionRequesters = actionRequesters,
height = heroHeight,
@@ -280,19 +385,36 @@ internal fun DetailPageScaffold(
)
}
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)
},
)
val onStripFocused = {
focusedZone = DetailZone.TABS
onZoneFocused(DetailZone.TABS)
reveal(1, -18)
}
// The band as one focus group, so a press that cannot reach the exact
// stop it wanted still arrives somewhere in the strip.
Box(
Modifier
.focusRequester(stripEntryRequester)
.focusGroup()
.onVerticalNavigation(
up = { focusFirstAvailable(heroReturn, playFocusRequester) },
down = enterContent,
),
) {
if (strip != null) {
strip(heroReturn, contentFocusRequester, onStripFocused)
} else {
DetailTabStrip(
tabs = tabs,
selected = selectedTab,
onSelect = onSelectTab,
selectedFocusRequester = tabFocusRequester,
onExitUp = { focusFirstAvailable(heroReturn, playFocusRequester) },
onExitDown = enterContent,
onFocused = onStripFocused,
)
}
}
}
item(key = "content") {
AnimatedContent(
@@ -310,6 +432,12 @@ internal fun DetailPageScaffold(
reveal(2, -72)
}
}
.focusRequester(contentEntryRequester)
.focusGroup()
// Deliberately a focus property and not a key handler: panes
// navigate vertically inside themselves (an episode list, its
// season chips) and override this where they do. A blanket
// handler here would take Up off every card in that list.
.focusProperties { up = tabFocusRequester },
) { visibleTab ->
Box(Modifier.fillMaxSize()) { content(visibleTab) }
@@ -345,14 +473,20 @@ private fun DetailHero(
item: BaseItem,
facts: List<String>,
badges: List<String>,
eyebrow: String?,
subtitle: String?,
title: String,
playLabel: String,
onPlay: () -> Unit,
playFocusRequester: FocusRequester,
tabFocusRequester: FocusRequester,
onNavigateDown: () -> Boolean,
progress: Float,
progressLabel: String?,
paceLabel: String?,
reasons: List<String>,
ratings: List<GatewayMovieRating>,
airingNotice: AiringNotice?,
ratings: List<MediaRating>,
showRatingsStrip: Boolean,
actions: List<DetailHeroAction>,
actionRequesters: List<FocusRequester>,
height: Dp,
@@ -367,7 +501,9 @@ private fun DetailHero(
if (repository.showTitleLogo) repository.logoUrl(item, 720) else null
}
val logo = logoUrl.takeIf { !useTextTitleForLogo(it) }
Box(Modifier.fillMaxWidth().height(height)) {
// Down belongs to the hero as a whole, not to the row of buttons inside it: whatever
// in here holds focus, the press means "take me to the band under this".
Box(Modifier.fillMaxWidth().height(height).onVerticalNavigation(down = onNavigateDown)) {
DetailBackdrop(item, Modifier.fillMaxSize())
Column(
modifier = Modifier
@@ -385,7 +521,7 @@ private fun DetailHero(
)
} else {
Text(
text = item.name,
text = title,
color = Color.White,
fontSize = 38.sp,
lineHeight = 42.sp,
@@ -394,12 +530,38 @@ private fun DetailHero(
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges, rating = ratingLabel(item))
if (ratings.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
DetailMovieRatings(ratings)
// An episode's logo belongs to its *series*, so without this the page would
// name the show and never say which episode it is about. The number goes
// first and the episode's own title second: the viewer already knows the
// show they picked, and "Season 3 · Episode 4" is what they came to confirm.
if (eyebrow != null) {
Spacer(Modifier.height(10.dp))
Text(
text = eyebrow,
color = DetailAccent,
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (subtitle != null) {
Spacer(Modifier.height(6.dp))
Text(
text = subtitle,
color = Color.White,
fontSize = 26.sp,
lineHeight = 30.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.height(10.dp))
DetailFactRow(facts = facts, badges = badges)
if (showRatingsStrip) Spacer(Modifier.height(8.dp))
RatingsStrip(ratings, visible = showRatingsStrip, reserveSpace = true)
if (item.genres.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text(
@@ -423,7 +585,22 @@ private fun DetailHero(
Spacer(Modifier.height(12.dp))
DetailProgress(progress, progressLabel)
}
if (reasons.isNotEmpty()) {
paceLabel?.let {
// Tighter under the bar than under the synopsis: with a bar above it this
// is the second half of one thought, without one it is a line of its own.
Spacer(Modifier.height(if (progress > 0f) 6.dp else 12.dp))
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (airingNotice != null) {
Spacer(Modifier.height(10.dp))
AiringNoticeBand(airingNotice)
} else if (reasons.isNotEmpty()) {
Spacer(Modifier.height(10.dp))
Text(
text = reasons.first(),
@@ -438,7 +615,7 @@ private fun DetailHero(
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.focusGroup().focusProperties { down = tabFocusRequester },
modifier = Modifier.focusGroup(),
) {
MembyPlayButton(
label = playLabel,
@@ -458,49 +635,67 @@ private fun DetailHero(
}
}
/** Informational only: rating chips deliberately do not take focus, so a remote's
* navigation path remains Play/actions -> tabs. FlowRow wraps narrow layouts while
* keeping every configured and available source visible. */
@OptIn(ExperimentalLayoutApi::class)
/**
* The schedule the viewer pressed, restated on the page it opened.
*
* A tinted band rather than one more accent line: it has to be findable in the half-second
* after the page appears, by someone who chose this show *because* of when it airs. It sits
* where the recommendation reason does, immediately above the actions, so nothing below the
* hero moves.
*/
@Composable
internal fun DetailMovieRatings(
ratings: List<GatewayMovieRating>,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(7.dp),
verticalArrangement = Arrangement.spacedBy(5.dp),
private fun AiringNoticeBand(notice: AiringNotice) {
val shape = RoundedCornerShape(MembyCardCorner)
Column(
modifier = Modifier
.clip(shape)
.background(DetailAccent.copy(alpha = 0.13f))
.border(1.dp, DetailAccent.copy(alpha = 0.45f), shape)
.padding(horizontal = 14.dp, vertical = 9.dp),
) {
ratings.forEach { rating ->
Row(
modifier = Modifier
.clip(RoundedCornerShape(6.dp))
.background(Color(0xB31B2025))
.border(1.dp, Color.White.copy(alpha = 0.16f), RoundedCornerShape(6.dp))
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = notice.label,
color = DetailAccent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.4.sp,
maxLines = 1,
)
if (notice.headline.isNotEmpty()) {
Text(
text = rating.name,
color = DetailQuietText,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
Text(
text = rating.score + rating.scale,
color = DetailText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
text = notice.headline,
color = Color.White,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 12.dp),
)
}
}
if (notice.detail.isNotEmpty()) {
Spacer(Modifier.height(3.dp))
Text(
text = notice.detail,
color = DetailText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
/** Compatibility entry point for previews/tests; all rendering lives in RatingsStrip. */
@Composable
internal fun DetailMovieRatings(
ratings: List<MediaRating>,
modifier: Modifier = Modifier,
) {
RatingsStrip(ratings, visible = true, modifier = modifier)
}
@Composable
private fun DetailCircularAction(
action: DetailHeroAction,
@@ -536,17 +731,12 @@ private fun DetailCircularAction(
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 ->
facts.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)
Text(value, color = 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.
@@ -563,14 +753,14 @@ private fun DetailTabStrip(
selected: DetailTab,
onSelect: (DetailTab) -> Unit,
selectedFocusRequester: FocusRequester,
heroFocusRequester: FocusRequester,
contentFocusRequester: FocusRequester,
onExitUp: () -> Boolean,
onExitDown: () -> Boolean,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailTabHeight)
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
@@ -579,16 +769,21 @@ private fun DetailTabStrip(
modifier = Modifier
.fillMaxSize()
.focusGroup()
.focusProperties { up = heroFocusRequester; down = contentFocusRequester },
.onVerticalNavigation(up = onExitUp, down = onExitDown),
horizontalArrangement = Arrangement.spacedBy(34.dp),
verticalAlignment = Alignment.Bottom,
) {
// Whatever else happens, one tab carries the requester the rest of the page
// aims Up and Down at. A selected tab that is not in the list — a key
// remembered from the other kind of item, for the one frame before it is
// resolved — must not leave the band with nothing attached to land on.
val anchor = tabs.firstOrNull { it == selected } ?: tabs.firstOrNull()
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)
.then(if (tab == anchor) Modifier.focusRequester(selectedFocusRequester) else Modifier)
.testTag("detail-tab-${tab.key}")
.onFocusChanged {
focused = it.isFocused
@@ -0,0 +1,255 @@
package com.ponzischeme89.memby.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.InfiniteRepeatableSpec
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.rememberInfiniteTransition
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.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.EmbyOutage
import kotlinx.coroutines.delay
import kotlin.math.ceil
private val OutageRed = Color(0xFFD6403A)
private val OutageTitle = Color(0xFFFFF1F0)
private val OutageBody = Color(0xFFF2C9C6)
/**
* Slightly taller than the news bar, because unlike that one this does not go away by
* itself and has a second line to carry. Still a strip rather than a panel: it appears
* over playback too, and whatever it says it is covering somebody's film.
*/
private val BannerHeight = 60.dp
/** The same broadcast overscan inset the alert bar uses, so the two line up. */
private val SafeAreaHorizontal = 48.dp
/**
* A red bar across the top of the screen saying Emby is not answering, with the time until
* the next attempt.
*
* It exists because of one specific asymmetry: video direct-plays from Emby while
* everything else comes from the gateway, so when Emby goes quiet the film stops and
* nothing on screen explains it and the gateway is still up and able to say why. The
* counterpart alert in [ServiceAlertBanner] announces the *moment* it happened; this is
* the standing state, which is what a television switched on midway through an outage
* needs instead.
*
* Never focusable, and never dismissable. There is nothing for the viewer to do, and a bar
* that stole D-pad focus mid-browse would be worse than the outage it is reporting. It
* disappears on its own when Emby answers.
*/
@Composable
fun EmbyOutageBanner(suppressed: Boolean = false, modifier: Modifier = Modifier) {
// Collected here rather than passed in, for the reason the whole UI-conventions note
// gives: read one scope up and every poll recomposes the launcher.
val current by ServiceLocator.maintenance.embyOutage.collectAsStateWithLifecycle()
val outage = current?.takeUnless { suppressed }
// Hold the last value so the slide-out has something to draw.
var lastOutage by remember { mutableStateOf<EmbyOutage?>(null) }
if (outage != null) lastOutage = outage
AnimatedVisibility(
visible = outage != null,
enter = slideInVertically(tween(520, easing = FastOutSlowInEasing)) { -it } +
fadeIn(tween(420)),
// Leaves faster than it arrives, and for a happier reason: Emby came back.
exit = slideOutVertically(tween(320, easing = FastOutSlowInEasing)) { -it } +
fadeOut(tween(240)),
// Above the news bar (8f) — an outage outranks an announcement — and below the
// mandatory update screen, which owns the whole display.
modifier = modifier.zIndex(9f),
) {
lastOutage?.let { OutageBanner(it) }
}
}
/**
* Internal so previews and the screenshot test can render the bar without the drop-in
* wrapper, whose entire job is an animation a still frame says nothing about.
*/
@Composable
internal fun OutageBanner(outage: EmbyOutage, nowMillis: () -> Long = { elapsedRealtime() }) {
// One tick a second, not a frame clock: this is a number that changes once a second
// and animating it would recompose the bar sixty times for every time it changes.
val remaining by produceState(
initialValue = secondsUntil(outage.nextAttemptAtMillis, nowMillis()),
outage.nextAttemptAtMillis,
) {
while (true) {
value = secondsUntil(outage.nextAttemptAtMillis, nowMillis())
delay(1_000L)
}
}
Column(Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(BannerHeight)
.background(
// Red, and deepest under the text. Over a paused film this bar has to
// supply its own contrast rather than borrow the picture's.
Brush.horizontalGradient(
0f to Color(0xFF7A1512),
0.55f to Color(0xF08E1A16),
1f to Color(0xD9601210),
),
)
.padding(horizontal = SafeAreaHorizontal),
verticalAlignment = Alignment.CenterVertically,
) {
OutagePulse()
Spacer(Modifier.width(16.dp))
Box(Modifier.width(3.dp).height(30.dp).background(OutageRed))
Spacer(Modifier.width(14.dp))
Column(Modifier.weight(1f)) {
Text(
"Emby Server failed to respond",
color = OutageTitle,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
maxLines = 1,
)
Spacer(Modifier.height(3.dp))
Text(
"Memby failed to connect to the Emby instance. Playback unavailable.",
color = OutageTitle,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.width(16.dp))
// Read directly rather than through an animated float: this changes once a
// second, so the recomposition it costs is one per second of a 60dp bar.
Text(
retryLabel(remaining),
color = OutageBody,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
)
}
// The same rule-and-fade the news bar ends on, so the two read as one system.
Box(
Modifier
.fillMaxWidth()
.height(1.dp)
.background(
Brush.horizontalGradient(
listOf(OutageRed, OutageRed.copy(alpha = 0.35f), Color.Transparent),
),
),
)
Box(
Modifier
.fillMaxWidth()
.height(10.dp)
.background(Brush.verticalGradient(listOf(Color(0x77000000), Color.Transparent))),
)
}
}
/**
* A slow pulse in place of the Emby mark the news bar shows. It says "still trying"
* without a spinner, which on a television reads as the app being busy rather than the
* server being away.
*
* The animated value is read inside the [Canvas] lambda and never in the composable body
* read it here and this whole bar would recompose every frame, permanently, for as long as
* the outage lasts. That is the one thing this app cannot afford on a weak TV box.
*/
@Composable
private fun OutagePulse() {
val transition = rememberInfiniteTransition(label = "outage-pulse")
val pulse = transition.animateFloat(
initialValue = 0.35f,
targetValue = 1f,
animationSpec = InfiniteRepeatableSpec(
animation = tween(1_400, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse,
),
label = "outage-pulse-alpha",
)
Canvas(Modifier.size(14.dp)) {
drawCircle(color = OutageRed.copy(alpha = pulse.value), radius = size.minDimension / 2f)
}
}
/** Whole seconds until the next attempt, never negative. */
internal fun secondsUntil(deadlineMillis: Long, nowMillis: Long): Int =
ceil((deadlineMillis - nowMillis).coerceAtLeast(0L) / 1000.0).toInt()
/**
* The countdown's wording. It says what is about to happen rather than counting for its
* own sake the only useful thing a viewer can take from this bar is that something is
* still trying and roughly when.
*/
internal fun retryLabel(secondsRemaining: Int): String = when {
secondsRemaining <= 0 -> "Retrying now…"
secondsRemaining == 1 -> "Retrying in 1s"
else -> "Retrying in ${secondsRemaining}s"
}
private fun elapsedRealtime(): Long = android.os.SystemClock.elapsedRealtime()
@TvPreview
@Composable
private fun EmbyOutageBannerPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
OutageBanner(
EmbyOutage(nextAttemptAtMillis = 42_000L, retryIntervalSeconds = 60),
nowMillis = { 0L },
)
}
}
/** The last second before the next attempt, which is a different string. */
@TvPreview
@Composable
private fun EmbyOutageBannerRetryingPreview() {
PreviewSurface(alignment = Alignment.TopCenter) {
OutageBanner(
EmbyOutage(nextAttemptAtMillis = 0L, retryIntervalSeconds = 60),
nowMillis = { 0L },
)
}
}
@@ -0,0 +1,531 @@
package com.ponzischeme89.memby.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
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.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.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.DoneAll
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Tv
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag
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 com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.SeasonMarker
import com.ponzischeme89.memby.ui.detail.SeasonProgress
import com.ponzischeme89.memby.ui.detail.episodeAfter
import com.ponzischeme89.memby.ui.detail.episodeEyebrow
import com.ponzischeme89.memby.ui.detail.episodeHeadline
import com.ponzischeme89.memby.ui.detail.episodesForSeason
import com.ponzischeme89.memby.ui.detail.detailPositions
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.seasonMarkers
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
import com.ponzischeme89.memby.ui.detail.seriesEpisodeComparator
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
import kotlinx.coroutines.delay
/**
* A single episode, opened from Continue Watching or from a show's episode list.
*
* It is the same page as a movie's and a series' [DetailPageScaffold], the same hero, the
* same fold with the two substitutions an episode needs. The logo belongs to the *series*,
* so the season and episode number go directly under it with the episode's own title
* beneath. And where a movie has tabs, this has the season scroller: the one thing worth
* navigating from an episode is the rest of the show, and it doubles as the answer to "where
* am I up to", which is the question somebody resuming a series actually has.
*/
@Composable
fun EpisodeDetailsOverlay(
item: BaseItem,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (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.seriesId) { mutableStateOf<List<BaseItem>?>(null) }
var loadFailed by remember(item.seriesId) { mutableStateOf(false) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
// Keyed on the series, not the episode: walking from one episode's page to the next
// within the same show must not re-fetch the whole catalogue of it.
LaunchedEffect(item.seriesId) {
val seriesId = item.seriesId
if (seriesId.isNullOrBlank()) {
episodes = emptyList()
return@LaunchedEffect
}
runCatching { repository.getSeriesEpisodes(seriesId) }
.onSuccess { episodes = it.sortedWith(seriesEpisodeComparator) }
.onFailure {
loadFailed = true
episodes = emptyList()
}
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
EpisodeDetailContent(
item = item,
episodes = episodes,
loadFailed = loadFailed,
onPlay = onPlay,
onToggleFavorite = onToggleFavorite,
onTogglePlayed = onTogglePlayed,
onOpenItem = onOpenItem,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
modifier = modifier,
)
}
/** [episodes] is null while the series' episode list is still coming. */
@Composable
internal fun EpisodeDetailContent(
item: BaseItem,
episodes: List<BaseItem>?,
loadFailed: Boolean,
onPlay: (BaseItem) -> Unit,
onToggleFavorite: (BaseItem, Boolean) -> Unit,
onTogglePlayed: (BaseItem, Boolean) -> Unit,
modifier: Modifier = Modifier,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
onOpenItem: (BaseItem) -> Unit = {},
) {
val currentSeason = item.parentIndexNumber
val markers = remember(episodes, currentSeason) {
seasonMarkers(episodes.orEmpty(), currentSeason)
}
// The scroller opens on the season this episode is in and stays wherever the viewer
// moves it. Selecting a season browses; it never changes which episode the page is about.
var selectedSeason by remember(item.id) { mutableStateOf(currentSeason) }
LaunchedEffect(markers) {
if (markers.none { it.season == selectedSeason }) {
selectedSeason = currentSeason ?: markers.firstOrNull()?.season
}
}
val seasonEpisodes = remember(episodes, selectedSeason) {
episodesForSeason(episodes.orEmpty(), selectedSeason)
}
val upNext = remember(episodes, item.id) { episodeAfter(episodes.orEmpty(), item) }
val seriesProgress = remember(markers, currentSeason) {
seriesProgressLabel(markers, currentSeason)
}
val play = remember(item.id) { FocusRequester() }
val firstEpisode = remember(item.id) { FocusRequester() }
val emptyPane = remember(item.id) { FocusRequester() }
val seasonRequesters = remember(markers) { markers.associate { it.season to FocusRequester() } }
val episodeListState = rememberLazyListState()
val seasonListState = rememberLazyListState()
// Every focus target has to be attached on the frame the press lands. While the episode
// request is in flight there are no cards and no chips, so both directions resolve to
// whatever is actually placed and the rest falls back to ordinary focus search.
val hasEpisodeCards = seasonEpisodes.isNotEmpty()
val contentEntry = if (hasEpisodeCards) firstEpisode else emptyPane
// The strip's entry point is the selected season's own stop, not a requester of the
// scaffold's — until the episode list lands there is no stop to land on, and Down from
// Play has to fall back to ordinary focus search rather than throw.
val selectedSeasonChip = seasonRequesters[selectedSeason]
val stripEntry = selectedSeasonChip ?: FocusRequester.Default
LaunchedEffect(markers, selectedSeason) {
val index = markers.indexOfFirst { it.season == selectedSeason }
if (index >= 0) runCatching { seasonListState.scrollToItem(index) }
}
// Open the list on the episode the page is about rather than at the top of the season.
// Episode 9 of a twelve-part season is otherwise nine presses below the fold, on the
// one screen where the viewer has already said which episode they mean.
LaunchedEffect(seasonEpisodes, item.id) {
val index = seasonEpisodes.indexOfFirst { it.id == item.id }
if (index > 0) runCatching { episodeListState.scrollToItem(index) }
}
RestoreDetailFocus(
itemId = item.id,
zone = DetailZone.PLAY,
play = play,
tabStrip = stripEntry,
related = firstEpisode,
relatedReady = hasEpisodeCards,
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 = mediaBadges(item),
eyebrow = episodeEyebrow(item),
subtitle = item.name.takeIf(String::isNotBlank),
title = item.seriesName?.takeIf(String::isNotBlank) ?: item.name,
playLabel = primaryActionLabel(item),
onPlay = { onPlay(item) },
playFocusRequester = play,
tabFocusRequester = stripEntry,
contentFocusRequester = contentEntry,
modifier = modifier,
progress = playbackProgress(item),
progressLabel = remainingLabel(item),
// An episode inherits no explanation of its own; what the page can say about what
// follows is more use than a taste the engine learned about the show.
reasons = listOfNotNull(upNext?.let { "Up next ${episodeHeadline(it)}" }),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
confirmation = confirmation,
onZoneFocused = { zone -> detailPositions.update(item.id) { it.copy(zone = zone) } },
strip = { _, _, onFocused ->
SeasonScroller(
markers = markers,
selectedSeason = selectedSeason,
currentSeason = currentSeason,
seriesProgress = seriesProgress,
selectedFocusRequester = selectedSeasonChip,
seasonFocusRequesters = seasonRequesters,
listState = seasonListState,
onSelect = { selectedSeason = it },
onFocused = onFocused,
)
},
heroActions = buildList {
add(
DetailHeroAction(
// A heart, not a tick: "Mark watched" beside it is a tick as well. The
// heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
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"
},
),
)
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) },
),
)
// The show this episode belongs to, so the full series page is one press away
// rather than a walk back through Continue Watching.
item.seriesId?.takeIf(String::isNotBlank)?.let { seriesId ->
add(
DetailHeroAction(
icon = Icons.Default.Tv,
description = "Open ${item.seriesName ?: "the series"}",
onClick = {
onOpenItem(
BaseItem(
id = seriesId,
name = item.seriesName.orEmpty(),
type = "Series",
),
)
},
),
)
}
},
) {
EpisodeSeasonPane(
episodes = episodes,
seasonEpisodes = seasonEpisodes,
currentEpisodeId = item.id,
loadFailed = loadFailed,
firstEpisodeFocusRequester = firstEpisode,
emptyFocusRequester = emptyPane,
aboveEpisodes = stripEntry,
listState = episodeListState,
onPlay = onPlay,
)
}
}
/**
* The band under the hero: every season the library holds, with the viewer's place in the
* show marked on it.
*
* A season behind the one being watched is dimmed and ticked. That is the whole point of
* the component it answers "where am I up to" at a glance, which is what somebody
* resuming a long show is actually asking, and it does it without a tab strip's promise
* that each stop is a different *kind* of content.
*/
@Composable
private fun SeasonScroller(
markers: List<SeasonMarker>,
selectedSeason: Int?,
currentSeason: Int?,
seriesProgress: String?,
selectedFocusRequester: FocusRequester?,
seasonFocusRequesters: Map<Int, FocusRequester>,
listState: LazyListState,
onSelect: (Int) -> Unit,
onFocused: () -> Unit,
) {
Box(
Modifier
.fillMaxWidth()
.height(DetailStripHeight)
.background(DetailBackground)
.padding(horizontal = DetailSideGutter),
) {
Box(Modifier.align(Alignment.BottomStart).fillMaxWidth().height(1.dp).background(DetailHairline))
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.Bottom,
) {
if (markers.isEmpty()) {
Text(
text = "Loading seasons…",
color = DetailQuietText,
fontSize = 13.sp,
modifier = Modifier.weight(1f).padding(bottom = 16.dp),
)
} else {
LazyRow(
state = listState,
// Up and Down out of the band belong to the scaffold, which knows
// where to fall back to when the exact stop it wants is not composed
// — a season chip scrolled out of this row is an ordinary state.
modifier = Modifier
.weight(1f)
.fillMaxSize()
.focusGroup(),
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom,
) {
items(markers, key = { it.season }) { marker ->
SeasonStop(
marker = marker,
selected = marker.season == selectedSeason,
watching = marker.season == currentSeason,
focusRequester = if (marker.season == selectedSeason) {
selectedFocusRequester
} else {
seasonFocusRequesters[marker.season]
},
onSelect = { onSelect(marker.season) },
onFocused = onFocused,
)
}
}
}
seriesProgress?.let {
Text(
text = it,
color = DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 18.dp, bottom = 15.dp),
)
}
// Same caption on the Down key the tab strip carries; never focusable.
Icon(
Icons.Default.KeyboardArrowDown,
contentDescription = null,
tint = DetailQuietText,
modifier = Modifier.padding(start = 12.dp, bottom = 14.dp).size(18.dp),
)
}
}
}
@Composable
private fun SeasonStop(
marker: SeasonMarker,
selected: Boolean,
watching: Boolean,
focusRequester: FocusRequester?,
onSelect: () -> Unit,
onFocused: () -> Unit,
) {
var focused by remember(marker.season) { mutableStateOf(false) }
val done = marker.progress == SeasonProgress.WATCHED
val labelColour = when {
selected || focused -> Color.White
// Behind the viewer: stated, not hidden. The tick beside it is what says why it
// is quieter than the rest, or a dimmed season reads as one that failed to load.
done -> DetailQuietText.copy(alpha = 0.55f)
else -> DetailQuietText
}
Column(
modifier = Modifier
.width(IntrinsicSize.Max)
.then(if (focusRequester != null) Modifier.focusRequester(focusRequester) else Modifier)
.testTag("season-stop-${marker.season}")
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) { onSelect(); onFocused() }
}
.clickable(onClick = onSelect),
horizontalAlignment = Alignment.Start,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
) {
if (done) {
Icon(
Icons.Default.Check,
contentDescription = "Watched",
tint = if (selected || focused) DetailAccent else DetailAccent.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp).padding(end = 1.dp),
)
Spacer(Modifier.width(5.dp))
}
Text(
text = marker.label,
color = labelColour,
fontSize = 15.sp,
fontWeight = if (selected || focused) FontWeight.Bold else FontWeight.Medium,
maxLines = 1,
)
if (watching) {
Spacer(Modifier.width(7.dp))
Text(
text = "WATCHING",
color = DetailAccent,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.8.sp,
maxLines = 1,
modifier = Modifier
.background(DetailAccent.copy(alpha = 0.16f), androidx.compose.foundation.shape.RoundedCornerShape(4.dp))
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
seasonProgressLabel(marker)?.let {
Text(
text = it,
color = if (done) DetailQuietText.copy(alpha = 0.5f) else DetailQuietText,
fontSize = 10.sp,
maxLines = 1,
modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 2.dp, bottom = 11.dp),
)
}
Box(
Modifier.fillMaxWidth().height(3.dp).background(
if (selected || focused) DetailAccent else Color.Transparent,
),
)
}
}
/**
* The pane under the scroller: the selected season's episodes, this one flagged.
*
* Internal so a screenshot test can render it at [detailPaneHeight] on its own it lives
* below the fold, where a capture of the whole page shows nothing but its top edge.
*/
@Composable
internal fun EpisodeSeasonPane(
episodes: List<BaseItem>?,
seasonEpisodes: List<BaseItem>,
currentEpisodeId: String,
loadFailed: Boolean,
firstEpisodeFocusRequester: FocusRequester,
emptyFocusRequester: FocusRequester,
aboveEpisodes: FocusRequester,
listState: LazyListState,
onPlay: (BaseItem) -> Unit,
) {
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 for this season.", 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(),
) {
itemsIndexed(seasonEpisodes, key = { _, episode -> episode.id }) { index, episode ->
EpisodeCard(
episode = episode,
onClick = { onPlay(episode) },
seasonFocusRequester = aboveEpisodes,
isFirst = index == 0,
isCurrent = episode.id == currentEpisodeId,
modifier = if (index == 0) {
Modifier.focusRequester(firstEpisodeFocusRequester)
} else {
Modifier
},
)
}
}
}
}
@@ -121,7 +121,6 @@ import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
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
@@ -129,7 +128,6 @@ import com.ponzischeme89.memby.ui.theme.MembyChipCorner
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
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
import kotlinx.coroutines.Job
@@ -158,7 +156,8 @@ enum class BrowseDestination(val label: String, val icon: ImageVector) {
SETTINGS("Settings", Icons.Default.Settings),
}
enum class MediaRowKind { CONTINUE, NEXT_UP, MOVIES, SHOWS, FAVORITES }
// No NEXT_UP: those episodes are part of CONTINUE, which is one row.
enum class MediaRowKind { CONTINUE, MOVIES, SHOWS, FAVORITES }
data class HomeBrowseRow(
val id: String,
@@ -179,8 +178,8 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
row.id == "continue" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
)
row.id == "next-up" -> HomeRowVisual(
Icons.Default.SkipNext,
row.id == "continue-shows" -> HomeRowVisual(
Icons.Default.PlayCircleFilled,
)
row.kind == MediaRowKind.FAVORITES -> HomeRowVisual(
Icons.Default.Favorite,
@@ -1041,33 +1040,30 @@ private fun MetadataContent(
Text(item.name, color = MutedText, fontSize = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
val facts = buildList {
item.episodeCode?.let(::add)
item.productionYear?.let { add(it.toString()) }
item.runtimeMinutes?.let { add(formatRuntime(it)) }
item.officialRating?.takeIf(String::isNotBlank)?.let(::add)
item.genres.take(2).takeIf { it.isNotEmpty() }?.let { add(it.joinToString(ValueSeparator)) }
}
// The score is its own run of text so it can carry the same gold as the detail
// page. Joined into the grey fact line it was the one rating in the app that
// changed colour depending on which screen you were looking at.
val score = ratingLabel(item)
if (facts.isNotEmpty() || score != null) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
}
score?.let {
if (facts.isNotEmpty()) Text(FactSeparator, color = QuietText, fontSize = 13.sp)
Text("$it", color = MembyScore, fontSize = 13.sp, fontWeight = FontWeight.SemiBold)
}
}
// No score on this line. Emby's community rating stood beside these facts in gold
// with nothing saying whose number it was; the strip below says that for every
// score it shows, and two ratings in one panel only invited the comparison.
if (facts.isNotEmpty()) {
Text(
facts.joinToString(FactSeparator),
color = MutedText,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ItemRatingsStrip(
item = item,
load = true,
compact = compact,
modifier = Modifier.fillMaxWidth(),
)
val badges = buildList {
airingBadgeLabel(item)?.let(::add)
addAll(mediaBadges(item))
@@ -1430,10 +1426,14 @@ internal fun MediaRow(
onItemFocused(item)
}
when (cardFormat(row.kind, item, artworkStyle)) {
// The resume bar belongs to the row, not to the card's shape: a
// household that has set artwork to posters still needs to see
// how far into something it is.
MediaCardFormat.PORTRAIT -> PortraitMediaCard(
item, availableWidth, row.showSecondaryMetadata, focused,
{ onItemSelected(item) }, { onItemLongPressed(item) }, cardModifier,
density, row.showWatchedEpisodeCount,
showProgress = row.kind == MediaRowKind.CONTINUE,
)
MediaCardFormat.LANDSCAPE -> if (row.kind == MediaRowKind.CONTINUE) {
ContinueWatchingCard(
@@ -1708,7 +1708,7 @@ private fun cardFormat(
): MediaCardFormat = when {
artworkStyle == "poster" -> MediaCardFormat.PORTRAIT
artworkStyle == "backdrop" -> MediaCardFormat.LANDSCAPE
kind == MediaRowKind.CONTINUE || kind == MediaRowKind.NEXT_UP -> MediaCardFormat.LANDSCAPE
kind == MediaRowKind.CONTINUE -> MediaCardFormat.LANDSCAPE
item.isEpisode -> MediaCardFormat.LANDSCAPE
else -> MediaCardFormat.PORTRAIT
}
@@ -1724,6 +1724,7 @@ fun PortraitMediaCard(
modifier: Modifier = Modifier,
density: String = "standard",
showWatchedEpisodeCount: Boolean = false,
showProgress: Boolean = false,
) {
val cardsAcross = when (density) {
"compact" -> 8
@@ -1732,7 +1733,7 @@ fun PortraitMediaCard(
}
val width = responsiveRowCardWidth(availableWidth, cardsAcross, 102.dp, 218.dp)
MediaCard(
item, width, 2f / 3f, preferPrimary = true, showProgress = false,
item, width, 2f / 3f, preferPrimary = true, showProgress = showProgress,
showSecondaryMetadata, showWatchedEpisodeCount, onFocused, onClick, onLongClick, modifier,
)
}
@@ -1941,10 +1942,23 @@ private fun MediaCard(
}
}
if (item.isSchedule) {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
scheduleStatusBadgeLabel(item.membyAvailability.orEmpty())?.let {
ScheduleStatusBadge(
status = item.membyAvailability.orEmpty(),
label = it,
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp),
)
}
// Bottom-left, because the two top corners already carry the air day and
// this episode's availability, and the progress bar owns the bottom edge
// only on cards that can be resumed — which a schedule card cannot.
lifecycleBadgeLabel(item)?.let {
LifecycleBadge(
status = item.membyLifecycle.orEmpty(),
label = it,
modifier = Modifier.align(Alignment.BottomStart).padding(8.dp),
)
}
}
airingBadgeLabel(item)?.let { label ->
MediaBadge(
@@ -1975,13 +1989,18 @@ private fun MediaCard(
} else {
Spacer(Modifier.height(3.dp))
}
ItemRatingsStrip(
item = item,
load = focused,
compact = true,
modifier = Modifier.padding(top = 2.dp).fillMaxWidth(),
)
}
}
}
@Composable
private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
val label = scheduleStatusBadgeLabel(status)
private fun ScheduleStatusBadge(status: String, label: String, modifier: Modifier = Modifier) {
val color = when (status) {
"available" -> EmbyGreen
"downloading" -> Color(0xFF5DA9FF)
@@ -2002,12 +2021,53 @@ private fun ScheduleStatusBadge(status: String, modifier: Modifier = Modifier) {
)
}
internal fun scheduleStatusBadgeLabel(status: String): String = when (status) {
internal fun scheduleStatusBadgeLabel(status: String): String? = when (status) {
"available" -> "ADDED"
"downloading" -> "DOWNLOADING"
"awaiting" -> "AWAITING"
"unmonitored" -> "UNMONITORED"
else -> "UPCOMING"
// The day badge and air-time subtitle already say when a future episode airs.
// Repeating "upcoming" adds no state and can collide with the more useful day.
else -> null
}
/**
* The show's or film's lifecycle, as Sonarr and Radarr word it CONTINUING, ENDED, IN
* CINEMAS. It answers a different question from the availability badge above it: that one
* is about the household's copy of this episode, this one is about whether there will be
* any more of them.
*
* The wording is the gateway's, taken verbatim off the card, so a status a build predates
* still reads correctly rather than falling back to a slug. A card carrying no lifecycle
* an older gateway, a cached row, a show *arr has no status for simply wears no tag.
*/
internal fun lifecycleBadgeLabel(item: BaseItem): String? =
item.membyLifecycleText?.trim()?.takeIf(String::isNotEmpty)?.uppercase()
@Composable
internal fun LifecycleBadge(status: String, label: String, modifier: Modifier = Modifier) {
// One colour per status, matching what the same word is coloured in *arr: still being
// made is green, over is red, not out yet is blue, in cinemas is amber.
val (background, foreground) = when (status) {
"continuing", "released" -> EmbyGreen to Color(0xFF071008)
"upcoming", "announced" -> Color(0xFF5DA9FF) to Color(0xFF090B0D)
"incinemas" -> Color(0xFFFFB454) to Color(0xFF090B0D)
"ended" -> Color(0xFFE04747) to Color.White
else -> Color(0xFF3A4249) to Color(0xFFE1E5E8)
}
Text(
text = label,
color = foreground,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.5.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier
.clip(RoundedCornerShape(4.dp))
.background(background)
.padding(horizontal = 7.dp, vertical = 4.dp),
)
}
@Composable
@@ -2199,6 +2259,7 @@ private fun cardDescription(
if (item.isSchedule) {
item.membyAirLabel?.let { append(", ").append(it) }
item.membyAvailabilityText?.let { append(", ").append(it) }
item.membyLifecycleText?.let { append(", ").append(it) }
}
}
@@ -21,8 +21,10 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -45,25 +47,58 @@ import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.floorMod
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
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
import kotlinx.coroutines.delay
import java.util.TimeZone
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.
* The current local day, re-read when the clock passes midnight.
*
* A television is not a phone: it is quite normal for this launcher to still be on screen
* at one in the morning, or to be woken from the screensaver into a composition that
* started yesterday. Reading the day once at composition would mean the "new releases" a
* household sees are the ones that were new whenever they last cold-started the app. The
* timer sleeps until the next local midnight rather than polling, so the cost of this is
* one suspended coroutine.
*/
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, focusedRowId: String?): Boolean =
hasMovies && (focusedRowId == null || focusedRowId == HOME_HERO_ROW_ID)
@Composable
internal fun rememberHomeHeroDay(): Long {
var day by remember { mutableStateOf(currentLocalDay()) }
LaunchedEffect(Unit) {
while (true) {
val now = System.currentTimeMillis()
// A floor keeps a clock correction that lands exactly on the boundary from
// turning this into a busy loop.
delay(
millisUntilNextLocalDay(now, TimeZone.getDefault().getOffset(now))
.coerceAtLeast(MIN_DAY_TICK_MS),
)
day = currentLocalDay()
}
}
return day
}
private fun currentLocalDay(): Long = System.currentTimeMillis().let { now ->
localEpochDay(now, TimeZone.getDefault().getOffset(now))
}
private const val MIN_DAY_TICK_MS = 1_000L
/** The movie feature owns the header whenever the Home shelves are back at their top. */
internal fun shouldShowHomeMovieHero(hasMovies: Boolean, listAtTop: Boolean): Boolean =
hasMovies && listAtTop
/**
* A hero card and the reason it is there.
@@ -79,8 +114,22 @@ 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> {
/**
* Picks a deliberate mix of fresh and popular movies while preserving server ranking.
*
* [day] is a count of local days (see [localEpochDay]) and rotates the starting point in
* each candidate list, so a household that leaves the launcher on the same four films for
* a fortnight instead sees a different set every morning. It is a rotation rather than a
* shuffle on purpose: the server's ranking is still the order, so the titles it thinks are
* worth leading with keep coming round, and yesterday's hero is merely one place further
* down rather than somewhere unpredictable. Passing the same day twice always produces the
* same four cards, which is what keeps the launcher from reshuffling under a viewer who
* simply walked back into the room.
*/
internal fun selectHomeHeroMovies(
rows: List<HomeBrowseRow>,
day: Long = 0L,
): List<HomeHeroPick> {
fun HomeBrowseRow.matches(vararg words: String): Boolean {
val label = "$id $title".lowercase()
return words.any(label::contains)
@@ -90,11 +139,13 @@ internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick>
.filter { it.matches("latest", "recent", "new release", "just added") }
.flatMap(HomeBrowseRow::items)
.filter(BaseItem::isMovie)
.rotatedBy(day)
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)
.rotatedBy(day)
val everyMovie = rows.flatMap(HomeBrowseRow::items).filter(BaseItem::isMovie).rotatedBy(day)
fun List<BaseItem>.labelled(label: String) = map { HomeHeroPick(it, label) }
@@ -119,6 +170,18 @@ internal fun selectHomeHeroMovies(rows: List<HomeBrowseRow>): List<HomeHeroPick>
}.distinctBy { it.item.id }.take(4)
}
/**
* Moves the start of the list on by [by] places, wrapping. A negative day is as valid as a
* positive one a television whose clock has not yet been set can report an instant before
* the epoch, and the launcher must still draw four cards rather than throw.
*/
private fun <T> List<T>.rotatedBy(by: Long): List<T> {
if (size <= 1) return this
val offset = floorMod(by, size.toLong()).toInt()
if (offset == 0) return this
return subList(offset, size) + subList(0, offset)
}
@Composable
internal fun HomeMovieHero(
movies: List<HomeHeroPick>,
@@ -222,45 +285,65 @@ private fun FeaturedMovieCard(
),
),
)
// 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.
// Play is measured before the words, and that is the whole layout.
//
// This column is the height of a fixed-height card. A Column hands each child
// the height left after the ones before it, so the chip — being last — was
// given whatever a two-line title had not already taken, and rendered as a
// green sliver with its label squeezed out of it. Not clipped: *compressed*,
// which is why it looked malformed rather than missing.
//
// Putting the text in a `weight(1f, fill = false)` child inverts the order:
// weighted children are measured from what is left over, so the spacer and the
// chip take their natural size first and the prose is what gives way. The
// synopsis still stands down on its own when the title wraps, so in practice
// nothing has to be cut at all — but the button can no longer be the thing that
// pays for a long title, whatever the artwork, the ratings strip or the
// viewport do.
var titleLines by remember(item.id) { mutableIntStateOf(1) }
Column(
modifier = Modifier.align(Alignment.CenterStart).fillMaxWidth(0.58f).padding(22.dp),
modifier = Modifier
.align(Alignment.CenterStart)
.fillMaxWidth(0.58f)
.fillMaxHeight()
.padding(22.dp),
verticalArrangement = Arrangement.Center,
) {
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,
)
Column(Modifier.weight(1f, fill = false)) {
// No eyebrow on the featured card. "NEW RELEASE" over a fact line that
// already prints the year was a whole line of the card's height spent
// on the least specific thing on it — and it is the line that pushed a
// wrapped title into the button. The three minis beside it keep theirs:
// they have no fact line, and there the label is the only reason given.
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)
ItemRatingsStrip(
item = item,
load = true,
modifier = Modifier.padding(top = 6.dp).fillMaxWidth(),
)
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))
@@ -271,32 +354,23 @@ private fun FeaturedMovieCard(
}
/**
* 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".
* Year, length and certificate the same wording and order 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". Scores are not here: they belong
* to the ratings strip, which names the provider behind each one.
*/
@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)
}
}
if (facts.isEmpty()) return
Text(
facts.joinToString(FactSeparator),
color = MembyMutedText,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
@@ -28,11 +28,15 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
enum class HomeSection { CONTINUE, NEXT_UP, FAVORITES, LATEST }
enum class HomeSection { CONTINUE, FAVORITES, LATEST }
data class HomeUiState(
/**
* Everything in progress, including the episode that follows one just finished:
* Continue Watching and Next Up are one row. The merge happens on the gateway, or in
* `EmbyRepository.getContinueWatching` on the direct path.
*/
val continueWatching: List<BaseItem> = emptyList(),
val nextUp: List<BaseItem> = emptyList(),
val favorites: List<BaseItem> = emptyList(),
val latestMovies: List<BaseItem> = emptyList(),
/**
@@ -54,9 +58,6 @@ data class HomeUiState(
*/
val maintenanceMessage: String? = 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
@@ -78,7 +79,6 @@ data class HomeUiState(
fun toCache() = HomeCache(
continueWatching = continueWatching,
nextUp = nextUp,
favorites = favorites,
latestMovies = latestMovies,
rows = rows,
@@ -86,14 +86,16 @@ data class HomeUiState(
companion object {
fun from(cache: HomeCache?) = HomeUiState(
continueWatching = cache?.continueWatching.orEmpty(),
nextUp = cache?.nextUp.orEmpty(),
// A cache written before the two rows merged still carries its Next Up items
// separately; folding them in keeps the cold-start row complete until the
// first refresh replaces it with a properly interleaved one.
continueWatching = (cache?.continueWatching.orEmpty() + cache?.nextUp.orEmpty())
.distinctBy(BaseItem::id),
favorites = cache?.favorites.orEmpty(),
latestMovies = cache?.latestMovies.orEmpty(),
rows = cache?.rows.orEmpty(),
loading = buildSet {
if (cache?.continueWatching.isNullOrEmpty()) add(HomeSection.CONTINUE)
if (cache?.nextUp.isNullOrEmpty()) add(HomeSection.NEXT_UP)
if (cache?.favorites.isNullOrEmpty()) add(HomeSection.FAVORITES)
if (cache?.latestMovies.isNullOrEmpty()) add(HomeSection.LATEST)
},
@@ -214,13 +216,15 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
fun refreshAll() {
viewModelScope.launch(Dispatchers.IO) {
refreshMutex.withLock {
// A refresh can make a newly imported episode the next playable item for
// an existing series ID. Never launch the pre-refresh negotiated session.
repository.invalidatePlaybackPrefetch()
_state.update { it.copy(loading = HomeSection.entries.toSet(), hasRefreshError = false) }
if (repository.supportsBatchHome) {
loadBatchHome()
} else {
coroutineScope {
launch { loadContinueWatching() }
launch { loadNextUp() }
launch { loadFavorites() }
launch { loadLatest() }
}
@@ -241,7 +245,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update { current ->
current.copy(
continueWatching = taggedHome.continueWatching,
nextUp = taggedHome.nextUp,
favorites = taggedHome.favorites,
latestMovies = taggedHome.latestMovies,
// Recommendation rows are built in the background by the gateway,
@@ -393,7 +396,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
_state.update {
it.copy(
continueWatching = it.continueWatching.map(BaseItem::updated),
nextUp = it.nextUp.map(BaseItem::updated),
favorites = it.favorites.map(BaseItem::updated),
latestMovies = it.latestMovies.map(BaseItem::updated),
// Server rows hold their own copies of the same items, so an optimistic
@@ -410,18 +412,14 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private suspend fun refreshWatching() {
refreshMutex.withLock {
_state.update { it.copy(loading = it.loading + setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) }
_state.update { it.copy(loading = it.loading + HomeSection.CONTINUE) }
if (repository.supportsBatchHome) {
// One request is cheaper than two here as well, and playback just
// invalidated this user's rows on the gateway anyway.
// Playback just invalidated this user's rows on the gateway anyway.
loadBatchHome()
} else {
coroutineScope {
launch { loadContinueWatching(clearLoading = false) }
launch { loadNextUp(clearLoading = false) }
}
loadContinueWatching(clearLoading = false)
}
_state.update { it.copy(loading = it.loading - setOf(HomeSection.CONTINUE, HomeSection.NEXT_UP)) }
_state.update { it.copy(loading = it.loading - HomeSection.CONTINUE) }
persistCurrentHome()
}
}
@@ -431,11 +429,6 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
state.copy(continueWatching = items)
}
private suspend fun loadNextUp(clearLoading: Boolean = true) =
load(HomeSection.NEXT_UP, clearLoading, { repository.getNextUp() }) { state, items ->
state.copy(nextUp = items)
}
private suspend fun loadFavorites() =
load(HomeSection.FAVORITES, true, { repository.getFavorites() }) { state, items ->
state.copy(favorites = items)
@@ -487,7 +480,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
private const val ANALYTICS_FLUSH_INTERVAL_MS = 20_000L
private fun initialFocusedItem(state: HomeUiState): BaseItem? =
state.watchingAndNextUp.firstOrNull()
state.continueWatching.firstOrNull()
?: state.latestMovies.firstOrNull()
?: state.favorites.firstOrNull()
}
@@ -496,10 +489,18 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
internal fun HomeSnapshot.withAiringTodayTags(): HomeSnapshot {
val airingTodayKeys = rows.airingTodayShowKeys()
if (airingTodayKeys.isEmpty()) return this
val taggedContinue = continueWatching
.withAiringTodayItemTags(airingTodayKeys)
.prioritizeAiringToday()
return copy(
rows = rows.withAiringTodayRowTags(airingTodayKeys),
continueWatching = continueWatching.withAiringTodayItemTags(airingTodayKeys),
nextUp = nextUp.withAiringTodayItemTags(airingTodayKeys),
rows = rows.withAiringTodayRowTags(airingTodayKeys).map { row ->
if (row.id == "continue") {
row.copy(items = row.items.prioritizeAiringToday())
} else {
row
}
},
continueWatching = taggedContinue,
favorites = favorites.withAiringTodayItemTags(airingTodayKeys),
latestMovies = latestMovies.withAiringTodayItemTags(airingTodayKeys),
)
@@ -518,13 +519,22 @@ private fun List<HomeRow>.withAiringTodayRowTags(keys: Set<String>): List<HomeRo
private fun List<BaseItem>.withAiringTodayItemTags(keys: Set<String>): List<BaseItem> =
map { item ->
if (!item.isTvSchedule && item.isSeries && item.name.showMatchKey() in keys) {
val showName = when {
item.isEpisode -> item.seriesName
item.isSeries -> item.name
else -> null
}
if (!item.isTvSchedule && showName != null && showName.showMatchKey() in keys) {
item.copy(membyAiringToday = true)
} else {
item
}
}
/** Stable partition: today's shows move forward without disturbing recency within groups. */
private fun List<BaseItem>.prioritizeAiringToday(): List<BaseItem> =
filter(BaseItem::membyAiringToday) + filterNot(BaseItem::membyAiringToday)
private fun String.showMatchKey(): String =
lowercase().filter(Char::isLetterOrDigit)
@@ -0,0 +1,194 @@
package com.ponzischeme89.memby.ui
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
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.Text
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.update.InstallPermission
/**
* The setup step that asks for permission to install updates.
*
* Every TV running Memby was sideloaded, which means the app that installed it holds
* Android's per-app install permission and Memby does not. Without this step nobody is ever
* asked: the permission is discovered to be missing months later, in the middle of an update
* that then cannot proceed, and the household's answer becomes "reinstall it by hand" once
* per release, forever.
*
* It is deliberately **skippable**. A fresh install must never be blocked by a permission
* that only matters later, and on a TV with no permission screen to open there would be
* nothing the viewer could do to satisfy it. Skipping costs them the same prompt at update
* time, which is where it used to live.
*/
@Composable
fun InstallPermissionScreen(onContinue: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
var noScreenAvailable by remember { mutableStateOf(false) }
var asked by remember { mutableStateOf(false) }
// Granting happens in Android's settings, which pauses Memby. Coming back with the
// permission in hand should simply carry on rather than leave the viewer looking at a
// step they have already completed.
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_RESUME && asked && InstallPermission.granted(context)) {
onContinue()
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
InstallPermissionContent(
noScreenAvailable = noScreenAvailable,
onOpenSettings = {
asked = true
noScreenAvailable = !InstallPermission.requestScreen(context)
},
onSkip = onContinue,
)
}
/**
* The screen itself, stateless so it can be screenshotted in both of its states
* (`OnboardingScreenshotTest`).
*
* The instructions are the substance here. Android's own screen is a bare list of app names
* with switches and no explanation of why anyone was sent there, and on a television the
* viewer has a remote, no back button they trust, and no idea what "unknown sources" means.
* Naming the steps *find Memby, turn it on, press Back* is the difference between a
* permission that gets granted and one that gets abandoned halfway.
*/
@Composable
fun InstallPermissionContent(
noScreenAvailable: Boolean,
onOpenSettings: () -> Unit,
onSkip: () -> Unit,
modifier: Modifier = Modifier,
) {
val allowFocus = remember { FocusRequester() }
val skipFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { allowFocus.requestFocus() } }
Box(
modifier.fillMaxSize().background(MembySurface),
contentAlignment = Alignment.Center,
) {
Column(
modifier = Modifier.widthIn(max = 780.dp).padding(horizontal = 56.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Let Memby update itself",
color = MembyOnSurface,
fontSize = 34.sp,
fontWeight = FontWeight.SemiBold,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(14.dp))
Text(
"Memby installs its own updates. Because it was installed from a file rather " +
"than a store, Android needs your permission once before it will let it.",
color = MembyMutedText,
fontSize = 17.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(26.dp))
if (noScreenAvailable) {
PermissionStep(1, "Open ${InstallPermission.MANUAL_PATH} on this TV.")
PermissionStep(2, "Find Memby in the list and turn it on.")
PermissionStep(3, "Come back here and carry on.")
} else {
PermissionStep(1, "Choose Open settings below.")
PermissionStep(2, "Turn on the switch for Memby.")
PermissionStep(3, "Press Back on your remote — Memby carries on by itself.")
}
Spacer(Modifier.height(18.dp))
Text(
"You can skip this. Memby will ask again the first time an update needs it, " +
"and until then nothing changes.",
color = MembyQuietText,
fontSize = 14.sp,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(30.dp))
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
UpdateButton(
label = if (noScreenAvailable) "Try again" else "Open settings",
primary = true,
enabled = true,
onClick = onOpenSettings,
modifier = Modifier
.focusRequester(allowFocus)
.focusProperties { right = skipFocus },
)
UpdateButton(
label = "Skip for now",
primary = false,
enabled = true,
onClick = onSkip,
modifier = Modifier
.focusRequester(skipFocus)
.focusProperties { left = allowFocus },
)
}
}
}
}
/** One numbered instruction. Never focusable — reading matter, not a control. */
@Composable
private fun PermissionStep(number: Int, text: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp),
horizontalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(
"$number",
color = MembyAccent,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.width(20.dp),
textAlign = TextAlign.End,
)
Text(text, color = MembyOnSurface.copy(alpha = 0.88f), fontSize = 16.sp)
}
}
File diff suppressed because it is too large Load Diff
@@ -2,9 +2,9 @@ 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.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.FirstPage
import androidx.compose.material.icons.filled.Movie
import androidx.compose.runtime.Composable
@@ -22,7 +22,7 @@ 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.data.model.GatewayMovieRating
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.creditRows
@@ -56,19 +56,15 @@ fun MediaDetailsOverlay(
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) }
var ratings by remember(item.id) { mutableStateOf<List<GatewayMovieRating>>(emptyList()) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
related = ServiceLocator.repository.getRelated(item)
}
LaunchedEffect(item.id) {
trailer = ServiceLocator.repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, item.isMovie) {
ratings = if (item.isMovie) {
ServiceLocator.repository.getMovieRatings(item.id)
} else {
emptyList()
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) ServiceLocator.repository.getRatings(item) else emptyList()
}
MediaDetailContent(
item = item,
@@ -79,6 +75,7 @@ fun MediaDetailsOverlay(
related = related,
trailer = trailer,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
modifier = modifier,
)
@@ -97,7 +94,8 @@ internal fun MediaDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
ratings: List<GatewayMovieRating> = emptyList(),
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
) {
@@ -179,12 +177,15 @@ internal fun MediaDetailContent(
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
ratings = ratings,
showRatingsStrip = showRatingsStrip,
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,
// A heart, not a tick: "Mark watched" two buttons along is a tick as
// well. The heart is what a home card and the screensaver already use.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -177,19 +177,11 @@ private fun MyShowCard(
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),
myShowBadge(show)?.let { (status, label) ->
LifecycleBadge(
status = status,
label = label,
modifier = Modifier.align(Alignment.TopStart).padding(8.dp),
)
}
}
@@ -215,10 +207,21 @@ private fun MyShowCard(
}
}
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)
/**
* The tag a followed show wears, as a `status to label` pair for [LifecycleBadge] the same
* vocabulary and therefore the same colours as the Sonarr and Radarr schedule rows, so one
* word means one thing wherever it appears on the launcher.
*
* The order is the priority. A cancelled show wins outright: nothing else on the card
* matters as much as "there will be no more of this", which is why it is the one tag drawn
* in red. Sonarr not monitoring it comes next, then a dated next episode, which is more
* useful than the general fact that the show continues.
*/
internal fun myShowBadge(show: MyShow): Pair<String, String>? = when {
show.lifecycle == "Cancelled" -> "ended" to "CANCELLED"
show.sonarrStatus == "Not monitored" -> "unmonitored" to "UNMONITORED"
!show.nextEpisode.isNullOrBlank() -> "upcoming" to "UPCOMING"
show.lifecycle == "Continuing" -> "continuing" to "CONTINUING"
else -> null
}
@@ -0,0 +1,201 @@
package com.ponzischeme89.memby.ui
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.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
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.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable
import com.ponzischeme89.memby.data.model.formattedScore
import com.ponzischeme89.memby.data.model.ratingDisplayLimit
import com.ponzischeme89.memby.data.model.wordmark
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembyRatingsSurface
import kotlin.math.roundToInt
private val RatingStripHeight = 27.dp
private val RatingMarkHeight = 15.dp
private val RatingMarkHeightCompact = 13.dp
/** One non-focusable, layout-stable presentation for ratings everywhere in the app. */
@Composable
fun RatingsStrip(
ratings: List<MediaRating>,
visible: Boolean,
modifier: Modifier = Modifier,
reserveSpace: Boolean = false,
compact: Boolean = false,
) {
if (!visible) return
val valid = remember(ratings) { ratings.displayable() }
if (valid.isEmpty()) {
if (reserveSpace) Spacer(modifier.height(RatingStripHeight))
return
}
BoxWithConstraints(
modifier = modifier
.then(if (reserveSpace) Modifier.height(RatingStripHeight) else Modifier)
.fillMaxWidth()
.clipToBounds()
.testTag("ratings-strip"),
contentAlignment = Alignment.CenterStart,
) {
// The responsive limit is based on the usable width inside the capsule.
val contentWidth = (maxWidth.value.roundToInt() - 20).coerceAtLeast(0)
val shown = valid.take(ratingDisplayLimit(contentWidth))
Row(
modifier = Modifier
.clip(RoundedCornerShape(percent = 50))
.background(MembyRatingsSurface)
.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(if (compact) 8.dp else 13.dp),
) {
shown.forEach { rating ->
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
RatingMark(rating, compact)
Text(
rating.formattedScore(),
color = MembyOnSurface,
fontSize = if (compact) 11.sp else 13.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
}
}
}
}
}
/**
* Lazy item adapter.
*
* A card whose payload already carries ratings draws them with the row, costing nothing:
* the gateway attaches what it has stored to every item it sends. Only a title it has
* never looked up falls back to the dedicated request, which cards make on focus and
* heroes and detail pages make immediately.
*/
@Composable
fun ItemRatingsStrip(
item: BaseItem,
load: Boolean,
modifier: Modifier = Modifier,
reserveSpace: Boolean = true,
compact: Boolean = false,
) {
val settings by ServiceLocator.repository.settingsFlow.collectAsState(initial = Settings.EMPTY)
var ratings by remember(item.id) { mutableStateOf(item.membyRatings) }
// A focused home item is replaced in-place by its full Emby details. Include the
// carried ratings in the key so an item can pick them up from the settled response
// without requiring the viewer to move focus away and back.
LaunchedEffect(item.id, item.membyRatings, load, settings.showRatingsStrip) {
if (item.membyRatings.isNotEmpty()) {
ratings = item.membyRatings
return@LaunchedEffect
}
if (load && settings.showRatingsStrip) ratings = ServiceLocator.repository.getRatings(item)
}
RatingsStrip(
ratings = ratings,
visible = settings.showRatingsStrip,
modifier = modifier,
reserveSpace = reserveSpace,
compact = compact,
)
}
/**
* A provider is shown by its own mark where one exists and by its wordmark where it does
* not the strip has to stay readable for a source no build anticipated, and the score
* beside a bare wordmark says nothing about where it came from.
*/
@Composable
private fun RatingMark(rating: MediaRating, compact: Boolean) {
val icon = ratingIcon(rating.source)
if (icon != null) {
val height = (if (compact) RatingMarkHeightCompact else RatingMarkHeight) *
markScale(rating.source)
Image(
painter = painterResource(icon),
contentDescription = rating.wordmark(),
contentScale = ContentScale.Fit,
modifier = Modifier.height(height),
)
} else {
Text(
rating.wordmark(),
color = providerColor(rating.source),
fontSize = if (compact) 10.sp else 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Clip,
)
}
}
/**
* Only the critics' tomato is drawn as a tomato: the audience score is a different
* measurement and wearing the same mark would claim it was the same number.
*/
private fun ratingIcon(source: String): Int? = when (source.lowercase()) {
"tomatoes" -> R.drawable.ic_rating_tomatoes
"metacritic" -> R.drawable.ic_rating_metacritic
"imdb" -> R.drawable.ic_rating_imdb
"letterboxd" -> R.drawable.ic_rating_letterboxd
"tmdb" -> R.drawable.ic_rating_tmdb
else -> null
}
/**
* Marks are set to a common height, which is the wrong measure for artwork that stacks
* its type on two lines: TMDb's lettering ends up half the size of everyone else's. The
* scale brings its letters back to the same cap height rather than its box.
*/
private fun markScale(source: String): Float =
if (source.lowercase() == "tmdb") 1.25f else 1f
private fun providerColor(source: String): Color = when (source.lowercase()) {
"imdb" -> Color(0xFFF5C518)
"tomatoes", "audience" -> Color(0xFFFA5252)
"metacritic" -> Color(0xFFFFCC34)
"letterboxd" -> Color(0xFF40BCF4)
"tmdb" -> Color(0xFF90CEA1)
"trakt" -> Color(0xFFED1C24)
"mal", "anilist", "anidb", "kitsu" -> Color(0xFF74A8FF)
else -> MembyMutedText
}
@@ -26,11 +26,11 @@ 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.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable
@@ -63,7 +63,11 @@ 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.estimateSeriesPace
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.seriesPaceLabel
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.DetailTab
import com.ponzischeme89.memby.ui.detail.DetailZone
import com.ponzischeme89.memby.ui.detail.availableSeasons
@@ -74,6 +78,7 @@ 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.formatAirDate
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.detail.isPlayed
import com.ponzischeme89.memby.ui.detail.nextEpisodeToWatch
@@ -84,6 +89,7 @@ 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
import java.util.TimeZone
/**
* A series. Loads its episodes and hands them to [SeriesDetailContent], which is where the
@@ -99,6 +105,7 @@ fun SeriesDetailsOverlay(
onToggleMyShow: (BaseItem, Boolean) -> Unit,
onClose: () -> Unit,
onOpenItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
modifier: Modifier = Modifier,
) {
val repository = ServiceLocator.repository
@@ -107,6 +114,7 @@ fun SeriesDetailsOverlay(
var loadFailed by remember(item.id) { mutableStateOf(false) }
var related by remember(item.id) { mutableStateOf<RelatedContent?>(null) }
var trailer by remember(item.id) { mutableStateOf<BaseItem?>(null) }
var ratings by remember(item.id) { mutableStateOf<List<MediaRating>>(emptyList()) }
LaunchedEffect(item.id) {
runCatching { repository.getSeriesEpisodes(item.id) }
@@ -124,6 +132,9 @@ fun SeriesDetailsOverlay(
LaunchedEffect(item.id) {
trailer = repository.getLocalTrailer(item.id)
}
LaunchedEffect(item.id, settings.showRatingsStrip) {
ratings = if (settings.showRatingsStrip) repository.getRatings(item) else emptyList()
}
SeriesDetailContent(
item = item,
@@ -135,8 +146,11 @@ fun SeriesDetailsOverlay(
onToggleMyShow = onToggleMyShow,
related = related,
trailer = trailer,
ratings = ratings,
showRatingsStrip = settings.showRatingsStrip,
hideWatchedMovies = settings.hideWatchedMovies,
onOpenItem = onOpenItem,
airingNotice = airingNotice,
modifier = modifier,
)
}
@@ -154,8 +168,11 @@ internal fun SeriesDetailContent(
modifier: Modifier = Modifier,
related: RelatedContent? = null,
trailer: BaseItem? = null,
ratings: List<MediaRating> = emptyList(),
showRatingsStrip: Boolean = true,
hideWatchedMovies: Boolean = false,
onOpenItem: (BaseItem) -> Unit = {},
airingNotice: AiringNotice? = null,
) {
val remembered = remember(item.id) { detailPositions.get(item.id) }
val seasons = remember(episodes) { availableSeasons(episodes.orEmpty()) }
@@ -170,6 +187,20 @@ internal fun SeriesDetailContent(
}
val nextEpisode = remember(episodes) { nextEpisodeToWatch(episodes.orEmpty()) }
val remaining = remember(episodes) { unwatchedCount(episodes.orEmpty()) }
// Recalculated from the episode list itself, so finishing an episode, marking one
// watched and a newly imported episode all move it with no cache to invalidate. The
// day is read once per composition of this page rather than on a timer: an estimate
// that ticks over at midnight under a page nobody is looking at is not worth a clock.
val paceLabel = remember(episodes, item.id) {
seriesPaceLabel(
estimateSeriesPace(
episodes = episodes.orEmpty(),
nowMs = System.currentTimeMillis(),
zoneOffsetMs = TimeZone.getDefault().getOffset(System.currentTimeMillis()),
ongoing = item.isOngoingSeries,
),
)
}
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) {
@@ -266,14 +297,22 @@ internal fun SeriesDetailContent(
modifier = modifier,
progress = nextEpisode?.let(::playbackProgress) ?: 0f,
progressLabel = nextEpisode?.let(::remainingLabel),
paceLabel = paceLabel,
reasons = related?.reasons?.takeIf(List<String>::isNotEmpty)
?: listOfNotNull(item.membyRecommendationReason?.takeIf(String::isNotBlank)),
airingNotice = airingNotice,
confirmation = confirmation,
ratings = ratings,
showRatingsStrip = showRatingsStrip,
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,
// A heart, not a plus: the bookmark beside it is also a "+" glyph, and
// two adjacent circles that both read as "add this" say nothing about
// which list is which. The heart is already what a home card, the
// screensaver and the Favourites row header use for this.
icon = if (item.isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
description = if (item.isFavorite) "Remove from Favourites" else "Add to Favourites",
active = item.isFavorite,
onClick = {
@@ -285,7 +324,10 @@ internal fun SeriesDetailContent(
)
if (ServerConfig.isGateway) {
add(DetailHeroAction(
icon = if (isMyShow) Icons.Default.BookmarkAdded else Icons.Default.BookmarkAdd,
// Filled/outline says on-or-off for both toggles, so the two differ by
// silhouette alone — heart against bookmark. The +/✓ variants said it
// a second way and borrowed the glyphs the other buttons use.
icon = if (isMyShow) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
description = if (isMyShow) "Remove from My Shows" else "Add to My Shows",
active = isMyShow,
onClick = { onToggleMyShow(item, !isMyShow) },
@@ -468,14 +510,21 @@ private fun SeasonChip(
}
}
/** A minimal D-pad row: the focused episode becomes the unmistakable selection. */
/**
* A minimal D-pad row: the focused episode becomes the unmistakable selection.
*
* Shared with the episode page, which passes [isCurrent] for the one the page is about
* the season's list is otherwise identical there, and two copies of a row this fiddly is
* how the two screens start disagreeing about what a watched episode looks like.
*/
@Composable
private fun EpisodeCard(
internal fun EpisodeCard(
episode: BaseItem,
onClick: () -> Unit,
seasonFocusRequester: FocusRequester,
isFirst: Boolean,
modifier: Modifier = Modifier,
isCurrent: Boolean = false,
) {
val repository = ServiceLocator.repository
var focused by remember { mutableStateOf(false) }
@@ -500,10 +549,20 @@ private fun EpisodeCard(
}
.zIndex(if (focused) 1f else 0f)
.clip(shape)
.background(if (focused) Color(0xFF23282C) else Color.White.copy(alpha = 0.035f))
.background(
when {
focused -> Color(0xFF23282C)
isCurrent -> DetailAccent.copy(alpha = 0.10f)
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),
color = when {
focused -> Color.White.copy(alpha = 0.30f)
isCurrent -> DetailAccent.copy(alpha = 0.55f)
else -> Color.White.copy(alpha = 0.08f)
},
shape = shape,
)
.focusProperties {
@@ -602,6 +661,29 @@ private fun EpisodeCard(
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (isCurrent) {
Text(
text = "THIS EPISODE",
color = DetailAccent,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
modifier = Modifier
.padding(start = 10.dp)
.clip(RoundedCornerShape(5.dp))
.background(DetailAccent.copy(alpha = 0.16f))
.padding(horizontal = 7.dp, vertical = 3.dp),
)
}
formatAirDate(episode.premiereDate)?.let {
Text(
text = it,
color = if (focused) Color(0xFFB7C0C6) else DetailQuietText,
fontSize = 12.sp,
maxLines = 1,
modifier = Modifier.padding(start = 12.dp),
)
}
episode.runtimeMinutes?.let {
Text(
text = "$it min",
@@ -61,6 +61,7 @@ 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.AppInstall
import com.ponzischeme89.memby.update.InstallPermissionRequiredException
import com.ponzischeme89.memby.update.UpdateChecker
import kotlinx.coroutines.launch
@@ -117,6 +118,11 @@ fun UpdateScreen(
}
}
// The install happens in the system installer, so its outcome arrives here rather than
// as the result of the call above. On a mandatory update this is the only thing that
// can tell a viewer why the screen they cannot leave is still there.
LaunchedEffect(Unit) { AppInstall.messages.collect { message = it } }
// 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.
@@ -310,7 +316,7 @@ fun UpdateScreen(
}
@Composable
private fun UpdateButton(
internal fun UpdateButton(
label: String,
primary: Boolean,
enabled: Boolean,
@@ -0,0 +1,82 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import java.util.Locale
/**
* What a detail page says when it was opened from the "Shows airing in the next 5 days"
* row: which episode is due, and when.
*
* It exists because that row's cards stand for an *episode* while the page they open is
* the *show*. Without it the viewer picks "Thursday, 9pm" from the launcher and arrives at
* a page that says nothing about Thursday. It is deliberately a property of the route
* taken, not of the show the same page reached from Favourites or a search carries no
* notice, because nothing there promised one.
*
* Every field is wording the gateway already authored ([BaseItem.membyAirLabel] and
* friends), so the TV never computes an air time from a timestamp and can never contradict
* the card the viewer just pressed.
*/
data class AiringNotice(
/** The eyebrow: "AIRING TOMORROW", "AIRED TODAY", "NEW EPISODE READY". */
val label: String,
/** "S03E04 • The Crossing", or empty when Sonarr named neither. */
val headline: String,
/** "In 8 hours (4:00 PM) • Awaiting download". */
val detail: String,
)
/**
* The notice for a schedule card, or null for anything else including a movie-schedule
* card, which has no episode to announce and opens no series page.
*/
fun airingNoticeFor(item: BaseItem): AiringNotice? {
if (!item.isTvSchedule) return null
val day = item.membyAirDayLabel?.trim().orEmpty()
val airLabel = item.membyAirLabel?.trim().orEmpty()
val availability = item.membyAvailabilityText?.trim().orEmpty()
val headline = listOfNotNull(
item.membyEpisodeCode?.trim()?.takeIf(String::isNotEmpty),
item.membyEpisodeTitle?.trim()?.takeIf(String::isNotEmpty),
).joinToString(FactSeparator)
val detail = listOf(airLabel, availability)
.filter(String::isNotEmpty)
.joinToString(FactSeparator)
// A notice with nothing to say is worse than none: it would claim a schedule the page
// cannot name.
if (headline.isEmpty() && detail.isEmpty()) return null
return AiringNotice(label = airingNoticeLabel(day, airLabel, item.membyAvailability), headline, detail)
}
/**
* The Emby series a schedule card stands for, as much of it as the card itself knows, or
* null when the library has never imported the show.
*
* The page opens on this and fills in from Emby a moment later, the same way the launcher
* draws its cached rows before the network answers waiting on one item request before
* anything appears is the one thing that would make the row feel broken. The episode's own
* overview and artwork are deliberately left behind: they belong to the episode, and the
* page this becomes is about the show.
*/
fun scheduleSeriesStub(card: BaseItem): BaseItem? {
if (!card.isTvSchedule) return null
val seriesId = card.membySeriesItemId?.trim()?.takeIf(String::isNotEmpty) ?: return null
return BaseItem(
id = seriesId,
name = card.name,
type = "Series",
genres = card.genres,
productionYear = card.productionYear,
)
}
private fun airingNoticeLabel(day: String, airLabel: String, availability: String?): String = when {
// The episode is already on the server, so "airing" would send someone to wait for
// something they could watch now.
availability == "available" -> "NEW EPISODE READY"
airLabel.startsWith("Aired", ignoreCase = true) ->
"AIRED ${day.ifEmpty { "TODAY" }.uppercase(Locale.ROOT)}"
day.isEmpty() || day.equals("Upcoming", ignoreCase = true) -> "UPCOMING EPISODE"
else -> "AIRING ${day.uppercase(Locale.ROOT)}"
}
@@ -1,6 +1,7 @@
package com.ponzischeme89.memby.ui.detail
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.ValueSeparator
import java.util.Locale
@@ -79,9 +80,29 @@ fun heroFacts(item: BaseItem, seasonCount: Int = 0): List<String> = buildList {
item.officialRating?.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) }
/**
* "12 Mar 2024" from Emby's `PremiereDate`, or null when there is no usable date.
*
* Only the date half of the timestamp is read, deliberately. Emby writes a premiere as
* midnight UTC, so resolving it against the set's own zone moves an episode a day earlier
* everywhere west of Greenwich and the day a programme aired is a fact about the
* broadcast, not an instant to be converted. Parsing the literal `YYYY-MM-DD` prefix also
* keeps this free of `java.time`, which this app's `minSdk` 23 cannot use.
*/
fun formatAirDate(raw: String?): String? {
val date = raw?.trim()?.takeIf { it.length >= 10 } ?: return null
if (date[4] != '-' || date[7] != '-') return null
val year = date.substring(0, 4).toIntOrNull() ?: return null
val month = date.substring(5, 7).toIntOrNull() ?: return null
val day = date.substring(8, 10).toIntOrNull() ?: return null
if (month !in 1..12 || day !in 1..31) return null
return "$day ${MONTH_ABBREVIATIONS[month - 1]} $year"
}
private val MONTH_ABBREVIATIONS = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
/** A label/value pair for the quiet technical area. */
data class TechnicalSpec(val label: String, val value: String)
@@ -301,20 +322,170 @@ fun episodeLabel(episode: BaseItem): String? {
return if (season != null) "S$season E$number" else "E$number"
}
/**
* "S04E04" the zero-padded form, used on the primary button and nowhere else.
*
* A button is read at a glance from across a room, and the padded code is the shape a
* viewer recognises without parsing it; [episodeLabel]'s spaced "S4 E4" is for running
* prose like [episodeHeadline], where it sits beside a title. Null when Emby numbered
* neither half, which is when the button has to fall back to a bare verb.
*/
fun paddedEpisodeCode(episode: BaseItem): String? {
val number = episode.indexNumber ?: return null
val season = episode.parentIndexNumber
?: return String.format(Locale.US, "E%02d", number)
return String.format(Locale.US, "S%02dE%02d", season, number)
}
/** "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(ValueSeparator)
// ---------------------------------------------------------------------------
// Episode pages
// ---------------------------------------------------------------------------
/**
* "SEASON 3 · EPISODE 4" the line under the series logo on an episode's own page.
*
* Spelled out rather than [episodeLabel]'s "S3 E4": this is the page's heading, where the
* short form is a code the viewer has to decode, and there is width for the words. Null
* when Emby numbered neither half, which is when the episode title has to carry the page
* on its own.
*/
fun episodeEyebrow(episode: BaseItem): String? {
val season = episode.parentIndexNumber?.let { if (it == 0) "SPECIALS" else "SEASON $it" }
val number = episode.indexNumber?.let { "EPISODE $it" }
return listOfNotNull(season, number).takeIf(List<String>::isNotEmpty)?.joinToString(ValueSeparator)
}
/** Where a season sits relative to the one the viewer is up to. */
enum class SeasonProgress { WATCHED, CURRENT, UPCOMING }
/**
* One stop on the season scroller: what to call it, how far through it the viewer is, and
* whether it is behind them, under them or ahead.
*/
data class SeasonMarker(
val season: Int,
val label: String,
val progress: SeasonProgress,
val watchedEpisodes: Int,
val totalEpisodes: Int,
) {
/** True when every episode the library holds for this season has been played. */
val complete: Boolean get() = totalEpisodes > 0 && watchedEpisodes == totalEpisodes
}
/**
* The scroller that replaces the tab strip on an episode page.
*
* A season counts as [SeasonProgress.WATCHED] when it is *behind* the episode being viewed
* or when nothing in it is left unplayed the two ways a household is done with a season.
* Being behind is enough on its own deliberately: someone who skipped an episode of season
* one and is now three seasons along has moved on, and greying it is the honest summary of
* where they are. Everything after the current season stays
* [SeasonProgress.UPCOMING] however much of it has been sampled, because a scroller that
* dims what is still to come says the show is finished when it is not.
*/
fun seasonMarkers(episodes: List<BaseItem>, currentSeason: Int?): List<SeasonMarker> =
availableSeasons(episodes).map { season ->
val inSeason = episodes.filter { it.parentIndexNumber == season }
val watched = inSeason.count(BaseItem::isPlayed)
val progress = when {
currentSeason != null && season == currentSeason -> SeasonProgress.CURRENT
inSeason.isNotEmpty() && watched == inSeason.size -> SeasonProgress.WATCHED
// Specials sort first but are not on the way to anywhere, so being "before"
// the current season earns them nothing: a tick on a special nobody has
// watched is the one claim this scroller must not make.
currentSeason != null && season in 1 until currentSeason -> SeasonProgress.WATCHED
else -> SeasonProgress.UPCOMING
}
SeasonMarker(
season = season,
label = seasonLabel(season),
progress = progress,
watchedEpisodes = watched,
totalEpisodes = inSeason.size,
)
}
/** "5 of 8 watched", or null for a season the library has nothing for. */
fun seasonProgressLabel(marker: SeasonMarker): String? = when {
marker.totalEpisodes <= 0 -> null
marker.complete && marker.totalEpisodes == 1 -> "Watched"
marker.complete -> "All ${marker.totalEpisodes} watched"
marker.watchedEpisodes == 0 && marker.totalEpisodes == 1 -> "1 episode"
marker.watchedEpisodes == 0 -> "${marker.totalEpisodes} episodes"
else -> "${marker.watchedEpisodes} of ${marker.totalEpisodes} watched"
}
/**
* The whole-series line beside the scroller: "Season 3 of 5 · 12 episodes left".
*
* Null when there is nothing worth saying a one-season show with everything watched has
* no progress to report, and a bar that says so anyway is noise on every episode page.
*/
fun seriesProgressLabel(markers: List<SeasonMarker>, currentSeason: Int?): String? {
if (markers.isEmpty()) return null
// Counted over the numbered seasons only. "Season 3 of 5" on a four-season show with a
// making-of special is wrong in the one place the viewer is most likely to read it.
val numbered = markers.filter { it.season > 0 }
val position = numbered.indexOfFirst { it.season == currentSeason }
val place = if (position >= 0 && numbered.size > 1) {
"${numbered[position].label} of ${numbered.size}"
} else {
null
}
val left = markers.sumOf { it.totalEpisodes - it.watchedEpisodes }
val remaining = when {
left <= 0 -> null
left == 1 -> "1 episode left"
else -> "$left episodes left"
}
return listOfNotNull(place, remaining).takeIf(List<String>::isNotEmpty)?.joinToString(FactSeparator)
}
/**
* The episode that follows [episode] in the same library, or null at the end of the show.
*
* Position in running order decides it, never the length of the list the same rule the
* player's auto-advance follows, so the page's "Up next" and what actually plays cannot
* disagree.
*/
fun episodeAfter(episodes: List<BaseItem>, episode: BaseItem): BaseItem? {
val ordered = episodes.sortedWith(seriesEpisodeComparator)
val index = ordered.indexOfFirst { it.id == episode.id }
return if (index < 0) null else ordered.getOrNull(index + 1)
}
/**
* The primary button. Series pass their next episode so the button can name it; a movie
* passes itself.
* passes itself; an episode names itself without being asked.
*
* An episode's button says "Play S04E04" because that page is reached from Continue
* Watching, where the viewer chose a specific episode and the one thing the button has to
* confirm is that it is the one they meant. A bare "Play" on a page headed by the series
* logo could plausibly mean the show.
*/
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String = when {
nextEpisode != null && nextEpisode.isResumable -> "Resume ${episodeLabel(nextEpisode) ?: "episode"}"
nextEpisode != null -> "Play ${episodeLabel(nextEpisode) ?: "next episode"}"
item.isResumable -> "Resume"
else -> "Play"
fun primaryActionLabel(item: BaseItem, nextEpisode: BaseItem? = null): String {
val episode = nextEpisode ?: item.takeIf(BaseItem::isEpisode)
if (episode != null) {
val verb = if (episode.isResumable) "Resume" else "Play"
val code = paddedEpisodeCode(episode)
return when {
code != null -> "$verb $code"
// An unnumbered episode of somebody else's show still has to say something.
nextEpisode != null -> "$verb next episode"
else -> verb
}
}
return when {
item.isResumable -> "Resume"
item.isMovie && item.isPlayed -> "Rewatch"
else -> "Play"
}
}
/** The supporting line under the primary button, or null when there is nothing to add. */
@@ -0,0 +1,185 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Color
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
/** One face in the cast panel. [role] is the character, which is what a viewer is asking. */
data class CastMember(
val name: String,
val role: String? = null,
val imageUrl: String? = null,
)
/**
* What the panel is showing.
*
* [loaded] is separate from an empty [members] list because "still fetching" and "this
* title has no cast recorded" are different things to be told, and a spinner that never
* resolves is the worse of the two to leave on screen.
*/
data class CastPanelState(
val title: String = "",
val members: List<CastMember> = emptyList(),
val loaded: Boolean = false,
)
/**
* Fills the cast overlay from a plain state.
*
* Like [bindSubtitleMenu], it lives apart from [PlayerActivity] so `CastPanelScreenshotTest`
* can render the real panel these cards, these colours, this layout with no player, no
* Emby server and no network. [loadImage] is injected for the same reason: artwork is the
* one thing a screenshot test cannot fetch, and passing it in means the test renders the
* initials fallback rather than a blank where every portrait should be.
*/
fun bindCastPanel(
overlay: View,
state: CastPanelState,
loadImage: (ImageView, String) -> Unit = { _, _ -> },
) {
val context = overlay.context
overlay.findViewById<TextView>(R.id.player_cast_title).apply {
text = state.title
isVisible = state.title.isNotBlank()
}
overlay.findViewById<TextView>(R.id.player_cast_status).apply {
text = when {
!state.loaded -> context.getString(R.string.player_cast_loading)
state.members.isEmpty() -> context.getString(R.string.player_cast_empty)
else -> ""
}
isVisible = !text.isNullOrEmpty()
}
val people = overlay.findViewById<LinearLayout>(R.id.player_cast_people)
people.removeAllViews()
state.members.forEach { member -> people.addView(castCard(context, member, loadImage)) }
overlay.findViewById<View>(R.id.player_cast_scroller).isVisible = state.members.isNotEmpty()
}
private fun castCard(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
): View {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
isFocusable = true
isClickable = true
// Nothing happens on a press. The panel is a reference, not a destination — there
// is no person page to open — but the card still has to be focusable, or a D-pad
// cannot scroll the row at all.
setOnClickListener { }
clipChildren = false
layoutParams = LinearLayout.LayoutParams(dp(132), ViewGroup.LayoutParams.WRAP_CONTENT).apply {
marginEnd = dp(18)
}
setOnFocusChangeListener { view, focused ->
view.animate()
.scaleX(if (focused) 1.06f else 1f)
.scaleY(if (focused) 1.06f else 1f)
.setDuration(120L)
.start()
}
addView(castPortrait(context, member, loadImage, ::dp))
addView(
TextView(context).apply {
text = member.name
setTextColor(Color.WHITE)
textSize = 14f
typeface = Typeface.create("sans-serif", Typeface.BOLD)
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(9), 0, 0)
},
)
member.role?.takeIf(String::isNotBlank)?.let { role ->
addView(
TextView(context).apply {
text = role
setTextColor(Color.rgb(158, 168, 178))
textSize = 11f
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
setPadding(0, dp(2), 0, 0)
},
)
}
}
}
/**
* The portrait, with the person's initials underneath it.
*
* Emby has no photo for a good part of a typical cast, and a row of identical grey
* rectangles tells a viewer nothing about which name is which. The initials sit *behind*
* the image rather than replacing it, so nothing has to decide in advance whether the
* artwork will arrive when it does, it simply covers them.
*/
private fun castPortrait(
context: Context,
member: CastMember,
loadImage: (ImageView, String) -> Unit,
dp: (Int) -> Int,
): View = FrameLayout(context).apply {
layoutParams = LinearLayout.LayoutParams(dp(132), dp(176))
background = context.getDrawable(R.drawable.player_cast_portrait_background)
// The ring is drawn over the artwork, and the frame follows the card's focus rather
// than its own — the card is what takes focus, the portrait is never focusable itself.
foreground = context.getDrawable(R.drawable.player_cast_portrait_frame)
isDuplicateParentStateEnabled = true
clipToOutline = true
addView(
TextView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
text = castInitials(member.name)
setTextColor(Color.rgb(126, 138, 148))
textSize = 34f
typeface = Typeface.create("sans-serif-light", Typeface.NORMAL)
gravity = Gravity.CENTER
},
)
addView(
ImageView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
)
scaleType = ImageView.ScaleType.CENTER_CROP
contentDescription = member.name
member.imageUrl?.takeIf(String::isNotBlank)?.let { loadImage(this, it) }
},
)
}
/**
* Up to two initials from a name. Pure, so it can be tested without a view: the cases that
* matter are a mononym, a name with a middle name (the *last* initial is the useful one,
* not the second) and a blank, which yields nothing rather than a stray character.
*/
internal fun castInitials(name: String): String {
val parts = name.trim().split(Regex("\\s+")).filter(String::isNotBlank)
return when (parts.size) {
0 -> ""
1 -> parts[0].take(1).uppercase()
else -> (parts.first().take(1) + parts.last().take(1)).uppercase()
}
}
@@ -11,6 +11,7 @@ internal data class PlaybackFailure(
val detail: String,
val canAutoRetry: Boolean,
val requiresTranscode: Boolean = false,
val requiresFreshStream: Boolean = false,
)
internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
@@ -18,6 +19,7 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT,
PlaybackException.ERROR_CODE_IO_UNSPECIFIED,
PlaybackException.ERROR_CODE_TIMEOUT,
-> PlaybackFailure(
title = "Connection interrupted",
detail = "Memby couldnt keep a reliable connection to the media server.",
@@ -26,10 +28,13 @@ internal fun describePlaybackFailure(errorCode: Int): PlaybackFailure =
PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS,
PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND,
PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE,
PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE,
-> PlaybackFailure(
title = "Video unavailable",
detail = "The media server couldnt provide this video. It may have moved or be temporarily unavailable.",
canAutoRetry = false,
canAutoRetry = true,
requiresFreshStream = true,
)
PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED,
@@ -0,0 +1,67 @@
package com.ponzischeme89.memby.ui.player
/**
* Times the stages between pressing Play and seeing a frame.
*
* "Playback is slow" is not something that can be acted on: the wait is shared between
* negotiating a stream with the server, starting the activity, opening the file over HTTP
* and initialising a decoder, and on a weak television any one of them can dominate.
* Cumulative marks make the log say which.
*
* Marks are cumulative from the moment the viewer pressed Play the interesting number is
* always "how long had they been waiting", not how long one step took in isolation and
* [summary] prints the per-stage deltas beside them so both readings are available. Pure
* and clock-injected so the arithmetic is unit-testable; [PlayerActivity] supplies
* `SystemClock.elapsedRealtime`.
*/
internal class PlaybackTrace(
private val startedAtMs: Long,
private val clock: () -> Long,
) {
private val marks = LinkedHashMap<String, Long>()
/** Records [stage] at the current time and returns milliseconds since Play was pressed. */
fun mark(stage: String): Long {
val elapsed = (clock() - startedAtMs).coerceAtLeast(0L)
// A stage only ever happens once per launch; a repeat is a retry, and the first
// time the viewer reached that point is the one that describes their wait.
marks.putIfAbsent(stage, elapsed)
return marks.getValue(stage)
}
fun elapsedMs(): Long = (clock() - startedAtMs).coerceAtLeast(0L)
/**
* One line naming every stage reached, as `stage=cumulative(+delta)`. Stages that never
* happened are simply absent rather than reported as zero on the resume path there is
* no pre-roll, and a launch that reused a prefetched stream never resolved one.
*/
fun summary(): String {
var previous = 0L
return marks.entries.joinToString(" ") { (stage, elapsed) ->
val delta = (elapsed - previous).coerceAtLeast(0L)
previous = elapsed
"$stage=${elapsed}ms(+${delta}ms)"
}
}
companion object {
/** The viewer's Play press has been handled and the player activity is alive. */
const val ACTIVITY_CREATED = "activity"
/** The ExoPlayer instance exists and its renderers are built. */
const val PLAYER_BUILT = "player"
/** A stream URL is in hand, whether from the launch prefetch or a fresh request. */
const val STREAM_RESOLVED = "stream"
/** [androidx.media3.exoplayer.ExoPlayer.prepare] has been called. */
const val PREPARED = "prepared"
/** The player reported it has buffered enough to play. */
const val READY = "ready"
/** A frame is on the screen. This is the number the viewer actually experiences. */
const val FIRST_FRAME = "first_frame"
}
}
@@ -0,0 +1,49 @@
package com.ponzischeme89.memby.ui.player
import androidx.tracing.Trace
import java.util.concurrent.atomic.AtomicInteger
/**
* Names the two spans of a playback launch in a systrace, so `:benchmark` can measure
* what [PlaybackTrace] can only log.
*
* The log line is for reading after the fact; these are for a machine that has to compare
* a hundred launches and tell you whether a change made things worse. They measure the same
* two things, from the same three points in [PlayerActivity], which is why the names live
* here beside each other rather than being typed into a benchmark and a player separately
* `:benchmark` imports [LAUNCH] and [FIRST_FRAME] by reference, so a rename cannot quietly
* turn the benchmark into one that measures nothing and reports zero.
*
* These are *async* sections deliberately: a launch begins on the main thread in `onCreate`
* and ends in a media3 callback, and a synchronous begin/end pair cannot span that.
*
* Everything here is a no-op unless a trace is being captured, so this costs a released
* build an atomic increment and a boolean check per playback.
*/
internal object PlaybackTraceSections {
/** The viewer pressed Play until there is a picture. What they actually experience. */
const val LAUNCH = "Memby.playbackLaunch"
/**
* `prepare()` until there is a picture: opening the file over HTTP, seeking to the
* resume point, and initialising a decoder. Measured separately because it is by far
* the largest share of a resume, and the one that a change to the player's HTTP stack
* or buffering would move.
*/
const val FIRST_FRAME = "Memby.playbackFirstFrame"
private val cookies = AtomicInteger()
/** A cookie distinguishing overlapping sections; auto-advance can start one while the
* previous episode's is still open. */
fun nextCookie(): Int = cookies.incrementAndGet()
fun begin(name: String, cookie: Int) {
if (Trace.isEnabled()) Trace.beginAsyncSection(name, cookie)
}
fun end(name: String, cookie: Int) {
if (Trace.isEnabled()) Trace.endAsyncSection(name, cookie)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,108 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import com.ponzischeme89.memby.data.remote.HttpStack
import java.util.concurrent.TimeUnit
/**
* Builds the one ExoPlayer this app plays video with.
*
* It lives apart from [PlayerActivity] because construction is on the critical path of
* every launch and wants to happen before any of the activity's decoration: the activity
* builds this first, hands it the stream, and only then wires up its overlays.
*
* The configuration here is aimed squarely at *time to first frame on a resume*, which is
* the slowest thing the player does a resume is a seek, and a seek over HTTP is several
* more requests before a single frame is decoded. Two of the choices are borrowed from
* Wholphin (https://github.com/damontecres/Wholphin), an Android TV Jellyfin client under
* the same GPL-2.0 licence as this app: constant-bitrate seeking, and pulling media bytes
* through the app's own OkHttp client rather than media3's default.
*/
@UnstableApi
internal object PlayerEngine {
fun create(context: Context): ExoPlayer = ExoPlayer.Builder(context)
.setMediaSourceFactory(mediaSourceFactory(context))
.setRenderersFactory(
DefaultRenderersFactory(context)
// Some Android TV firmwares advertise a preferred hardware decoder which
// fails only after initialization. Let Media3 try another installed decoder
// before declaring the file unsupported.
.setEnableDecoderFallback(true),
)
.setTrackSelector(DefaultTrackSelector(context))
.setLoadControl(loadControl())
.build()
.apply {
setAudioAttributes(
AudioAttributes.Builder().setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
/* handleAudioFocus = */ false,
)
}
private fun mediaSourceFactory(context: Context) = DefaultMediaSourceFactory(
// DefaultDataSource still handles the non-HTTP schemes (file:, asset:, content:);
// only the HTTP half is swapped for ours.
DefaultDataSource.Factory(context, OkHttpDataSource.Factory(streamClient)),
extractorsFactory(),
)
/**
* Derived from the shared stack, so the stream, the artwork and the API calls share one
* connection pool. That is what a resume is really paying for: opening a file part-way
* through means a request for the container header, usually another for the seek index
* (in a Matroska file the cues commonly sit at the *end*), and then a third at the
* offset the viewer actually stopped at. On the default client each of those is a fresh
* connection and over HTTPS, a fresh TLS handshake before any bytes arrive.
*
* The read timeout is generous rather than absent: a stalled stream must eventually
* fail so [PlaybackRecovery] can re-negotiate it, but it must not fail on a slow seek.
* No call timeout is set, and none should be that would cap the length of the film.
*/
private val streamClient by lazy {
HttpStack.base.newBuilder()
.connectTimeout(STREAM_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(STREAM_READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/**
* Constant-bitrate seeking is the difference between a resume that is a byte-offset
* calculation and one that is a scan through the file. Media3 only uses it for
* containers with no seek table of their own, so switching it on cannot make an indexed
* MP4 or MKV any less accurate; it only rescues the formats that would otherwise have
* to read their way to the position. `AlwaysEnabled` extends that to streams that
* declare a seek table but cannot use it.
*/
private fun extractorsFactory() = DefaultExtractorsFactory()
.setConstantBitrateSeekingEnabled(true)
.setConstantBitrateSeekingAlwaysEnabled(true)
private fun loadControl() = DefaultLoadControl.Builder()
.setBufferDurationsMs(
MIN_BUFFER_MS,
MAX_BUFFER_MS,
BUFFER_FOR_PLAYBACK_MS,
BUFFER_AFTER_REBUFFER_MS,
)
.setPrioritizeTimeOverSizeThresholds(true)
.build()
private const val STREAM_CONNECT_TIMEOUT_SECONDS = 15L
private const val STREAM_READ_TIMEOUT_SECONDS = 30L
private const val MIN_BUFFER_MS = 10_000
private const val MAX_BUFFER_MS = 30_000
private const val BUFFER_FOR_PLAYBACK_MS = 750
private const val BUFFER_AFTER_REBUFFER_MS = 1_500
}
@@ -0,0 +1,100 @@
package com.ponzischeme89.memby.ui.player
import kotlin.math.abs
/**
* The arithmetic and the wording behind Left/Right skipping, kept apart from
* [PlayerActivity] so it can be unit-tested without a player, a decoder or a server.
*
* The shape that matters is [SeekPreview]: a press does not seek. It moves a *target* that
* the OSD renders, and the seek is committed once the presses stop. Seeking over HTTP
* costs several requests before a frame is decoded (see "Time to first frame" in
* CLAUDE.md), so four quick presses of Right must be one seek of two minutes rather than
* four seeks the viewer waits through in turn which is also what makes the number on
* screen honest while they are still pressing.
*/
/**
* The last second of a title is not a place to land: seeking there ends playback, which on
* an episode rolls into the next one. Fast-forward stops short of the end instead.
*/
private const val SEEK_END_GUARD_MS: Long = 1_000L
/**
* Where the next commit will land, and how far that is from where the burst of presses
* started. [offsetMs] is deliberately cumulative rather than one step: the viewer is
* reading a running total ("Forward 1 min 30 secs"), not the last button they pressed.
*/
data class SeekPreview(val targetMs: Long, val offsetMs: Long)
/**
* Adds one step to an in-flight preview, or starts one from the playhead.
*
* [previous] is the base when there is one, never the live position: the film keeps
* playing while somebody holds down Right, and measuring each step from the playhead
* would quietly swallow part of every press after the first.
*/
fun accumulateSeek(
previous: SeekPreview?,
positionMs: Long,
durationMs: Long,
stepMs: Long,
forward: Boolean,
): SeekPreview {
val origin = previous?.let { it.targetMs - it.offsetMs } ?: positionMs.coerceAtLeast(0L)
val base = previous?.targetMs ?: positionMs.coerceAtLeast(0L)
val step = if (forward) stepMs else -stepMs
val target = clampSeekTarget(base + step, durationMs)
return SeekPreview(targetMs = target, offsetMs = target - origin)
}
/** Bounds a target to the title: never before the start, never onto its final second. */
fun clampSeekTarget(targetMs: Long, durationMs: Long): Long {
if (durationMs <= 0L) return targetMs.coerceAtLeast(0L)
val ceiling = (durationMs - SEEK_END_GUARD_MS).coerceAtLeast(0L)
return targetMs.coerceIn(0L, ceiling)
}
/**
* "30 seconds", "1 min 30 secs" the amount alone, with no sign. Direction is said by the
* glyph and by the wording around it ("Back …" / "Forward …"), because a minus sign read
* across a room at the size this is drawn is not a difference anybody can see.
*/
fun seekAmountLabel(offsetMs: Long): String {
val totalSeconds = (abs(offsetMs) + 500L) / 1_000L
val minutes = totalSeconds / 60L
val seconds = totalSeconds % 60L
return when {
minutes == 0L -> if (seconds == 1L) "1 second" else "$seconds seconds"
seconds == 0L -> if (minutes == 1L) "1 min" else "$minutes mins"
else -> "${if (minutes == 1L) "1 min" else "$minutes mins"} " +
if (seconds == 1L) "1 sec" else "$seconds secs"
}
}
/** Clock for the OSD: hours only when the title has them, so a 42-minute episode reads short. */
fun formatSeekClock(positionMs: Long): String {
val totalSeconds = positionMs.coerceAtLeast(0L) / 1_000L
val hours = totalSeconds / 3_600L
val minutes = (totalSeconds % 3_600L) / 60L
val seconds = totalSeconds % 60L
return if (hours > 0L) {
"%d:%02d:%02d".format(hours, minutes, seconds)
} else {
"%d:%02d".format(minutes, seconds)
}
}
/**
* "12:34 / 1:45:00". Both halves are formatted against the *duration*, so a position under
* an hour in a two-hour film still reads with the hours field its total has.
*/
fun seekPositionLabel(targetMs: Long, durationMs: Long): String {
if (durationMs <= 0L) return formatSeekClock(targetMs)
val position = if (durationMs >= 3_600_000L && targetMs < 3_600_000L) {
"0:" + formatSeekClock(targetMs).padStart(5, '0')
} else {
formatSeekClock(targetMs)
}
return "$position / ${formatSeekClock(durationMs)}"
}
@@ -0,0 +1,141 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Typeface
import android.text.TextUtils
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.R
/**
* One entry in the subtitle drop-up: what it says, whether it is the choice in force, and
* whether it can be pressed. [enabled] is false while a search is running the row is
* still drawn, because removing it would move everything under the viewer's thumb, but it
* cannot take focus and so cannot start a second search.
*/
data class SubtitleMenuEntry(
val label: String,
val selected: Boolean,
val enabled: Boolean = true,
)
/**
* The download section's whole state.
*
* [available] is the gateway's answer to "can this backend fetch a subtitle at all", and
* false hides the section rather than disabling it: a row that can never do anything is
* worse than no row. [status] is the sentence printed above the list it carries both the
* waiting message and the reason there is nothing, which are the two things a viewer
* standing in front of the set needs and cannot get anywhere else.
*/
data class SubtitleDownloadState(
val available: Boolean = false,
val status: String = "",
val entries: List<SubtitleMenuEntry> = emptyList(),
/**
* Whether the panel has switched to being *about* downloading, which hides the track
* list and the text-size chips. It is not decoration: the drop-up is 344dp wide by
* about a third of a 720p screen, and holding a track list, a size row and a list of
* search results at once squeezed the tracks down to a single visible row. One question
* at a time, and Back is what steps out of this one.
*/
val expanded: Boolean = false,
)
/**
* Fills the drop-up's containers from plain lists.
*
* It lives apart from [PlayerActivity] so `SubtitleMenuScreenshotTest` can render the real
* menu these rows, these colours, this layout without a player, an Emby server or a
* decoder, the same property that makes the detail page's content composables previewable.
* Deciding *what* the entries are stays with the activity, which is the only thing that can
* read media3's tracks and the only thing that can talk to the repository.
*/
fun bindSubtitleMenu(
overlay: View,
tracks: List<SubtitleMenuEntry>,
sizes: List<SubtitleMenuEntry>,
downloads: SubtitleDownloadState = SubtitleDownloadState(),
onTrack: (Int) -> Unit = {},
onSize: (Int) -> Unit = {},
onDownload: (Int) -> Unit = {},
) {
val context = overlay.context
val trackContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_tracks)
val sizeContainer = overlay.findViewById<LinearLayout>(R.id.player_subtitle_sizes)
trackContainer.removeAllViews()
sizeContainer.removeAllViews()
tracks.forEachIndexed { index, entry ->
trackContainer.addView(subtitleMenuOption(context, entry) { onTrack(index) })
}
sizes.forEachIndexed { index, entry ->
sizeContainer.addView(subtitleMenuOption(context, entry, chip = true) { onSize(index) })
}
bindDownloadSection(overlay, downloads, onDownload)
}
private fun bindDownloadSection(
overlay: View,
downloads: SubtitleDownloadState,
onDownload: (Int) -> Unit,
) {
val section = overlay.findViewById<View>(R.id.player_subtitle_download_section) ?: return
section.isVisible = downloads.available
// The two halves swap: expanded means the panel is about downloading and nothing else.
// The rule goes with the menu, or it draws a line under the panel's own top edge.
val expanded = downloads.available && downloads.expanded
overlay.findViewById<View>(R.id.player_subtitle_main_section).isVisible = !expanded
overlay.findViewById<View>(R.id.player_subtitle_download_rule).isVisible = !expanded
if (!downloads.available) return
overlay.findViewById<TextView>(R.id.player_subtitle_download_status).apply {
text = downloads.status
isVisible = downloads.status.isNotBlank()
}
val container = overlay.findViewById<LinearLayout>(R.id.player_subtitle_downloads)
container.removeAllViews()
downloads.entries.forEachIndexed { index, entry ->
container.addView(subtitleMenuOption(overlay.context, entry) { onDownload(index) })
}
}
/**
* One row of the drop-up. A [chip] sizes itself to its label for the text-size row.
* Nothing carries a tick: the current choice is the plate and the green label, and a tick on
* only some rows shifted every other label along by its width.
*/
private fun subtitleMenuOption(
context: Context,
entry: SubtitleMenuEntry,
chip: Boolean = false,
onClick: () -> Unit,
): TextView = TextView(context).apply {
val density = context.resources.displayMetrics.density
fun dp(value: Int) = (value * density).toInt()
layoutParams = LinearLayout.LayoutParams(
if (chip) ViewGroup.LayoutParams.WRAP_CONTENT else ViewGroup.LayoutParams.MATCH_PARENT,
dp(if (chip) 40 else 46),
).apply {
if (chip) marginEnd = dp(6) else bottomMargin = dp(2)
}
background = context.getDrawable(R.drawable.player_overlay_option_background)
setTextColor(context.getColorStateList(R.color.player_overlay_option_text))
gravity = if (chip) Gravity.CENTER else Gravity.CENTER_VERTICAL
setPadding(dp(14), 0, dp(14), 0)
text = entry.label
maxLines = 1
ellipsize = TextUtils.TruncateAt.END
textSize = if (chip) 14f else 15f
typeface = Typeface.create("sans-serif", if (entry.selected) Typeface.BOLD else Typeface.NORMAL)
// A disabled row keeps its place and its label and only stops being reachable, so a
// search in flight cannot be started twice and nothing moves while it runs.
isFocusable = entry.enabled
isClickable = entry.enabled
alpha = if (entry.enabled) 1f else 0.45f
isSelected = entry.selected
setOnClickListener { onClick() }
}
@@ -16,6 +16,7 @@ import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
@@ -39,7 +40,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Backspace
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.PlayCircleFilled
@@ -63,7 +66,9 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
@@ -82,6 +87,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.imageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.model.BaseItem
@@ -653,6 +659,7 @@ private fun ResultsPane(
resultsEntry = resultsEntry,
keyboardReturn = keyboardReturn,
onRequest = onRequest,
modifier = Modifier.weight(1f),
)
items.isEmpty() -> SearchEmptyMessage(state = state, showingDiscovery = showingDiscovery)
else -> ResultsGrid(
@@ -772,45 +779,253 @@ private fun RequestOptions(
resultsEntry: FocusRequester,
keyboardReturn: FocusRequester,
onRequest: (GatewayRequestCandidate) -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
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()
Column(
modifier = modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp))
.background(Color(0xFF11171C))
.border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(16.dp))
.padding(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
Modifier
.size(42.dp)
.clip(RoundedCornerShape(11.dp))
.background(Accent.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center,
) {
Icon(Icons.Default.Add, contentDescription = null, tint = Accent, modifier = Modifier.size(24.dp))
}
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 ->
Spacer(Modifier.width(13.dp))
Column(Modifier.weight(1f)) {
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
Text(
label,
color = if (focused) KeyLabelFocused else KeyLabel,
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
"We found a few close matches. Choose the exact movie or series you want added.",
color = Muted,
fontSize = 13.sp,
maxLines = 2,
)
}
Text(
"${state.requestCandidates.take(6).size} MATCH${if (state.requestCandidates.size == 1) "" else "ES"}",
color = Accent,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
}
Spacer(Modifier.height(16.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f),
) {
itemsIndexed(
state.requestCandidates.take(6),
key = { _, candidate -> "${candidate.mediaType}:${candidate.foreignId}" },
) { index, candidate ->
RequestCandidateCard(
candidate = candidate,
busy = state.requestingCandidateKey == "${candidate.mediaType}:${candidate.foreignId}",
onRequest = { onRequest(candidate) },
previewArtwork = previewArtwork,
modifier = Modifier
.fillMaxWidth()
.background(if (focused) KeyFocused else KeyIdle)
.padding(horizontal = 18.dp, vertical = 12.dp),
.then(if (index == 0) Modifier.focusRequester(resultsEntry) else Modifier)
.focusProperties { if (index % 2 == 0) left = keyboardReturn },
)
}
}
state.requestMessage?.let { Text(it, color = Muted, fontSize = 14.sp) }
state.requestMessage?.let { message ->
val statusColor = if (state.requestMessageIsError) Color(0xFFFF8A80) else Accent
Spacer(Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(statusColor.copy(alpha = 0.12f))
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
if (state.requestMessageIsError) Icons.Default.Close else Icons.Default.CheckCircle,
contentDescription = null,
tint = statusColor,
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(9.dp))
Text(message, color = KeyLabel, fontSize = 14.sp, fontWeight = FontWeight.Medium)
}
}
}
}
@Composable
private fun RequestCandidateCard(
candidate: GatewayRequestCandidate,
busy: Boolean,
onRequest: () -> Unit,
modifier: Modifier = Modifier,
previewArtwork: ImageBitmap? = null,
) {
val mediaLabel = if (candidate.mediaType.equals("movie", ignoreCase = true)) "MOVIE" else "TV SERIES"
val actionLabel = when {
candidate.alreadyAdded -> "REQUESTED"
busy -> "REQUESTING…"
else -> "REQUEST"
}
FocusScaleContainer(
onFocused = {},
onClick = { if (!candidate.alreadyAdded && !busy) onRequest() },
contentDescription = "${candidate.title}, $mediaLabel, $actionLabel",
modifier = modifier,
) { focused ->
Row(
modifier = Modifier
.fillMaxWidth()
.height(140.dp)
.clip(RoundedCornerShape(12.dp))
.background(
when {
focused -> Color(0xFF203228)
candidate.alreadyAdded -> Color(0xFF17231C)
else -> Color(0xFF1A2127)
},
)
.border(
width = if (focused) 2.dp else 1.dp,
color = when {
focused -> Accent
candidate.alreadyAdded -> Accent.copy(alpha = 0.45f)
else -> Color.White.copy(alpha = 0.10f)
},
shape = RoundedCornerShape(12.dp),
)
.padding(9.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.width(76.dp)
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF0B0F12)),
contentAlignment = Alignment.Center,
) {
if (previewArtwork != null) {
Image(
bitmap = previewArtwork,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else if (candidate.posterUrl.isNotBlank()) {
AsyncImage(
model = candidate.posterUrl,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
} else {
Icon(
if (mediaLabel == "MOVIE") Icons.Default.Movie else Icons.Default.LiveTv,
contentDescription = null,
tint = Muted.copy(alpha = 0.65f),
modifier = Modifier.size(30.dp),
)
}
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(5.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(mediaLabel, color = Accent, fontSize = 10.sp, fontWeight = FontWeight.Bold)
candidate.year.takeIf { it > 0 }?.let { year ->
Text("$year", color = Muted, fontSize = 11.sp)
}
}
Text(
candidate.title,
color = Heading,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
if (candidate.overview.isNotBlank()) {
Text(
candidate.overview,
color = Muted,
fontSize = 11.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (candidate.alreadyAdded) {
Icon(Icons.Default.CheckCircle, contentDescription = null, tint = Accent, modifier = Modifier.size(14.dp))
Spacer(Modifier.width(5.dp))
}
Text(
actionLabel,
color = if (candidate.alreadyAdded || focused) Accent else KeyLabel,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
if (!candidate.alreadyAdded && !busy) Text("", color = Accent, fontSize = 13.sp)
}
}
}
}
}
/** Stable, data-free fixture used by the screenshot test and design reviews. */
@Composable
internal fun RecommendationRequestPreview(previewArtwork: ImageBitmap? = null) {
val firstCardFocus = remember { FocusRequester() }
LaunchedEffect(Unit) { runCatching { firstCardFocus.requestFocus() } }
Box(
Modifier
.fillMaxSize()
.background(Color(0xFF080B0E))
.padding(28.dp),
) {
RequestOptions(
state = SearchUiState(
query = "the last voyage",
hasSearched = true,
requestCandidates = listOf(
GatewayRequestCandidate(
mediaType = "movie", foreignId = 101, title = "The Last Voyage",
year = 2026,
overview = "A lone cartographer follows a signal beyond the edge of every known map.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 102, title = "Voyagers",
year = 2024,
overview = "Six strangers wake aboard a ship already halfway to another world.",
),
GatewayRequestCandidate(
mediaType = "movie", foreignId = 103, title = "The Long Way Home",
year = 2022,
overview = "A rescue pilot gets one final chance to cross the storm.",
),
GatewayRequestCandidate(
mediaType = "series", foreignId = 104, title = "Beyond the Horizon",
year = 2025,
overview = "An observatory discovers that tomorrow is broadcasting back.",
alreadyAdded = true,
),
),
),
resultsEntry = firstCardFocus,
keyboardReturn = remember { FocusRequester() },
onRequest = {},
previewArtwork = previewArtwork,
)
}
}
@@ -40,6 +40,7 @@ data class SearchUiState(
val requestLookupLoading: Boolean = false,
val requestingCandidateKey: String? = null,
val requestMessage: String? = null,
val requestMessageIsError: Boolean = false,
) {
/** The query is long enough to search but nothing came back. */
val isEmptyResult: Boolean
@@ -111,6 +112,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
requestCandidates = emptyList(),
requestLookupLoading = false,
requestMessage = null,
requestMessageIsError = false,
)
}
queryFlow.value = query
@@ -128,6 +130,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
query = "", results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
queryFlow.value = ""
@@ -145,7 +148,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
if (candidate.alreadyAdded || state.value.requestingCandidateKey != null) return
val candidateKey = "${candidate.mediaType}:${candidate.foreignId}"
_state.update {
it.copy(requestingCandidateKey = candidateKey, requestMessage = null)
it.copy(requestingCandidateKey = candidateKey, requestMessage = null, requestMessageIsError = false)
}
viewModelScope.launch {
runCatching { repository.requestMedia(candidate) }
@@ -159,6 +162,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
) option.copy(alreadyAdded = true) else option
},
requestMessage = "${title.ifBlank { candidate.title }} was requested.",
requestMessageIsError = false,
)
}
}
@@ -167,6 +171,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
it.copy(
requestingCandidateKey = null,
requestMessage = friendlyEmbyError(error),
requestMessageIsError = true,
)
}
}
@@ -203,6 +208,7 @@ class SearchViewModel(private val repository: EmbyRepository) : ViewModel() {
results = emptyList(), isLoading = false, hasSearched = false,
errorMessage = null, requestCandidates = emptyList(),
requestLookupLoading = false, requestMessage = null,
requestMessageIsError = false,
)
}
return
@@ -40,7 +40,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Devices
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Palette
@@ -59,6 +58,9 @@ 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.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
@@ -68,8 +70,8 @@ import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
@@ -83,11 +85,14 @@ import androidx.compose.foundation.text.KeyboardOptions
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.ui.PreviewSurface
import com.ponzischeme89.memby.ui.TvPreview
import com.ponzischeme89.memby.ui.WelcomeQuoteStyle
import com.ponzischeme89.memby.update.AppInstall
import com.ponzischeme89.memby.update.UpdateChecker
import com.ponzischeme89.memby.update.UpdateStatus
import kotlinx.coroutines.delay
@@ -110,6 +115,12 @@ private val DensityOptions = listOf(
ChoiceOption("large", "Large"),
)
// Values are the seconds themselves, as text, so the chip row and the stored integer
// cannot drift: the vocabulary is SEEK_INTERVAL_SECONDS and nothing else names it twice.
private val SeekIntervalOptions = SEEK_INTERVAL_SECONDS.map {
ChoiceOption(it.toString(), "$it seconds")
}
private val ArtworkOptions = listOf(
ChoiceOption("automatic", "Automatic"),
ChoiceOption("poster", "Posters"),
@@ -126,37 +137,50 @@ internal enum class SettingsPage(
val icon: ImageVector,
val showInRail: Boolean = true,
) {
APPEARANCE("Appearance", "Artwork and accents", Icons.Default.Palette),
PLAYBACK("Playback", "Reminders and episodes", Icons.Default.PlayArrow),
HOME("Home screen", "Rows and cards", Icons.Default.Home),
WELCOME("Welcome", "Greeting personality", Icons.Default.TagFaces),
UPDATES("Updates", "Version status", Icons.Default.SystemUpdate),
DEVICES("Devices", "Signed-in TVs", Icons.Default.Devices),
ABOUT("About", "Memby for Android TV", Icons.Default.Info),
LICENSES("Licences", "Source and open-source terms", Icons.Default.Description, showInRail = false),
APPEARANCE("Appearance", "How Memby looks", Icons.Default.Palette),
PLAYBACK("Playback", "What happens while you watch", Icons.Default.PlayArrow),
HOME("Home screen", "What you see when Memby opens", Icons.Default.Home),
WELCOME("Welcome", "The line you get when you sign in", Icons.Default.TagFaces),
UPDATES("Updates", "Keep this TV up to date", Icons.Default.SystemUpdate),
DEVICES("Devices", "TVs signed in to your account", Icons.Default.Devices),
ABOUT("About", "Version and source code", Icons.Default.Info),
}
// Black, and one lit thing at a time.
//
// Settings used to stack four surfaces to show two switches: the page, the rail, a titled
// section card, and the rows inside it. On a television that reads as grey boxes on grey
// boxes, and none of it carried information — the rail already says which page you are on
// and the header already names it. So the canvas and the rail are black, the section card
// is gone, and rows sit flat on it separated by a hairline. The only surface that lights up
// is the row under focus, which is the one thing a viewer actually needs to find.
//
// The controls are deliberately untouched: the green pill toggle and the chip row are what
// make this screen feel like Memby, and they read better against black than they did
// against a card.
private val EmbyGreen = Color(0xFF52B54B)
private val Canvas = Color(0xFF090B0D)
private val Panel = Color(0xFF0D1114)
private val SectionSurface = Color(0xFF12171B)
private val RowFocused = Color(0xFF20282F)
private val ControlIdle = Color(0xFF252D34)
private val Canvas = Color(0xFF000000)
private val Panel = Color(0xFF040506)
private val RowFocused = Color(0xFF1B2228)
private val ControlIdle = Color(0xFF1E252B)
private val TextPrimary = Color(0xFFF2F5F7)
private val TextSecondary = Color(0xFFC2CBD2)
private val TextQuiet = Color(0xFF8F9AA3)
private val Hairline = Color.White.copy(alpha = 0.09f)
private val TextSecondary = Color(0xFFAFB8BF)
private val TextQuiet = Color(0xFF7C868E)
private val Hairline = Color.White.copy(alpha = 0.08f)
private val RailEdge = Color.White.copy(alpha = 0.10f)
internal data class SettingsPanelState(
val showLogo: Boolean = true,
val autoPlayNext: Boolean = true,
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard",
val artworkStyle: String = "automatic",
val hiddenHomeRowCount: Int = 0,
val showCardMetadata: Boolean = true,
val showRatingsStrip: Boolean = true,
val hideWatchedMovies: Boolean = false,
val welcomeQuoteStyle: String = WelcomeQuoteStyle.NEUTRAL.value,
val selectedPage: SettingsPage = SettingsPage.APPEARANCE,
@@ -164,6 +188,7 @@ internal data class SettingsPanelState(
val updateStatus: UpdateStatus? = null,
val installMessage: String? = null,
val installedVersion: String = "",
val releaseHistory: List<ReleaseNote> = MembyReleaseHistory,
val devices: List<GatewayDevice> = emptyList(),
val devicesLoading: Boolean = false,
val devicesError: String? = null,
@@ -176,12 +201,14 @@ internal data class SettingsPanelActions(
val onShowLogoChanged: (Boolean) -> Unit = {},
val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {},
val onArtworkStyleChanged: (String) -> Unit = {},
val onRestoreHiddenRows: () -> Unit = {},
val onShowCardMetadataChanged: (Boolean) -> Unit = {},
val onShowRatingsStripChanged: (Boolean) -> Unit = {},
val onHideWatchedMoviesChanged: (Boolean) -> Unit = {},
val onWelcomeQuoteStyleChanged: (String) -> Unit = {},
val onPageSelected: (SettingsPage) -> Unit = {},
@@ -192,7 +219,6 @@ internal data class SettingsPanelActions(
val onRemoveDevice: (GatewayDevice) -> Unit = {},
val onCancelDeviceRemoval: () -> Unit = {},
val onOpenSourceCode: () -> Unit = {},
val onOpenLicenses: () -> Unit = {},
)
/**
@@ -206,6 +232,7 @@ fun SettingsSheet(
modifier: Modifier = Modifier,
overlay: Boolean = false,
onInstallerLaunched: (() -> Unit)? = null,
navigationFocusRequester: FocusRequester? = null,
) {
val context = LocalContext.current
val store = ServiceLocator.settings
@@ -216,17 +243,21 @@ fun SettingsSheet(
var showLogo by rememberSaveable { mutableStateOf(settings.showTitleLogo) }
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
var artworkStyle by rememberSaveable { mutableStateOf(settings.homeArtworkStyle) }
var showCardMetadata by rememberSaveable { mutableStateOf(settings.showHomeCardMetadata) }
var showRatingsStrip by rememberSaveable { mutableStateOf(settings.showRatingsStrip) }
var hideWatchedMovies by rememberSaveable { mutableStateOf(settings.hideWatchedMovies) }
var welcomeQuoteStyle by rememberSaveable { mutableStateOf(settings.welcomeQuoteStyle) }
var selectedPage by rememberSaveable { mutableStateOf(SettingsPage.APPEARANCE) }
var checking by remember { mutableStateOf(false) }
var status by remember { mutableStateOf<UpdateStatus?>(null) }
var installMessage by remember { mutableStateOf<String?>(null) }
// The system installer reports back here, not to downloadAndInstall's caller.
LaunchedEffect(Unit) { AppInstall.messages.collect { installMessage = it } }
var devices by remember { mutableStateOf<List<GatewayDevice>>(emptyList()) }
var devicesLoading by remember { mutableStateOf(false) }
var devicesError by remember { mutableStateOf<String?>(null) }
@@ -235,10 +266,6 @@ fun SettingsSheet(
var editingDevice by remember { mutableStateOf<GatewayDevice?>(null) }
var deviceJob by remember { mutableStateOf<Job?>(null) }
BackHandler(enabled = selectedPage == SettingsPage.LICENSES) {
selectedPage = SettingsPage.ABOUT
}
suspend fun refreshDevices() {
devicesLoading = true
devicesError = null
@@ -271,19 +298,23 @@ fun SettingsSheet(
settings.homeCardDensity,
settings.homeArtworkStyle,
settings.showHomeCardMetadata,
settings.showRatingsStrip,
settings.hideWatchedMovies,
settings.autoPlayNextEpisode,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.welcomeQuoteStyle,
) {
showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity
artworkStyle = settings.homeArtworkStyle
showCardMetadata = settings.showHomeCardMetadata
showRatingsStrip = settings.showRatingsStrip
hideWatchedMovies = settings.hideWatchedMovies
welcomeQuoteStyle = settings.welcomeQuoteStyle
}
@@ -300,12 +331,14 @@ fun SettingsSheet(
showLogo = showLogo,
autoPlayNext = autoPlayNext,
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
ringColor = ringColor,
homeSections = homeSections,
cardDensity = cardDensity,
artworkStyle = artworkStyle,
hiddenHomeRowCount = settings.homeHiddenRows.lineSequence().count { it.isNotBlank() },
showCardMetadata = showCardMetadata,
showRatingsStrip = showRatingsStrip,
hideWatchedMovies = hideWatchedMovies,
welcomeQuoteStyle = welcomeQuoteStyle,
selectedPage = selectedPage,
@@ -333,6 +366,10 @@ fun SettingsSheet(
showTenMinuteReminder = it
scope.launch { store.setShowTenMinuteReminder(it) }
},
onSeekIntervalChanged = {
seekInterval = it
scope.launch { store.setSeekIntervalSeconds(it) }
},
onRingColorChanged = {
ringColor = it
scope.launch { store.setRingColor(it) }
@@ -362,6 +399,10 @@ fun SettingsSheet(
showCardMetadata = it
scope.launch { store.setShowHomeCardMetadata(it) }
},
onShowRatingsStripChanged = {
showRatingsStrip = it
scope.launch { store.setShowRatingsStrip(it) }
},
onHideWatchedMoviesChanged = {
hideWatchedMovies = it
scope.launch { store.setHideWatchedMovies(it) }
@@ -443,7 +484,6 @@ fun SettingsSheet(
)
}
},
onOpenLicenses = { selectedPage = SettingsPage.LICENSES },
)
Box(modifier = modifier.fillMaxSize()) {
@@ -478,6 +518,7 @@ fun SettingsSheet(
actions = actions,
overlay = overlay,
firstFocusRequester = firstFocus,
navigationFocusRequester = navigationFocusRequester,
)
}
editingDevice?.let { device ->
@@ -515,7 +556,14 @@ internal fun SettingsPanelContent(
overlay: Boolean,
modifier: Modifier = Modifier,
firstFocusRequester: FocusRequester? = null,
navigationFocusRequester: FocusRequester? = null,
) {
val contentScrollState = rememberScrollState()
LaunchedEffect(state.selectedPage) {
contentScrollState.scrollTo(0)
}
Row(
modifier = modifier
.then(if (overlay) Modifier.width(760.dp) else Modifier.fillMaxWidth())
@@ -534,6 +582,7 @@ internal fun SettingsPanelContent(
onSelected = actions.onPageSelected,
onClose = actions.onClose,
firstFocusRequester = firstFocusRequester,
navigationFocusRequester = navigationFocusRequester,
compact = overlay,
modifier = Modifier
.width(if (overlay) 190.dp else 224.dp)
@@ -543,7 +592,7 @@ internal fun SettingsPanelContent(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.verticalScroll(rememberScrollState())
.verticalScroll(contentScrollState)
.padding(
start = if (overlay) 26.dp else 42.dp,
end = if (overlay) 28.dp else 64.dp,
@@ -557,54 +606,55 @@ internal fun SettingsPanelContent(
version = state.installedVersion,
)
when (state.selectedPage) {
SettingsPage.APPEARANCE -> SettingsSection(
title = "Appearance",
description = "Artwork and playback accents",
icon = Icons.Default.Palette,
) {
SettingsPage.APPEARANCE -> SettingsGroup {
SettingsToggleRow(
title = "Title artwork",
description = "Use each film or show logo when artwork is available.",
title = "Show logos",
description = "Use a film or show's own logo instead of plain text.",
checked = state.showLogo,
onCheckedChange = actions.onShowLogoChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Progress ring",
description = "Colour used for resume and countdown progress.",
title = "Progress colour",
description = "The colour of progress bars and countdowns.",
options = RingOptions,
selected = state.ringColor,
onSelected = actions.onRingColorChanged,
)
}
SettingsPage.PLAYBACK -> SettingsSection(
title = "Playback",
description = "Reminders and episode behaviour",
icon = Icons.Default.PlayArrow,
) {
SettingsPage.PLAYBACK -> SettingsGroup {
SettingsToggleRow(
title = "10-minute reminder",
description = "Show a brief reminder when a film or episode has ten minutes left.",
title = "Ten minutes left",
description = "A small reminder near the end of what you're watching.",
checked = state.showTenMinuteReminder,
onCheckedChange = actions.onShowTenMinuteReminderChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Play the next episode",
description = "Show a countdown near the end, then continue automatically.",
description = "Roll straight into the next one when an episode ends.",
checked = state.autoPlayNext,
onCheckedChange = actions.onAutoPlayNextChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Skip with left and right",
description = "How far one press moves what you are watching.",
options = SeekIntervalOptions,
selected = state.seekIntervalSeconds.toString(),
onSelected = { value ->
value.toIntOrNull()?.let(actions.onSeekIntervalChanged)
},
)
}
SettingsPage.HOME -> SettingsSection(
title = "Home screen",
description = "Choose what appears when Memby opens",
icon = Icons.Default.Home,
) {
SettingsPage.HOME -> SettingsGroup {
listOf(
"continue" to ("Continue watching" to "Resume films and episodes in progress."),
"favorites" to ("Favourites" to "Keep starred films and shows close by."),
"latest" to ("Latest movies" to "Show recently added films."),
"continue" to (
"Continue watching" to
"Pick up where you left off, and the next episode when you finish one."
),
"favorites" to ("Favourites" to "Films and shows you have starred."),
"latest" to ("Latest movies" to "Films added to the library recently."),
).forEachIndexed { index, (key, copy) ->
if (index > 0) SettingDivider()
SettingsToggleRow(
@@ -616,52 +666,55 @@ internal fun SettingsPanelContent(
}
SettingDivider()
SettingsToggleRow(
title = "Hide watched movies",
description = "Remove completed films from browse rows and the Home hero.",
title = "Hide films you have seen",
description = "Keep finished films out of the rows on Home.",
checked = state.hideWatchedMovies,
onCheckedChange = actions.onHideWatchedMoviesChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Card size",
description = "How much content fits across each row.",
description = "How big the artwork is on each row.",
options = DensityOptions,
selected = state.cardDensity,
onSelected = actions.onCardDensityChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Artwork style",
description = "Prefer portrait posters, wide backdrops, or let Memby choose.",
title = "Card shape",
description = "Tall posters, wide images, or let Memby pick.",
options = ArtworkOptions,
selected = state.artworkStyle,
onSelected = actions.onArtworkStyleChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Card details",
description = "Show episode, runtime and resume information below artwork.",
title = "Text under cards",
description = "Show the year, length and episode under each card.",
checked = state.showCardMetadata,
onCheckedChange = actions.onShowCardMetadataChanged,
)
SettingDivider()
SettingsToggleRow(
title = "Ratings",
description = "Show IMDb and Rotten Tomatoes scores.",
checked = state.showRatingsStrip,
onCheckedChange = actions.onShowRatingsStripChanged,
)
if (state.hiddenHomeRowCount > 0) {
SettingDivider()
SettingsActionRow(
title = "Restore hidden rows",
description = "Make all rows available on Home again.",
title = "Bring back hidden rows",
description = "Show every row on Home again.",
badge = "${state.hiddenHomeRowCount} HIDDEN",
onClick = actions.onRestoreHiddenRows,
)
}
}
SettingsPage.WELCOME -> SettingsSection(
title = "Welcome messages",
description = "Choose Membys mood when you arrive",
icon = Icons.Default.TagFaces,
) {
SettingsPage.WELCOME -> SettingsGroup {
SettingsChoiceRow(
title = "Quote style",
description = "A different line is picked after sign-in and while Memby loads.",
title = "Tone",
description = "A different line is picked each time Memby opens.",
options = WelcomeOptions,
selected = state.welcomeQuoteStyle,
onSelected = actions.onWelcomeQuoteStyleChanged,
@@ -675,15 +728,11 @@ internal fun SettingsPanelContent(
positive = true,
)
}
SettingsPage.UPDATES -> SettingsSection(
title = "Updates",
description = "Keep this TV on the current Memby release",
icon = Icons.Default.SystemUpdate,
) {
VersionRow("Current version", state.installedVersion)
SettingsPage.UPDATES -> SettingsGroup {
VersionRow("On this TV", state.installedVersion)
SettingDivider()
VersionRow(
"New version",
"Newest release",
when (val update = state.updateStatus) {
is UpdateStatus.Available -> update.version
is UpdateStatus.UpToDate -> update.version
@@ -693,16 +742,16 @@ internal fun SettingsPanelContent(
)
SettingDivider()
SettingsActionRow(
title = if (state.checking) "Checking for updates" else "Check for updates",
description = "Ask the configured Memby release server.",
title = if (state.checking) "Checking…" else "Check for updates",
description = "Ask whether a newer build is available.",
badge = if (state.checking) "WORKING" else "CHECK NOW",
onClick = actions.onCheckForUpdates,
)
when (val update = state.updateStatus) {
is UpdateStatus.UpToDate -> SettingsNotice("Memby is up to date.", positive = true)
is UpdateStatus.UpToDate -> SettingsNotice("This TV is up to date.", positive = true)
is UpdateStatus.Error -> SettingsNotice(update.message, positive = false)
is UpdateStatus.Available -> {
SettingsNotice("Version ${update.version} is ready.", positive = true)
SettingsNotice("Version ${update.version} is ready to install.", positive = true)
if (update.notes.isNotBlank()) {
Text(
update.notes,
@@ -714,8 +763,8 @@ internal fun SettingsPanelContent(
)
}
SettingsActionRow(
title = "Download and install",
description = "Android will ask for confirmation.",
title = "Install it",
description = "Android will ask you to confirm.",
badge = "INSTALL",
onClick = { actions.onInstallUpdate(update) },
)
@@ -724,16 +773,12 @@ internal fun SettingsPanelContent(
}
state.installMessage?.let { SettingsNotice(it, positive = true) }
}
SettingsPage.DEVICES -> SettingsSection(
title = "Signed-in devices",
description = "Review and remove TVs connected to this account",
icon = Icons.Default.Devices,
) {
SettingsPage.DEVICES -> SettingsGroup {
when {
state.devicesLoading && state.devices.isEmpty() ->
SettingsNotice("Loading signed-in devices…", positive = true)
SettingsNotice("Looking for signed-in TVs…", positive = true)
state.devices.isEmpty() ->
SettingsNotice("No signed-in devices were found.", positive = false)
SettingsNotice("No other TVs are signed in.", positive = false)
else -> state.devices.forEachIndexed { index, device ->
if (index > 0) SettingDivider()
DeviceManagementRow(
@@ -748,17 +793,13 @@ internal fun SettingsPanelContent(
}
state.devicesError?.let { SettingsNotice(it, positive = false) }
SettingsActionRow(
title = if (state.devicesLoading) "Refreshing devices" else "Refresh devices",
description = "Check the gateway for TVs that are currently signed in.",
title = if (state.devicesLoading) "Refreshing…" else "Refresh the list",
description = "Check which TVs are signed in right now.",
badge = "REFRESH",
onClick = actions.onRefreshDevices,
)
}
SettingsPage.ABOUT -> SettingsSection(
title = "About",
description = "Memby for Android TV",
icon = Icons.Default.Info,
) {
SettingsPage.ABOUT -> SettingsGroup {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -771,7 +812,7 @@ internal fun SettingsPanelContent(
fontWeight = FontWeight.Bold,
)
Text(
"Made by ${stringResource(R.string.developer_name)}",
"Memby (Matt's Emby) is built for Android TV, because the default client sucks...",
color = TextSecondary,
fontSize = 13.sp,
)
@@ -779,7 +820,7 @@ internal fun SettingsPanelContent(
Text(
"v${state.installedVersion}",
color = EmbyGreen,
fontSize = 13.sp,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
@@ -790,31 +831,14 @@ internal fun SettingsPanelContent(
badge = "OPEN",
onClick = actions.onOpenSourceCode,
)
SettingDivider()
SettingsActionRow(
title = "Open-source licences",
description = "Memby's GPLv2 terms and third-party acknowledgements.",
badge = "VIEW",
onClick = actions.onOpenLicenses,
)
}
SettingsPage.LICENSES -> SettingsSection(
title = "Open-source licences",
description = "Available offline with every Memby installation",
icon = Icons.Default.Description,
) {
LegalTextBlock(
title = "Memby and acknowledgements",
body = BuildConfig.PROJECT_NOTICE_TEXT,
)
SettingDivider()
LegalTextBlock(
title = "GNU General Public License v2",
body = BuildConfig.GPL_LICENSE_TEXT,
monospace = true,
)
}
}
if (state.selectedPage == SettingsPage.ABOUT) {
VersionHistorySection(
releases = state.releaseHistory,
installedVersion = state.installedVersion,
)
}
Spacer(Modifier.height(12.dp))
}
}
@@ -984,11 +1008,12 @@ private fun SettingsSecondaryRail(
onSelected: (SettingsPage) -> Unit,
onClose: () -> Unit,
firstFocusRequester: FocusRequester?,
navigationFocusRequester: FocusRequester?,
compact: Boolean,
modifier: Modifier = Modifier,
) {
val pages = SettingsPage.entries.filter { it.showInRail }
val selectedRailPage = selected.takeIf { it.showInRail } ?: SettingsPage.ABOUT
val selectedRailPage = selected
// The home screen remains composed behind this panel. Relying on spatial focus
// search therefore lets a covered media card beat the next rail item when their
// bounds happen to be closer (notably below Playback on a 540p viewport). Give
@@ -1010,8 +1035,17 @@ private fun SettingsSecondaryRail(
}
Column(
modifier = modifier
.background(Color(0xFF0A0E11))
.border(width = 1.dp, color = Hairline)
.background(Canvas)
// A single hairline down the right edge instead of a box around the rail. The
// rail is not a card; it is the left edge of the screen.
.drawBehind {
drawRect(
color = RailEdge,
topLeft = Offset(size.width - 1f, 0f),
size = Size(1f, size.height),
)
}
.verticalScroll(rememberScrollState())
.padding(
horizontal = if (compact) 14.dp else 18.dp,
vertical = if (compact) 28.dp else 38.dp,
@@ -1031,6 +1065,7 @@ private fun SettingsSecondaryRail(
onClick = onClose,
focusRequester = railFocusRequesters[0],
downFocusRequester = railFocusRequesters[1],
leftFocusRequester = navigationFocusRequester,
)
pages.forEachIndexed { index, page ->
var focused by remember { mutableStateOf(false) }
@@ -1063,9 +1098,17 @@ private fun SettingsSecondaryRail(
} else {
railFocusRequesters[index + 2]
}
left = FocusRequester.Cancel
// Left leaves for the app's main rail when it is beside us, and is
// cancelled rather than left to spatial search otherwise: the home
// screen stays composed behind this panel and a covered card can
// otherwise win the focus search.
left = navigationFocusRequester ?: FocusRequester.Cancel
}
.onFocusChanged { focused = it.isFocused }
.onFocusChanged {
focused = it.isFocused
if (it.isFocused && page != selected) onSelected(page)
}
.testTag("settings-rail-${page.name.lowercase()}")
.clickable { onSelected(page) }
.padding(horizontal = 12.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -1090,9 +1133,9 @@ private fun SettingsSecondaryRail(
)
}
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.height(16.dp))
Text(
"Use ↑ ↓ to move\nbetween pages",
"↑ ↓ moves between pages",
color = TextQuiet,
fontSize = 11.sp,
lineHeight = 16.sp,
@@ -1107,6 +1150,7 @@ private fun SettingsExitRailItem(
onClick: () -> Unit,
focusRequester: FocusRequester,
downFocusRequester: FocusRequester,
leftFocusRequester: FocusRequester?,
) {
var focused by remember { mutableStateOf(false) }
Row(
@@ -1123,7 +1167,7 @@ private fun SettingsExitRailItem(
.focusProperties {
up = FocusRequester.Cancel
down = downFocusRequester
left = FocusRequester.Cancel
left = leftFocusRequester ?: FocusRequester.Cancel
}
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onClick)
@@ -1149,20 +1193,24 @@ private fun SettingsExitRailItem(
@Composable
private fun SettingsHeader(page: SettingsPage, version: String) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(
"MEMBY · TV",
color = EmbyGreen,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.8.sp,
)
Text(page.label, color = TextPrimary, fontSize = 38.sp, fontWeight = FontWeight.Bold)
Row(
verticalAlignment = Alignment.CenterVertically,
// Indented to the rows' own text rather than to the panel edge. With the section
// card gone, a row's padding is what insets its focus highlight from its label, and
// a header sitting 16dp left of every setting under it reads as a second column.
modifier = Modifier
.padding(horizontal = 16.dp)
.testTag("settings-page-${page.name.lowercase()}"),
) {
// No eyebrow above the title. It said "MEMBY · TV" on every page of a sheet that is
// already inside Memby on a television, so it named the one thing nobody could be in
// doubt about while taking the vertical space the settings themselves want.
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(page.label, color = TextPrimary, fontSize = 26.sp, fontWeight = FontWeight.Bold)
Text(
page.description,
color = TextSecondary,
fontSize = 15.sp,
fontSize = 13.sp,
)
}
Spacer(Modifier.weight(1f))
@@ -1186,42 +1234,31 @@ private fun VersionRow(label: String, version: String) {
}
}
/**
* A run of settings on the black canvas.
*
* There is no card and no icon chip. Every page but About holds exactly one of these, and
* the header above it already names the page a titled box inside a titled page said the
* same word twice and cost two nested surfaces to do it. [label] exists only for the pages
* that genuinely have two groups, and it is a quiet caption rather than a second heading.
*/
@Composable
private fun SettingsSection(
title: String,
description: String,
icon: ImageVector,
private fun SettingsGroup(
label: String? = null,
content: @Composable ColumnScope.() -> Unit,
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
Row(
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Box(
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(9.dp))
.background(EmbyGreen.copy(alpha = 0.13f)),
contentAlignment = Alignment.Center,
) {
Icon(icon, contentDescription = null, tint = EmbyGreen, modifier = Modifier.size(18.dp))
}
Column(verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(title, color = TextPrimary, fontSize = 17.sp, fontWeight = FontWeight.Bold)
Text(description, color = TextQuiet, fontSize = 12.sp)
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
label?.let {
Text(
it.uppercase(),
color = TextQuiet,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.2.sp,
modifier = Modifier.padding(start = 16.dp, top = 10.dp),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(SectionSurface)
.border(1.dp, Color.White.copy(alpha = 0.065f), RoundedCornerShape(14.dp))
.padding(7.dp),
content = content,
)
Column(modifier = Modifier.fillMaxWidth(), content = content)
}
}
@@ -1374,10 +1411,11 @@ private fun SettingsActionRow(
description: String,
badge: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Row(
modifier = Modifier
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) RowFocused else Color.Transparent)
@@ -1419,6 +1457,9 @@ private fun SettingsNotice(text: String, positive: Boolean) {
fontWeight = FontWeight.SemiBold,
modifier = Modifier
.fillMaxWidth()
// Inset to the same 16dp as the rows and the dividers, so the one tinted band on
// a black page lines up with everything above it instead of hanging off both edges.
.padding(horizontal = 16.dp, vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (positive) EmbyGreen.copy(alpha = 0.10f) else Color(0xFFE75852).copy(alpha = 0.12f),
@@ -1427,35 +1468,116 @@ private fun SettingsNotice(text: String, positive: Boolean) {
)
}
/**
* The release history, as a list of collapsed releases that open in place. A remote has
* no scrollbar to aim at, so the whole changelog rendered flat would be a very long blind
* scroll; one focusable row per release makes Down mean "next release" until the viewer
* asks for a particular one. The newest is open on arrival because that is the one being
* looked for.
*/
@Composable
private fun LegalTextBlock(
title: String,
body: String,
monospace: Boolean = false,
private fun VersionHistorySection(
releases: List<ReleaseNote>,
installedVersion: String,
) {
var expandedVersion by rememberSaveable(releases.firstOrNull()?.version) {
mutableStateOf(releases.firstOrNull()?.version)
}
SettingsGroup(label = "What changed in each release") {
if (releases.isEmpty()) {
SettingsNotice("No release history shipped with this build.", positive = false)
return@SettingsGroup
}
releases.forEachIndexed { index, release ->
if (index > 0) SettingDivider()
ReleaseHistoryRow(
release = release,
installed = release.version == installedVersion,
expanded = release.version == expandedVersion,
onToggle = {
expandedVersion = if (expandedVersion == release.version) null else release.version
},
)
}
}
}
@Composable
private fun ReleaseHistoryRow(
release: ReleaseNote,
installed: Boolean,
expanded: Boolean,
onToggle: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(if (focused) RowFocused else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = RoundedCornerShape(10.dp),
)
.onFocusChanged { focused = it.isFocused }
.clickable(onClick = onToggle)
.testTag("settings-release-${release.version}")
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalArrangement = Arrangement.spacedBy(9.dp),
) {
Text(
title,
color = TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
Text(
body,
color = TextSecondary,
fontSize = if (monospace) 11.sp else 13.sp,
lineHeight = if (monospace) 16.sp else 18.sp,
fontFamily = if (monospace) FontFamily.Monospace else FontFamily.Default,
)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
"v${release.version}",
color = if (installed) EmbyGreen else TextPrimary,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
)
if (installed) {
Text(
"INSTALLED",
color = EmbyGreen,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.6.sp,
)
}
Spacer(Modifier.weight(1f))
release.date.takeIf(String::isNotBlank)?.let {
Text(it, color = TextQuiet, fontSize = 12.sp)
}
Text(
if (expanded) "HIDE" else "${release.changes.size} CHANGES",
color = if (focused) Color(0xFF062307) else EmbyGreen,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 0.7.sp,
modifier = Modifier
.clip(RoundedCornerShape(999.dp))
.background(if (focused) EmbyGreen else EmbyGreen.copy(alpha = 0.13f))
.padding(horizontal = 11.dp, vertical = 7.dp),
)
}
AnimatedVisibility(visible = expanded) {
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
release.changes.forEach { change ->
Row(horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text("", color = EmbyGreen, fontSize = 13.sp, fontWeight = FontWeight.Bold)
Text(change, color = TextSecondary, fontSize = 13.sp, lineHeight = 19.sp)
}
}
}
}
}
}
@Composable
private fun SettingDivider() {
Box(Modifier.fillMaxWidth().padding(horizontal = 14.dp).height(1.dp).background(Hairline))
Box(Modifier.fillMaxWidth().padding(horizontal = 16.dp).height(1.dp).background(Hairline))
}
@TvPreview
@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.settings
/** One release as the About page shows it. */
internal data class ReleaseNote(
val version: String,
val date: String,
val changes: List<String>,
)
private val HeadingPattern = Regex("""^##\s+v?(\d+\.\d+\.\d+)\s*(?:[—–-]\s*(.+))?$""")
/**
* Parses CHANGELOG.md into the release list. Pure so it can be unit-tested, and
* deliberately forgiving: anything that is not a `## <version>` heading or a `- ` bullet
* under one is prose (the file's own format notes) and is skipped rather than rendered.
*
* Order is the file's own newest first by convention, never re-sorted here, because a
* version string is not reliably comparable once a release carries a suffix.
*/
internal fun parseChangelog(markdown: String): List<ReleaseNote> {
val releases = mutableListOf<ReleaseNote>()
var version: String? = null
var date = ""
var changes = mutableListOf<String>()
fun flush() {
version?.let { releases += ReleaseNote(it, date, changes.toList()) }
}
markdown.lineSequence().forEach { rawLine ->
val line = rawLine.trim()
val heading = HeadingPattern.find(line)
when {
heading != null -> {
flush()
version = heading.groupValues[1]
date = heading.groupValues[2].trim()
changes = mutableListOf()
}
version == null -> Unit
line.startsWith("- ") || line.startsWith("* ") -> changes += line.drop(2).trim()
// A wrapped bullet continues the previous one rather than starting a new
// entry: the file is written to a column width, not to one line per change.
line.isNotEmpty() && changes.isNotEmpty() ->
changes[changes.lastIndex] = "${changes.last()} $line"
}
}
flush()
return releases
}
/** The catalogue Memby ships with, parsed once. */
internal val MembyReleaseHistory: List<ReleaseNote> by lazy {
parseChangelog(com.ponzischeme89.memby.BuildConfig.CHANGELOG_TEXT)
}
@@ -0,0 +1,160 @@
package com.ponzischeme89.memby.ui.setup
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
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ui.TvTextField
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyOnSurface
import com.ponzischeme89.memby.ui.theme.MembySurface
/**
* The sign-in form, with no dependency on the repository so it can be screenshotted and
* looked at without a server (`OnboardingScreenshotTest`). `SetupScreen` owns the
* authentication and hands the state in.
*
* It sits on flat [MembySurface] like every other full-screen surface in the app. It used to
* be a bordered translucent card floating on a radial gradient, which was the only place in
* Memby that looked like that and it is the *first* thing anyone sees, so it set an
* expectation the rest of the app then contradicted. The panel earned nothing: there is
* nothing behind it to be raised above.
*/
@Composable
fun SignInContent(
username: String,
password: String,
onUsernameChange: (String) -> Unit,
onPasswordChange: (String) -> Unit,
onSubmit: () -> Unit,
connecting: Boolean,
error: String?,
onBack: (() -> Unit)?,
modifier: Modifier = Modifier,
usernameFocus: FocusRequester = remember { FocusRequester() },
passwordFocus: FocusRequester = remember { FocusRequester() },
signInFocus: FocusRequester = remember { FocusRequester() },
backFocus: FocusRequester = remember { FocusRequester() },
) {
val addingViewer = onBack != null
Box(
modifier = modifier
.fillMaxSize()
.background(MembySurface)
.padding(horizontal = 72.dp, vertical = 40.dp),
contentAlignment = Alignment.Center,
) {
Column(
// The form is taller than the safe content area on some Android TVs, and a
// scroll container is also what lets Compose lift the focused field above the
// on-screen keyboard instead of clipping it.
modifier = Modifier.width(560.dp).verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(R.drawable.emby_logo),
contentDescription = null,
modifier = Modifier.size(52.dp),
)
Spacer(Modifier.height(20.dp))
Text(
if (addingViewer) "Add another viewer" else "Welcome to Memby",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
Spacer(Modifier.height(8.dp))
Text(
if (addingViewer) {
"Their account is saved as another profile on this TV."
} else {
"Sign in with your Emby account."
},
color = MembyMutedText,
fontSize = 16.sp,
)
Spacer(Modifier.height(30.dp))
TvTextField(
label = "Username",
value = username,
onValueChange = onUsernameChange,
focusRequester = usernameFocus,
onNext = { passwordFocus.requestFocus() },
)
Spacer(Modifier.height(16.dp))
TvTextField(
label = "Password",
value = password,
onValueChange = onPasswordChange,
isPassword = true,
focusRequester = passwordFocus,
downFocusRequester = signInFocus,
onDone = onSubmit,
)
error?.let {
Spacer(Modifier.height(16.dp))
Text(it, color = SignInError, fontSize = 15.sp)
}
Spacer(Modifier.height(28.dp))
Row(
modifier = Modifier.fillMaxWidth(),
// Centred under a centred form. End-aligned buttons were the last trace of
// the card this screen used to be, and read as belonging to a dialog.
horizontalArrangement = Arrangement.spacedBy(14.dp, Alignment.CenterHorizontally),
) {
if (onBack != null) {
UpdateButton(
label = "Back",
primary = false,
enabled = !connecting,
onClick = onBack,
modifier = Modifier
.focusRequester(backFocus)
.focusProperties { right = signInFocus },
)
}
UpdateButton(
label = if (connecting) "Signing in…" else "Sign in",
primary = true,
enabled = !connecting,
onClick = onSubmit,
modifier = Modifier
.focusRequester(signInFocus)
.focusProperties { if (onBack != null) left = backFocus },
)
}
}
}
}
/** The one red in the app that means "this went wrong", matched to Settings' own notices. */
private val SignInError = Color(0xFFFF7777)
@@ -35,8 +35,8 @@ 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)
/** A quiet neutral capsule behind third-party ratings. */
val MembyRatingsSurface = Color(0xFF20252A)
// --- Shape ---------------------------------------------------------------------------
// Three steps, largest last. Anything that needs a radius picks the nearest one rather
@@ -0,0 +1,75 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.settings.ReleaseNote
/** What a launch should do about the release notes for the build that is running. */
internal sealed interface WhatsNewDecision {
/** Show these notes, then record the version once the viewer dismisses them. */
data class Show(val release: ReleaseNote) : WhatsNewDecision
/** Record the version without showing anything. */
data class MarkSeen(val version: String) : WhatsNewDecision
/** Neither — this launch has nothing to say and nothing to write. */
data object Nothing : WhatsNewDecision
}
/**
* Decides whether a television that has just been updated should be told what changed.
*
* Pure, because the interesting part is not the panel but which launches it must stay out
* of. Three cases are deliberately not "show":
*
* - **Already recorded.** The whole contract is once per update; every later launch of the
* same build is silent, which is what [seenVersion] exists for.
* - **A fresh install** (no record at all, and nobody signed in yet). Everything in this
* build is new to that TV, so a list of changes since a version it never ran is noise at
* the worst moment. It is marked seen during setup so the first launcher is clean.
* - **A build the changelog does not describe.** An unreleased or locally-built version has
* no entry, and an empty panel is worse than none it is marked seen instead, so the
* next real update still lands.
*
* Signed out with a record already present is [Nothing] rather than [MarkSeen]: the notes
* belong over the launcher, so that launch simply waits for whoever is about to sign in.
*/
internal fun whatsNewDecision(
installedVersion: String,
seenVersion: String?,
isSignedIn: Boolean,
releases: List<ReleaseNote>,
): WhatsNewDecision {
val version = installedVersion.trim()
if (version.isEmpty()) return WhatsNewDecision.Nothing
if (seenVersion?.trim() == version) return WhatsNewDecision.Nothing
if (seenVersion == null && !isSignedIn) return WhatsNewDecision.MarkSeen(version)
if (!isSignedIn) return WhatsNewDecision.Nothing
val release = releases.firstOrNull { it.version == version && it.changes.isNotEmpty() }
?: return WhatsNewDecision.MarkSeen(version)
return WhatsNewDecision.Show(release)
}
/** One changelog bullet, split into its optional leading label and the sentence itself. */
internal data class ChangeLine(val tag: String?, val text: String)
/**
* The labels the changelog is written with. A closed set on purpose: any other colon in a
* bullet ("Fixed: Settings → About: …") is part of the sentence, and lifting it into a
* chip would break the line in half.
*/
private val ChangeTags = setOf("Added", "Changed", "Fixed", "Removed")
/** Splits `"Added: Subtitles are saved"` into the chip and the copy beside it. */
internal fun changeLine(raw: String): ChangeLine {
val text = raw.trim()
val colon = text.indexOf(':')
if (colon <= 0) return ChangeLine(null, text)
val tag = text.take(colon)
if (tag !in ChangeTags) return ChangeLine(null, text)
return ChangeLine(tag.uppercase(), text.drop(colon + 1).trim())
}
@@ -0,0 +1,238 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.whatsnew
import androidx.activity.compose.BackHandler
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Text
import com.ponzischeme89.memby.ui.UpdateButton
import com.ponzischeme89.memby.ui.settings.ReleaseNote
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyChipCorner
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.MembySurfaceRaised
import kotlinx.coroutines.delay
/**
* The most bullets the panel will print even on a tall screen. A release with more is not
* losing anything the full history is in Settings About, which the closing line points
* at but past half a dozen the panel stops being a note and becomes a document.
*/
private const val MAX_CHANGES = 6
/** Everything above and below the bullets: eyebrow, version, date, button and padding. */
private const val CHROME_HEIGHT_DP = 320
/** One bullet's budget, allowing for a sentence that wraps onto a second line. */
private const val CHANGE_ROW_HEIGHT_DP = 52
/**
* How many bullets fit on this screen. Derived rather than fixed, for the same reason the
* detail pages derive their pane height: a panel that runs off the bottom of a 720p set has
* no scrollbar to aim at and no way for the remote to reach the button under it. If a
* release needs more room than the screen has, it loses bullets never the button.
*/
internal fun maxChangesFor(availableHeightDp: Int): Int =
((availableHeightDp - CHROME_HEIGHT_DP) / CHANGE_ROW_HEIGHT_DP).coerceIn(1, MAX_CHANGES)
/** The label column, fixed so every sentence starts at the same place down the list. */
private val TagColumnWidth = 88.dp
private val Scrim = Color(0xE60A0C0F)
/**
* What changed in the build that has just been installed, shown once over the launcher.
*
* Deliberately an overlay rather than a screen: the rows are already drawn behind it from
* the home cache, so the viewer sees their television working and this as a note on top of
* it, not another wait before it. Stateless [whatsNewDecision] decides whether it appears
* and `SettingsStore.markWhatsNewSeen` records that it did so it can be screenshotted
* with no store, no gateway and no session.
*/
@Composable
internal fun WhatsNewOverlay(
release: ReleaseNote,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val dismissFocus = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
// The launcher requests focus for its first row on the same frame, and on a slow set
// that request can land after this one. Ask again until it sticks rather than leaving a
// panel on screen with the remote still driving the rows behind it.
LaunchedEffect(release.version) {
repeat(6) {
if (focused) return@LaunchedEffect
runCatching { dismissFocus.requestFocus() }
delay(100)
}
}
BackHandler(enabled = true, onBack = onDismiss)
var entered by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { entered = true }
val entrance by animateFloatAsState(
targetValue = if (entered) 1f else 0f,
animationSpec = tween(280),
label = "whats-new-entrance",
)
BoxWithConstraints(
modifier = modifier
.fillMaxSize()
.zIndex(8f)
.background(Scrim)
.testTag("whats-new"),
contentAlignment = Alignment.Center,
) {
val shown = maxChangesFor(maxHeight.value.toInt())
Column(
modifier = Modifier
.graphicsLayer {
alpha = entrance
translationY = (1f - entrance) * 18.dp.toPx()
}
.widthIn(max = 760.dp)
.clip(RoundedCornerShape(MembyPanelCorner))
.background(MembySurfaceRaised)
.border(1.dp, Color.White.copy(alpha = 0.10f), RoundedCornerShape(MembyPanelCorner))
.padding(horizontal = 40.dp, vertical = 34.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"WHAT'S NEW",
color = MembyAccent,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 2.sp,
)
Spacer(Modifier.height(10.dp))
Text(
"Memby ${release.version}",
color = MembyOnSurface,
fontSize = 30.sp,
fontWeight = FontWeight.SemiBold,
)
if (release.date.isNotBlank()) {
Spacer(Modifier.height(6.dp))
Text("Installed on this TV · ${release.date}", color = MembyQuietText, fontSize = 14.sp)
}
Spacer(Modifier.height(22.dp))
Column(
modifier = Modifier.widthIn(max = 620.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
release.changes.take(shown).forEach { ChangeRow(changeLine(it)) }
}
if (release.changes.size > shown) {
Spacer(Modifier.height(16.dp))
Text(
"And more — the full history is in Settings → About.",
color = MembyQuietText,
fontSize = 14.sp,
)
}
Spacer(Modifier.height(28.dp))
UpdateButton(
label = "Continue",
primary = true,
enabled = true,
onClick = onDismiss,
modifier = Modifier
.focusRequester(dismissFocus)
// The only focusable thing on screen, over a launcher full of them.
// Every direction points back here, or one press of Down walks into
// rows the viewer cannot see behind the scrim.
.focusProperties {
up = dismissFocus
down = dismissFocus
left = dismissFocus
right = dismissFocus
}
.onFocusChanged { focused = it.isFocused }
.testTag("whats-new-continue"),
)
}
}
}
@Composable
private fun ChangeRow(line: ChangeLine) {
Row(verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
// Fixed column: the labels are different lengths, and ragged sentence starts read
// as a list that has not been laid out rather than one that has.
Box(modifier = Modifier.width(TagColumnWidth), contentAlignment = Alignment.TopStart) {
if (line.tag != null) {
Text(
line.tag,
color = MembyAccent,
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(MembyChipCorner))
.background(MembyAccent.copy(alpha = 0.12f))
.padding(horizontal = 9.dp, vertical = 4.dp),
)
} else {
Box(
modifier = Modifier
.padding(top = 8.dp)
.size(6.dp)
.clip(CircleShape)
.background(MembyAccent.copy(alpha = 0.7f)),
)
}
}
Text(line.text, color = MembyMutedText, fontSize = 16.sp)
}
}
@@ -0,0 +1,43 @@
package com.ponzischeme89.memby.update
import android.content.Context
import android.content.Intent
import android.os.Build
import android.provider.Settings
import androidx.core.net.toUri
/**
* The per-app "install unknown apps" permission, which is what actually decides whether
* Memby can ever update itself.
*
* Every television here is sideloaded, in practice through Downloader which means
* *Downloader* holds this permission and Memby does not. Nothing in a fresh install asks for
* it, so the first time anyone finds out is halfway through an update that then refuses to
* proceed. Asking during setup, while somebody is sitting in front of the TV expecting to be
* asked things, is the whole point of [com.ponzischeme89.memby.ui.InstallPermissionScreen].
*/
object InstallPermission {
/** Where the setting lives on a television, for when there is no screen to open. */
const val MANUAL_PATH =
"Settings → Device Preferences → Security & restrictions → Unknown sources"
fun granted(context: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O ||
context.packageManager.canRequestPackageInstalls()
/**
* Opens Android's per-app permission screen.
*
* Returns false when this TV has no such screen several do not, and the difference
* between "not granted yet" and "nowhere to grant it" is the difference between a
* prompt that works and one that silently does nothing.
*/
fun requestScreen(context: Context): Boolean {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
return runCatching { context.startActivity(intent) }.isSuccess
}
}
@@ -0,0 +1,118 @@
package com.ponzischeme89.memby.update
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
/**
* Where a self-update ends up.
*
* The install itself is asynchronous and happens in the system installer, not in Memby, so
* the only way the app learns that an update failed is this broadcast. Without it the TV
* says "Opening the installer…" and then never mentions it again which on a *mandatory*
* update leaves a blocking screen whose button appears to do nothing, and a viewer whose
* only remaining idea is to sideload the APK by hand.
*/
object AppInstall {
private val _messages = MutableSharedFlow<String>(
replay = 1,
extraBufferCapacity = 1,
)
/** Progress and failure text for whichever screen started the install. */
val messages: SharedFlow<String> = _messages
internal fun report(message: String) {
_messages.tryEmit(message)
}
}
/**
* Receives the [PackageInstaller] session result.
*
* [PackageInstaller.STATUS_PENDING_USER_ACTION] is the ordinary case and is not a failure:
* the system hands back an intent for its own confirmation screen and expects the app to
* launch it. That indirection is the reason this path is used instead of an
* `ACTION_VIEW` handoff the confirmation activity arrives as an explicit intent from the
* system, so it does not depend on a TV build exposing an APK viewer or on package
* visibility rules.
*/
class InstallResultReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != ACTION) return
val status = intent.getIntExtra(
PackageInstaller.EXTRA_STATUS,
PackageInstaller.STATUS_FAILURE,
)
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
val confirm = confirmationIntent(intent)
if (confirm == null) {
AppInstall.report(installStatusMessage(PackageInstaller.STATUS_FAILURE, null))
return
}
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
val launched = runCatching { context.startActivity(confirm) }.isSuccess
AppInstall.report(
if (launched) {
"Confirm the update when your TV asks."
} else {
"This TV would not open Android's install screen. " +
"Reinstall Memby from the install page to get this version."
},
)
return
}
AppInstall.report(
installStatusMessage(
status,
intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE),
),
)
}
@Suppress("DEPRECATION")
private fun confirmationIntent(intent: Intent): Intent? =
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java)
} else {
intent.getParcelableExtra(Intent.EXTRA_INTENT)
}
companion object {
const val ACTION = "com.ponzischeme89.memby.INSTALL_RESULT"
}
}
/**
* Turns a [PackageInstaller] status into something worth putting on a television.
*
* Pure so it can be tested without an installer session. The system's own
* `EXTRA_STATUS_MESSAGE` is developer-facing ("INSTALL_FAILED_VERSION_DOWNGRADE"), so it is
* used only as a last resort, and every case that a viewer can actually act on says what
* to do rather than what went wrong.
*/
internal fun installStatusMessage(status: Int, systemMessage: String?): String = when (status) {
PackageInstaller.STATUS_SUCCESS ->
"Update installed. Memby will restart."
PackageInstaller.STATUS_FAILURE_ABORTED ->
"The update was cancelled. Press Update now to try again."
PackageInstaller.STATUS_FAILURE_STORAGE ->
"There is not enough free space on this TV to install the update."
PackageInstaller.STATUS_FAILURE_BLOCKED ->
"Your TV blocked the install. Allow Memby under Settings → Device Preferences → " +
"Security & restrictions → Unknown sources, then try again."
PackageInstaller.STATUS_FAILURE_CONFLICT ->
"Android refused the update because the installed copy of Memby does not match it. " +
"Uninstall Memby and install this version from the install page."
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE ->
"This update is not compatible with this TV."
PackageInstaller.STATUS_FAILURE_INVALID ->
"The downloaded update was rejected as damaged. Try again."
else -> systemMessage?.takeIf { it.isNotBlank() }?.let { "The update could not be installed ($it)." }
?: "The update could not be installed."
}
@@ -1,12 +1,13 @@
package com.ponzischeme89.memby.update
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageInstaller
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
@@ -157,9 +158,12 @@ class UpdateChecker(private val context: Context) {
}
/**
* Downloads the APK and launches the system installer. On Android O+ the app
* 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.
* Downloads the APK, verifies it, and commits it to a [PackageInstaller] session. On
* Android O+ the app 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.
*
* Success here means the *session was committed*, not that the update is installed
* the rest of the story arrives on [AppInstall.messages] via [InstallResultReceiver].
*/
suspend fun downloadAndInstall(
apkUrl: String,
@@ -170,19 +174,24 @@ class UpdateChecker(private val context: Context) {
): 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 &&
!context.packageManager.canRequestPackageInstalls()
) {
runCatching {
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
"package:${context.packageName}".toUri(),
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
}
//
// Sending the viewer to Android's per-app permission screen is best, but many
// television builds do not implement it. When it is missing, saying so and
// giving the path through the TV's own settings is the only thing that gets
// anyone unstuck — before this, the activity failed to start, the failure was
// swallowed, and the screen said "the update will continue when you return"
// about a screen that never opened.
if (!InstallPermission.granted(context)) {
val opened = InstallPermission.requestScreen(context)
return@withContext Result.failure(
InstallPermissionRequiredException(
"Allow Memby to install apps. The update will continue when you return.",
if (opened) {
"Allow Memby to install apps. The update will continue when you return."
} else {
"Memby needs permission to install updates. On this TV, open " +
"${InstallPermission.MANUAL_PATH} and turn on Memby, then press " +
"Update again."
},
),
)
}
@@ -250,24 +259,76 @@ class UpdateChecker(private val context: Context) {
partial.delete()
error("The verified update could not be prepared.")
}
val uri = FileProvider.getUriForFile(
context, "${context.packageName}.fileprovider", file,
)
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(install)
commitInstallSession(file)
}
}
/**
* Hands the verified APK to [PackageInstaller].
*
* The previous implementation sent an `ACTION_VIEW` intent at the APK's content URI.
* That is the phone idiom and it fails quietly on televisions: the implicit intent is
* subject to package visibility on Android 11+, several TV builds expose no activity
* for the package-archive MIME type at all, and either way the app learns nothing about
* what the installer then did. A session is explicit, needs no viewer activity, and
* reports its outcome to [InstallResultReceiver].
*/
private fun commitInstallSession(file: File) {
val installer = context.packageManager.packageInstaller
// An abandoned session from an earlier attempt holds its staged copy of the APK
// and counts against the per-app session limit, so a TV that failed a few times
// would eventually stop being able to start one at all.
installer.mySessions.forEach { runCatching { installer.abandonSession(it.sessionId) } }
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL,
).apply {
setAppPackageName(context.packageName)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Honoured only once Memby is its own installer of record — that is, from
// the second self-update onwards — and ignored otherwise, in which case the
// system asks as usual. Nothing here depends on it being granted.
setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
}
}
val sessionId = installer.createSession(params)
var committed = false
try {
installer.openSession(sessionId).use { session ->
session.openWrite(SESSION_ENTRY, 0, file.length()).use { out ->
file.inputStream().use { it.copyTo(out) }
session.fsync(out)
}
session.commit(installStatusSender(sessionId))
committed = true
}
} finally {
if (!committed) runCatching { installer.abandonSession(sessionId) }
}
}
/** Where the system reports the session's outcome. Mutable: the system fills in extras. */
private fun installStatusSender(sessionId: Int) = PendingIntent.getBroadcast(
context,
sessionId,
Intent(context, InstallResultReceiver::class.java)
.setAction(InstallResultReceiver.ACTION)
.setPackage(context.packageName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
},
).intentSender
@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
}
// Both flags, on both sides, on every API level. GET_SIGNING_CERTIFICATES alone is
// correct for an *installed* package but not for an archive: getPackageArchiveInfo
// leaves signingInfo null on several Android versions, and asking only for it meant
// the digests came back empty and a correctly signed update was rejected as forged.
val flags = PackageManager.GET_SIGNING_CERTIFICATES or 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) {
@@ -285,8 +346,8 @@ class UpdateChecker(private val context: Context) {
) {
error("Android requires an update with a higher version code.")
}
if (signerDigests(archive) != signerDigests(installed) ||
signerDigests(archive).isEmpty()
if (signerVerdict(signerDigests(archive), signerDigests(installed)) ==
SignerVerdict.MISMATCH
) {
error("The update was not signed by Membys trusted release key.")
}
@@ -294,12 +355,16 @@ class UpdateChecker(private val context: Context) {
@Suppress("DEPRECATION")
private fun signerDigests(info: PackageInfo): Set<String> {
val signatures = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners.orEmpty()
// Prefer the modern accessor and fall back, rather than choosing by SDK level: which
// of the two a given Android version fills in depends on whether this PackageInfo
// describes an installed package or a file on disk, not only on the API level.
val modern = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
info.signingInfo?.apkContentsSigners
} else {
info.signatures.orEmpty()
null
}
return signatures.mapTo(linkedSetOf()) { signature ->
val signatures = modern?.takeIf { it.isNotEmpty() } ?: info.signatures.orEmpty()
return signatures.filterNotNull().mapTo(linkedSetOf()) { signature ->
MessageDigest.getInstance("SHA-256")
.digest(signature.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
@@ -325,11 +390,37 @@ class UpdateChecker(private val context: Context) {
companion object {
private const val MAX_APK_BYTES = 250L * 1024L * 1024L
private const val SESSION_ENTRY = "memby-update.apk"
}
}
class InstallPermissionRequiredException(message: String) : IllegalStateException(message)
/** What comparing an update's signers against the installed app's could establish. */
internal enum class SignerVerdict { MATCH, MISMATCH, UNVERIFIABLE }
/**
* Decides whether a downloaded update may proceed to the installer.
*
* The distinction that matters is between *knowing* the key is wrong and *not being able to
* read* it. Treating the second as the first is what stopped every in-app update on the TVs
* where `getPackageArchiveInfo` returns no signing information: the APK was correctly signed
* with the same key it always had, and the app said it was not, so the only way forward was
* a manual reinstall.
*
* Proceeding when the read fails is safe because this check is a courtesy, not the defence.
* The APK has already been matched against the published SHA-256, its package name and its
* version; and Android itself enforces signature identity at install time and cannot be
* talked out of it. All this check can honestly add is a clearer message when it *can* see
* a genuine mismatch which is why an unreadable archive falls through to the installer,
* where a real mismatch surfaces as STATUS_FAILURE_CONFLICT.
*/
internal fun signerVerdict(archive: Set<String>, installed: Set<String>): SignerVerdict = when {
archive.isEmpty() || installed.isEmpty() -> SignerVerdict.UNVERIFIABLE
archive == installed -> SignerVerdict.MATCH
else -> SignerVerdict.MISMATCH
}
/**
* 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
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="#FF090B0D" android:state_focused="true" />
<item android:color="#FFFFFFFF" />
<item android:color="#FF07090A" android:state_focused="true" />
<item android:color="#FF52B54B" android:state_selected="true" />
<item android:color="#FFE9EDF0" />
</selector>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Two people, for the cast panel. Deliberately not Android's "cast" glyph (the screen
with waves), which on a television means sending the picture somewhere else and is the
one thing this button must not be mistaken for. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5V19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45V19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z" />
</vector>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The mirror of ic_player_rewind, so the pair reads as one control rather than two. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF69CD61"
android:pathData="M4,18l8.5,-6L4,6v12zM13,6v12l8.5,-6L13,6z" />
</vector>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Two chevrons pointing back. Deliberately not a clock or a circular arrow: the OSD
already prints the number of seconds, and the glyph only has to say which way. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF69CD61"
android:pathData="M11,18V6l-8.5,6L11,18zM11.5,12l8.5,6V6L11.5,12z" />
</vector>
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Drawn over the portrait, not behind it, so the accent ring reads on the artwork rather
than being covered by it. A remote has no hover: the ring plus the scale is the only
thing saying which face is selected. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<stroke
android:width="2dp"
android:color="#FF52B54B" />
<corners android:radius="12dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<stroke
android:width="1dp"
android:color="#1FFFFFFF" />
<corners android:radius="12dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The cast panel has no card edge. It sits on somebody's film, so it fades into the
picture from the bottom rather than dropping a box over it: the scene stays visible
above the names, which is the whole reason the panel was opened. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:centerColor="#D40A0C0F"
android:centerY="0.35"
android:endColor="#00000000"
android:startColor="#FA07080A"
android:type="linear" />
</shape>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The subtitle drop-up sits over somebody's film, so it is near-black rather than a lit
panel: a bright surface at night is the thing that hurts, and the option under focus is
the only lit thing on it. -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#F7000000" />
<stroke
android:width="1dp"
android:color="#1FFFFFFF" />
<corners android:radius="14dp" />
</shape>
@@ -1,27 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Focus is the only fill on the menu; the current choice is a quiet grey plate, so an
unfocused row never competes with the one the remote is on. -->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<shape android:shape="rectangle">
<solid android:color="#FFFFFFFF" />
<corners android:radius="12dp" />
<solid android:color="#FF52B54B" />
<corners android:radius="10dp" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#2E52B54B" />
<stroke
android:width="1dp"
android:color="#B852B54B" />
<corners android:radius="12dp" />
<solid android:color="#1AFFFFFF" />
<corners android:radius="10dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#16FFFFFF" />
<stroke
android:width="1dp"
android:color="#20FFFFFF" />
<corners android:radius="12dp" />
<solid android:color="#00000000" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
@@ -22,6 +22,11 @@
controller, so it can appear briefly without opening the transport controls. -->
<include layout="@layout/player_playback_identity" />
<!-- Skipping. Declared before the timing cues so those sit above it: a lower third on
the left and this centred chip do not overlap, and if they ever do, the cue that
appears once matters more than one that appears on every press. -->
<include layout="@layout/player_seek_indicator" />
<!-- 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" />
@@ -242,6 +242,15 @@
android:src="@drawable/exo_ic_subtitle_on"
tools:ignore="PrivateResource" />
<!-- Beside the subtitle picker rather than buried in the settings list:
"who is this?" is asked mid-scene, and a question that has to survive a
dialog and four menu rows is one nobody asks twice. -->
<ImageButton
android:id="@+id/player_cast"
style="@style/MembyPlayerControlButton"
android:contentDescription="@string/player_cast_open"
android:src="@drawable/ic_player_cast" />
<ImageButton
android:id="@id/exo_settings"
style="@style/MembyPlayerControlButton"
+35 -18
View File
@@ -1,64 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The cast panel. It covers the bottom of somebody's film, so it is a fade rather than a
card: the scrim carries the names and the scene stays legible above them. The paddings
match the transport row's 48dp side inset, so opening this does not shift the column
the title and controls are read in. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_cast_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000"
android:background="@drawable/player_cast_scrim"
android:clickable="true"
android:clipChildren="false"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="340dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="@drawable/player_overlay_panel_background"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="vertical"
android:paddingStart="48dp"
android:paddingTop="25dp"
android:paddingTop="20dp"
android:paddingEnd="48dp"
android:paddingBottom="22dp">
android:paddingBottom="34dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_now_playing"
android:text="@string/player_cast_eyebrow"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<!-- The title, not the word "Cast". The eyebrow above already says what the panel
is, and a viewer who opened this mid-scene wants confirmation of what they are
watching more than a heading repeating the button they just pressed. -->
<TextView
android:layout_width="wrap_content"
android:id="@+id/player_cast_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/player_cast"
android:ellipsize="end"
android:maxLines="1"
android:textColor="#FFFFFFFF"
android:textSize="28sp"
android:textSize="26sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_cast_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="18dp"
android:layout_marginTop="14dp"
android:text="@string/player_cast_loading"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
<HorizontalScrollView
android:id="@+id/player_cast_scroller"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="14dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:clipChildren="false"
android:clipToPadding="false"
android:fillViewport="false"
android:overScrollMode="never">
android:overScrollMode="never"
android:paddingTop="6dp"
android:paddingBottom="6dp"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_cast_people"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:gravity="top"
android:orientation="horizontal" />
</HorizontalScrollView>
@@ -66,8 +81,10 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:letterSpacing="0.08"
android:text="@string/player_back_to_close"
android:textColor="#78FFFFFF"
android:textSize="12sp" />
android:textColor="#6EFFFFFF"
android:textSize="11sp" />
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- What a press of Left or Right just did, and where it lands. It sits over somebody's
film, so it is near-black, non-focusable and centred low on the screen where the
transport controls would be — the viewer is already looking there. It never opens the
transport OSD: skipping is meant to cost one press, not one press plus a menu. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_seek_indicator"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="104dp"
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="18dp"
android:paddingTop="12dp"
android:paddingEnd="22dp"
android:paddingBottom="12dp">
<ImageView
android:id="@+id/player_seek_glyph"
android:layout_width="26dp"
android:layout_height="26dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/ic_player_forward" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:orientation="vertical">
<TextView
android:id="@+id/player_seek_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Forward 30 seconds" />
<TextView
android:id="@+id/player_seek_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="13sp"
tools:text="12:34 / 1:45:00" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -1,107 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A drop-up over the subtitle button rather than a full-height panel: the film keeps
playing behind it, so there is no scrim and nothing bigger than the question being
asked. The margins line the panel up with the transport row's end inset (48dp) and
clear its 72dp button strip plus the controls' 28dp bottom padding; the top margin is
what caps how tall the track list can grow before it scrolls.
The panel holds two screens, not three sections. Choosing a track and fetching one the
title does not have are separate questions, and at 344dp by a third of a 720p screen
there is not room to ask both — stacking them squeezed the track list to a single row.
So `player_subtitle_main_section` and the results half of the download section swap:
the search row is the way in, Back is the way out, one press per level. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/player_subtitle_overlay"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#70000000"
android:background="@android:color/transparent"
android:clickable="true"
android:focusable="true"
android:visibility="gone">
<LinearLayout
android:layout_width="520dp"
android:layout_height="match_parent"
android:layout_gravity="end"
android:layout_marginStart="30dp"
android:background="@drawable/player_overlay_panel_background"
android:id="@+id/player_subtitle_menu"
android:layout_width="344dp"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginTop="96dp"
android:layout_marginEnd="44dp"
android:layout_marginBottom="112dp"
android:background="@drawable/player_menu_background"
android:orientation="vertical"
android:paddingStart="34dp"
android:paddingTop="32dp"
android:paddingEnd="34dp"
android:paddingBottom="26dp">
android:paddingStart="10dp"
android:paddingTop="14dp"
android:paddingEnd="10dp"
android:paddingBottom="12dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.14"
android:text="@string/player_playback_options"
android:textColor="#FF52B54B"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:text="@string/player_subtitles"
android:textColor="#FFFFFFFF"
android:textSize="30sp"
android:textStyle="bold" />
<TextView
<LinearLayout
android:id="@+id/player_subtitle_main_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="7dp"
android:lineSpacingExtra="2dp"
android:text="@string/player_subtitle_overlay_hint"
android:textColor="#AFFFFFFF"
android:textSize="14sp" />
android:orientation="vertical">
<ScrollView
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_marginTop="24dp"
android:layout_weight="1"
android:clipToPadding="false"
android:fillViewport="true"
android:overScrollMode="never">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_track"
android:textColor="#7AFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_track"
android:textColor="#8FFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
android:layout_marginTop="8dp"
android:layout_weight="1"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_subtitle_tracks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:orientation="vertical" />
</ScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="25dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#8FFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="10dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:letterSpacing="0.12"
android:text="@string/player_text_size"
android:textColor="#7AFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<LinearLayout
android:id="@+id/player_subtitle_sizes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:orientation="horizontal" />
</LinearLayout>
<!-- Fetching a subtitle the title does not have. The whole group is GONE on a
backend that cannot do it, rather than showing a row that leads nowhere. -->
<LinearLayout
android:id="@+id/player_subtitle_download_section"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:visibility="gone">
<!-- Hidden with the section above it: with the menu swapped out this heading is
the first thing on the panel, and a rule above the first thing is a line
under the panel's own top edge. -->
<View
android:id="@+id/player_subtitle_download_rule"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="14dp"
android:background="#14FFFFFF" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="12dp"
android:letterSpacing="0.12"
android:text="@string/player_subtitle_download"
android:textColor="#7AFFFFFF"
android:textSize="11sp"
android:textStyle="bold" />
<TextView
android:id="@+id/player_subtitle_download_status"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:layout_marginTop="6dp"
android:layout_marginEnd="14dp"
android:textColor="#96FFFFFF"
android:textSize="12sp"
android:visibility="gone" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_weight="1"
android:fillViewport="false"
android:overScrollMode="never"
android:scrollbars="none">
<LinearLayout
android:id="@+id/player_subtitle_sizes"
android:id="@+id/player_subtitle_downloads"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="9dp"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:text="@string/player_back_to_close"
android:textColor="#78FFFFFF"
android:textSize="12sp" />
</ScrollView>
</LinearLayout>
</LinearLayout>
</FrameLayout>
+12 -4
View File
@@ -23,14 +23,20 @@
<string name="player_live">Live</string>
<string name="player_back">Back to previous screen</string>
<string name="player_hide_controls">Hide controls</string>
<string name="player_playback_options">PLAYBACK OPTIONS</string>
<string name="player_subtitles">Subtitles</string>
<string name="player_cast">Cast</string>
<string name="player_cast_eyebrow">CAST</string>
<string name="player_cast_loading">Loading cast…</string>
<string name="player_cast_empty">No cast information is available.</string>
<string name="player_subtitle_overlay_hint">Choose a track and tune the text size without leaving playback.</string>
<string name="player_subtitle_track">SUBTITLE TRACK</string>
<string name="player_subtitle_track">SUBTITLES</string>
<string name="player_text_size">TEXT SIZE</string>
<string name="player_subtitle_download">GET SUBTITLES</string>
<string name="player_subtitle_search">Search for subtitles…</string>
<string name="player_subtitle_search_again">Search again</string>
<string name="player_subtitle_searching">Looking for subtitles. This can take a moment.</string>
<string name="player_subtitle_search_empty">No subtitles were found for this release.</string>
<string name="player_subtitle_downloading">Downloading %1$s subtitles…</string>
<string name="player_subtitle_download_failed">That subtitle could not be downloaded. Try another.</string>
<string name="player_cast_open">Cast</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 7 seconds…</string>
@@ -51,6 +57,8 @@
<item quantity="one">%1$d min</item>
<item quantity="other">%1$d mins</item>
</plurals>
<string name="player_seek_forward">Forward %1$s</string>
<string name="player_seek_back">Back %1$s</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- Matches context.cacheDir, where the update APK is downloaded. -->
<cache-path name="apk_cache" path="." />
</paths>
@@ -0,0 +1,105 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Deliberately parallel to the gateway's `continue_watching_test.go`. Continue Watching
* must hold the same cards in the same order whether or not the container is up.
*/
class ContinueWatchingTest {
private fun resume(id: String, seriesId: String? = null, playedAt: String? = null) =
BaseItem(
id = id,
seriesId = seriesId,
userData = UserItemData(lastPlayedDate = playedAt),
)
private fun upNext(id: String, seriesId: String) =
BaseItem(id = id, seriesId = seriesId, userData = UserItemData())
/**
* The case the merged row exists for: an episode was just finished, so it has left the
* resume list, and the next one must be the first card rather than sitting in a second
* row further down the launcher.
*/
@Test
fun leadsWithTheJustFinishedShow() {
val merged = mergeContinueWatching(
resume = listOf(resume("film", playedAt = "2026-08-01T20:00:00.0000000Z")),
nextUp = listOf(upNext("s2e5", "severance")),
seriesLastPlayed = mapOf("severance" to "2026-08-06T21:00:00.0000000Z"),
)
assertEquals(listOf("s2e5", "film"), merged.map(BaseItem::id))
}
/** A show being watched right now appears once, as the episode it is part-way through. */
@Test
fun prefersTheResumeEpisodeOfASeries() {
val merged = mergeContinueWatching(
resume = listOf(resume("s1e3", "show", "2026-08-06T19:00:00Z")),
nextUp = listOf(upNext("s1e4", "show"), upNext("other-e1", "other")),
seriesLastPlayed = mapOf(
"show" to "2026-08-06T19:00:00Z",
"other" to "2026-08-02T19:00:00Z",
),
)
assertEquals(listOf("s1e3", "other-e1"), merged.map(BaseItem::id))
}
/**
* Each source keeps its own order. Emby's ordering within a list is the useful part of
* both, so this is a merge of two sorted lists and never a sort of their union.
*/
@Test
fun keepsEachSourceOrder() {
val merged = mergeContinueWatching(
resume = listOf(
resume("r1", playedAt = "2026-08-06T10:00:00Z"),
resume("r2", playedAt = "2026-08-04T10:00:00Z"),
resume("r3", playedAt = "2026-08-01T10:00:00Z"),
),
nextUp = listOf(upNext("n1", "alpha"), upNext("n2", "beta")),
seriesLastPlayed = mapOf(
"alpha" to "2026-08-05T10:00:00Z",
"beta" to "2026-08-03T10:00:00Z",
),
)
assertEquals(listOf("r1", "n1", "r2", "n2", "r3"), merged.map(BaseItem::id))
}
/**
* With no play dates to go on the lookup failed, or Emby wrote something
* unparseable the row is the resume list followed by Next Up, which is the order the
* launcher had when they were two rows. Nothing is dropped.
*/
@Test
fun fallsBackToResumeFirst() {
val merged = mergeContinueWatching(
resume = listOf(resume("r1", playedAt = "not a date"), resume("r2")),
nextUp = listOf(upNext("n1", "alpha"), upNext("n2", "beta")),
seriesLastPlayed = emptyMap(),
)
assertEquals(listOf("r1", "r2", "n1", "n2"), merged.map(BaseItem::id))
}
@Test
fun normalizesEmbyTimestampsAndRejectsEverythingElse() {
assertEquals("2026-08-06T21:04:05", normalizePlayedAt("2026-08-06T21:04:05.1234567Z"))
assertEquals("2026-08-06T21:04:05", normalizePlayedAt("2026-08-06T21:04:05Z"))
assertEquals("2026-08-06T21:04:05", normalizePlayedAt(" 2026-08-06T21:04:05 "))
assertNull(normalizePlayedAt(null))
assertNull(normalizePlayedAt(""))
assertNull(normalizePlayedAt("2026-08-06"))
assertNull(normalizePlayedAt("yesterday afternoon"))
// Emby's "never played" sentinel is not a date to order a card by.
assertNull(normalizePlayedAt("0001-01-01T00:00:00.0000000Z"))
}
}
@@ -0,0 +1,47 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The device id is the television's identity in Emby's devices list and in
* Settings Devices. What these pin is that reinstalling the app reproduces it that is
* the whole reason it is derived rather than random and that the platform values which
* cannot identify a set do not silently make two televisions into one.
*/
class DeviceIdTest {
@Test
fun `same android id yields the same device id`() {
assertEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor("a1b2c3d4e5f60718"))
}
@Test
fun `different android ids yield different device ids`() {
assertNotEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor("18f7e6f5d4c3b2a1"))
}
@Test
fun `device id never carries the platform identifier`() {
val id = deviceIdFor("a1b2c3d4e5f60718")
assertTrue(id.startsWith("memby-"))
assertTrue(!id.contains("a1b2c3d4e5f60718"))
}
@Test
fun `surrounding whitespace does not change the identity`() {
assertEquals(deviceIdFor("a1b2c3d4e5f60718"), deviceIdFor(" a1b2c3d4e5f60718 "))
}
@Test
fun `unusable android ids fall back to a unique id`() {
// A null, blank, short or all-zero value identifies nothing, and the id a batch of
// early devices shared would make every one of them the same television. Two calls
// must not agree: one duplicate entry is better than two sets sharing a session.
for (value in listOf(null, "", " ", "9774d56d682e549c", "0000000000000000", "abc")) {
assertNotEquals("android id $value", deviceIdFor(value), deviceIdFor(value))
}
}
}
@@ -0,0 +1,90 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.GatewayEmbyHealth
import com.ponzischeme89.memby.ui.retryLabel
import com.ponzischeme89.memby.ui.secondsUntil
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Test
class EmbyOutageTest {
private val down = GatewayEmbyHealth(monitored = true, reachable = false, retrySeconds = 60)
private val up = GatewayEmbyHealth(monitored = true, reachable = true, retrySeconds = 60)
/**
* With the probe switched off nothing updates `reachable`, so trusting it would mean a
* permanent red bar on a server that is working perfectly well.
*/
@Test
fun `an unmonitored server never raises the bar`() {
val unmonitored = GatewayEmbyHealth(monitored = false, reachable = false)
assertNull(nextOutageState(unmonitored, existing = null, nowMillis = 0L))
}
@Test
fun `a reachable server clears the bar`() {
val existing = EmbyOutage(nextAttemptAtMillis = 60_000L, retryIntervalSeconds = 60)
assertNull(nextOutageState(up, existing, nowMillis = 0L))
}
@Test
fun `an outage arms a countdown from the retry interval`() {
val outage = nextOutageState(down, existing = null, nowMillis = 5_000L)
assertNotNull(outage)
assertEquals(65_000L, outage!!.nextAttemptAtMillis)
assertEquals(60, outage.retryIntervalSeconds)
}
/**
* The status poll runs six times per retry. If each one re-armed the deadline the
* counter would reset every ten seconds and never reach zero, which reads as an app
* that is stuck rather than a server that is being retried.
*/
@Test
fun `a live countdown survives the polls in the middle of it`() {
val armed = nextOutageState(down, existing = null, nowMillis = 0L)!!
var current = armed
listOf(10_000L, 20_000L, 30_000L, 40_000L, 50_000L).forEach { now ->
current = nextOutageState(down, current, now)!!
}
assertSame(armed, current)
}
/** Once the attempt is due, the next one is a fresh interval away. */
@Test
fun `an expired countdown is re-armed`() {
val armed = nextOutageState(down, existing = null, nowMillis = 0L)!!
val next = nextOutageState(down, armed, nowMillis = 60_000L)!!
assertEquals(120_000L, next.nextAttemptAtMillis)
}
/** A server too old to send an interval still has to produce a sane countdown. */
@Test
fun `a missing retry interval falls back to the shared default`() {
val outage = nextOutageState(
GatewayEmbyHealth(monitored = true, reachable = false),
existing = null,
nowMillis = 0L,
)!!
assertEquals(MaintenanceMonitor.DEFAULT_RETRY_SECONDS, outage.retryIntervalSeconds)
}
@Test
fun `the countdown never runs negative`() {
assertEquals(0, secondsUntil(deadlineMillis = 1_000L, nowMillis = 9_000L))
assertEquals(9, secondsUntil(deadlineMillis = 9_000L, nowMillis = 0L))
// A part-second still has time left in it, so it rounds up rather than to zero.
assertEquals(1, secondsUntil(deadlineMillis = 400L, nowMillis = 0L))
}
@Test
fun `the countdown says what is about to happen`() {
assertEquals("Retrying now…", retryLabel(0))
assertEquals("Retrying now…", retryLabel(-3))
assertEquals("Retrying in 1s", retryLabel(1))
assertEquals("Retrying in 42s", retryLabel(42))
}
}
@@ -1,5 +1,6 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.GatewayHome
import com.ponzischeme89.memby.data.model.GatewayDevices
import com.ponzischeme89.memby.data.model.GatewayNextEpisode
@@ -7,6 +8,7 @@ import com.ponzischeme89.memby.data.model.GatewayMovieRatings
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.GatewayPreferences
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
@@ -46,6 +48,21 @@ class GatewayPayloadTest {
}
@Test
fun `decodes ratings carried on a row item and defaults them when absent`() {
val carried = json.decodeFromString<BaseItem>(
"""{"Id":"7","Name":"Arrival","Type":"Movie","MembyRatings":[{"source":"imdb","name":"IMDb","score":"7.9","scale":"/10"}]}""",
)
assertEquals(listOf("IMDb"), carried.membyRatings.map { it.name })
assertEquals("7.9", carried.membyRatings.single().score)
// A gateway that has never looked the title up, an older build, and every cached
// home payload written before this field existed all decode to no ratings rather
// than failing — the card then falls back to the dedicated request on focus.
val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
assertTrue(bare.membyRatings.isEmpty())
}
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
@@ -80,22 +97,92 @@ class GatewayPayloadTest {
assertEquals("safe_mode", policy.features.single().source)
}
@Test
fun `decodes the live emby reachability the outage bar renders`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"emby":{"monitored":true,"reachable":false,"since":"2026-08-04T19:04:00Z","checkedAt":"2026-08-04T19:05:00Z","retrySeconds":60}}""",
)
assertTrue(status.emby.isOutage)
assertEquals(60, status.emby.retrySeconds)
}
/**
* A server predating the field must not produce a red bar. The default is a healthy,
* unmonitored reading precisely so an app that cannot tell says nothing.
*/
@Test
fun `a status payload without emby health reports no outage`() {
val status = json.decodeFromString<GatewayServiceStatus>("""{"maintenance":false}""")
assertEquals(false, status.emby.isOutage)
assertEquals(0L, status.preferencesRevision)
}
@Test
fun `decodes the settings revision an operator push arrives as`() {
val status = json.decodeFromString<GatewayServiceStatus>(
"""{"maintenance":false,"preferencesRevision":12}""",
)
assertEquals(12L, status.preferencesRevision)
}
/**
* The document stays a JsonObject on the wire so a server that has learned a setting
* this build has never heard of is still decodable the unknown key is simply carried
* past by [decodeUserPreferences].
*/
@Test
fun `decodes a settings document containing an unknown setting`() {
val payload = json.decodeFromString<GatewayPreferences>(
"""{"schemaVersion":1,"revision":4,"source":"admin","preferences":{"homeCardDensity":"large","hideWatchedMovies":true,"somethingNewer":"value"}}""",
)
val decoded = decodeUserPreferences(payload.preferences)
assertEquals(4L, payload.revision)
assertEquals("admin", payload.source)
assertEquals("large", decoded.homeCardDensity)
assertTrue(decoded.hideWatchedMovies)
}
@Test
fun `decodes recommendation onboarding ratings and emby items`() {
val onboarding = json.decodeFromString<RecommendationOnboarding>(
"""{
"completed":false,
"prompted":true,
"ratings":{"movie-1":5},
"items":[
{"Id":"movie-1","Name":"Arrival","Type":"Movie"},
{"Id":"series-1","Name":"Severance","Type":"Series"}
]
],
"movies":[{"Id":"movie-1","Name":"Arrival","Type":"Movie"}],
"shows":[{"Id":"series-1","Name":"Severance","Type":"Series"}],
"actors":[{"id":"actor-1","name":"Jeremy Renner","imageTag":"portrait-1"}],
"actresses":[{"id":"actor-2","name":"Amy Adams","imageTag":"portrait-2"}],
"directors":[{"id":"director-1","name":"Denis Villeneuve"}]
}""",
)
assertEquals(false, onboarding.completed)
assertTrue(onboarding.prompted)
assertEquals(5, onboarding.ratings["movie-1"])
assertEquals(listOf("Arrival", "Severance"), onboarding.items.map { it.name })
assertEquals(listOf("Arrival"), onboarding.movies.map { it.name })
assertEquals(listOf("Severance"), onboarding.shows.map { it.name })
assertEquals("Jeremy Renner", onboarding.actors.single().name)
assertEquals("Amy Adams", onboarding.actresses.single().name)
assertEquals("Denis Villeneuve", onboarding.directors.single().name)
}
@Test
fun `legacy onboarding response remains promptable`() {
val onboarding = json.decodeFromString<RecommendationOnboarding>(
"""{"completed":false,"items":[]}""",
)
assertTrue(onboarding.prompted)
}
@Test
@@ -198,7 +285,10 @@ class GatewayPayloadTest {
"MembyAirLabel":"Tomorrow: 8:00 PM",
"MembyAvailability":"downloading",
"MembyAvailabilityText":"Downloading",
"MembyPlayable":false
"MembyLifecycle":"continuing",
"MembyLifecycleText":"CONTINUING",
"MembyPlayable":false,
"MembySeriesItemId":"emby-1041"
}]
}],
"continueWatching":[],
@@ -215,7 +305,41 @@ class GatewayPayloadTest {
assertEquals("S02E04", item.membyEpisodeCode)
assertEquals("Tomorrow", item.membyAirDayLabel)
assertEquals("Downloading", item.membyAvailabilityText)
// Sonarr's lifecycle for the show, which is a different fact from this episode's
// availability and wears its own badge.
assertEquals("continuing", item.membyLifecycle)
assertEquals("CONTINUING", item.membyLifecycleText)
assertEquals(false, item.membyPlayable)
// The link that lets an unplayable card open the show's own page.
assertEquals("emby-1041", item.membySeriesItemId)
}
@Test
fun `a schedule card for a show Emby has not imported carries no series link`() {
val payload = """
{
"rows":[{
"id":"sonarr-airing-today",
"title":"Shows airing in the next 5 days",
"kind":"schedule",
"items":[{
"Id":"sonarr:9:11",
"Name":"Unimported",
"Type":"MembySonarrEpisode",
"MembySource":"sonarr",
"MembyAirDayLabel":"Friday",
"MembyAirLabel":"Friday: 8:00 PM",
"MembyPlayable":false
}]
}]
}
""".trimIndent()
val item = json.decodeFromString<GatewayHome>(payload).rows.single().items.single()
assertTrue(item.isTvSchedule)
assertNull(item.membySeriesItemId)
assertNull(item.membyLifecycle)
}
@Test
@@ -237,6 +361,8 @@ class GatewayPayloadTest {
"MembyAirLabel":"Digital release Saturday",
"MembyAvailability":"upcoming",
"MembyAvailabilityText":"Upcoming digital release",
"MembyLifecycle":"incinemas",
"MembyLifecycleText":"IN CINEMAS",
"MembyPlayable":false
}]
}]
@@ -250,6 +376,8 @@ class GatewayPayloadTest {
assertTrue(item.isMovieSchedule)
assertTrue(item.isSchedule)
assertEquals("Digital release Saturday", item.membyAirLabel)
assertEquals("incinemas", item.membyLifecycle)
assertEquals("IN CINEMAS", item.membyLifecycleText)
assertEquals(false, item.membyPlayable)
}
@@ -286,6 +414,10 @@ class GatewayPayloadTest {
"""{"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)
// Absent on an older gateway: subtitles on, nothing chosen, so the television falls
// back to its own pick rather than being told to turn them off.
assertTrue(playback.subtitlesEnabled)
assertEquals("", playback.selectedSubtitleId)
assertEquals(42_000L, playback.resumePositionMs)
assertEquals("S01E01", playback.episodeCode)
assertEquals(3_420_000L, playback.runtimeMs)
@@ -0,0 +1,16 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PlayablePrefetchTest {
@Test
fun `launch accepts only a recent focus prefetch`() {
val now = 100_000L
assertTrue(isFreshPlayablePrefetch(now - PLAYABLE_PREFETCH_MAX_AGE_MS, now))
assertFalse(isFreshPlayablePrefetch(now - PLAYABLE_PREFETCH_MAX_AGE_MS - 1L, now))
assertFalse(isFreshPlayablePrefetch(now + 1L, now))
}
}
@@ -0,0 +1,55 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.MediaRating
import com.ponzischeme89.memby.data.model.displayable
import com.ponzischeme89.memby.data.model.formattedScore
import com.ponzischeme89.memby.data.model.ratingDisplayLimit
import com.ponzischeme89.memby.data.model.ratingsStripVisible
import com.ponzischeme89.memby.data.model.wordmark
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RatingsTest {
@Test fun `sources retain familiar compact presentation`() {
val imdb = MediaRating("imdb", "IMDb", "8.2", "/10")
val rt = MediaRating("tomatoes", "Rotten Tomatoes", "91", "%")
val letterboxd = MediaRating("letterboxd", "Letterboxd", "4.1", "/5")
assertEquals("IMDb", imdb.wordmark())
assertEquals("8.2", imdb.formattedScore())
assertEquals("RT", rt.wordmark())
assertEquals("91%", rt.formattedScore())
assertEquals("4.1", letterboxd.formattedScore())
}
@Test fun `missing invalid and duplicate scores are omitted`() {
val ratings = listOf(
MediaRating("imdb", "IMDb", "", "/10"),
MediaRating("tmdb", "TMDb", "0", "/10"),
MediaRating("trakt", "Trakt", "80", "%"),
MediaRating("trakt", "Trakt", "81", "%"),
)
assertEquals(listOf("80"), ratings.displayable().map(MediaRating::score))
}
@Test fun `visibility setting hides the whole strip`() {
val ratings = listOf(MediaRating("imdb", "IMDb", "8.2", "/10"))
assertTrue(ratingsStripVisible(true, ratings))
assertFalse(ratingsStripVisible(false, ratings))
assertFalse(ratingsStripVisible(true, emptyList()))
}
@Test fun `narrow layouts truncate progressively`() {
assertEquals(1, ratingDisplayLimit(100))
assertEquals(2, ratingDisplayLimit(180))
assertEquals(3, ratingDisplayLimit(300))
assertEquals(Int.MAX_VALUE, ratingDisplayLimit(500))
}
@Test fun `ratings preference belongs to each profile`() {
val profile = EmbyProfile("id", "server", "token", "user", "name", showRatingsStrip = false)
assertFalse(profile.showRatingsStrip)
assertTrue(profile.copy(id = "other", showRatingsStrip = true).showRatingsStrip)
}
}
@@ -0,0 +1,364 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.UserItemData
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* When Memby is willing to guess a finish date, and when it holds its tongue.
*
* The guards matter more than the arithmetic here: an estimate is a promise made to
* somebody about their own evening, so most of these tests are about the cases that must
* produce nothing at all rather than about the ones that produce a date.
*/
class SeriesPaceTest {
// A fixed "now" so every case reads as a calendar rather than as offsets: midday on
// Wednesday 5 August 2026, UTC, with the television in UTC too.
private val now = playedAtMillis("2026-08-05T12:00:00Z")!!
private val utc = 0
// ---------------------------------------------------------------------------
// Ordinary viewing
// ---------------------------------------------------------------------------
@Test
fun `an episode a night projects a night per remaining episode`() {
val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc)
assertNotNull(estimate)
assertEquals(5, estimate!!.remainingEpisodes)
assertEquals(1.0, estimate.episodesPerDay, 0.001)
// Five left at one a day: one tonight and the last four days from now.
assertEquals(4, estimate.daysAway)
assertEquals("Estimated finish: 9 August", seriesPaceLabel(estimate))
}
@Test
fun `a binge across days is measured as a binge`() {
// Three on Monday, three on Tuesday: three a day, not six.
val episodes = library(
watched = listOf(
"2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T21:00:00Z",
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
),
remaining = 6,
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(3.0, estimate.episodesPerDay, 0.001)
assertEquals(1, estimate.daysAway)
assertEquals("You'll likely finish tomorrow", seriesPaceLabel(estimate))
}
@Test
fun `a fast pace with little left finishes today`() {
val episodes = library(
watched = listOf(
"2026-08-03T19:00:00Z", "2026-08-03T20:00:00Z",
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z",
),
remaining = 2,
)
assertEquals("You'll likely finish today", seriesPaceLabel(estimateSeriesPace(episodes, now, utc)))
}
@Test
fun `a slow pace over a long tail is rounded to weeks rather than dated`() {
// One a week, forty left: naming a Tuesday nine months out would be a fiction.
val episodes = library(
watched = listOf("2026-07-15T20:00:00Z", "2026-07-22T20:00:00Z", "2026-07-29T20:00:00Z"),
remaining = 8,
)
val label = seriesPaceLabel(estimateSeriesPace(episodes, now, utc))
assertEquals("At your current pace: about 6 weeks remaining", label)
}
// ---------------------------------------------------------------------------
// Not enough to go on
// ---------------------------------------------------------------------------
@Test
fun `one episode is never a pace`() {
val episodes = library(watched = listOf("2026-08-04T20:00:00Z"), remaining = 6)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `the opening of a binge does not project one`() {
// Three episodes, one evening. There is no daily rate in a single sitting, and
// reading one off it would promise a finish this week.
val episodes = library(
watched = listOf(
"2026-08-04T19:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T21:00:00Z",
),
remaining = 20,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `two episodes on separate days are enough when they are close together`() {
val episodes = library(
watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z"),
remaining = 4,
)
val estimate = estimateSeriesPace(episodes, now, utc)
assertNotNull(estimate)
assertEquals(1.0, estimate!!.episodesPerDay, 0.001)
}
@Test
fun `two episodes a fortnight apart are not a rhythm`() {
val episodes = library(
watched = listOf("2026-07-24T20:00:00Z", "2026-08-04T20:00:00Z"),
remaining = 4,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `nothing watched at all says nothing`() {
assertNull(estimateSeriesPace(library(watched = emptyList(), remaining = 8), now, utc))
}
// ---------------------------------------------------------------------------
// Breaks and returns
// ---------------------------------------------------------------------------
@Test
fun `a long silence is not averaged into the current pace`() {
// A season watched last summer, then a return this week. Including the gap would
// put the finish years out; only the return counts.
val episodes = library(
watched = listOf(
"2025-08-01T20:00:00Z", "2025-08-02T20:00:00Z", "2025-08-03T20:00:00Z",
"2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z",
),
remaining = 4,
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(1.0, estimate.episodesPerDay, 0.001)
assertEquals(3, estimate.daysAway)
}
@Test
fun `a viewer who has not come back has no current pace`() {
val episodes = library(
watched = listOf(
"2026-05-01T20:00:00Z", "2026-05-02T20:00:00Z", "2026-05-03T20:00:00Z",
),
remaining = 6,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `speeding up moves the date in`() {
val steady = library(
watched = listOf("2026-08-01T20:00:00Z", "2026-08-02T20:00:00Z", "2026-08-03T20:00:00Z"),
remaining = 6,
)
val quickened = library(
watched = listOf(
"2026-08-03T18:00:00Z", "2026-08-03T20:00:00Z", "2026-08-03T22:00:00Z",
"2026-08-04T18:00:00Z", "2026-08-04T20:00:00Z", "2026-08-04T22:00:00Z",
),
remaining = 6,
)
val before = estimateSeriesPace(steady, now, utc)!!
val after = estimateSeriesPace(quickened, now, utc)!!
assertTrue(after.episodesPerDay > before.episodesPerDay)
assertTrue(after.daysAway < before.daysAway)
}
// ---------------------------------------------------------------------------
// Nothing worth saying
// ---------------------------------------------------------------------------
@Test
fun `a finished series has no estimate`() {
assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 0), now, utc))
}
@Test
fun `one episode left needs no date`() {
assertNull(estimateSeriesPace(nightly(watched = 4, remaining = 1), now, utc))
}
@Test
fun `a horizon beyond a year is not offered`() {
// An episode every couple of weeks against a very long show. The arithmetic is
// sound and the answer — some day in 2031 — is not one to put on a television.
val episodes = library(
watched = listOf("2026-07-08T20:00:00Z", "2026-07-21T20:00:00Z", "2026-08-03T20:00:00Z"),
remaining = 200,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `a label is null when the estimate is`() {
assertNull(seriesPaceLabel(null))
}
// ---------------------------------------------------------------------------
// Ongoing shows
// ---------------------------------------------------------------------------
@Test
fun `a show still in production is caught up with, not finished`() {
val estimate = estimateSeriesPace(nightly(watched = 4, remaining = 5), now, utc, ongoing = true)!!
assertTrue(estimate.catchUp)
assertEquals("You'll catch up around 9 August", seriesPaceLabel(estimate))
}
@Test
fun `catching up tomorrow is worded as catching up`() {
val episodes = library(
watched = listOf(
"2026-08-03T19:00:00Z", "2026-08-03T21:00:00Z",
"2026-08-04T19:00:00Z", "2026-08-04T21:00:00Z",
),
remaining = 4,
)
assertEquals(
"You'll likely catch up tomorrow",
seriesPaceLabel(estimateSeriesPace(episodes, now, utc, ongoing = true)),
)
}
// ---------------------------------------------------------------------------
// Awkward data
// ---------------------------------------------------------------------------
@Test
fun `specials count towards neither the pace nor what is left`() {
val episodes = nightly(watched = 4, remaining = 5) +
listOf(
episode(season = 0, number = 1, playedAt = "2026-08-04T23:00:00Z"),
episode(season = 0, number = 2, playedAt = null),
episode(season = 0, number = 3, playedAt = null),
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(5, estimate.remainingEpisodes)
assertEquals(1.0, estimate.episodesPerDay, 0.001)
}
@Test
fun `a library of nothing but specials is treated as the show`() {
val episodes = listOf(
episode(season = 0, number = 1, playedAt = "2026-08-03T20:00:00Z"),
episode(season = 0, number = 2, playedAt = "2026-08-04T20:00:00Z"),
episode(season = 0, number = 3, playedAt = null),
episode(season = 0, number = 4, playedAt = null),
)
assertNotNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `a part-watched episode is remaining and is not a completion`() {
val episodes = nightly(watched = 3, remaining = 3).map { item ->
if (item.indexNumber == 4) {
item.copy(userData = UserItemData(played = false, playbackPositionTicks = 5_000_000_000L))
} else {
item
}
}
val estimate = estimateSeriesPace(episodes, now, utc)!!
// Three unwatched, one of them the episode the viewer is part-way through, and a
// pace measured only from the three that were actually completed.
assertEquals(3, estimate.remainingEpisodes)
assertEquals(1.0, estimate.episodesPerDay, 0.001)
}
@Test
fun `out of order and unstamped history is tolerated`() {
// Emby's "never played" sentinel on one watched episode, and the list shuffled.
val episodes = listOf(
episode(1, 3, "2026-08-04T20:00:00Z"),
episode(1, 1, "2026-08-02T20:00:00Z"),
episode(1, 5, null),
episode(1, 2, "0001-01-01T00:00:00Z", played = true),
episode(1, 4, "2026-08-03T20:00:00Z"),
episode(1, 6, null),
episode(1, 7, null),
)
val estimate = estimateSeriesPace(episodes, now, utc)!!
assertEquals(3, estimate.remainingEpisodes)
// Three dated completions across three days.
assertEquals(1.0, estimate.episodesPerDay, 0.001)
}
@Test
fun `a timestamp from the future is a clock, not a habit`() {
val episodes = library(
watched = listOf("2026-08-03T20:00:00Z", "2026-08-04T20:00:00Z", "2027-01-01T20:00:00Z"),
remaining = 4,
)
assertNull(estimateSeriesPace(episodes, now, utc))
}
@Test
fun `an empty library says nothing`() {
assertNull(estimateSeriesPace(emptyList(), now, utc))
}
// ---------------------------------------------------------------------------
// The viewer's own midnight
// ---------------------------------------------------------------------------
@Test
fun `days are counted in the television's zone, not Greenwich`() {
// Two episodes at 13:00 UTC on consecutive days. In UTC+13 that is one o'clock the
// following morning, still two separate local days — but the *finish* is counted
// from the local today, which is already 6 August in Auckland.
val nz = 13 * 60 * 60 * 1000
val episodes = library(
watched = listOf("2026-08-03T13:00:00Z", "2026-08-04T13:00:00Z"),
remaining = 3,
)
val here = estimateSeriesPace(episodes, now, utc)!!
val there = estimateSeriesPace(episodes, now, nz)!!
assertEquals(here.finishEpochDay + 1, there.finishEpochDay)
}
@Test
fun `a date is written day first with the month named`() {
// 1 March 2028 — a leap year, which is where the calendar arithmetic goes wrong.
assertEquals("1 March", formatPaceDate(playedAtMillis("2028-03-01T00:00:00Z")!! / 86_400_000L))
assertEquals("29 February", formatPaceDate(playedAtMillis("2028-02-29T00:00:00Z")!! / 86_400_000L))
assertEquals("31 December", formatPaceDate(playedAtMillis("2026-12-31T00:00:00Z")!! / 86_400_000L))
}
// ---------------------------------------------------------------------------
/** [watched] episodes on consecutive nights ending yesterday, then [remaining] unwatched. */
private fun nightly(watched: Int, remaining: Int): List<BaseItem> {
val stamps = (0 until watched).map { index ->
val day = 4 - (watched - 1 - index)
"2026-08-%02dT20:00:00Z".format(day)
}
return library(stamps, remaining)
}
private fun library(watched: List<String>, remaining: Int): List<BaseItem> =
watched.mapIndexed { index, at -> episode(1, index + 1, at) } +
(0 until remaining).map { episode(1, watched.size + it + 1, null) }
private fun episode(
season: Int,
number: Int,
playedAt: String?,
played: Boolean = playedAt != null,
) = BaseItem(
id = "s${season}e$number",
name = "Episode $number",
type = "Episode",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played, lastPlayedDate = playedAt),
)
}
@@ -0,0 +1,217 @@
package com.ponzischeme89.memby.data
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.mutablePreferencesOf
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Mirrors the private constant, so a schema bump that forgets these tests fails loudly. */
private const val CURRENT_SETTINGS_SCHEMA_FOR_TEST = 2
class SettingsMigrationTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun `migration repairs profiles individually and retains malformed source`() {
val valid = EmbyProfile(
id = "https://emby::alex",
serverUrl = "https://emby",
token = "token",
userId = "alex",
username = "Alex",
)
val raw = "[${json.encodeToString(valid)},{\"id\":17}]"
val source = mutablePreferencesOf(stringPreferencesKey("profiles") to raw)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val repaired = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
)
assertEquals(listOf(valid), repaired)
assertEquals(raw, migrated[stringPreferencesKey("profiles_recovery_v0")])
assertEquals(
CURRENT_SETTINGS_SCHEMA_FOR_TEST,
migrated[intPreferencesKey("settings_schema_version")],
)
}
@Test
fun `migration recovers a complete profile from legacy flat settings`() {
val source = mutablePreferencesOf(
stringPreferencesKey("server_url") to "https://emby/",
stringPreferencesKey("token") to "token",
stringPreferencesKey("user_id") to "alex",
intPreferencesKey("for_you_minutes") to 45,
booleanPreferencesKey("has_opened_for_you") to true,
booleanPreferencesKey("show_ratings_strip") to false,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val profile = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
).single()
assertEquals("https://emby::alex", profile.id)
assertEquals("Memby user", profile.username)
assertEquals(45, profile.forYouMinutes)
assertTrue(profile.hasOpenedForYou)
assertFalse(profile.showRatingsStrip)
}
@Test
fun `migration accepts profiles written by a newer app version`() {
val raw = """[{"id":"server::user","serverUrl":"server","token":"token","userId":"user","username":"User","futureSetting":"kept-compatible"}]"""
val migrated = SettingsMigrationLogic.migrateToCurrent(
mutablePreferencesOf(stringPreferencesKey("profiles") to raw),
)
val profiles = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
)
assertEquals("user", profiles.single().userId)
assertEquals(null, migrated[stringPreferencesKey("profiles_recovery_v0")])
}
/**
* The regression this schema step exists for. Before settings moved to the server the
* three toggles were device-wide, so an existing install holds them in the flat keys
* and in no profile at all. Nothing looks wrong until the viewer switches profile
* `applyProfile` copies profile values into the flat keys, so all three would come
* back on, and the sync would then push that up as a deliberate choice.
*/
@Test
fun `device-wide toggles are carried into every stored profile`() {
val alex = EmbyProfile(
id = "https://emby::alex", serverUrl = "https://emby",
token = "token", userId = "alex", username = "Alex",
)
val sam = EmbyProfile(
id = "https://emby::sam", serverUrl = "https://emby",
token = "token", userId = "sam", username = "Sam",
)
// Written by the previous build: schema 1, and the profiles carry no toggles.
val stored = """[${json.encodeToString(alex)},${json.encodeToString(sam)}]"""
.replace(Regex(""","showTitleLogo":[a-z]+"""), "")
val source = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 1,
stringPreferencesKey("profiles") to stored,
booleanPreferencesKey("show_title_logo") to false,
booleanPreferencesKey("auto_play_next_episode") to false,
booleanPreferencesKey("show_ten_minute_reminder") to true,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val profiles = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
)
assertEquals(2, profiles.size)
profiles.forEach { profile ->
assertFalse("${profile.userId} lost the title logo choice", profile.showTitleLogo)
assertFalse("${profile.userId} lost the auto-play choice", profile.autoPlayNextEpisode)
assertTrue(profile.showTenMinuteReminder)
}
assertEquals(2, migrated[intPreferencesKey("settings_schema_version")])
}
/**
* The migration must run exactly once, and the schema version is the only thing making
* that true it cannot detect its own work, because the encoder omits default values
* and so a profile that chose `true` is byte-identical to one that never chose.
*/
@Test
fun `the toggle migration does not run a second time`() {
val profile = EmbyProfile(
id = "https://emby::alex", serverUrl = "https://emby",
token = "token", userId = "alex", username = "Alex",
)
val source = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 1,
stringPreferencesKey("profiles") to "[${json.encodeToString(profile)}]",
booleanPreferencesKey("show_title_logo") to false,
)
val once = SettingsMigrationLogic.migrateToCurrent(source)
// The viewer turns it back on afterwards; the device-wide key is stale from here
// on, and must never be applied again.
val afterEdit = once.toMutablePreferences().apply {
this[stringPreferencesKey("profiles")] =
"[${json.encodeToString(profile.copy(showTitleLogo = true))}]"
}
val twice = SettingsMigrationLogic.migrateToCurrent(afterEdit)
assertTrue(
json.decodeFromString<List<EmbyProfile>>(
twice[stringPreferencesKey("profiles")]!!,
).single().showTitleLogo,
)
}
/** Nothing chosen means nothing to preserve, and no whole-file rewrite to pay for. */
@Test
fun `an install with default toggles is left alone`() {
val profile = EmbyProfile(
id = "https://emby::alex", serverUrl = "https://emby",
token = "token", userId = "alex", username = "Alex",
)
val stored = "[${json.encodeToString(profile)}]"
val source = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 1,
stringPreferencesKey("profiles") to stored,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
assertEquals(stored, migrated[stringPreferencesKey("profiles")])
assertEquals(2, migrated[intPreferencesKey("settings_schema_version")])
}
/**
* A first-ever launch of the new build on an install that predates the profile list
* has to come out at the current schema in one pass, not stop halfway.
*/
@Test
fun `an unversioned install migrates all the way to the current schema`() {
val source = mutablePreferencesOf(
stringPreferencesKey("server_url") to "https://emby/",
stringPreferencesKey("token") to "token",
stringPreferencesKey("user_id") to "alex",
booleanPreferencesKey("auto_play_next_episode") to false,
)
val migrated = SettingsMigrationLogic.migrateToCurrent(source)
val profile = json.decodeFromString<List<EmbyProfile>>(
migrated[stringPreferencesKey("profiles")]!!,
).single()
assertEquals(CURRENT_SETTINGS_SCHEMA_FOR_TEST, migrated[intPreferencesKey("settings_schema_version")])
assertFalse(profile.autoPlayNextEpisode)
// Never synced, so the first sync pushes these values up rather than pulling
// defaults down over them.
assertEquals(0L, profile.preferencesRevision)
}
@Test
fun `migration is idempotent and does not alter a future schema`() {
val current = SettingsMigrationLogic.migrateToCurrent(mutablePreferencesOf())
assertEquals(current, SettingsMigrationLogic.migrateToCurrent(current))
val future = mutablePreferencesOf(
intPreferencesKey("settings_schema_version") to 99,
stringPreferencesKey("profiles") to "future-format",
)
val unchanged = SettingsMigrationLogic.migrateToCurrent(future)
assertNotNull(unchanged)
assertEquals("future-format", unchanged[stringPreferencesKey("profiles")])
assertEquals(99, unchanged[intPreferencesKey("settings_schema_version")])
}
}
@@ -0,0 +1,100 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The direct path's copy of the gateway's subtitle rule. It exists so the two agree the
* matching Go tests are in `server/internal/api/subtitles_test.go`, and the cases here are
* deliberately the same ones.
*/
class SubtitlePreferenceTest {
private fun track(
id: String,
language: String?,
default: Boolean = false,
forced: Boolean = false,
hearingImpaired: Boolean = false,
) = SubtitleCandidate(id, language, default, forced, hearingImpaired)
@Test
fun `folds the codes Emby writes onto one vocabulary`() {
assertEquals("it", normalizeSubtitleLanguage("ita"))
assertEquals("it", normalizeSubtitleLanguage(" ITA "))
assertEquals("fr", normalizeSubtitleLanguage("fra"))
assertEquals("fr", normalizeSubtitleLanguage("fre"))
assertEquals("pt", normalizeSubtitleLanguage("pt-BR"))
assertEquals("", normalizeSubtitleLanguage(null))
// An unrecognised code survives as itself, so an exact match still works.
assertEquals("klingon", normalizeSubtitleLanguage("Klingon"))
}
@Test
fun `subtitles turned off means nothing is selected`() {
val tracks = listOf(track("1", "eng", default = true))
assertNull(selectSubtitleId(tracks, enabled = false, language = "en"))
}
@Test
fun `a chosen language takes the full track, not the forced or SDH one`() {
val tracks = listOf(
track("1", "eng", default = true),
track("2", "ita", forced = true),
track("3", "ita", hearingImpaired = true),
track("4", "ita"),
)
assertEquals("4", selectSubtitleId(tracks, enabled = true, language = "it"))
}
@Test
fun `a language the title does not have falls back to forced only`() {
val withForced = listOf(
track("1", "eng", default = true),
track("2", "eng", forced = true),
)
assertEquals("2", selectSubtitleId(withForced, enabled = true, language = "it"))
// Nothing rather than English, which the viewer did not ask for.
val withoutForced = listOf(track("1", "eng", default = true))
assertNull(selectSubtitleId(withoutForced, enabled = true, language = "it"))
}
@Test
fun `no chosen language keeps the flag order an untouched install had`() {
val tracks = listOf(
track("1", "eng"),
track("2", "eng", default = true),
track("3", "eng", forced = true),
)
assertEquals("3", selectSubtitleId(tracks, enabled = true, SUBTITLE_LANGUAGE_AUTO))
assertEquals("2", selectSubtitleId(tracks.take(2), enabled = true, language = ""))
assertEquals("1", selectSubtitleId(tracks.take(1), enabled = true, SUBTITLE_LANGUAGE_AUTO))
assertNull(selectSubtitleId(emptyList(), enabled = true, SUBTITLE_LANGUAGE_AUTO))
}
@Test
fun `the choice rides the synced settings document`() {
val local = UserPreferences(subtitlesEnabled = false, subtitleLanguage = "it")
val encoded = local.encode()
assertEquals(false, encoded["subtitlesEnabled"].toString().toBoolean())
assertEquals(local, decodeUserPreferences(encoded))
}
@Test
fun `a gateway that omits the keys leaves this TV's choice alone`() {
// The whole point of the fallback: an older server growing the setting later must
// not reset somebody who has already chosen one.
val local = UserPreferences(subtitlesEnabled = false, subtitleLanguage = "it")
val decoded = decodeUserPreferences(buildJsonObject { put("showTitleLogo", true) }, local)
assertEquals(false, decoded.subtitlesEnabled)
assertEquals("it", decoded.subtitleLanguage)
}
}
@@ -0,0 +1,171 @@
package com.ponzischeme89.memby.data
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
private fun json(text: String): JsonObject = Json.parseToJsonElement(text) as JsonObject
class UserPreferencesTest {
/**
* The document has to survive the round trip unchanged, or two televisions could
* disagree about settings they had just agreed on and the disagreement would push
* itself back and forth forever, since a difference is what triggers a push.
*/
@Test
fun `encoding and decoding is a fixed point`() {
val original = UserPreferences(
homeSections = listOf("latest", "continue"),
homeCardDensity = "large",
homeArtworkStyle = "poster",
showHomeCardMetadata = false,
showRatingsStrip = false,
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "homicidal",
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
seekIntervalSeconds = 30,
forYouMinutes = 60,
homeRowOrder = listOf("recommended", "latest"),
homePinnedRows = listOf("continue"),
homeHiddenRows = listOf("studio-a24"),
)
assertEquals(original, decodeUserPreferences(original.encode()))
}
/**
* A key the server did not send must leave that setting alone. This is what lets the
* gateway grow a setting before every television in the house has the release that
* knows about it the alternative is a silent reset of anything not yet understood.
*/
@Test
fun `a missing key keeps the local value`() {
val local = UserPreferences(homeCardDensity = "compact", hideWatchedMovies = true)
val decoded = decodeUserPreferences(json("""{"showRatingsStrip":false}"""), local)
assertEquals("compact", decoded.homeCardDensity)
assertTrue(decoded.hideWatchedMovies)
assertEquals(false, decoded.showRatingsStrip)
}
/** A value of the wrong type is as good as absent, and must not become a reset. */
@Test
fun `a wrongly typed value keeps the local value`() {
val local = UserPreferences(showTitleLogo = false, forYouMinutes = 120)
val decoded = decodeUserPreferences(
json("""{"showTitleLogo":"false","forYouMinutes":"120"}"""),
local,
)
assertEquals(false, decoded.showTitleLogo)
assertEquals(120, decoded.forYouMinutes)
}
/**
* The skip interval is the one number both ends normalise. A value this build has no
* vocabulary for must become the default rather than reaching the player as a step
* size the gateway's catalogue is allowed to grow one before every TV understands it.
*/
@Test
fun `an unknown skip interval falls back to the default`() {
assertEquals(
DEFAULT_SEEK_INTERVAL_SECONDS,
decodeUserPreferences(json("""{"seekIntervalSeconds":45}""")).seekIntervalSeconds,
)
assertEquals(
30,
decodeUserPreferences(json("""{"seekIntervalSeconds":30}""")).seekIntervalSeconds,
)
assertEquals(
DEFAULT_SEEK_INTERVAL_SECONDS,
Settings(seekIntervalSeconds = 45).toUserPreferences().seekIntervalSeconds,
)
}
/** An empty row selection would be a launcher with nothing on it. */
@Test
fun `an empty section list falls back rather than emptying the launcher`() {
val local = UserPreferences(homeSections = listOf("favorites"))
val decoded = decodeUserPreferences(json("""{"homeSections":[]}"""), local)
assertEquals(listOf("favorites"), decoded.homeSections)
}
/** Row ids are stored newline-separated, so a blank entry would become a phantom row. */
@Test
fun `blank and duplicate row ids are dropped`() {
val decoded = decodeUserPreferences(
json("""{"homeRowOrder":["recommended"," ","recommended","latest",""]}"""),
)
assertEquals(listOf("recommended", "latest"), decoded.homeRowOrder)
}
/**
* The flat active-profile keys are what every screen renders from, so they are what a
* push must carry including the encoding used for the row lists.
*/
@Test
fun `settings project onto the document the server holds`() {
val settings = Settings(
homeSections = "continue,latest",
homeCardDensity = "compact",
homeArtworkStyle = "backdrop",
showHomeCardMetadata = false,
showRatingsStrip = false,
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = "recommended\nlatest",
homePinnedRows = "continue",
homeHiddenRows = "",
)
assertEquals(
UserPreferences(
homeSections = listOf("continue", "latest"),
homeCardDensity = "compact",
homeArtworkStyle = "backdrop",
showHomeCardMetadata = false,
showRatingsStrip = false,
hideWatchedMovies = true,
showTitleLogo = false,
welcomeQuoteStyle = "positive",
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
forYouMinutes = 30,
homeRowOrder = listOf("recommended", "latest"),
homePinnedRows = listOf("continue"),
homeHiddenRows = emptyList(),
),
settings.toUserPreferences(),
)
}
/**
* Nothing that identifies a *television* may reach the wire. Carrying the device name
* or the update token across would rename someone's other set, or hand a private
* credential to whichever server the document came from.
*/
@Test
fun `device identity never enters the document`() {
val encoded = Settings(
deviceName = "Living room",
deviceId = "device-1",
updateToken = "gitea-secret",
updateBaseUrl = "https://git.example",
token = "session-token",
ringColorHex = "FF0000",
rotationIntervalSeconds = 45,
).toUserPreferences().encode().toString()
listOf("Living room", "device-1", "gitea-secret", "git.example", "session-token")
.forEach { assertTrue("$it reached the wire", it !in encoded) }
}
}
@@ -0,0 +1,38 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.ui.detail.formatAirDate
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AirDateTest {
@Test
fun `an Emby premiere date reads as a day, month and year`() {
assertEquals("12 Mar 2024", formatAirDate("2024-03-12T00:00:00.0000000Z"))
assertEquals("1 Jan 2019", formatAirDate("2019-01-01T00:00:00Z"))
assertEquals("31 Dec 2026", formatAirDate("2026-12-31"))
}
/**
* Emby writes a premiere as midnight UTC. Reading the date half literally is what stops
* an episode broadcast on the 12th being listed as the 11th on every set west of
* Greenwich, so a timestamp late in the day must still yield its own date.
*/
@Test
fun `the date is taken literally, never shifted into the set's own zone`() {
assertEquals("12 Mar 2024", formatAirDate("2024-03-12T23:30:00Z"))
}
@Test
fun `anything unusable is no date rather than a wrong one`() {
assertNull(formatAirDate(null))
assertNull(formatAirDate(""))
assertNull(formatAirDate(" "))
assertNull(formatAirDate("2024"))
assertNull(formatAirDate("not a date at all"))
assertNull(formatAirDate("2024/03/12"))
assertNull(formatAirDate("2024-13-01T00:00:00Z"))
assertNull(formatAirDate("2024-03-32T00:00:00Z"))
assertNull(formatAirDate("20xx-03-12"))
}
}
@@ -0,0 +1,125 @@
package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringNoticeTest {
private fun card(
availability: String = "upcoming",
availabilityText: String = "Upcoming",
dayLabel: String = "Tomorrow",
airLabel: String = "Tomorrow: 9:00 PM",
source: String? = "sonarr",
seriesItemId: String? = "emby-42",
) = BaseItem(
id = "sonarr:7:42",
name = "Northbound",
type = "MembySonarrEpisode",
genres = listOf("Drama"),
productionYear = 2024,
membySource = source,
membyEpisodeTitle = "The Crossing",
membyEpisodeCode = "S02E04",
membyAirDayLabel = dayLabel,
membyAirLabel = airLabel,
membyAvailability = availability,
membyAvailabilityText = availabilityText,
membyPlayable = false,
membySeriesItemId = seriesItemId,
)
@Test
fun `notice names the episode and repeats the card's own wording`() {
val notice = requireNotNull(airingNoticeFor(card()))
assertEquals("AIRING TOMORROW", notice.label)
assertTrue(notice.headline.startsWith("S02E04"))
assertTrue(notice.headline.endsWith("The Crossing"))
assertTrue(notice.detail.contains("Tomorrow: 9:00 PM"))
assertTrue(notice.detail.contains("Upcoming"))
}
@Test
fun `an episode already on the server is not announced as airing`() {
val notice = requireNotNull(
airingNoticeFor(
card(
availability = "available",
availabilityText = "Added at 8:12 PM",
dayLabel = "Today",
airLabel = "Aired today at 8:00 PM",
),
),
)
assertEquals("NEW EPISODE READY", notice.label)
}
@Test
fun `an episode that has aired but not landed says so`() {
val notice = requireNotNull(
airingNoticeFor(
card(
availability = "awaiting",
availabilityText = "Awaiting download",
dayLabel = "Today",
airLabel = "Aired today at 8:00 PM",
),
),
)
assertEquals("AIRED TODAY", notice.label)
}
@Test
fun `an air time the gateway could not date is not given one`() {
val notice = requireNotNull(
airingNoticeFor(card(dayLabel = "Upcoming", airLabel = "Coming up")),
)
assertEquals("UPCOMING EPISODE", notice.label)
}
@Test
fun `only schedule cards carry a notice`() {
assertNull(airingNoticeFor(card(source = null)))
assertNull(airingNoticeFor(card(source = "radarr")))
assertNull(airingNoticeFor(BaseItem(id = "series", name = "Northbound", type = "Series")))
}
@Test
fun `a card with nothing to say produces no notice`() {
val silent = BaseItem(
id = "sonarr:7:42",
name = "Northbound",
type = "MembySonarrEpisode",
membySource = "sonarr",
)
assertNull(airingNoticeFor(silent))
}
@Test
fun `the stub opens the show, not the episode that was pressed`() {
val stub = requireNotNull(scheduleSeriesStub(card()))
assertEquals("emby-42", stub.id)
assertEquals("Northbound", stub.name)
assertTrue(stub.isSeries)
assertEquals(listOf("Drama"), stub.genres)
// The card's overview is the *episode's*; the page it opens is about the show.
assertNull(stub.overview)
}
@Test
fun `a show Emby has never imported opens nothing`() {
assertNull(scheduleSeriesStub(card(seriesItemId = null)))
assertNull(scheduleSeriesStub(card(seriesItemId = " ")))
assertNull(scheduleSeriesStub(BaseItem(id = "movie", name = "Film", type = "Movie")))
}
}
@@ -3,12 +3,49 @@ package com.ponzischeme89.memby.ui
import com.ponzischeme89.memby.data.HomeSnapshot
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AiringTodayTagsTest {
@Test
fun `continue watching promotes episodes from shows airing today`() {
val ended = BaseItem(
id = "ended", name = "Old episode", type = "Episode", seriesName = "Ended Rewatch",
)
val pausedMovie = BaseItem(id = "movie", name = "Paused Movie", type = "Movie")
val airing = BaseItem(
id = "airing", name = "New episode", type = "Episode", seriesName = "The Bear",
)
val home = HomeSnapshot(
continueWatching = listOf(ended, pausedMovie, airing),
rows = listOf(
HomeRow(
id = "continue", title = "Continue Watching",
items = listOf(ended, pausedMovie, airing),
),
HomeRow(
id = "sonarr-airing-today", title = "Shows airing today", kind = "schedule",
items = listOf(
BaseItem(
id = "sonarr:7:42", name = "The Bear", type = "MembySonarrEpisode",
membySource = "sonarr", membyAirDayLabel = "Today",
),
),
),
),
)
val ranked = home.withAiringTodayTags()
assertEquals(listOf("airing", "ended", "movie"), ranked.continueWatching.map { it.id })
assertEquals(listOf("airing", "ended", "movie"), ranked.rows.first().items.map { it.id })
assertTrue(ranked.continueWatching.first().membyAiringToday)
assertFalse(ranked.continueWatching[1].membyAiringToday)
}
@Test
fun `tags matching recommended series from today's TV schedule`() {
val home = HomeSnapshot(
@@ -174,8 +211,55 @@ class AiringTodayTagsTest {
}
@Test
fun `upcoming schedule status does not claim the show airs today`() {
assertEquals("UPCOMING", scheduleStatusBadgeLabel("upcoming"))
assertEquals("UPCOMING", scheduleStatusBadgeLabel(""))
fun `upcoming schedule status leaves the air day unobstructed`() {
assertEquals(null, scheduleStatusBadgeLabel("upcoming"))
assertEquals(null, scheduleStatusBadgeLabel(""))
assertEquals("DOWNLOADING", scheduleStatusBadgeLabel("downloading"))
}
@Test
fun `a schedule card wears the lifecycle the gateway worded`() {
fun card(lifecycle: String?, text: String?) = BaseItem(
id = "sonarr:1:2", name = "Show", type = "MembySonarrEpisode",
membySource = "sonarr", membyLifecycle = lifecycle, membyLifecycleText = text,
)
assertEquals("CONTINUING", lifecycleBadgeLabel(card("continuing", "CONTINUING")))
assertEquals("IN CINEMAS", lifecycleBadgeLabel(card("incinemas", "IN CINEMAS")))
// A status this build predates still reads correctly, because the wording is the
// server's rather than derived from the slug.
assertEquals("PILOT ORDERED", lifecycleBadgeLabel(card("pilot", "Pilot ordered")))
// An older gateway, or a show *arr has no status for: no tag rather than a blank one.
assertEquals(null, lifecycleBadgeLabel(card(null, null)))
assertEquals(null, lifecycleBadgeLabel(card("continuing", " ")))
}
@Test
fun `a cancelled show outranks everything else on its My Shows card`() {
val cancelled = MyShow(
itemId = "1", title = "Ended Show", lifecycle = "Cancelled",
sonarrStatus = "Monitored", nextEpisode = "2026-08-09T20:00:00Z",
)
// Red, and the same word Sonarr uses, is worth more than a next-episode date on a
// show that will not have one.
assertEquals("ended" to "CANCELLED", myShowBadge(cancelled))
}
@Test
fun `a continuing show says so`() {
val continuing = MyShow(
itemId = "1", title = "Severance", lifecycle = "Continuing", sonarrStatus = "Monitored",
)
assertEquals("continuing" to "CONTINUING", myShowBadge(continuing))
val dated = continuing.copy(nextEpisode = "2026-08-09T20:00:00Z")
assertEquals("upcoming" to "UPCOMING", myShowBadge(dated))
val unmonitored = continuing.copy(sonarrStatus = "Not monitored")
assertEquals("unmonitored" to "UNMONITORED", myShowBadge(unmonitored))
val unknown = continuing.copy(lifecycle = "Unknown", sonarrStatus = "Not found")
assertEquals(null, myShowBadge(unknown))
}
}
@@ -22,6 +22,7 @@ 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.AiringNotice
import com.ponzischeme89.memby.ui.detail.creditRows
import com.ponzischeme89.memby.ui.detail.technicalSpecs
import org.junit.Before
@@ -33,7 +34,7 @@ import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders the movie and series detail pages to PNGs under `build/screenshots/`.
* Renders the movie and series detail pages to PNGs under `build/screenshots/detail-page/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*DetailPageScreenshotTest"
@@ -108,6 +109,32 @@ class DetailPageScreenshotTest {
}
}
/**
* Opened from the "Shows airing in the next 5 days" row. The band takes the accent line
* the recommendation reason holds on every other route into this page compare against
* `df_detail-series`, which is the same show reached any other way.
*/
@Test
fun `series page opened from the airing row`() {
capture("df_detail-series-airing") {
SeriesDetailContent(
item = series,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
isMyShow = false,
onToggleMyShow = { _, _ -> },
related = related,
airingNotice = AiringNotice(
label = "AIRING TOMORROW",
headline = "S02E04 • The Crossing",
detail = "Tomorrow: 9:00 PM • Awaiting download",
),
)
}
}
/** The first frame, before the episode request lands. The header must already be whole. */
@Test
fun `series page loading`() {
@@ -215,14 +242,14 @@ class DetailPageScreenshotTest {
) { pane() }
}
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
private fun captureTab(name: String, tab: String, content: @Composable () -> Unit) {
@@ -230,7 +257,7 @@ class DetailPageScreenshotTest {
PreviewSurface(alignment = Alignment.TopStart) { content() }
}
compose.onNodeWithText(tab).performClick()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/detail-page/$name.png")
}
/**
@@ -0,0 +1,99 @@
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
import com.ponzischeme89.memby.data.EmbyOutage
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 Emby outage bar to PNGs under `build/screenshots/emby-outage-banner/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*EmbyOutageBannerScreenshotTest"
* ```
*
* Drawn over the same playback still the alert bar uses, because that is the situation the
* red bar is really for: the film has stalled and this is the only thing on screen that
* explains why. Seeing the two against the same frame is also how their two reds and their
* shared rule-and-fade stay a deliberate pair rather than a coincidence.
*
* [OutageBanner] is rendered rather than [EmbyOutageBanner]: the wrapper's job is the
* slide-in, and the clock is passed in so the countdown is a chosen value instead of
* whatever the test machine's uptime happened to be.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class EmbyOutageBannerScreenshotTest {
@get:Rule
val compose = createComposeRule()
/** The state the bar spends nearly all of an outage in. */
@Test
fun `waiting for the next attempt`() {
capture("outage-banner-waiting", nextAttemptInMillis = 42_000L)
}
/** Freshly armed: the longest number the countdown ever shows. */
@Test
fun `full interval remaining`() {
capture("outage-banner-full-interval", nextAttemptInMillis = 60_000L)
}
/** The moment the attempt is due, which is a different string entirely. */
@Test
fun `retrying now`() {
capture("outage-banner-retrying", nextAttemptInMillis = 0L)
}
/** Single digits, where the label is at its narrowest and the layout can shift. */
@Test
fun `last seconds`() {
capture("outage-banner-last-seconds", nextAttemptInMillis = 1_000L)
}
private fun capture(name: String, nextAttemptInMillis: Long) {
compose.setContent {
OutageBannerOnPlaybackStill(
EmbyOutage(
nextAttemptAtMillis = nextAttemptInMillis,
retryIntervalSeconds = 60,
),
)
}
compose.onRoot().captureRoboImage("build/screenshots/emby-outage-banner/$name.png")
}
@Composable
private fun OutageBannerOnPlaybackStill(outage: EmbyOutage) {
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(),
)
// A fixed clock, so each capture shows the countdown value it is named for.
OutageBanner(outage, nowMillis = { 0L })
}
}
}
@@ -0,0 +1,256 @@
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.foundation.lazy.LazyListState
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.onNodeWithTag
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.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 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 an episode's own detail page to PNGs under `build/screenshots/episode-detail/`.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*EpisodeDetailScreenshotTest"
* ```
*
* The viewer here is part-way through season 3 of a four-season show, which is the state
* the season scroller exists for: seasons 1 and 2 dimmed and ticked, 3 selected and
* flagged, 4 still ahead. As in [DetailPageScreenshotTest] there is no network, so every
* artwork URL resolves to null and the page renders on its own scrims the worst case and
* the one worth looking at.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class EpisodeDetailScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Before
fun locator() {
ServiceLocator.init(ApplicationProvider.getApplicationContext())
}
/** The opening frame: series logo, season and episode number, episode title. */
@Test
fun `episode page`() {
capture("df_episode-detail") { EpisodePage() }
}
/** Part-watched, which is how an episode reaches this page from Continue Watching. */
@Test
fun `episode page resuming`() {
capture("df_episode-detail-resuming") {
EpisodePage(item = current.copy(userData = UserItemData(playbackPositionTicks = 21L * TICKS_PER_MINUTE)))
}
}
/** The first frame, before the series' episode list lands. The hero must be whole. */
@Test
fun `episode page loading`() {
capture("df_episode-detail-loading") { EpisodePage(episodes = null) }
}
/** Season 1: behind the viewer, so its stop is ticked and its episodes all watched. */
@Test
fun `episode page browsing an earlier season`() {
captureSeason("df_episode-detail-earlier-season", season = 1) { EpisodePage() }
}
/** Season 4: still ahead, and nothing in it is dimmed. */
@Test
fun `episode page browsing a later season`() {
captureSeason("df_episode-detail-later-season", season = 4) { EpisodePage() }
}
/** Specials, which sort first and are the one stop that is not "Season n". */
@Test
fun `episode page browsing specials`() {
captureSeason("df_episode-detail-specials", season = 0) {
EpisodePage(episodes = library + episode(0, 1, "The Making of Signal Hill"))
}
}
/**
* The pane at the geometry the scaffold gives it the band the D-pad reaches by
* pressing Down twice, and the only place the episode this page is about is flagged.
*/
@Test
fun `episode list pane at its real slot geometry`() {
capturePane("df_episode-detail-pane") {
EpisodeSeasonPane(
episodes = library,
seasonEpisodes = library.filter { it.parentIndexNumber == 3 },
currentEpisodeId = current.id,
loadFailed = false,
firstEpisodeFocusRequester = FocusRequester(),
emptyFocusRequester = FocusRequester(),
aboveEpisodes = FocusRequester.Default,
// Where the page opens the list: on the episode it is about, not at the
// top of the season.
listState = LazyListState(firstVisibleItemIndex = 3),
onPlay = {},
)
}
}
/** A season the household is done with: every row ticked, none of them flagged. */
@Test
fun `finished season pane`() {
capturePane("df_episode-detail-pane-watched") {
EpisodeSeasonPane(
episodes = library,
seasonEpisodes = library.filter { it.parentIndexNumber == 1 },
currentEpisodeId = current.id,
loadFailed = false,
firstEpisodeFocusRequester = FocusRequester(),
emptyFocusRequester = FocusRequester(),
aboveEpisodes = FocusRequester.Default,
listState = LazyListState(),
onPlay = {},
)
}
}
@Composable
private fun EpisodePage(
item: BaseItem = current,
episodes: List<BaseItem>? = library,
) {
EpisodeDetailContent(
item = item,
episodes = episodes,
loadFailed = false,
onPlay = {},
onToggleFavorite = { _, _ -> },
onTogglePlayed = { _, _ -> },
)
}
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("$DIR/$name.png")
}
private fun capture(name: String, content: @Composable () -> Unit) {
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { content() } }
compose.onRoot().captureRoboImage("$DIR/$name.png")
}
private fun captureSeason(name: String, season: Int, content: @Composable () -> Unit) {
compose.setContent { PreviewSurface(alignment = Alignment.TopStart) { content() } }
compose.onNodeWithTag("season-stop-$season").performClick()
compose.onRoot().captureRoboImage("$DIR/$name.png")
}
private val cast = listOf(
person("Aria Vance", "Detective Iris Kell"),
person("Marcus Oyelaran", "Samuel Reed"),
person("Nina Kowalczyk", "Dr. Halvorsen"),
)
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"),
MediaStream(type = "Subtitle", codec = "subrip", language = "eng"),
)
private val current = episode(
season = 3,
number = 4,
title = "The Long Count",
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. Iris takes the " +
"tape to the only person who was on the hill that winter.",
).copy(
productionYear = 2024,
officialRating = "TV-MA",
communityRating = 8.7,
genres = listOf("Thriller", "Drama"),
studios = listOf(Studio(name = "Harbour Line")),
mediaStreams = streams,
people = cast,
)
private val seasonOneTitles = listOf(
"Carrier Wave", "Dead Air", "Nightingale", "Six Weeks Out", "The Hill", "Landfall",
)
private val seasonThreeTitles = listOf(
"Shortwave", "Ground Truth", "The Operator", "The Long Count",
"Blackout", "Tape Nine", "What the Log Says", "Signal Hill",
)
private val library = buildList {
(1..6).forEach { add(episode(1, it, seasonOneTitles[it - 1], played = true)) }
(1..8).forEach { add(episode(2, it, "Season Two, Part $it", played = it <= 6)) }
(1..8).forEach { add(episode(3, it, seasonThreeTitles[it - 1], played = it < 4)) }
(1..6).forEach { add(episode(4, it, "Season Four, Part $it")) }
}
private fun episode(
season: Int,
number: Int,
title: String,
played: Boolean = false,
overview: String = "A coastal radio station keeps receiving a broadcast that has " +
"not been transmitted yet, and the night operator has started writing it down.",
) = BaseItem(
id = "s${season}e$number",
name = title,
type = "Episode",
seriesId = "series-1",
seriesName = "Signal Hill",
parentIndexNumber = season,
indexNumber = number,
runTimeTicks = 48L * TICKS_PER_MINUTE,
overview = overview,
userData = UserItemData(played = played),
)
private fun person(name: String, role: String) = EmbyPerson(
id = name.filter(Char::isLetter),
name = name,
role = role,
type = "Actor",
)
private companion object {
const val DIR = "build/screenshots/episode-detail"
const val TICKS_PER_MINUTE = 600_000_000L
}
}
@@ -0,0 +1,159 @@
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.SeasonProgress
import com.ponzischeme89.memby.ui.detail.episodeAfter
import com.ponzischeme89.memby.ui.detail.episodeEyebrow
import com.ponzischeme89.memby.ui.detail.seasonMarkers
import com.ponzischeme89.memby.ui.detail.seasonProgressLabel
import com.ponzischeme89.memby.ui.detail.seriesProgressLabel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* What an episode's own detail page says, in plain JUnit.
*
* The season scroller's whole job is to be right about where the household is up to, so the
* marking rules are pinned here rather than left to a screenshot to notice.
*/
class EpisodeDetailTest {
@Test
fun `eyebrow spells the season and episode out`() {
assertEquals("SEASON 3 · EPISODE 4", episodeEyebrow(episode(3, 4)))
}
@Test
fun `eyebrow names specials rather than season zero`() {
assertEquals("SPECIALS · EPISODE 1", episodeEyebrow(episode(0, 1)))
}
@Test
fun `an unnumbered episode has no eyebrow to show`() {
assertNull(episodeEyebrow(BaseItem(id = "x", name = "Pilot", type = "Episode")))
}
@Test
fun `seasons behind the one being watched are marked watched`() {
val markers = seasonMarkers(library, currentSeason = 3)
assertEquals(
listOf(SeasonProgress.WATCHED, SeasonProgress.WATCHED, SeasonProgress.CURRENT, SeasonProgress.UPCOMING),
markers.map { it.progress },
)
}
/** Skipping an episode of season one does not put the viewer back in season one. */
@Test
fun `an earlier season with unwatched episodes is still behind the viewer`() {
val markers = seasonMarkers(library, currentSeason = 3)
val secondSeason = markers.single { it.season == 2 }
assertEquals(SeasonProgress.WATCHED, secondSeason.progress)
assertEquals(1, secondSeason.watchedEpisodes)
assertEquals(2, secondSeason.totalEpisodes)
}
/** A season ahead that has been sampled must not read as finished. */
@Test
fun `later seasons stay upcoming`() {
val markers = seasonMarkers(library, currentSeason = 1)
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 4 }.progress)
}
@Test
fun `a fully watched season is marked watched without a current season`() {
val markers = seasonMarkers(library, currentSeason = null)
assertEquals(SeasonProgress.WATCHED, markers.single { it.season == 1 }.progress)
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 2 }.progress)
}
@Test
fun `season progress reads as a count`() {
val markers = seasonMarkers(library, currentSeason = 3)
assertEquals("All 2 watched", seasonProgressLabel(markers.single { it.season == 1 }))
assertEquals("1 of 2 watched", seasonProgressLabel(markers.single { it.season == 2 }))
assertEquals("2 episodes", seasonProgressLabel(markers.single { it.season == 4 }))
}
@Test
fun `the series line places the season and counts what is left`() {
val markers = seasonMarkers(library, currentSeason = 3)
assertEquals("Season 3 of 4 • 4 episodes left", seriesProgressLabel(markers, 3))
}
@Test
fun `a finished show reports nothing left`() {
val watched = library.map { it.copy(userData = UserItemData(played = true)) }
val markers = seasonMarkers(watched, currentSeason = 4)
assertEquals("Season 4 of 4", seriesProgressLabel(markers, 4))
}
/** A special nobody has watched must not be ticked just for sorting first. */
@Test
fun `specials are not watched merely by being before the current season`() {
val withSpecials = library + episode(0, 1)
val markers = seasonMarkers(withSpecials, currentSeason = 3)
assertEquals(SeasonProgress.UPCOMING, markers.single { it.season == 0 }.progress)
assertEquals("1 episode", seasonProgressLabel(markers.single { it.season == 0 }))
}
@Test
fun `a watched special is still marked watched`() {
val markers = seasonMarkers(library + episode(0, 1, played = true), currentSeason = 3)
assertEquals(SeasonProgress.WATCHED, markers.single { it.season == 0 }.progress)
assertEquals("Watched", seasonProgressLabel(markers.single { it.season == 0 }))
}
/** Specials are not a season, and counting them displaces every numbered one. */
@Test
fun `the series line counts numbered seasons only`() {
val markers = seasonMarkers(library + episode(0, 1), currentSeason = 3)
assertEquals("Season 3 of 4 • 5 episodes left", seriesProgressLabel(markers, 3))
}
@Test
fun `no seasons means no progress line`() {
assertNull(seriesProgressLabel(emptyList(), 1))
}
/** Position decides what is next, so a season boundary is not a stopping point. */
@Test
fun `the next episode crosses into the following season`() {
val last = library.single { it.parentIndexNumber == 1 && it.indexNumber == 2 }
assertEquals("s2e1", episodeAfter(library, last)?.id)
}
@Test
fun `the last episode of the show has nothing after it`() {
val last = library.single { it.parentIndexNumber == 4 && it.indexNumber == 2 }
assertNull(episodeAfter(library, last))
}
@Test
fun `an episode the library does not hold resolves to nothing`() {
assertNull(episodeAfter(library, episode(9, 9)))
}
private val library = listOf(
episode(1, 1, played = true),
episode(1, 2, played = true),
episode(2, 1, played = true),
episode(2, 2),
episode(3, 1, played = true),
episode(3, 2),
episode(4, 1),
episode(4, 2),
)
private fun episode(season: Int, number: Int, played: Boolean = false) = BaseItem(
id = "s${season}e$number",
name = "Episode $number",
type = "Episode",
seriesId = "series-1",
seriesName = "Signal Hill",
parentIndexNumber = season,
indexNumber = number,
userData = UserItemData(played = played),
)
}
@@ -151,7 +151,7 @@ class HomeMovieHeroScreenshotTest {
}
compose.onNodeWithText("Play").fetchSemanticsNode()
compose.onRoot().captureRoboImage("build/screenshots/$name.png")
compose.onRoot().captureRoboImage("build/screenshots/home-movie-hero/$name.png")
}
private val movies = listOf(
@@ -1,6 +1,8 @@
package com.ponzischeme89.memby.ui
import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -8,11 +10,10 @@ 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))
fun `home hero follows the vertical scroll position`() {
assertTrue(shouldShowHomeMovieHero(hasMovies = true, listAtTop = true))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = true, listAtTop = false))
assertEquals(false, shouldShowHomeMovieHero(hasMovies = false, listAtTop = true))
}
@Test
@@ -65,6 +66,120 @@ class HomeMovieHeroTest {
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
}
// --- Daily variants ----------------------------------------------------------
@Test
fun `each day leads with a different new release`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
assertEquals("new-1", selectHomeHeroMovies(rows, day = 0).first().item.id)
assertEquals("new-2", selectHomeHeroMovies(rows, day = 1).first().item.id)
assertEquals("new-3", selectHomeHeroMovies(rows, day = 2).first().item.id)
}
/** The pool is finite, so it has to come round rather than run out. */
@Test
fun `the rotation wraps once the pool is exhausted`() {
val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))
assertEquals(
selectHomeHeroMovies(rows, day = 0).map { it.item.id },
selectHomeHeroMovies(rows, day = 3).map { it.item.id },
)
}
/**
* The launcher rebuilds constantly every home refresh, every focus change. Asking
* twice within a day has to give the same four cards or the hero would churn under a
* viewer who was only walking past.
*/
@Test
fun `the same day always picks the same cards`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
assertEquals(
selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
selectHomeHeroMovies(rows, day = 19_000).map { it.item.id },
)
}
/** A clock that has not been set yet reports an instant before the epoch. */
@Test
fun `a negative day still fills the hero`() {
val rows = listOf(row("latest-movies", "Recently Added", "new-1", "new-2", "new-3"))
assertEquals(3, selectHomeHeroMovies(rows, day = -1).size)
assertEquals("new-3", selectHomeHeroMovies(rows, day = -1).first().item.id)
}
@Test
fun `rotation never drops or duplicates a title`() {
val rows = listOf(
row("latest-movies", "Recently Added Movies", "new-1", "new-2", "new-3"),
row("popular", "Popular Movies", "popular-1", "popular-2"),
)
(0L..7L).forEach { day ->
val picked = selectHomeHeroMovies(rows, day).map { it.item.id }
assertEquals("day $day", picked.size, picked.distinct().size)
}
}
// --- Midnight ----------------------------------------------------------------
/** Local, not UTC: the day must turn over at the viewer's midnight. */
@Test
fun `the day is counted in the viewer's own time zone`() {
val newYearUtc = 1_735_689_600_000L // 2025-01-01T00:00:00Z
// An hour ahead: already the 1st. An hour behind: still New Year's Eve.
assertEquals(
localEpochDay(newYearUtc, HOUR_MS.toInt()) - 1,
localEpochDay(newYearUtc, -HOUR_MS.toInt()),
)
}
@Test
fun `midnight is a whole day away from itself, never zero`() {
val midnightUtc = 1_735_689_600_000L
assertEquals(DAY_MS, millisUntilNextLocalDay(midnightUtc, 0))
}
@Test
fun `the wait shortens as the evening goes on`() {
val midnightUtc = 1_735_689_600_000L
assertEquals(DAY_MS - HOUR_MS, millisUntilNextLocalDay(midnightUtc + HOUR_MS, 0))
assertEquals(HOUR_MS, millisUntilNextLocalDay(midnightUtc + 23 * HOUR_MS, 0))
}
/** Waiting the reported time must always land on the following day, in any zone. */
@Test
fun `waiting the reported time advances the day exactly once`() {
val offsets = listOf(0, HOUR_MS.toInt(), -5 * HOUR_MS.toInt(), 13 * HOUR_MS.toInt())
val instants = listOf(0L, 1_735_689_600_000L, 1_735_689_600_000L + 37L, -86_400_001L)
offsets.forEach { offset ->
instants.forEach { now ->
val before = localEpochDay(now, offset)
val after = localEpochDay(now + millisUntilNextLocalDay(now, offset), offset)
assertEquals("offset=$offset now=$now", before + 1, after)
}
}
}
private companion object {
const val HOUR_MS = 60L * 60L * 1000L
const val DAY_MS = 24L * HOUR_MS
}
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
id = id,
title = title,
@@ -8,20 +8,6 @@ import org.junit.Assert.assertTrue
import org.junit.Test
class HomeUiStateTest {
@Test
fun combinedWatchingRowPreservesOrderAndRemovesDuplicates() {
val resumable = BaseItem(id = "resume", name = "Resume")
val duplicate = BaseItem(id = "same", name = "Resume copy")
val next = BaseItem(id = "next", name = "Next")
val state = HomeUiState(
continueWatching = listOf(resumable, duplicate),
nextUp = listOf(duplicate.copy(name = "Next copy"), next),
)
assertEquals(listOf("resume", "same", "next"), state.watchingAndNextUp.map { it.id })
}
@Test
fun cachedContentIsShownWhileOnlyMissingRowsLoad() {
val cache = HomeCache(
@@ -32,8 +18,27 @@ class HomeUiStateTest {
val state = HomeUiState.from(cache)
assertFalse(HomeSection.CONTINUE in state.loading)
assertTrue(HomeSection.NEXT_UP in state.loading)
assertFalse(HomeSection.FAVORITES in state.loading)
assertTrue(HomeSection.LATEST in state.loading)
}
/**
* A cache written by the build that still had two rows must not lose its Next Up
* episodes on the first launch after the update that cache is what the launcher
* draws before any network response arrives.
*/
@Test
fun cachedNextUpItemsFoldIntoContinueWatching() {
val cache = HomeCache(
continueWatching = listOf(BaseItem(id = "resume"), BaseItem(id = "same")),
nextUp = listOf(BaseItem(id = "same", name = "Next copy"), BaseItem(id = "next")),
)
val state = HomeUiState.from(cache)
assertEquals(
listOf("resume", "same", "next"),
state.continueWatching.map(BaseItem::id),
)
}
}

Some files were not shown because too many files have changed in this diff Show More