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:
co-authored by
Claude Opus 5
parent
2675e6d82b
commit
4a4df7a73c
@@ -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 20–200 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.
|
||||
|
||||
Reference in New Issue
Block a user