This commit is contained in:
ponzischeme89
2026-08-24 09:16:19 +12:00
parent 30155e1c5f
commit 067395a362
38 changed files with 104 additions and 60756 deletions
+6 -8
View File
@@ -16,10 +16,9 @@ Read it first. This file carries the rules that are easiest to break without not
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,
This covers on-screen copy in Kotlin and `app/src/main/res/values/strings.xml`, 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.
@@ -48,7 +47,7 @@ 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
app/src/main server/internal dist
```
Expect hits — most are the exemptions above. Read each one and ask whether a viewer or an
@@ -56,6 +55,5 @@ operator ever sees it.
## Version bumps
Every version bump is a release change: update the version and changelog together, then
commit and push the complete release to GitHub. Do not leave a bumped version only in the
local working tree.
Every version bump is a release change: commit and push the complete release to GitHub. Do
not leave a bumped version only in the local working tree.
+4 -24
View File
@@ -145,9 +145,8 @@ Android identifies an app by
applicationId **plus** signing key, so a changed key forces every user to uninstall and
reinstall.
Every version bump is also a publish operation: update the version and changelog together,
commit the complete release, and push it to GitHub. Never leave a bumped version only in the
local working tree.
Every version bump is also a publish operation: commit the complete release and push it to
GitHub. Never leave a bumped version only in the local working tree.
For direct TV deployment without publishing a release, `deploy-tv.ps1` builds and verifies
the signed release, connects over wireless ADB, installs it with `-r`, and launches the
@@ -159,23 +158,6 @@ 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.
**An update is acknowledged once with a toast.** `ui/whatsnew/whatsNewDecision` compares the
running build with `Settings.whatsNewSeenVersion`, and `AppRoot` briefly says “Memby has been
updated to version …” over the launcher before recording it. The record is device state,
deliberately not a synced preference: what is new is a property of the APK on *this* set.
Two quiet cases matter: a **fresh install** has not updated from anything, so setup records
the current build without announcing it; and a signed-out set with an older record waits
until somebody signs in, because the notice belongs over the launcher. The changelog is no
longer part of this decision — release history remains available in Settings → About, while
local or unreleased builds still receive the same one-time update acknowledgement.
**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
@@ -2636,8 +2618,7 @@ logo. Things to preserve:
`-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
in Kotlin and `res/values/strings.xml`, 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.
@@ -2942,8 +2923,7 @@ seen"), not feature names.
**Up out of the top of a page returns to the page list.** Nothing sits above a pane's first
control, so that press did nothing at all on every page — and a remote that stops responding
is not read as a list that has run out. About is where it was reported, its pane being a
changelog long enough that walking back up it is the ordinary way to leave. The escape is an
is not read as a list that has run out. The escape is an
`onKeyEvent` on the content column that makes the move the default handler would have made
and falls back to the rail only when it fails, so a page's own vertical navigation is
untouched: `focusProperties { up = … }` is inherited by every row and would take that
-70
View File
@@ -1,70 +0,0 @@
# Home performance baseline
This is the repeatable performance check for Home. The source baseline is the current
good-performing release line at commit `5467fba` (0.2.84). Numbers are only comparable on
the same television, Android build, server, network and signed-in profile, so benchmark
results belong with the test run or release notes rather than as universal targets here.
## What is measured
- `coldStartToHomeNoCompilation`: cold launch without ahead-of-time compilation, used as
the control for the baseline profile.
- `coldStartToHomeWithProfile`: cold launch as a release is shipped. `timeToFullDisplayMs`
ends when Home reports its first D-pad-ready, settled frame.
- `warmStartToHomeWithProfile`: returning to a resident process.
- `homeDpadAndRows`: frame timing during rapid travel across cards, down four rows, and back
through already displayed artwork. The route deliberately does not idle between presses.
- Debug milestones in `MembyStartup`: process start to `home_visible`,
`first_row_visible`, and `home_interactive`.
- Debug frame windows in `EmbyClientPerf`: jank percentage, p50/p95/p99 UI frame time,
frames over 16 ms and 33 ms, and the maximum frame time.
Compose Layout Inspector supplies card and row recomposition counts. Capture a system trace
at the same time when a frame regression needs attribution; the trace shows Compose work,
Coil fetch/decode activity and main-thread scheduling together.
## Run protocol
Use a physical TV with a signed-in profile and a populated Home cache. Keep the TV's display
mode, server, network and Home row preferences unchanged between the reference and candidate
runs.
```powershell
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
.\gradlew.bat :benchmark:connectedBenchmarkReleaseAndroidTest `
-P android.testInstrumentationRunnerArguments.class=com.ponzischeme89.memby.benchmark.HomeBenchmark
```
For an interactive debug capture, clear logcat, cold-start Memby, wait for Home, then rapidly
hold Right and move through at least four rows. Record:
```powershell
adb logcat -s MembyStartup EmbyClientPerf
```
In Layout Inspector, enable recomposition counts and repeat the same route. Unchanged cards
outside the old and new focus targets should normally be skipped; focus animation should not
cause per-frame card recomposition. In Android Studio's system trace, inspect the slowest
frames for main-thread Compose work and Coil image fetch/decode slices.
## Regression gates
Use the median of at least five iterations. Investigate before release when, against a fresh
reference run on the same setup:
- profiled cold-start `timeToFullDisplayMs` or warm start regresses by more than 10%;
- `frameDurationCpuMs` p95 regresses by more than 10%, or p99 crosses 33 ms;
- a previously displayed card causes a network fetch or another decode on the return pass;
- focus animation recomposes a card on every animation frame or invalidates unaffected cards;
- adding a Home row materially changes first-row visibility time. Below-the-fold rows may
affect `home_interactive`, but must not delay `first_row_visible`.
Regenerate the baseline profile after changing the startup or first-browse path:
```powershell
.\gradlew.bat :app:generateReleaseBaselineProfile
```
Keep the reference and candidate benchmark result directories together. Hardware variance
makes the paired result the baseline; a number copied from a different TV is not evidence of
a regression or an improvement.
-135
View File
@@ -1,135 +0,0 @@
# Performance: the Wholphin review, and what came of it
An engineering note on how Memby's performance work compares with
[Wholphin](https://github.com/damontecres/Wholphin), which techniques were taken from it,
and what the licence requires. It is a record of decisions, not a tutorial — the reasoning
behind each individual mechanism lives beside the code in `CLAUDE.md`.
## Licensing position
Memby is GPL-2.0. Wholphin is GPL-2.0. Adapting Wholphin source into Memby is therefore
permitted outright, provided the combined work remains under GPLv2 and attribution is
preserved. Both obligations are met in `NOTICE`, which names Wholphin, names the Memby
files adapted from it, records that Memby's modifications are its own, and notes that
Wholphin's own profiling code descends from Jellyfin Android TV.
Two practical rules were followed:
- **No verbatim copying.** Every technique below was reimplemented against Memby's own
architecture. That is a deliberate choice rather than a licence requirement — Memby's
repository is dual-path (gateway and direct-to-Emby) and its player is a single
`PlayerActivity` with a pre-roll, credits pane and trailer chain, so ported code would
have needed more reshaping than rewriting.
- **Attribution attaches to techniques, not only to code.** `PlayerEngine.kt` and
`TrickplayPreview.kt` carry in-file attribution because their *design* is Wholphin's even
though the source is not. Anything reproduced independently is marked below so the
distinction stays legible.
## Comparison
Wholphin's speed comes from a small number of decisions rather than from micro-optimisation,
and Memby had already absorbed most of them before this review:
| Technique | Wholphin | Memby |
| --- | --- | --- |
| One shared OkHttp connection pool across API, artwork and media | yes | yes — `data/remote/HttpStack.kt` |
| Media bytes pulled through the app's own HTTP client | yes | yes — `PlayerEngine.streamClient`, adapted |
| Constant-bitrate seeking for containers with no usable seek table | yes | yes — `PlayerEngine.extractorsFactory`, adapted |
| Seek-preview thumbnails from a server-side index | tile sheets | BIF, independently implemented — `data/Trickplay.kt` |
| Cached home rendered before the network answers | yes | yes — `HomeCache`, per profile |
| Server-side artwork resizing rather than full-resolution downloads | yes | yes — `maxWidth`/`quality` on both paths |
| Request de-duplication and single-flighting | yes | yes — repository-wide, including focus prefetches |
| Speculative resolution while a card is focused | partial | yes — two-wave warm, metadata then detail page |
| Pre-opened connection to the media host | n/a (same host as API) | **new, see below** |
The last row is the one real divergence, and it is architectural rather than a matter of
discipline. In Wholphin the API and the media stream are the same Jellyfin host, so the
connection is already warm by the time anybody presses Play. In Memby's gateway mode they
are two different machines: everything except video goes to the gateway, and video
direct-plays from Emby. Wholphin gets that warm connection for free; Memby had to be told
to open one.
## Bottleneck identified
Memby's own measurements (Chromecast with Google TV, NAS gateway, `MembyPlayback` trace)
had already established the shape of a resume: `prepare()` → first frame is over 90% of it,
stream negotiation ~120 ms, app startup ~365 ms. Within that, the largest single term was
cold versus warm connection to Emby — **the same file at the same seek position took
4177 ms on the first playback of a session and 2004 ms on the second.** The first playback
pays DNS, the TCP handshake, the TLS handshake and Emby's file open, none of which the
second pays.
Two things that look like causes and are not: subtitle auto-selection costs 20200 ms, and
the seek itself about 900 ms.
## What was implemented
**`data/remote/StreamWarmer.kt`** — opens a connection to the machine video comes from
before any video is requested. It works only because of the shared pool: a connection opened
by an unrelated HEAD request is the one ExoPlayer picks up later, so nothing is handed over
but the address.
The design constraints that shaped it are recorded in `CLAUDE.md`; in brief, it warms the
*host* and never the title (warming the film's first byte would put a delivery for a title
nobody watched into somebody's server history, which is the same objection that stops this
app warming `PlaybackInfo` on focus); it remembers the address across process restarts, since
on the gateway path a resolved stream URL is the only thing that ever names Emby and the
slowest playback was always the first one after a cold start; it is warmed both at
application start and when card focus settles on a playable item; and every failure is
silent, because it is a hint whose worst case is the behaviour that existed before it.
This is reproduced independently. Wholphin has no equivalent — it does not have the problem.
**`performance/StartupTrace.kt`** — the launch and navigation timings the playback trace did
not cover: `home_visible`, `first_row_visible`, `home_interactive` as cumulative launch
milestones, and `detail_visible` as a repeatable span. Debug-only, and every entry point
returns before allocating on a release build. Milestones are recorded from `LaunchedEffect`s
rather than composable bodies, so measuring the launcher cannot itself be a reason the
launcher recomposes.
**`PlaybackTrace.AUDIO_START`** — recorded from a media3 `AnalyticsListener`, which is the
only interface reporting the moment the audio position genuinely begins advancing. It is
worth having beside `first_frame` rather than inferred from it: a bitstreamed surround track
waits on the receiver to lock to the format, and a track with no hardware decoder falls back
to software. Either leaves a picture running silently for a moment, which a viewer reads as
broken rather than slow, and which no video-side mark would show.
## What was reviewed and deliberately left alone
Most of the brief's checklist was already satisfied, and saying so is more useful than
changing working code:
- **Artwork** is already resized server-side (`maxWidth` and `quality` on both the gateway
proxy and direct Emby paths), cached in a memory cache sized at 25% and a 128 MB disk
cache, decoded to hardware bitmaps, and fetched over the shared pool with crossfade off.
- **Home startup** already renders `HomeCache` before the network answers, decodes that
cache off the main thread, skips unchanged writes, and refreshes sections in parallel.
- **Compose** is already disciplined in the ways that matter here: `HomeViewModel` exposes
three `distinctUntilChanged` projections rather than one state object, animated values are
read only in draw and layout lambdas, and lists deduplicate keys rather than folding in an
index.
- **Request cancellation and de-duplication** are already repository-wide, with focus
prefetches single-flighted on the repository's own scope precisely so a cancelled prefetch
does not abort the request a press is about to want.
- **Returning from playback** already restores tab, season, grid offset and focused band via
`ui/detail/DetailPosition.kt`, and deliberately re-requests the item because the episode
just watched is now watched.
No obsolete workaround was found that the new work makes redundant; the connection warm adds
a capability rather than replacing a compensation.
## Measuring it
```
adb logcat -s MembyStartup MembyPlayback
```
`MembyStartup` prints launch milestones as `stage=cumulative(+delta)` and detail spans as
`detail_visible=Nms`. `MembyPlayback` prints one `event=first_frame` line per launch carrying
the full stage breakdown.
The connection warm is a network effect and cannot be measured by a unit test. The honest
comparison is first-playback-of-a-session against second, on one television, server and
title — the pairing the original 4177/2004 ms figure came from. `:benchmark`'s
`PlaybackBenchmark` measures the same span off trace sections when a signed-in television is
available; it needs a real Emby, which is why it is excluded from ordinary CI.
-468
View File
@@ -1,468 +0,0 @@
# Viewers
**A Memby account is the household's relationship with an Emby user. A viewer is one
person under it.** Every account has exactly one **main** viewer, whose state is Emby's,
and any number of **shadow** viewers, whose state is Memby's alone. A shadow viewer
watches through the account's Emby credentials and Emby never learns what they watched.
```
Emby user
└── Memby account (Living Room)
├── Matt main → watched state, progress and favourites go to Emby
├── Alessandra shadow → state lives in Memby
└── Guest shadow → state lives in Memby
```
A viewer does **not** belong to a television. Somebody starts an episode in the lounge and
finishes it in the bedroom, because the state is the gateway's.
## What was verified before any of this was written
**Every write of Emby viewer state passes through four methods** on `emby.Client`:
`ReportPlayback`, `SetFavorite`, `SetPlayed` and `HideFromResume`. All four are called
only from `api` handlers holding a `store.Session`. Nothing else writes viewer state, so
keeping a shadow viewer out of Emby is four branches rather than a sweep of the codebase.
That is the property this feature is built on, and it is the one to re-check before
adding any new call into `emby.Client`.
**Two things still reach Emby, and both are accepted deliberately.** `PlaybackInfo` is
sent with `IsPlayback: true`, which registers a play session — so shadow playback appears
in Emby's Now Playing and activity log as the account. And `tracearr` identifies viewers
by *Emby username* (`tracearr/client.go`), so a shadow viewer's watching feeds the
account's Tracearr history, watch-time figures and genre affinity. Neither touches watched
state or resume position, which is what the feature promises. Both are v2 problems.
**There is no second implementation on the direct path.** On the direct-to-Emby path the
television reports straight to Emby itself, so there is nothing in between to route a
viewer's state to — the stance the TV calendar and genre affinity already take. Viewers
are gateway-only, and a set with no gateway has one viewer, which is the account.
## The seam: a viewer id, not a second user
Before this, `sess.EmbyUserID` did two unrelated jobs — it was the Emby credential *and*
Memby's key for "who is watching". Viewers separate them:
- **The credential stays the account's.** `credentials(sess)` is unchanged, and every
library read, image, stream URL and subtitle still goes out as the Emby user. A shadow
viewer has no Emby identity and needs none.
- **The person is `viewer.ID`.** Preferences, notifications, followed shows, row
statistics, search history, recommendation profiles and cache keys belong to whoever is
watching.
**The main viewer's id *is* the Emby user id**, which is the whole reason this needed no
migration. Every table in the schema keys a person by a bare `emby_user_id TEXT` with no
foreign key behind it, so substituting a viewer id leaves an existing household's rows
exactly where they were, and the main viewer behaves as the account did by construction
rather than by care. A shadow id is `"v"` plus 32 hex characters, so it can be told apart
from Emby's 32-hex GUIDs by inspection in a log line or a cache key
(`store.IsShadowViewerID`).
`Viewer.Kind` is still stored and is what every branch reads. The id shape is a safety
property, not a source of truth.
## How a request learns who is watching
`X-Memby-Viewer`, resolved by `s.activeViewer` (`api/viewers.go`).
It is a header rather than part of the session because switching between two people on one
television must not be a re-authentication, and because the same person is one viewer on
every set in the house. The session still answers "which account is this and what may it
read".
Things to preserve:
- **It is stated, never inferred** — the stance `Credentials.Gateway` takes. An app that
predates viewers sends nothing and resolves to the main viewer, which is exactly its
previous behaviour.
- **Every unknown case falls back to the main viewer**, including a list that will not
load. This is on the path of every authenticated request, and a television unable to do
anything because a header could not be checked is a far worse failure than one request
attributed to the account. The one thing it will not do is accept an id it could not
confirm belongs to this account.
- **An unknown id is logged, not refused.** The ordinary cause is a set still holding a
viewer somebody has since deleted, and a 403 on every request would leave it unable to
reach the picker that would fix it.
- **The list is cached for `viewerListTTL`** and the write clears it, because `/v1/status`
alone is every open television every ten seconds — the bargain `featurePolicyCache`
already makes. A failed refresh keeps the previous reading rather than dropping to the
main viewer, which would silently move a shadow viewer's playback back onto Emby.
## The common state layer
`viewerUserData` renders a viewer's state in the shape of Emby's `UserData` block, and the
television never learns which it received. That is what keeps viewers from becoming a
special case in every screen: a card draws its progress bar, tick and heart from
`UserData`, and where the block came from is not a question anything above that line asks.
The client reads exactly five fields (`UserItemData` in `EmbyModels.kt`), and
`viewers_test.go` pins them. Two are omitted rather than sent empty — a title with no
runtime has no percentage, and one never played has no date — because a card claiming a
zero-length progress bar is worse than one claiming nothing.
## Where state is written
`viewer_playback_state`, keyed `(viewer_id, item_id)`. The Emby item id is the common
identifier, so no library metadata is duplicated and nothing here needs invalidating when
the catalogue changes. **There is deliberately no row for a main viewer**: their state is
Emby's, and a copy here would be a second answer free to disagree with what the
household's other Emby clients see.
- **A completed title is stored at position zero**, the way Emby stores one. The position
is what Continue Watching reads, and a finished episode left at its last frame is one the
row keeps offering to resume four seconds from the end.
- **Only a stop can complete a title.** A progress report crossing the threshold is
somebody still watching the closing minutes, and marking it played there would take the
episode out of Continue Watching underneath them.
- **`PlayedFromPosition` is the completion rule and it matches Emby's own** (90%), so a
household cannot disagree with itself about whether an episode is finished depending on
who watched it. A runtime of zero means the length was not known rather than that the
title is zero long, so it can never complete anything.
- **`play_count` moves only on the transition into played**, so the ten-second reports
either side of the threshold cannot count one viewing several times.
- **The series is read out of `library_items` inside the upsert**, not asked of Emby and
not carried by the television. It is already there, it is what will order this viewer's
Continue Watching, and a report arrives every ten seconds.
- **Hiding keeps the position.** Hiding is a statement about the row, not about where
somebody got to, and pressing Play again should still resume.
## What a viewer reads
The write side is four branches. The read side is the larger half, and it is deliberately
**one substitution rather than a second code path**: the fan-out, the failure counting and
the Continue Watching merge in `handleHome` are identical for both kinds of viewer, and
only the fetch behind three rows changes.
**Everything is keyed on `viewerKeyOf(ctx, sess)`.** The viewer is resolved once, in
`authed`, and carried in the request context (`withViewer` / `viewerOf`). That is the
point: the alternative is calling `activeViewer` at each of the fifteen sites that build a
cache key, and the failure of forgetting one is silent — that view is keyed under the
account, and one viewer is served another's rows. Resolving at the boundary makes
forgetting impossible. A request that never passed through `authed` — a scheduled task, a
probe, a test — resolves to the account, which is the value every one of these keys held
before viewers existed.
**Cache keys that moved to the viewer**: home, search, genre and library browse, item
detail, series episodes, person filmography, related, the active hero. **Keys that
deliberately did not**: genre affinity, trailers, the screensaver, recommendations and the
Magic pool. Those answer for the household or are computed from the account's Tracearr
history, and keying them per viewer would multiply the misses without changing the answer.
They are the v2 list.
**`decorateItems` is the one door items leave the gateway through.** It attaches the
stored review scores and, for a shadow viewer, replaces the `UserData` block. It rides
exactly where `decorateItemRatings` rode — one indexed read for a whole launcher, at every
point items are served. The two remained separate functions because they are separate
concerns, but every call site wanted both, and *a decoration added at seven sites is a
decoration missing from the eighth*.
Things to preserve in `decorateViewerState`:
- **A main viewer returns immediately.** Their state is Emby's and is already on the
payload, so a household running no viewers pays one comparison for the whole launcher.
- **Every item is rewritten, not only the ones with something stored.** The `UserData`
that arrived from Emby is the *account's*, and leaving it on a title this viewer has
never touched is precisely the leak the feature exists to prevent. A title with no row
gets the zero state, which is the truth about it. A failed state read blanks everything
for the same reason: a launcher with no progress bars is a poor answer, one showing
somebody else's is a wrong one.
- **The block is replaced, never merged.** A partial overlay leaves whichever fields Memby
had nothing to say about carrying the account's values — the same leak from a narrower
angle.
- **A series and a season are answered from a count**, not from a row of their own. Emby
fills their block in from children it has never heard of for this person, so
`ViewerContainerStates` counts the episodes the shared catalogue holds against this
viewer's own played set and `viewerAggregateUserData` renders the result. This was the
last place a shadow viewer was shown the account's answer — a series ticked because
somebody else had finished it.
## What a series card says
`UnplayedItemCount` and `PlayedPercentage` are **omitted rather than sent as zero** when the
catalogue cannot count the title, the rule the leaf block follows: a library not yet
imported, and a show the import has never seen, would otherwise tick every series in the
house. `Played` is only true where there is something to have finished, for the same reason.
The **favourite is the container's own** and comes from the row against the series id, not
from the count: somebody marks a *show* a favourite, not the sum of its episodes. And a
container is never resumable — what resumes is an episode — so `PlaybackPositionTicks` is
zero, which is what Emby reports too.
The query groups by the **season/series pair** and the two rollups are done in Go
(`addViewerAggregate`). That is deliberately dull — no grouping sets, no second pass — and
it is exact for both answers because a season belongs to exactly one series. `containerIDsIn`
asks for a season's series alongside it, which is what keeps a partial series total from
belonging to anything drawn. `library_items_series_episodes_idx` is what makes it affordable;
without it, a series card costs a scan of every episode in the library, and it is also what
`ViewerNextUp` walks.
## The three rows that are about a person
`resume`, `favourites` and `nextup` are substituted in the home fan-out. The ranking is
Postgres's and Emby is asked only to *describe* the titles — which is why `orderItemsByID`
exists: Emby answers an `Ids=` query in its own order, and for these rows the order **is**
the answer. Handing Emby's order back would keep the right titles and throw away the reason
they were chosen. It also drops a title Emby will not answer for (a card that cannot be
opened is worse than a missing one) and collapses a repeated id, because every keyed list
on the television throws on a duplicate.
**Next Up is computed entirely in Postgres** (`store.ViewerNextUp`), out of the shared
catalogue and this viewer's own state. Emby's `NextUp` answers for the account and there is
nobody else to ask — and the alternative, walking each series' episode list over the wire,
is one Emby request per show on the tail of the launcher. It applies the same three rules
Emby's own answer does: an episode already resumable is left out (it is in Continue
Watching, and the merge would offer the show twice), specials are not next episodes, and a
series with nothing unwatched left contributes no row rather than an empty one.
**The merge is unchanged.** `ViewerWatchedSeries` returns exactly the
`map[string]time.Time` that `recentlyPlayedSeries` does, so `mergeContinueWatching` never
learns which viewer it is ordering for.
## Where a shadow viewer's playback starts
Taken from the store in `handlePlayback`, not trusted from the card. The hint the
television sends is read off a card this gateway already decorated with the viewer's own
state, so the two normally agree — but only normally: the store has heard about the episode
they were part-way through on the *other* television, and a card is only as fresh as the
last home refresh. It is also the value handed to `PlaybackInfo`, so taking it here fixes
the negotiated stream as well as the number sent back. A failed read starts from the
beginning, because that is a recoverable disappointment where starting from where somebody
else got to is not.
**Pressing Play on a series** resolves through `firstUnwatchedEpisodeFor` rather than
Emby's `NextUp`, which is what would otherwise drop a shadow viewer into the middle of
somebody else's season. A part-watched episode wins over the first unwatched one — somebody
eleven minutes in wants that episode, the same judgement the Continue Watching merge makes.
A viewer with nothing recorded for the series falls through to Emby's first episode, which
is the right answer for somebody who has never watched any of it.
**Auto-advance** keeps Emby's answer for *which* episode follows — that is a property of
the season and is the same for everybody — and replaces only how far into it this viewer
already is.
## Signing out is about the account
`invalidateAccountViews` drops every viewer's cached views, not only the account's.
Invalidating one key would leave each shadow viewer's rows behind, to be served intact to
the next person who signs in on that set. It is best-effort: what it misses expires on its
own TTL, and nothing there is worth failing a sign-out over.
## The television
**`X-Memby-Viewer` is sent by `GatewayAuthInterceptor`**, read on each request rather than
captured — the bargain the audio capability tokens already make. Switching between two
people must not rebuild the HTTP client, and the answer can change between any two
requests. The header is **omitted rather than sent empty** when nobody has been chosen,
because that is precisely what an app predating viewers sends and what the gateway reads as
the account's own viewer. The read is guarded, like the audio tokens, because the
interceptor also runs before the service locator exists in a screenshot context.
`viewers_v1` joins `MEMBY_CAPABILITIES`, so an operator cannot switch the feature on for a
household half of whose televisions have no way of choosing between people.
**The active viewer is device state**, and it is the one thing about viewers that is: a
viewer follows the person to every set in the house, but which of them is sitting in front
of *this* one is that set's own answer — the lounge and the bedroom are commonly two
different people at the same moment. `activeViewerId` and `activeViewerName` are written in
**one** edit (DataStore rewrites the whole file per edit, and a name landing apart from the
id it labels would leave the launcher greeting one person while every request named
another), and both are cleared with the session and on any account switch: the people under
one Emby account are not the people under another.
**The home cache is keyed per viewer**, `home_cache::<userId>@<serverUrl>#<viewerId>`. A
cold start draws the cache before the first refresh lands, so without this a shadow viewer
would open on the account's evening. **The account's own viewer keys exactly as it always
did, with no suffix**, so every existing install keeps the cache it already has. Removing a
profile now clears *every* viewer's key for it (`profileHomeCacheKeys`), or the largest
values this store holds would be orphaned.
`switchViewer` clears the playable, series-episode, local-resume and genre-affinity caches —
the same set a profile switch clears, for the same reason: they hold one person's watched
state, resume positions and reasons. It deliberately does **not** clear the persisted home
cache, which is keyed per viewer, so switching back is instant.
**The gateway's `active` field is adopted.** `viewers()` compares what the server resolved
the request to against what this set is sending, and takes the server's answer when the two
disagree and the id is not in the list. The case it exists for is a viewer deleted on
another television: this set is still sending an id nothing recognises, the gateway has
quietly fallen back, and without adopting that the picker would go on showing somebody who
no longer exists as selected.
## The picker
`ui/viewers/ViewerPicker.kt` — "Who's watching?", a full screen rather than another row in
the user switcher. That panel lists *accounts* and the actions beside them; a viewer is a
different grain of thing, and a household picks a person the way they pick one on any
television service, by looking at a row of faces. Folding them into the same 292dp column
would have made two unrelated questions look like one list.
Stateless, the stance `SignInContent` and the detail panes take, so
`ViewerPickerScreenshotTest` renders it with no gateway → `build/screenshots/viewers/`.
Things to preserve:
- **Focus opens on whoever is watching**, not on the first card: a television is switched on
by the person who last used it far more often than not, so the common case is one confirm
press rather than a walk along the row.
- **The scroll keeps one card of context behind the focused one**
(`viewerPickerScrollIndex`). Scrolling straight to the focused card pins it against the
left edge and the people before it vanish with nothing saying they are there — the capture
is what caught it. Focus and scroll are separate functions because they answer different
questions: where the remote is, and what the eye can see.
- **"Synced with Emby" is a reserved line, not a conditional one**, or a card without it
sits taller than the one beside it — the rule the cast grid's character line follows. It
is most of the difference between a household understanding this feature and being puzzled
by it.
- **The scale is read only inside `graphicsLayer`**, so travelling the row redraws two cards
rather than recomposing every card in it.
- **A blank active id means the account's own viewer**, because that is what the header's
absence means to the gateway. Writing the main viewer's id instead would work on the wire
and would be worse in one way: an app never told the account's Emby user id could not then
express "nobody in particular", which is the state every existing install starts in.
- **The Add control is removed at the limit, never dimmed** — the stance the two optional
transport controls take.
- `MAX_SHADOW_VIEWERS` mirrors `store.MaxShadowViewers`; the gateway is what enforces it,
and this copy only decides whether to offer a button whose one possible outcome would be a
refusal.
## The way in
A row in `UserSwitcherOverlay`, above Notifications, because it changes *whose* menu that
is — the notifications and requests below belong to whichever viewer it selects.
`viewerMenuLabel` names the person once somebody other than the account is watching
("Watching as Alessandra") and asks the question otherwise: a household running no viewers
must not have a badge appear over its launcher explaining a feature it is not using. The
panel's own subtitle becomes "Choose an account" when the row is present, since the list
above it is no longer the thing that answers "who is watching".
`shouldOfferViewerPicker` gates it on two conditions and both matter: there is nobody to ask
on the direct path, and an account nobody has added a viewer to would be offered a question
with one answer, which reads as a fault rather than as a feature waiting to be used.
**The switcher's rows are now a list** (`userSwitcherMenuItems`) rather than four
hand-written offsets (`profiles.size + 1`, `if (showRequests) 2 else 1`,
`actionCount - 1`). A count that disagrees with the rows actually drawn is how the last item
in a menu becomes unreachable, and a fifth conditional row is exactly the change that breaks
it — the shape `QuickAction` already moved to for the same reason. The D-pad test now runs
over every combination of optional rows.
## Built
- `viewers` and `viewer_playback_state` in `store/schema.sql`, with the main viewer created
on demand so an account predating the feature resolves on its first request.
- `store/viewers.go`, `store/viewer_playback.go` — the viewer list, the state layer,
Continue Watching, Next Up, favourites and the series-recency ordering.
- `api/viewers.go` — header resolution, the cached list, the context plumbing,
`viewerUserData`, `invalidateAccountViews`, and the four CRUD routes.
- `api/viewer_state_attach.go`, `api/viewer_rows.go` — the read side.
- **The four mutations are gated**, and For You is marked dirty only for the main viewer.
- Every log line names the viewer when it is somebody other than the account.
- **The television**: the header and its capability, the active viewer as device state, the
per-viewer home cache, `EmbyRepository`'s viewer operations, the picker, the row in the
user menu that opens it, and the name-entry and manage screens behind it.
- **The operator's switch**, and the Viewers card on the console's account page.
- **Series and season aggregates**, so a shadow viewer no longer sees the account's progress
anywhere.
## Naming somebody, from the television
`ui/viewers/ViewerNameEntry.kt` reuses the **search keyboard** rather than growing a second
one — the note `TvKeyboard` already carried, written before there was a second caller to
prove it. Two on-screen keyboards is two focus contracts to keep in step, and where the
letters are is the one thing a viewer must never have to relearn. Things to preserve:
- **The keyboard is the child that gives way, never the buttons.** A `Column` hands each
child what the ones before it left over, so the confirm row — being last — was measured
from the remainder and drew as two squeezed slivers with their labels pressed out. The
keyboard takes `weight(1f, fill = false)`, and weighted children are measured from what
the unweighted ones leave: the same inversion the home hero makes for its Play chip, and
it broke here in exactly the same way. `viewers-name-empty.png` is the capture it is
answerable to.
- **The rules are pure and refuse early** (`ViewerEditing.kt`). A blank name is refused
because the gateway refuses it. A **repeated** name is refused by the app alone — the
gateway is happy to hold two people called Sam — because the picker is a row of faces with
a name under each, and two identical names is a choice nobody in the household can make.
A rename skips the person being renamed, so correcting somebody's capitalisation is not
refused as a duplicate of themselves.
- **The limit is enforced by refusing the keypress**, not by rejecting the save. A remote
types one character at a time, and a limit that only announces itself at the end is one
somebody discovers after typing a sentence.
- **A refusal is not drawn in the accent.** Every affirmative thing on a Memby screen is
green, and a refusal wearing the confirmation colour reads at a glance as the name having
been accepted. The message line is *reserved* rather than conditional, or it would push
the keyboard down by a line at the moment somebody is typing into it.
- **A local rule and a server refusal are separate parameters.** One is true before anything
is sent and the other is what came back; showing them in one slot would leave a stale
refusal sitting under a name that has since been corrected. A refused save keeps what was
typed — retyping a name somebody has just entered is the worst possible answer to a
request that failed for a reason the television does not know.
`ViewerManageScreen` is the list beside it. It is a *list* where the picker is a row of
faces, because the two answer different questions — "who is watching" is a glance and one
press, "who is here" is read a line at a time and acted on per person. A row holds **two
focus targets** rather than opening a menu, the shape the alerts page settled on: a remote
has one confirm key, and a press that opened a list of actions would make renaming somebody
three presses deep for nothing. The **main viewer has neither** and is still listed, because
a list of the people here that omitted the one whose watching actually reaches Emby would be
the more confusing of the two. Removing somebody is a full-stop question following
`ExitConfirmation`'s rules — the two answers do not look alike, the safe one takes focus
first, and **Back means keep**.
Both open **over** the picker rather than instead of it, the arrangement the add-a-user
sign-in already takes over the manage-users page: Back is one step out of each and the row
of faces is still underneath, with nothing to restore because nothing was unmounted.
`ViewerActionButton` is the one button all three screens are built from — there were nearly
three of it, the same shape at the same size in the same green written on three different
days.
## The operator's switch
`viewers` in `featureCatalogue`, capability `viewers_v1`. It is read in **`activeViewer`**
and nowhere else: that is the one place a request learns who is watching, so with it off
every branch downstream — the gated writes, the substituted rows, the per-viewer cache keys
— falls back to the account by construction rather than by fifteen separate checks.
**Off is not a deletion.** A viewer's rows stay in Postgres untouched and come back intact;
what stops is the gateway routing anybody's watching anywhere but Emby, which is the state a
household was in before this existed. `/v1/viewers` answers with `mainViewerOnly`, a
*shortened list* rather than an error or an empty one, because the television decides whether
to offer the picker by counting what it was sent — so a switched-off household looks like one
that never used the feature rather than like one whose picker has broken. The four mutations
answer 403 with a sentence naming the reason, since a television has no log and no support
channel.
**Default off**, the stance the genre browser takes. This is the switch that decides where a
household's watched state is written, and a feature arriving already on is one every server
running the build starts using before anybody has decided to — so it is opted into rather
than out of. An account with no shadow viewers behaves identically either way, which is what
makes switching it on a safe thing to try rather than a migration.
## The operator's copy
`/admin/accounts/{userID}` carries a Viewers card (`admin_viewers.go`,
`admin-ui/src/pages/Account.tsx`). It is on the **account page** rather than a rail entry of
its own because a viewer only exists under an account, and a top-level page would open by
asking which account — the question the page an operator reached this from has answered. It
is the arrangement the per-account preference editor and the device list already take.
The televisions can do all of this themselves now, so this is the operator's copy rather than
the only way in: what it is for is a household asking for help, and the case a remote cannot
reach — a viewer created on a set that has since been unplugged. Two things to preserve: the
write clears the cached list (`forgetViewers`) for the same reason the client-facing route
does, and the card **states** that the feature is switched off rather than quietly offering
controls whose effect nothing on any television would show.
The list is its own request rather than a field on `/admin/api/accounts`: that response is the
whole household and this is a list per person, so folding it in would make every accounts poll
read one table per account for a page showing one of them.
## Not built yet, in the order it should be
1. **The personalisation layer** keyed on the viewer: recommendations, the Magic pool, genre
affinity, journeys, notifications and watch time all still answer for the account.
2. **PINs**, and the Tracearr attribution problem.
## Untested
`ViewerContainerStates` is the one piece of this with no test behind its SQL: the store's
tests are pure and there is no Postgres in the build, so the query has been read but not run.
The Go either side of it — the rollup and the block it renders — is pinned. It is the first
thing to exercise against a real database.
+7 -30
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { matchPath, NavLink, Outlet, useLocation } from 'react-router-dom';
import { NavLink, Outlet, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
import { OmniSearch } from './OmniSearch';
import { NotificationBell } from './NotificationBell';
@@ -16,13 +16,12 @@ import { Confirm } from './ui';
function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const { unread } = useNotifications();
const location = useLocation();
// A group holding the current page is open regardless of what was collapsed last time:
// a rail that hides the page you are on is a rail that has lost its place.
// Every category is visible. Children can be folded independently and the choice is
// remembered between visits.
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(() => {
try {
return JSON.parse(localStorage.getItem('memby-admin-nav') ?? '{}') as Record<string, boolean>;
return JSON.parse(localStorage.getItem('memby-admin-nav-v2') ?? '{}') as Record<string, boolean>;
} catch {
return {};
}
@@ -30,37 +29,18 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
const storeCollapsed = (next: Record<string, boolean>) => {
try {
localStorage.setItem('memby-admin-nav', JSON.stringify(next));
localStorage.setItem('memby-admin-nav-v2', JSON.stringify(next));
} catch {
// Private-mode storage refusals cost the memory of which group is open, and
// nothing else.
}
};
// The labelled groups are an accordion. Following a destination opens its group and
// folds the one the operator has just left, including navigation from search or Back.
useEffect(() => {
const activeGroup = nav.find((group) =>
group.items.some((item) => matchPath({ path: item.path, end: true }, location.pathname)),
);
if (!activeGroup) return;
setCollapsed((current) => {
const next = { ...current };
nav.forEach((group) => {
if (group.collapsible !== false) next[group.id] = group.id !== activeGroup.id;
});
storeCollapsed(next);
return next;
});
}, [location.pathname]);
const toggle = (id: string, defaultCollapsed = false) => {
setCollapsed((current) => {
const next = { ...current };
const collapseSelected = !(current[id] ?? defaultCollapsed);
nav.forEach((group) => {
if (group.collapsible !== false) next[group.id] = group.id === id ? collapseSelected : true;
});
next[id] = collapseSelected;
storeCollapsed(next);
return next;
});
@@ -71,11 +51,8 @@ function Rail({ open, onNavigate }: { open: boolean; onNavigate: () => void }) {
{nav.map((group) => {
const items = group.items.filter((item) => !item.hidden);
if (items.length === 0) return null;
const holdsCurrent = items.some((item) =>
matchPath({ path: item.path, end: true }, location.pathname),
);
const collapsible = group.collapsible !== false;
const expanded = holdsCurrent || !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
const expanded = !collapsible || !(collapsed[group.id] ?? group.defaultCollapsed ?? false);
return (
<div className="rail-group" key={group.id}>
{group.label && collapsible ? (
+72 -367
View File
@@ -1,18 +1,8 @@
import type { IconName } from './components/Icon';
/* The console's table of contents, and the only place a page is declared.
*
* The rail, the page search and the router all read it, so a page cannot be in the menu
* and 404, or be reachable and unnamed. It used to have to agree with a matching list in
* Go as well the gateway rendered the rail and decided which /admin URLs were legal
* which is a duplication the single-page console removes: the gateway now serves the
* console for any /admin path and the routing is entirely here.
*
* `hidden` marks a destination that is reachable and titled but not in the rail: a page
* about one person or one television belongs to the thing it is about, not to a menu.
* Everyday is deliberately frequency-based; the remaining sections are organised by the
* operator's question and begin folded so the common routes never scroll out of reach. */
/* The console's table of contents. The rail and page search both read this catalogue so
* every destination has one name and one home. Detail pages stay in the catalogue for
* routing and search, but remain out of the rail when they need an id in their URL. */
export interface NavItem {
id: string;
path: string;
@@ -21,406 +11,121 @@ export interface NavItem {
intro: string;
icon?: IconName;
hidden?: boolean;
/** badge names a live count the rail should show beside this item today only the
* unread activity count. */
badge?: 'notifications';
}
export interface NavGroup {
id: string;
label?: string;
/** Less-frequent sections begin folded, but still open whenever they hold the current
* page. An operator's explicit choice is remembered and takes precedence. */
label: string;
defaultCollapsed?: boolean;
/** A group may opt out of the accordion when its links must remain visible. */
collapsible?: boolean;
items: NavItem[];
}
const item = (
id: string,
path: string,
label: string,
title: string,
intro: string,
icon: IconName,
extra: Pick<NavItem, 'hidden' | 'badge'> = {},
): NavItem => ({ id, path, label, title, intro, icon, ...extra });
export const nav: NavGroup[] = [
{
id: 'everyday',
label: 'Everyday',
defaultCollapsed: false,
id: 'dashboard', label: 'Dashboard', defaultCollapsed: false, collapsible: false,
items: [item('overview', '/admin', 'Overview', 'Overview', 'What the gateway is doing right now.', 'overview')],
},
{
id: 'map', label: 'Map', defaultCollapsed: false, collapsible: false,
items: [
{
id: 'overview',
path: '/admin',
label: 'Overview',
title: 'Overview',
intro: 'What the gateway is doing right now.',
icon: 'overview',
},
{
id: 'activity',
path: '/admin/activity',
label: 'Activity',
title: 'Activity',
intro: 'Every administrative event, newest first.',
icon: 'bell',
badge: 'notifications',
},
{
id: 'accounts',
path: '/admin/accounts',
label: 'Users',
title: 'Users',
intro: 'Who uses Memby, and the devices they are signed in on.',
icon: 'people',
},
{
id: 'requests',
path: '/admin/requests',
label: 'Media requests',
title: 'Media requests',
intro: 'Who can ask for something the library does not have.',
icon: 'inbox',
},
{
/* The record of what Memby sent, which is a different question from the activity
feed above it: that is the operator's own bell, this is every outbound
notification to a viewer or an external service, whichever feature produced it. */
id: 'notifications',
path: '/admin/notifications',
label: 'Notifications',
title: 'Notifications',
intro: 'Everything Memby sent: who it went to, over which channel, and whether it worked.',
icon: 'send',
},
{
id: 'notification-settings',
path: '/admin/notification-settings',
label: 'TV notifications',
title: 'TV notifications',
intro: 'Choose where notifications appear and who receives them.',
icon: 'bell',
},
{
id: 'media-reports',
path: '/admin/media-reports',
label: 'Media reports',
title: 'Media reports',
intro: 'Problems viewers reported with a film or episode.',
icon: 'alert',
},
{
id: 'updates',
path: '/admin/updates',
label: 'App updates',
title: 'App updates',
intro: 'Publish an optional or a required client update.',
icon: 'upload',
},
{
id: 'logs',
path: '/admin/logs',
label: 'Logs',
title: 'Logs',
intro: 'Structured gateway events as they happen.',
icon: 'list',
},
item('journeys', '/admin/journeys', 'Journeys', 'User journeys', 'How viewers move through Memby and complete flows.', 'journey'),
item('journey-viewer', '/admin/journeys/:userId', 'Journey', 'User journey', 'One viewers journey through Memby.', 'journey', { hidden: true }),
],
},
{
id: 'people',
label: 'Devices & access',
defaultCollapsed: true,
id: 'history', label: 'History', defaultCollapsed: false,
items: [
{
id: 'account',
path: '/admin/accounts/:userId',
label: 'User',
title: 'User',
intro: 'Devices, recommendation setup and synced settings for one person.',
icon: 'person',
hidden: true,
},
{
id: 'settings-history',
path: '/admin/accounts/:userId/settings',
label: 'Settings history',
title: 'Settings history',
intro: "Every change to one person's synced settings, and which devices took it.",
icon: 'sliders',
hidden: true,
},
{
id: 'clients',
path: '/admin/clients',
label: 'Devices',
title: 'Devices',
intro: 'Which sets have reported in, what they are running and what their build understands.',
icon: 'tv',
},
{
id: 'logins',
path: '/admin/logins',
label: 'Sign-ins',
title: 'Sign-in history',
intro: 'Every connection attempt: who, which television, from where, and whether it got in.',
icon: 'key',
},
{
id: 'device',
path: '/admin/devices/:deviceId',
label: 'Device',
title: 'Device',
intro: 'One television: how often it connects, at what times, and from which addresses.',
icon: 'tv',
hidden: true,
},
item('activity', '/admin/activity', 'Activity', 'Activity', 'Every administrative event, newest first.', 'bell', { badge: 'notifications' }),
item('logins', '/admin/logins', 'Sign-ins', 'Sign-in history', 'Every connection attempt and whether it got in.', 'key'),
item('notifications', '/admin/notifications', 'Notifications', 'Notifications', 'Everything Memby sent and whether it worked.', 'send'),
item('imports', '/admin/imports', 'Imports', 'Imports', 'Catalogue synchronisation history.', 'database'),
],
},
{
id: 'content',
label: 'Content & discovery',
defaultCollapsed: true,
id: 'stats', label: 'Stats', defaultCollapsed: false,
items: [
{
id: 'library',
path: '/admin/library',
label: 'Library',
title: 'Library',
intro: 'Import and inspect the catalogue Memby ranks.',
icon: 'library',
},
{
id: 'hero',
path: '/admin/hero',
label: 'Home hero',
title: 'Home hero',
intro:
'Choose films or television shows for the launcher spotlight while recent releases fill the remaining places.',
icon: 'star',
},
{
id: 'metadata-hero',
path: '/admin/metadata-hero',
label: 'Metadata hero',
title: 'Metadata hero',
intro: 'Order the focused-title information shown above browse rows on every television.',
icon: 'tv',
},
{
id: 'recommendations',
path: '/admin/recommendations',
label: 'For You',
title: 'For You',
intro: 'The prepared pools personalised rows are drawn from.',
icon: 'sparkle',
},
{
id: 'inspector',
path: '/admin/inspector',
label: 'Score inspector',
title: 'Score inspector',
intro: 'Re-run the ranker for one person and read every component.',
icon: 'search',
},
item('views', '/admin/views', 'Views', 'App views', 'Home-screen visits, viewers and the times Memby is used.', 'overview'),
item('engagement', '/admin/engagement', 'Engagement', 'Row engagement', 'Impressions, focus, dwell and selections per launcher row.', 'chart'),
item('searches', '/admin/searches', 'Searches', 'Searches', 'What viewers have been looking for.', 'search'),
],
},
{
id: 'experience',
label: 'Viewing experience',
defaultCollapsed: true,
id: 'media', label: 'Media', defaultCollapsed: false,
items: [
{
id: 'features',
path: '/admin/features',
label: 'Client configuration',
title: 'Client configuration',
intro: 'Control everything the thin TV client renders, without releasing an APK.',
icon: 'sliders',
},
{
id: 'playback',
path: '/admin/playback',
label: 'Playback',
title: 'Playback',
intro: 'Presentation policy sent with every playback launch.',
icon: 'play',
},
{
id: 'subtitles',
path: '/admin/subtitles',
label: 'Subtitles',
title: 'Subtitles',
intro: 'Which providers a viewer may fetch a missing subtitle from.',
icon: 'captions',
},
{
id: 'credits',
path: '/admin/credits',
label: 'Credits detection',
title: 'Credits detection',
intro: 'Control predictive scanning and review every completed credits scan.',
icon: 'clock',
},
item('library', '/admin/library', 'Library', 'Library', 'Import and inspect the catalogue Memby ranks.', 'library'),
item('requests', '/admin/requests', 'Requests', 'Media requests', 'Who can ask for something the library does not have.', 'inbox'),
item('recommendations', '/admin/recommendations', 'Recommendations', 'For You', 'The prepared pools personalised rows are drawn from.', 'sparkle'),
item('inspector', '/admin/inspector', 'Score inspector', 'Score inspector', 'Re-run the ranker for one person and read every component.', 'search'),
item('hero', '/admin/hero', 'Home hero', 'Home hero', 'Choose films or television shows for the launcher spotlight.', 'star'),
item('metadata-hero', '/admin/metadata-hero', 'Metadata hero', 'Metadata hero', 'Order focused-title information shown above browse rows.', 'tv'),
item('playback', '/admin/playback', 'Playback', 'Playback', 'Presentation policy sent with every playback launch.', 'play'),
item('subtitles', '/admin/subtitles', 'Subtitles', 'Subtitles', 'Which providers a viewer may fetch a missing subtitle from.', 'captions'),
item('credits', '/admin/credits', 'Credits detection', 'Credits detection', 'Control predictive scanning and review completed scans.', 'clock'),
],
},
{
id: 'operations',
label: 'Operations',
defaultCollapsed: true,
id: 'performance', label: 'Performance', defaultCollapsed: false,
items: [
{
id: 'tasks',
path: '/admin/tasks',
label: 'Scheduled tasks',
title: 'Scheduled tasks',
intro: 'What the gateway does in the background, when it last ran and whether it worked.',
icon: 'clock',
},
{
/* One task, addressed by its id a page about a single job belongs to the job
rather than to the rail, the stance the user and device pages take. */
id: 'task',
path: '/admin/tasks/:taskId',
label: 'Task',
title: 'Task',
intro: 'One scheduled task: its cadence, its switch and its own run history.',
icon: 'clock',
hidden: true,
},
{
id: 'imports',
path: '/admin/imports',
label: 'Imports',
title: 'Imports',
intro: 'Catalogue synchronisation history.',
icon: 'database',
},
{
/* The process, rather than the household. It sits beside Logs and Maintenance
because the question it answers is the container healthy is the one an
operator arrives with when something is slow rather than wrong. */
id: 'runtime',
path: '/admin/runtime',
label: 'Runtime',
title: 'Runtime',
intro: 'Goroutines, memory and the background workers inside the gateway process.',
icon: 'chip',
},
{
id: 'maintenance',
path: '/admin/maintenance',
label: 'Maintenance',
title: 'Maintenance',
intro: 'Take Memby offline now or schedule daily quiet time.',
icon: 'wrench',
},
{
/* Hidden because its way in is the account menu in the top bar, not the rail:
these are settings for the server process rather than for the household, and
they belong beside "signed in as". It is still declared here so the page search
can find it and the router has one place a page is named. */
id: 'gateway-settings',
path: '/admin/settings',
label: 'Gateway settings',
title: 'Gateway settings',
intro: 'Timezone, logging and the other server-level settings for this gateway.',
icon: 'sliders',
hidden: true,
},
item('runtime', '/admin/runtime', 'Runtime', 'Runtime', 'Goroutines, memory and background workers.', 'chip'),
item('logs', '/admin/logs', 'Logs', 'Logs', 'Structured gateway events as they happen.', 'list'),
item('updates', '/admin/updates', 'App updates', 'App updates', 'Publish an optional or required client update.', 'upload'),
item('tasks', '/admin/tasks', 'Scheduled tasks', 'Scheduled tasks', 'What the gateway does in the background.', 'clock'),
item('task', '/admin/tasks/:taskId', 'Task', 'Task', 'One scheduled task and its run history.', 'clock', { hidden: true }),
],
},
{
/* External services, and the one place they are configured.
*
* Its own section rather than an entry under Operations, because the question it
* answers is everything Memby depends on working is not the same as "what is the
* container doing", and because four services' settings previously lived on four
* unrelated pages: MDBList under Movie ratings, the *arr switches and request
* policies on a page about Discord, and Tracearr nowhere at all. */
id: 'integrations',
label: 'Integrations',
defaultCollapsed: true,
id: 'users', label: 'Users', defaultCollapsed: false,
items: [
{
id: 'integrations',
path: '/admin/integrations',
label: 'Overview',
title: 'Integrations',
intro: 'Every external service Memby depends on: configured, working, and what it last did.',
icon: 'plug',
},
{
/* One service, addressed by its id a page about a single integration belongs to
the integration rather than to the rail, the stance the user, device and task
pages take. */
id: 'integration',
path: '/admin/integrations/:integrationId',
label: 'Integration',
title: 'Integration',
intro: 'One external service: its switch, its settings, its jobs and its run history.',
icon: 'plug',
hidden: true,
},
{
id: 'webhooks',
path: '/admin/integrations/webhooks',
label: 'Event webhooks',
title: 'Event webhooks',
intro: 'Send administrative events to Discord and, in time, elsewhere.',
icon: 'send',
},
item('accounts', '/admin/accounts', 'Users', 'Users', 'Who uses Memby and the devices they are signed in on.', 'people'),
item('account', '/admin/accounts/:userId', 'User', 'User', 'Devices, recommendation setup and synced settings for one person.', 'person', { hidden: true }),
item('settings-history', '/admin/accounts/:userId/settings', 'Settings history', 'Settings history', 'Changes to one persons synced settings.', 'sliders', { hidden: true }),
item('clients', '/admin/clients', 'Devices', 'Devices', 'Which sets have reported in and what they are running.', 'tv'),
item('device', '/admin/devices/:deviceId', 'Device', 'Device', 'One televisions connections and activity.', 'tv', { hidden: true }),
],
},
{
id: 'insights',
label: 'Insights',
defaultCollapsed: true,
id: 'rules', label: 'Rules', defaultCollapsed: false,
items: [
{
id: 'views',
path: '/admin/views',
label: 'Views',
title: 'App views',
intro: 'Home-screen visits, viewers and the times Memby is used.',
icon: 'overview',
},
{
id: 'searches',
path: '/admin/searches',
label: 'Searches',
title: 'Searches',
intro: 'What viewers have been looking for, and what was searched just now.',
icon: 'search',
},
{
id: 'journeys',
path: '/admin/journeys',
label: 'Journeys',
title: 'User journeys',
intro: 'How viewers move through Memby, use features and complete flows.',
icon: 'journey',
},
{
id: 'engagement',
path: '/admin/engagement',
label: 'Row engagement',
title: 'Row engagement',
intro: 'Impressions, focus, dwell and selections per launcher row.',
icon: 'chart',
},
item('features', '/admin/features', 'Client configuration', 'Client configuration', 'Control everything the thin TV client renders.', 'sliders'),
item('notification-settings', '/admin/notification-settings', 'Notification rules', 'TV notifications', 'Choose where notifications appear and who receives them.', 'bell'),
item('webhooks', '/admin/integrations/webhooks', 'Event webhooks', 'Event webhooks', 'Send administrative events to external services.', 'send'),
],
},
{
id: 'violations', label: 'Violations', defaultCollapsed: false, collapsible: false,
items: [item('media-reports', '/admin/media-reports', 'Media reports', 'Media reports', 'Problems viewers reported with a film or episode.', 'alert')],
},
{
id: 'settings', label: 'Settings', defaultCollapsed: false,
items: [
item('integrations', '/admin/integrations', 'Integrations', 'Integrations', 'External services Memby depends on.', 'plug'),
item('integration', '/admin/integrations/:integrationId', 'Integration', 'Integration', 'One external service, its settings and run history.', 'plug', { hidden: true }),
item('maintenance', '/admin/maintenance', 'Maintenance', 'Maintenance', 'Take Memby offline or schedule quiet time.', 'wrench'),
item('gateway-settings', '/admin/settings', 'Gateway settings', 'Gateway settings', 'Timezone, logging and other server-level settings.', 'sliders', { hidden: true }),
],
},
];
export const allNavItems: NavItem[] = nav.flatMap((group) => group.items);
/** searchableNavItems is what the omni search offers: every page that has a URL of its
* own, which is every page that is not addressed by an id in the path. That test is the
* path rather than `hidden`, because the two answer different questions a page can be
* off the rail (gateway settings is reached from the account menu) and still be a real
* address somebody would type. A page about one person or one television is the case
* this excludes: without knowing which person, the URL does not exist. */
export const searchableNavItems = nav.flatMap((group) =>
group.items
.filter((item) => !item.path.includes(':'))
.map((item) => ({ ...item, group: group.label ?? '' })),
.filter((navItem) => !navItem.path.includes(':'))
.map((navItem) => ({ ...navItem, group: group.label })),
);
export function navItem(id: string): NavItem | undefined {
return allNavItems.find((item) => item.id === id);
return allNavItems.find((navItem) => navItem.id === id);
}
+1 -29
View File
@@ -38,31 +38,7 @@ val membyGatewayUrl: String = (project.findProperty("memby.gatewayUrl") as Strin
val membyDiagnosticLogLevel: String = (project.findProperty("memby.diagnosticLogLevel") as String?)
?.trim()?.uppercase()?.takeIf { it in setOf("INFO", "DEBUG", "TRACE") } ?: "INFO"
// Kept in BuildConfig so the TV can show the exact corresponding-source location and
// the complete legal documents offline. Deployments can override the public source URL
// without changing application code.
val membySourceUrl: String =
(project.findProperty("memby.sourceUrl") as String?)
?.trim()
?.takeIf(String::isNotEmpty)
?: "https://g.sublogue.com/admin/memby"
fun buildConfigString(value: String): String =
"\"" + value
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\r\n", "\\n")
.replace("\n", "\\n") + "\""
// 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()
.replace("https://g.sublogue.com/admin/memby", membySourceUrl)
val defaultVersionName = "0.3.15"
val defaultVersionName = "0.3.16"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -126,10 +102,6 @@ extensions.configure<ApplicationExtension> {
buildConfigField("String", "EMBY_SERVER_URL", "\"${embyServerUrl.replace("\"", "\\\"")}\"")
buildConfigField("String", "MEMBY_GATEWAY_URL", "\"${membyGatewayUrl.replace("\"", "\\\"")}\"")
buildConfigField("String", "DIAGNOSTIC_LOG_LEVEL", "\"$membyDiagnosticLogLevel\"")
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
@@ -405,17 +405,6 @@ data class Settings(
* and remains authoritative for anyone not listed here.
*/
val onboardedUserIds: Set<String> = emptySet(),
/**
* The app version this television has already announced. 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 update notice.
*/
val whatsNewSeenVersion: String? = null,
/** The most recent app-update alert retained for this television's Notifications page. */
val updateAlertVersion: String? = null,
val updateAlertAt: String? = null,
val updateAlertRead: Boolean = false,
/**
* The version the gateway last refused this build over, or null while it has said
* nothing. Device state, and deliberately outlives both the session and the process:
@@ -604,10 +593,6 @@ 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 UPDATE_ALERT_VERSION = stringPreferencesKey("update_alert_version")
val UPDATE_ALERT_AT = stringPreferencesKey("update_alert_at")
val UPDATE_ALERT_READ = booleanPreferencesKey("update_alert_read")
val REQUIRED_UPDATE_VERSION = stringPreferencesKey("required_update_version")
val PREFERENCES_REVISION = longPreferencesKey("preferences_revision")
val ACTIVE_VIEWER_ID = stringPreferencesKey("active_viewer_id")
@@ -1143,48 +1128,6 @@ class SettingsStore(private val context: Context) {
}
}
/**
* Records the version announced on this television, so the update toast appears exactly
* once. Also written silently on a fresh install, which has not updated from an earlier
* build and therefore has nothing to announce.
*/
suspend fun markWhatsNewSeen(version: String, updateAlertAt: String? = null) {
val trimmed = version.trim()
if (trimmed.isEmpty()) return
context.dataStore.edit { preferences ->
preferences[Keys.WHATS_NEW_VERSION] = trimmed
updateAlertAt?.trim()?.takeIf(String::isNotEmpty)?.let { occurredAt ->
preferences[Keys.UPDATE_ALERT_VERSION] = trimmed
preferences[Keys.UPDATE_ALERT_AT] = occurredAt
preferences[Keys.UPDATE_ALERT_READ] = false
}
}
}
/**
* The local update notice's read flag, both ways.
*
* This alert is the one row on the Notifications page the gateway knows nothing about
* it is a property of the APK on *this* set so its seen toggle has to be written here
* rather than posted. Guarded on the version still being recorded: a flag left behind by
* an alert somebody has already dismissed describes nothing.
*/
suspend fun setUpdateAlertRead(read: Boolean) {
context.dataStore.edit { preferences ->
if (!preferences[Keys.UPDATE_ALERT_VERSION].isNullOrBlank()) {
preferences[Keys.UPDATE_ALERT_READ] = read
}
}
}
suspend fun dismissUpdateAlert() {
context.dataStore.edit { preferences ->
preferences.remove(Keys.UPDATE_ALERT_VERSION)
preferences.remove(Keys.UPDATE_ALERT_AT)
preferences.remove(Keys.UPDATE_ALERT_READ)
}
}
/**
* Records that the gateway has refused this build, so the refusal survives the process
* it arrived in. Ignored once this television is already running the version being
@@ -1648,10 +1591,6 @@ class SettingsStore(private val context: Context) {
themeIconSet = preferences[Keys.THEME_ICON_SET].orEmpty(),
themeRevision = preferences[Keys.THEME_REVISION].orEmpty(),
onboardedUserIds = preferences[Keys.ONBOARDED_USERS].orEmpty(),
whatsNewSeenVersion = preferences[Keys.WHATS_NEW_VERSION],
updateAlertVersion = preferences[Keys.UPDATE_ALERT_VERSION],
updateAlertAt = preferences[Keys.UPDATE_ALERT_AT],
updateAlertRead = preferences[Keys.UPDATE_ALERT_READ] ?: false,
requiredUpdateVersion = preferences[Keys.REQUIRED_UPDATE_VERSION],
preferencesRevision = preferences[Keys.PREFERENCES_REVISION] ?: 0,
activeViewerId = preferences[Keys.ACTIVE_VIEWER_ID].orEmpty(),
@@ -1,6 +1,5 @@
package com.ponzischeme89.memby.ui
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -18,7 +17,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.Settings
@@ -26,9 +24,7 @@ import com.ponzischeme89.memby.data.model.GatewayUpdate
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
import com.ponzischeme89.memby.ui.whatsnew.WhatsNewDecision
import com.ponzischeme89.memby.ui.seasonal.SeasonalDecorations
import com.ponzischeme89.memby.ui.whatsnew.whatsNewDecision
import com.ponzischeme89.memby.ui.theme.MembySurface
import com.ponzischeme89.memby.update.InstallPermission
import com.ponzischeme89.memby.update.RequiredUpdateSignal
@@ -40,7 +36,6 @@ import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.time.Instant
import kotlin.time.Duration.Companion.milliseconds
/**
@@ -76,7 +71,6 @@ internal fun AppRoot(
onHomeInteractive: () -> Unit,
) {
val repo = ServiceLocator.repository
val context = LocalContext.current
// This client intentionally has no token provider and no dependency on the active
// profile. Updates are an app lifecycle concern, checked before login/session work.
val updateService = remember { ServerUpdateService.create(ServerConfig.gatewayUrl) }
@@ -266,38 +260,6 @@ internal fun AppRoot(
recommendationOnboarding = answered ?: RecommendationOnboarding(completed = true)
}
// Whether this TV has already been told about the build it is running. Keyed
// on the recorded version and the session, because a fresh install records the current
// version during setup and a sign-in is what turns "waiting" into "announce it now".
LaunchedEffect(settings?.whatsNewSeenVersion, settings?.isSignedIn) {
val loaded = settings ?: return@LaunchedEffect
val decision = whatsNewDecision(
installedVersion = BuildConfig.VERSION_NAME,
seenVersion = loaded.whatsNewSeenVersion,
isSignedIn = loaded.isSignedIn,
)
when (decision) {
is WhatsNewDecision.Notify -> {
// Persist the receipt before producing the external effect. If Android
// recreates the activity immediately after the toast, the new composition
// must not announce the same build a second time.
ServiceLocator.settings.markWhatsNewSeen(
decision.version,
updateAlertAt = Instant.now().toString(),
)
currentCoroutineContext().ensureActive()
Toast.makeText(
context,
context.getString(R.string.app_updated_to_version, decision.version),
Toast.LENGTH_LONG,
).show()
}
is WhatsNewDecision.MarkSeen ->
ServiceLocator.settings.markWhatsNewSeen(decision.version)
WhatsNewDecision.Nothing -> Unit
}
}
Box(Modifier.fillMaxSize().background(MembySurface)) {
val loaded = settings
val update = appUpdate
@@ -132,25 +132,6 @@ private val PLAYBACK_SOURCE_RESOLUTION_TIMEOUT = 15.seconds
/** The maximum time one row-to-row focus move may hold the D-pad. */
private val ROW_FOCUS_MOVE_TIMEOUT = 1_200.milliseconds
internal const val MEMBY_UPDATE_NOTIFICATION_ID = -1L
internal fun membyUpdateNotification(
version: String?,
occurredAt: String?,
read: Boolean,
): UserNotification? {
val installed = version?.trim()?.takeIf(String::isNotEmpty) ?: return null
return UserNotification(
id = MEMBY_UPDATE_NOTIFICATION_ID,
kind = "app-update",
title = "Memby updated",
message = "This TV is now running Memby $installed.",
eventAt = occurredAt,
createdAt = occurredAt.orEmpty(),
readAt = if (read) occurredAt ?: "read" else null,
)
}
/**
* The followed show a press stands for, as much of it as the card already knows.
*
@@ -359,13 +340,7 @@ internal fun HomeScreen(
var notificationsLoading by remember(settings.userId) { mutableStateOf(true) }
var notificationsError by remember(settings.userId) { mutableStateOf<String?>(null) }
var notificationsMutationBusy by remember(settings.userId) { mutableStateOf(false) }
val displayedNotifications = listOfNotNull(
membyUpdateNotification(
settings.updateAlertVersion,
settings.updateAlertAt,
settings.updateAlertRead,
),
) + notificationState.notifications
val displayedNotifications = notificationState.notifications
var showNotifications by remember { mutableStateOf(false) }
var showRequests by remember { mutableStateOf(false) }
// The people under this account. Fetched once the launcher is up rather than on the
@@ -1995,19 +1970,14 @@ internal fun HomeScreen(
notificationsMutationBusy = true
val previous = notificationState
val offered = displayedNotifications.size
val hadLocalUpdate = settings.updateAlertVersion != null
notificationState = notificationState.copy(notifications = emptyList())
closeUserQuickActions()
scope.launch {
// The update notice is this television's own — there is no server
// row behind it, so it is dismissed locally and counted here.
if (hadLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
runCatching { repo.clearNotifications() }
.onSuccess { cleared ->
val total = cleared + if (hadLocalUpdate) 1 else 0
Toast.makeText(
context,
clearedNotificationsMessage(total),
clearedNotificationsMessage(cleared),
Toast.LENGTH_SHORT,
).show()
homeViewModel.trackJourney(
@@ -2018,7 +1988,7 @@ internal fun HomeScreen(
// The one free-text field, and the count is the only
// thing that separates one use of this shortcut from
// the next.
itemName = "$total cleared",
itemName = "$cleared cleared",
outcome = "success",
)
}
@@ -2505,15 +2475,9 @@ internal fun HomeScreen(
//
// Optimistic and reversed on failure, like the dismissal below: the flag is
// the only thing that changed, so a row that sat unmoved while its request
// was in flight is one pressed a second time. The locally-held update notice
// has no server row to post, so its flag is written to this television's own
// settings instead — it is a fact about the APK on this set.
// was in flight is one pressed a second time.
onToggleSeen = onToggleSeen@{ notification ->
val markingSeen = notification.unread
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.setUpdateAlertRead(markingSeen) }
return@onToggleSeen
}
val previousReadAt = notification.readAt
notificationState = notificationState.copy(
notifications = notificationState.notifications.map {
@@ -2552,10 +2516,6 @@ internal fun HomeScreen(
// back where it was rather than quietly losing somebody's alert.
onDismiss = onDismiss@{ notification ->
if (notificationsMutationBusy) return@onDismiss
if (notification.id == MEMBY_UPDATE_NOTIFICATION_ID) {
scope.launch { ServiceLocator.settings.dismissUpdateAlert() }
return@onDismiss
}
notificationsMutationBusy = true
val previous = notificationState
notificationState = notificationState.copy(
@@ -2584,20 +2544,14 @@ internal fun HomeScreen(
// emptying Seen must not also throw away an Inbox the viewer has not
// read — a bulk action nobody can see the extent of is one nobody presses.
val pendingIds = pending.map(UserNotification::id).toSet()
val dismissLocalUpdate = MEMBY_UPDATE_NOTIFICATION_ID in pendingIds &&
settings.updateAlertVersion != null
notificationState = notificationState.copy(
notifications = notificationState.notifications.filterNot {
it.id in pendingIds
},
)
scope.launch {
if (dismissLocalUpdate) ServiceLocator.settings.dismissUpdateAlert()
// The local update notice has no server row, so asking the gateway to
// dismiss it would be one guaranteed failure per pass.
val failed = pendingIds.filter { id ->
id != MEMBY_UPDATE_NOTIFICATION_ID &&
runCatching { repo.dismissNotification(id) }.isFailure
runCatching { repo.dismissNotification(id) }.isFailure
}
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
@@ -329,7 +329,7 @@ fun UpdateScreen(
// easy to back out of by accident.
"Choose Update now — Memby downloads the new version, then your TV asks you " +
"to confirm the install. If it asks permission to install apps, allow it and " +
"the update continues. Your profiles and sign-in stay on this TV.",
"the update continues.",
color = UpdateFaint,
fontSize = 14.sp,
textAlign = TextAlign.Center,
@@ -370,7 +370,7 @@ fun UpdateScreen(
if (update.isMandatory) {
Spacer(Modifier.height(22.dp))
Text(
"Stuck? Ask whoever set up Memby for you.",
"Stuck? Try clearing app data under Settings > App > Clear Data",
color = UpdateFaint.copy(alpha = 0.75f),
fontSize = 13.sp,
)
@@ -147,6 +147,11 @@ internal object PlayerEngine {
* Media3 1.11, and this app is pinned to the 1.9 line by the Jellyfin FFmpeg extension (see
* `app/build.gradle.kts`). Dynamic scheduling is the part of that same work which *is*
* available here, and it is behind its own switch.
*
* `Surface.setFrameRate()` (API 30+) matching is already ExoPlayer's default here
* [C.VIDEO_CHANGE_FRAME_RATE_STRATEGY_ONLY_IF_SEAMLESS] is what `ExoPlayer.Builder` sets
* unless told otherwise, and it is the strongest option this Media3 line offers: 1.9.x
* carries no `ALWAYS` strategy, only this and `OFF`. Nothing to set explicitly.
*/
private fun exoPlayerBuilder(context: Context) = ExoPlayer.Builder(context)
.experimentalSetDynamicSchedulingEnabled(DYNAMIC_SCHEDULING_ENABLED)
@@ -778,10 +778,8 @@ internal fun SettingsPanelContent(
val contentFocusRequester = remember { FocusRequester() }
// And the way back out of it. The pane's first control had nothing above it on any
// page, so Up there did nothing at all — which from the sofa is a screen that has
// stopped responding rather than a list that has run out. About is where it bites,
// because its pane is a changelog long enough that walking back up is the ordinary
// way to leave it. Left already returns to the page list; this makes Up say the same
// thing once the pane has no row above.
// stopped responding rather than a list that has run out. Left already returns to the
// page list; this makes Up say the same thing once the pane has no row above.
val railSelectionFocusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
@@ -1,77 +0,0 @@
/*
* 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 ReleaseMonths = listOf(
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
)
/** Turns the changelog's wire-friendly ISO date into the day-first date shown in About. */
internal fun formatReleaseDate(raw: String): String {
val date = raw.trim()
if (date.length != 10 || date[4] != '-' || date[7] != '-') return raw
val year = date.substring(0, 4).toIntOrNull() ?: return raw
val month = date.substring(5, 7).toIntOrNull() ?: return raw
val day = date.substring(8, 10).toIntOrNull() ?: return raw
if (month !in 1..12 || day !in 1..31) return raw
return "$day ${ReleaseMonths[month - 1]} $year"
}
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)
}
@@ -1,49 +0,0 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.whatsnew
/** What a launch should do about the update notice for the build that is running. */
internal sealed interface WhatsNewDecision {
/** Briefly announce this installed version, then record it. */
data class Notify(val version: String) : 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 announce the new version.
*
* Pure, because the interesting part is which launches the toast must stay out of. Three
* cases are deliberately not "notify":
*
* - **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). It has not updated
* from an earlier version, so it is marked seen during setup and the first launcher stays
* quiet.
* - **Signed out.** A toast belongs over the launcher, so an updated television with an
* existing record waits until somebody signs in rather than consuming the notice early.
*
* 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,
): 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
return WhatsNewDecision.Notify(version)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

-12
View File
@@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- A restrained memorial marker: recognisable at TV distance without turning a cast
card into an obituary badge. -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFB9C1C8"
android:pathData="M9,2h6v4h3v5h-3v11H9V11H6V6h3z" />
</vector>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="270"
android:endColor="#00000000"
android:startColor="#D9000000"
android:type="linear" />
</shape>
@@ -1,23 +0,0 @@
<?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="#FF52B54B" />
<corners android:radius="10dp" />
</shape>
</item>
<item android:state_selected="true">
<shape android:shape="rectangle">
<solid android:color="#1AFFFFFF" />
<corners android:radius="10dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#00000000" />
<corners android:radius="10dp" />
</shape>
</item>
</selector>
@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FA0B0E11" />
<stroke
android:width="1dp"
android:color="#24FFFFFF" />
<corners
android:bottomLeftRadius="24dp"
android:topLeftRadius="24dp" />
</shape>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FF20252A" />
<corners android:radius="10dp" />
<stroke android:width="1dp" android:color="#30FFFFFF" />
</shape>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient
android:angle="0"
android:endColor="#00000000"
android:startColor="#EB07090B"
android:type="linear" />
</shape>
-1
View File
@@ -99,7 +99,6 @@
<string name="end_credits_next_in_label">NEXT EPISODE STARTING IN</string>
<string name="next_up_starting_now">Starting now…</string>
<string name="app_name">Memby</string>
<string name="app_updated_to_version">Memby has been updated to version %1$s.</string>
<string name="screensaver_name">Memby Screensaver</string>
<string name="developer_name">ponzischeme89</string>
</resources>
@@ -1,24 +0,0 @@
/*
* Copyright (C) 2026 Memby contributors
*
* SPDX-License-Identifier: GPL-2.0-only
*/
package com.ponzischeme89.memby.ui.settings
import com.ponzischeme89.memby.BuildConfig
import org.junit.Assert.assertTrue
import org.junit.Test
class LegalNoticesTest {
@Test
fun `distributed app embeds source and complete GPL notice`() {
assertTrue(BuildConfig.SOURCE_CODE_URL.startsWith("https://"))
assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains("Memby"))
assertTrue(BuildConfig.PROJECT_NOTICE_TEXT.contains(BuildConfig.SOURCE_CODE_URL))
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("GNU GENERAL PUBLIC LICENSE"))
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("Version 2, June 1991"))
assertTrue(BuildConfig.GPL_LICENSE_TEXT.contains("END OF TERMS AND CONDITIONS"))
}
}
@@ -1,88 +0,0 @@
package com.ponzischeme89.memby.ui.settings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.ZoneId
class VersionHistoryTest {
@Test
fun `parses versions dates and bullets`() {
val releases = parseChangelog(
"""
# Changelog
Prose before the first release is format documentation, not history.
## 0.2.16 2026-08-03
- Signed-in devices can be renamed.
- MDBList ratings.
## 0.1.53
- Renamed to Memby.
""".trimIndent(),
)
assertEquals(listOf("0.2.16", "0.1.53"), releases.map { it.version })
assertEquals("2026-08-03", releases[0].date)
assertEquals(2, releases[0].changes.size)
assertEquals("MDBList ratings.", releases[0].changes[1])
// A release with no date renders without one rather than showing a placeholder.
assertEquals("", releases[1].date)
}
@Test
fun `joins a bullet wrapped across lines`() {
val releases = parseChangelog(
"""
## 1.0.0 - 2026-01-01
- A change long enough that the file
wraps it onto a second line.
- A second change.
""".trimIndent(),
)
assertEquals(
listOf(
"A change long enough that the file wraps it onto a second line.",
"A second change.",
),
releases.single().changes,
)
}
@Test
fun `ignores text that is not a release`() {
assertTrue(parseChangelog("# Changelog\n\nNothing released yet.\n").isEmpty())
}
@Test
fun `the shipped changelog is readable and newest first`() {
val releases = MembyReleaseHistory
assertTrue("CHANGELOG.md parsed to no releases", releases.size >= 2)
assertTrue(releases.all { it.changes.isNotEmpty() })
assertEquals(
"the About page shows the file's order verbatim",
releases.map { it.version }.sortedByDescending { version ->
version.split('.').fold(0) { acc, part -> acc * 1_000 + part.toInt() }
},
releases.map { it.version },
)
}
@Test
fun `release dates are shown day first`() {
assertEquals("11 Aug 2026", formatReleaseDate("2026-08-11"))
assertEquals("unknown", formatReleaseDate("unknown"))
}
@Test
fun `device activity is shown in local day first format`() {
assertEquals(
"11 Aug 2026, 10:05 pm",
formatDeviceLastSeen("2026-08-11T10:05:00Z", ZoneId.of("Pacific/Auckland")),
)
assertEquals("not-a-date", formatDeviceLastSeen("not-a-date", ZoneId.of("UTC")))
}
}
@@ -1,66 +0,0 @@
package com.ponzischeme89.memby.ui.whatsnew
import com.ponzischeme89.memby.ui.MEMBY_UPDATE_NOTIFICATION_ID
import com.ponzischeme89.memby.ui.membyUpdateNotification
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
class WhatsNewTest {
private fun decide(
installed: String = "0.2.24",
seen: String? = "0.2.23",
signedIn: Boolean = true,
) = whatsNewDecision(installed, seen, signedIn)
@Test
fun `an updated television is notified of the build it is running`() {
assertEquals(WhatsNewDecision.Notify("0.2.24"), decide())
}
@Test
fun `the same build is only announced once`() {
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.24"))
}
@Test
fun `a fresh install records the version instead of announcing it`() {
assertEquals(
WhatsNewDecision.MarkSeen("0.2.24"),
decide(seen = null, signedIn = false),
)
}
@Test
fun `an install that predates the record is announced once it is signed in`() {
assertEquals(WhatsNewDecision.Notify("0.2.24"), decide(seen = null, signedIn = true))
}
@Test
fun `a signed-out television with a record waits rather than consuming the notice`() {
assertEquals(WhatsNewDecision.Nothing, decide(seen = "0.2.23", signedIn = false))
}
@Test
fun `an updated build does not depend on a changelog entry`() {
assertEquals(WhatsNewDecision.Notify("0.9.0"), decide(installed = "0.9.0"))
}
@Test
fun `an unreadable version does nothing at all`() {
assertEquals(WhatsNewDecision.Nothing, decide(installed = " "))
}
@Test
fun `an installed update becomes a local Notifications entry`() {
val alert = membyUpdateNotification("0.2.52", "2026-08-11T10:00:00Z", read = false)!!
assertEquals(MEMBY_UPDATE_NOTIFICATION_ID, alert.id)
assertEquals("Memby updated", alert.title)
assertEquals("This TV is now running Memby 0.2.52.", alert.message)
assertFalse(alert.readAt != null)
assertNull(membyUpdateNotification(" ", null, read = false))
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

-50873
View File
File diff suppressed because it is too large Load Diff
-8223
View File
File diff suppressed because it is too large Load Diff