0.3.16
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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 20–200 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.
|
||||
@@ -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.
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 viewer’s 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 person’s 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 television’s 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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
Before Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
@@ -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>
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 393 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 29 KiB |