Big changes
This commit is contained in:
@@ -46,6 +46,29 @@ go build ./... && go test ./... # add -buildvcs=false on Windows if .git is
|
||||
docker compose up -d --build # from the repo root; needs .env (see .env.example)
|
||||
```
|
||||
|
||||
**Deploying the gateway to the NAS** is `deploy-server.ps1` (PowerShell 7):
|
||||
|
||||
```powershell
|
||||
.\deploy-server.ps1 # local tree -> 10.0.0.213:/share/Docker/Memby
|
||||
.\deploy-server.ps1 -SourceDirectory C:\src\memby -Destination /share/Docker/Memby-test
|
||||
```
|
||||
|
||||
It tars the local `server/`, `docker-compose.yml` and `.env.example`, and streams them over
|
||||
one SSH connection (interactive password; stdin carries the
|
||||
archive, so OpenSSH prompts on the tty). The remote half stages into
|
||||
`<destination>.new.$$`, builds, then swaps directories and waits for all three health
|
||||
checks, restoring the previous release if anything fails. The named Postgres volume is
|
||||
preserved — it never runs `compose down -v`.
|
||||
|
||||
**`.env.example` is the configuration.** It holds real values, and every deployment
|
||||
overwrites the NAS's `.env` with the local copy (the old one is kept beside it as
|
||||
`.env.previous`). The script requires `MEMBY_PORT=32768`, `MEMBY_ADMIN_TOKEN`,
|
||||
`MEMBY_EMBY_URL` and `POSTGRES_PASSWORD` before activation, then confirms the admin token
|
||||
reached the running container. The database volume is always preserved; deployment stops
|
||||
before activation if the Postgres password differs from the deployed value, because a
|
||||
credential change requires an explicit database migration. The local working tree is
|
||||
deployed directly; no commit or push is required.
|
||||
|
||||
`local.properties` must contain `sdk.dir=...` when building from the CLI.
|
||||
|
||||
Lint has `abortOnError = false` (media3's `@UnstableApi` opt-in check would otherwise fail
|
||||
@@ -82,6 +105,12 @@ keystore the build still succeeds but emits an unsigned APK and logs a warning.
|
||||
matters more than the code: Android identifies an app by applicationId **plus** signing
|
||||
key, so a changed key forces every user to uninstall and reinstall.
|
||||
|
||||
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
|
||||
Leanback activity. It reads the same signing settings from the current user's persistent
|
||||
`MEMBY_KEYSTORE*` environment variables and defaults to the living-room Chromecast endpoint;
|
||||
pass `-Device host:port` when Android rotates the wireless-debugging port.
|
||||
|
||||
`UpdateChecker` supports two sources, chosen by URL shape in `isManifestUrl` — a `.json`
|
||||
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`.
|
||||
@@ -142,7 +171,9 @@ the container is down. Specifics worth knowing:
|
||||
gateway proxy, `api_key=` for Emby.
|
||||
- Video always direct-plays from Emby. The gateway returns a URL; it never proxies a
|
||||
stream. Don't route playback through it.
|
||||
- Search exists on the gateway (`repository.search`) but has no UI yet.
|
||||
- Search is dual-path like the rest: `/v1/search` on the gateway (Postgres full-text,
|
||||
falling back to Emby before the first import), `SearchTerm` on `Users/{id}/Items`
|
||||
directly. `ui/search/` renders it — see "Search" below.
|
||||
|
||||
The wire contract is pinned from both ends: `GatewayPayloadTest.kt` / `ServerHomeRowsTest.kt`
|
||||
(Kotlin) and `internal/api/api_test.go` (Go). Change a field name or a row `kind` and one
|
||||
@@ -168,6 +199,50 @@ navigation rail stays mounted beside it so Settings and Switch user still work,
|
||||
retry button takes `contentFocusRequester` (with `focusProperties { left = … }` back to
|
||||
the rail) because otherwise D-pad focus has nowhere to go once the rows are gone.
|
||||
|
||||
**Service alerts.** `/v1/status` is the only thing an open app polls continuously (10s,
|
||||
`MaintenanceMonitor`), so it doubles as the push channel: alongside maintenance state it
|
||||
carries an `alerts` array, and `ui/ServiceAlertBanner.kt` drops one in as a full-width bar
|
||||
across the top of the screen, broadcast-notice style (it spans the navigation rail too).
|
||||
The only producer today is `api/alerts.go` — an episode whose Sonarr air time has passed
|
||||
but which Emby has not imported yet ("aired, coming soon"). It reads the *cached*
|
||||
airing-today calendar, so polling clients never cost a Sonarr request. Things to preserve:
|
||||
the server has no idea which TVs saw what, so the client dedupes by id against
|
||||
`SettingsStore.markAlertSeen` (persisted, or every relaunch replays yesterday's news); an
|
||||
alert is only *offered* until the banner calls `alertShown` — nothing is persisted and no
|
||||
dismissal timer runs before that, so one arriving behind the screensaver waits rather than
|
||||
being consumed by nobody, and `pendingAlertExpired` drops it once the gateway stops
|
||||
offering it. The status loop itself runs under
|
||||
`ProcessLifecycleOwner … repeatOnLifecycle(STARTED)`, so a backgrounded app stops polling
|
||||
entirely instead of hitting the gateway every 10s at a TV nobody is watching. The banner is
|
||||
never focusable and
|
||||
times itself out after `MaintenanceMonitor.ALERT_VISIBLE_MS` (10s, with a ring counting it
|
||||
down — take the duration from that constant, or the ring and the timer drift apart),
|
||||
because stealing D-pad focus mid-browse is worse than a missed notice;
|
||||
and alerts are suppressed under maintenance and under a mandatory update, which own the
|
||||
screen. `MEMBY_SONARR_ALERT_WINDOW=0` turns them off without touching the schedule row.
|
||||
|
||||
**Search** (`ui/search/`) is a two-pane instant-search destination on the rail: a fixed
|
||||
6×6 on-screen keyboard on the left, a results grid on the right that updates as you type.
|
||||
Nothing is ever "submitted". `SearchViewModel` runs one pipeline — `debounce(250)` →
|
||||
`trim` → `distinctUntilChanged` → `collectLatest { repository.search(it) }` — and
|
||||
`collectLatest` is the load-bearing part: it cancels the in-flight request, so a slow
|
||||
response for a prefix can never overwrite the results for what was typed after it.
|
||||
Searching starts at two characters (`shouldSearch`); one letter matches half a library.
|
||||
`rankSearchResults` is a pure, stable sort that only lifts exact/prefix/word-boundary
|
||||
title matches above the backend's own relevance order — it never re-sorts alphabetically,
|
||||
and it keeps weak matches rather than showing an empty pane. A small access-ordered map
|
||||
caches results per query for the session, so backspacing is instant.
|
||||
|
||||
Focus is the hard part and is explicit: the leftmost keyboard column goes to the rail, the
|
||||
rightmost goes to the results grid, the grid's first column goes back to the *last key
|
||||
used* (a `FocusRequester` attached to whichever key that is), and the grid has a
|
||||
`focusRestorer`. Back moves results → keyboard → clear query → leave, one step per press.
|
||||
Physical keyboards and phone-remote apps feed the same state through one
|
||||
`onPreviewKeyEvent` that consumes only printable characters and backspace — D-pad and Back
|
||||
must fall through. The voice button needs the `android.speech.RecognitionService` entry in
|
||||
the manifest's `<queries>`, or `isRecognitionAvailable` returns false on Android 11+ and
|
||||
it hides itself on devices that actually support it.
|
||||
|
||||
**Row analytics.** `data/analytics/RowAnalytics.kt` buffers impression/focus/select events
|
||||
with dwell timing (injectable clock, unit-tested) and `HomeViewModel` flushes every 20s,
|
||||
on `ON_STOP`, and on dispose. Fire-and-forget by design — `reportRowEvents` swallows
|
||||
@@ -234,6 +309,20 @@ a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
|
||||
`media3-exoplayer-hls` dependency is already present for when that's added. Progress is
|
||||
reported back to Emby via `reportPlaybackStarted/Progress/Stopped`.
|
||||
|
||||
**Next up / auto-advance.** 30 s before an episode ends, `PlayerActivity` slides up
|
||||
`player_next_up_banner.xml` and rolls into the next episode when it reaches zero (Settings
|
||||
→ Playback turns it off; `Settings.autoPlayNextEpisode`). Which episode that is comes from
|
||||
`repository.nextEpisode`, dual-path like everything else: `/v1/items/{id}/next` on the
|
||||
gateway, `Shows/{seriesId}/Episodes?AdjacentTo=` directly. Both rely on Emby returning
|
||||
`[previous, current, next]` in running order, so it is the *position* of the current
|
||||
episode that identifies the next one — never the length of the list, which shrinks at both
|
||||
ends of a season (`episodeAfter` in `playback.go`, unit-tested). Three things are easy to
|
||||
break: the countdown is driven off the playhead, not a timer of its own, so pausing holds
|
||||
it and seeking backwards out of the window re-arms it; advancing swaps the `MediaItem`
|
||||
inside the running player instead of relaunching the activity, so `itemId`/`playbackStarted`
|
||||
/`stopReported` must all be reset together or the outgoing episode is never reported
|
||||
stopped; and a movie simply resolves to null, which is why nothing special-cases item type.
|
||||
|
||||
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
|
||||
tag `EmbyClientPerf`; `benchmark/` is a `com.android.test` macrobenchmark module currently
|
||||
targeting the debug build (`suppressErrors = DEBUGGABLE`), so its numbers are
|
||||
@@ -246,3 +335,31 @@ Material 3 ones. `MainActivity.kt`, `HomeComponents.kt` and `ScreensaverContent.
|
||||
three large files — new screens generally belong in `ui/<feature>/` rather than growing
|
||||
them further. Focus handling is explicit (`FocusRequester`, `focusRestorer`, `focusGroup`);
|
||||
everything must be reachable by D-pad only.
|
||||
|
||||
**Animations must not recompose.** This app ships to weak TV boxes, so an animated value
|
||||
read in a composable body — `val x by animateFloat(...)` then using `x` in the layout — is
|
||||
a bug: it recomposes that whole scope every frame. Pass the value down as a lambda and
|
||||
read it inside a `Canvas`/`drawBehind` block (draw phase only), and derive any text from it
|
||||
with `derivedStateOf` so it recomposes when the *displayed* value changes, not when the
|
||||
float does. `ServiceAlertBanner`'s countdown ring and pulse are the worked example: ~10
|
||||
recompositions of one number over ten seconds instead of ~600 of the whole bar. The same
|
||||
rule applies to collecting flows — collect in the smallest composable that needs the value,
|
||||
not at the top of `MainActivity`, or every emission recomposes the launcher.
|
||||
|
||||
**Previews.** `ui/PreviewSupport.kt` holds the one preview shape: `@TvPreview` (1080p TV,
|
||||
landscape, launcher black) plus `PreviewSurface { }` for the real theme. Use those rather
|
||||
than a bare `@Preview`, which defaults to a phone and misrepresents every layout here.
|
||||
A preview does not run `ServiceLocator`, so only composables that take their state as
|
||||
parameters are previewable — the same property that makes them unit-testable. Prefer
|
||||
previewing the still inner composable over an animated wrapper (`AlertBanner`, not
|
||||
`ServiceAlertBanner`): a frozen frame of a slide-in shows nothing useful.
|
||||
|
||||
**Screenshots.** `app/src/test/.../ServiceAlertBannerScreenshotTest.kt` renders composables
|
||||
to PNGs under `app/build/screenshots/` via Roborazzi + Robolectric, at TV 1080p qualifiers
|
||||
— the way to look at a layout without a TV to hand. This is the *only* Android dependency
|
||||
allowed in `app/src/test`; keep it confined to `*ScreenshotTest.kt` files so logic tests
|
||||
stay pure JUnit. Recording is always on (`roborazzi.test.record` in `testOptions`): these
|
||||
are artifacts to look at, not checked-in goldens, and a screenshot test that silently
|
||||
captures nothing is worse than none. AGP's own `com.android.compose.screenshot` plugin was
|
||||
tried first and discovers zero previews on AGP 8.13.2 — don't re-litigate it without
|
||||
checking that upstream.
|
||||
|
||||
Reference in New Issue
Block a user