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
+19
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
|
||||
@@ -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 couldn’t 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 couldn’t 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 Memby’s 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 Memby’s 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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user