Release v0.2.33
This commit is contained in:
@@ -116,6 +116,10 @@ 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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -427,10 +431,94 @@ title matches above the backend's own relevance order — it never re-sorts alph
|
||||
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.
|
||||
|
||||
**Every search the tab performs is recorded, and the gateway is what records it.** It used
|
||||
to depend entirely on the television posting to `/v1/search/history` after a result landed,
|
||||
which meant a query answered from the client's own cache, one whose post failed, or one
|
||||
from a build that predates the call was never written down at all — and the table feeding
|
||||
the recent-searches row and future per-user ranking was a partial record of what the
|
||||
household looks for. `handleSearch` now calls `recordSearchQuery` itself, before the Redis
|
||||
lookup, so a cached answer counts the same as one that reached Emby. Things to preserve:
|
||||
|
||||
- **The write is detached from the request context.** Instant search cancels the in-flight
|
||||
request on every keystroke, so a write hung off `r.Context()` would be abandoned for
|
||||
exactly the searches somebody typed fastest. It is also fire-and-forget: a search whose
|
||||
record failed still returns results, and the failure is DEBUG for the same reason the
|
||||
search line is.
|
||||
- **`store.SearchDedupeWindow` is what makes two writers safe.** The handler and the
|
||||
client's post both describe one search, and the POST route is still there because an
|
||||
older APK is the only thing that records at all. An identical query inside the window is
|
||||
the same search; a minute later it is its own row.
|
||||
- **`searchQueryRecordable` is the one rule both routes apply**, so a query `/v1/search`
|
||||
records is exactly one `/v1/search/history` would have accepted. Length is counted in
|
||||
runes, or a title in Japanese is rejected at a third of an English one's length.
|
||||
|
||||
**And the console can read it back** — `/admin/searches`, in the Insights group beside Row
|
||||
engagement, over `internal/api/admin_searches.go` and the store queries in
|
||||
`internal/store/searches.go` (where the writer moved to, so one table's rules sit in one
|
||||
file). It is **two tables of the same rows on purpose**: the summary groups by
|
||||
`lower(query)` and answers "what does this house look for", which is what a library is
|
||||
organised against; the log is uncollapsed and newest-first and answers "what happened just
|
||||
now", which is the one to read when somebody reports that search is not finding something,
|
||||
because it shows the query as it was typed, by whom, and when. Things to preserve:
|
||||
|
||||
- **The widest window is the retention period.** `searchWindowDays` is derived from
|
||||
`store.SearchRetention` rather than written down, because `RecordSearch` prunes to it —
|
||||
a page offering 90 days would draw a flat line for two thirds of it. A tile states the
|
||||
retention for the same reason: a quiet week and a window that has aged out look identical.
|
||||
- **`SearchTotals` is its own query, not a sum of the table above it.** The summary is
|
||||
capped at `searchTermLimit`, so adding it up would report the top twenty-five's total as
|
||||
the household's, wrong by however long the tail is.
|
||||
- **Names are resolved in Go from `KnownUsers`, not joined per row**, and an id with no
|
||||
session left keeps its row wearing the id — the query is what the page is for, and a
|
||||
viewer whose sessions have expired is still one searcher rather than nobody.
|
||||
- **Nothing on it is editable**, the stance every insights page takes. It also cannot
|
||||
delete: the 30-day prune is the only thing that removes a row.
|
||||
|
||||
**A genre is browsed, not searched.** The chips on the empty state ran their own label
|
||||
through `/v1/search`, which is a text query: "Drama" matched a film *called* Drama, anything
|
||||
with the word in its overview, and — relevance being a score rather than a rule — a
|
||||
scattering of titles not in the genre at all, while missing most of the ones that were. A
|
||||
chip now opens `GET /v1/genres/{genre}/items` (`server/internal/api/genres.go`,
|
||||
`EmbyRepository.browseGenre`), which filters on Emby's `Genres` parameter and answers a page
|
||||
at a time. Things to preserve:
|
||||
|
||||
- **It is a mode, not a query.** `SearchUiState.genre` sits beside the query rather than
|
||||
pretending to be one, which is what lets the pane head itself "Comedy" instead of "Search
|
||||
results for “Comedy”" and lets Back step out of the shelf without clearing something
|
||||
nobody typed. Typing supersedes it; `runSearch` returns early while a genre is open, or
|
||||
the empty-query transition that opening one causes would wipe the shelf it just filled.
|
||||
- **Paging must not repeat or skip a card**, which is why both paths sort on
|
||||
`PremiereDate,SortName` rather than a date alone — two titles sharing a premiere could
|
||||
otherwise swap places between requests, and the scroll would show one twice and the other
|
||||
never. Newest first, because the alphabet is not an answer to "show me Comedy".
|
||||
- **Two rules end the scroll and both are needed** (`hasMoreGenreItems`, pure and tested):
|
||||
reaching the total is the ordinary end, and a page shorter than the one asked for is the
|
||||
other — a backend that would not count says nothing useful with its total. `genreTotal` on
|
||||
the server is the same judgement from the other side, for an Emby that did not count.
|
||||
- **A page is appended by its own offset**, never by order of arrival: a response for an
|
||||
offset already scrolled past, or for a genre the viewer has left, is dropped rather than
|
||||
pasted into the middle of the grid. A page that fails part way down keeps what is on
|
||||
screen and simply stops paging.
|
||||
- **The scroll trigger is a `snapshotFlow`, not a composable read.** The last visible index
|
||||
changes on every frame of a scroll, and reading it in the body would recompose the grid
|
||||
the whole way down a genre — the same rule as the animation one above. It asks
|
||||
`LOAD_MORE_ROWS_AHEAD` rows early, since a request that starts when the viewer arrives at
|
||||
the end is one they watch.
|
||||
- **Something has to take the focus the chip was holding**, because opening a shelf unmounts
|
||||
the whole discovery pane. The grid claims it when the first page lands, and an empty or
|
||||
failed genre hands it back to the keyboard rather than leaving a television with nothing
|
||||
focused.
|
||||
- **Episodes are excluded on both paths.** An episode inherits its series' genres, so
|
||||
including them fills a page with twenty entries of one comedy and buries the rest.
|
||||
- **The gateway path degrades to a keyword search on the *first* page only**, so a set on a
|
||||
new build talking to a gateway that predates the route still shows something. A later page
|
||||
does not: a gateway that answered page one and failed on page two is having trouble, not
|
||||
missing the route, and search results pasted onto the end of a genre would be nonsense.
|
||||
|
||||
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.
|
||||
`focusRestorer`. Back moves results → keyboard → genre → 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
|
||||
@@ -574,6 +662,38 @@ fields to a home query is a startup-cost regression — extend the detail call i
|
||||
Emby time values are 100-ns ticks; convert at the boundary (`millisecondsToTicks`,
|
||||
`resumePositionMs`).
|
||||
|
||||
**A detail page is warmed while its card is focused, in two waves.** By the time somebody
|
||||
presses a card, the item record, its "why you might enjoy it" and its playable URL have all
|
||||
been fetched — and now so have its episode list and its trailer, which were the two things
|
||||
the page still opened cold. That mattered most on Continue Watching, where every card is an
|
||||
episode and pressing one opened a page with no season scroller and no episode list until the
|
||||
network answered. Things to preserve:
|
||||
|
||||
- **The two waves have deliberately different delays.** The metadata warm follows the D-pad
|
||||
closely at `FOCUS_METADATA_DEBOUNCE_MS` (140 ms) because it decides what the panel beside
|
||||
the row says; `warmDetailPage` waits `DETAIL_PREFETCH_DELAY_MS` (450 ms) because an
|
||||
episode list is the largest request this client makes — a long-running show is a thousand
|
||||
records — and warming one per card as somebody scans a shelf would spend more than it
|
||||
saves. The job is cancelled when focus moves, so a viewer travelling along a row never
|
||||
reaches it and one who has stopped, which is what precedes a press, does.
|
||||
- **Everything warmed on focus must be single-flighted on the repository's own scope**, for
|
||||
the reason `getRelated` already was: the warming job dies with the D-pad, and a request
|
||||
cancelled at the socket is one the gateway logs as a failure and one nobody keeps the
|
||||
answer of. `getSeriesEpisodes` and `getLocalTrailer` now take the same shape. A prefetch
|
||||
added without it makes navigation *slower*, because the press that follows re-asks.
|
||||
- **`getLocalTrailer` caches its negative answer** (`CachedTrailer`, the `CachedTrickplay`
|
||||
precedent). Most of a library has no local trailer, so before this every detail page opened
|
||||
with a request the gateway answered 404 to, repeated on walking Back and again for every
|
||||
step of the "More like this" trail.
|
||||
- **An episode is keyed on its series**, not on itself — that is what its own page will ask
|
||||
for, and it is what makes one warm serve a whole row of Continue Watching.
|
||||
|
||||
**A detail overlay seeds its settings from `repository.currentSettings`**, never
|
||||
`Settings.EMPTY`. Collecting a flow with an empty initial value draws the first frame under
|
||||
default preferences and then recomposes the entire page — and restarts the effects keyed on
|
||||
those preferences, which is a second ratings request — one frame later, at exactly the moment
|
||||
the page is trying to appear.
|
||||
|
||||
**Synced settings.** A viewer's settings live on the *server* and follow the person to
|
||||
whichever television they sign into; an operator can also read and push them per user from
|
||||
the admin console's accounts page. Three pieces:
|
||||
@@ -651,7 +771,7 @@ What syncs is a person's choices; what does not is anything identifying a *telev
|
||||
device name, update source and token, the screensaver's rotation and ring colour.
|
||||
`showTitleLogo` / `autoPlayNextEpisode` / `showTenMinuteReminder` moved from device-wide to
|
||||
per-profile as part of this, because a device-wide value would push whoever signed in last
|
||||
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all eighteen
|
||||
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all nineteen
|
||||
keys and the revision in **one** edit — DataStore rewrites the whole file per edit, and the
|
||||
revision landing apart from the values it describes would leave a TV permanently believing
|
||||
it was up to date while holding something else.
|
||||
@@ -764,13 +884,23 @@ is intercepted in `dispatchKeyEvent` and routed via the `ScreensaverActions` hol
|
||||
Playback from the dream `finish()`es first and starts `PlayerActivity` on a delayed main-
|
||||
thread post to avoid the "activity behind the dream" race.
|
||||
|
||||
**In-app updates.** `UpdateChecker` polls a user-configured **Gitea** release
|
||||
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos), downloads and
|
||||
verifies the APK, then commits it to a **`PackageInstaller` session**. Because replacing the
|
||||
APK kills a running Dream and leaves a black surface, `UpdateRecoveryReceiver` catches
|
||||
`MY_PACKAGE_REPLACED` and relaunches `MainActivity` with
|
||||
**In-app updates.** `UpdateChecker` polls a **Gitea** release
|
||||
(`/api/v1/repos/{owner}/{repo}/releases/latest`, token auth for private repos) or a static
|
||||
manifest, downloads and verifies the APK, then commits it to a **`PackageInstaller`
|
||||
session**. Because replacing the APK kills a running Dream and leaves a black surface,
|
||||
`UpdateRecoveryReceiver` catches `MY_PACKAGE_REPLACED` and relaunches `MainActivity` with
|
||||
`EXTRA_LAUNCH_UPDATED_SLIDESHOW`.
|
||||
|
||||
**There is no "check for updates" on the television.** Settings had an Updates page with that
|
||||
button on it, and the address, repository and token it needs could not be entered anywhere on
|
||||
this app — so the only thing it could ever report was a failure, on the one screen a viewer
|
||||
opens when they already suspect something is wrong. The single answer about updates is the
|
||||
gateway's (`server/internal/appupdate` → `ui/UpdateScreen.kt`), which arrives on every launch
|
||||
and carries its own `downloadUrl`; `SettingsPage` has no `UPDATES` entry and the version this
|
||||
TV is running is stated once, on About. The `updateBaseUrl`/`updateRepo`/`updateToken` keys
|
||||
remain in `Settings` and nothing writes them — they are what an install predating this still
|
||||
has in its DataStore.
|
||||
|
||||
The session is not an implementation detail — it is why the updater works on a television.
|
||||
The phone idiom, an `ACTION_VIEW` intent at the APK's content URI, fails three ways here: the
|
||||
implicit intent is subject to package visibility on Android 11+, several TV builds expose no
|
||||
@@ -854,31 +984,89 @@ person to every set rather than staying in the room it was made in. Things to pr
|
||||
embedded track, which is why `preferredTextTrack` falls back to running the same rule over
|
||||
the player's real tracks rather than giving up.
|
||||
|
||||
**A subtitle the library does not have is fetched through Bazarr**, from the player, in
|
||||
`server/internal/bazarr` and `internal/api/subtitle_download.go`. The whole feature rests on
|
||||
one property of Bazarr: it writes the file *beside the media file*. So the gateway stores
|
||||
nothing, serves nothing and never holds a provider credential — it asks Bazarr to fetch,
|
||||
**A subtitle the library does not have is fetched from the player**, in
|
||||
`internal/api/subtitle_download.go` over the provider layer in `subtitle_providers.go`.
|
||||
**Two backends answer and they are not the same shape**, which is the one thing to hold on
|
||||
to about this feature. **Bazarr** (`server/internal/bazarr`) writes the file *beside the
|
||||
media file*, so the gateway stores nothing and serves nothing — it asks Bazarr to fetch,
|
||||
calls `emby.RefreshItem` so Emby notices, waits `embyRefreshSettleDelay`, and re-reads the
|
||||
streams; the new track then arrives down the ordinary `PlaybackInfo` path, which is why
|
||||
`playableSubtitle` needed no new shape and `selectSubtitle` works on it with no special
|
||||
case. Things to preserve:
|
||||
case. **OpenSubtitles** (`server/internal/opensubtitles`) hands back *bytes*, and the
|
||||
gateway has no reach into the media directory, so a file fetched there is stored in
|
||||
`downloaded_subtitles` and served back as a sidecar from `/v1/subtitles/{file}`. That
|
||||
difference is the entire reason the gateway now holds a subtitle at all, and it is
|
||||
contained: `mergeSubtitleTracks` puts what the gateway holds beside Emby's tracks inside
|
||||
`playbackSubtitles`, so a downloaded subtitle is an ordinary track on every later playback
|
||||
rather than something that exists only in the response that produced it. Things to
|
||||
preserve:
|
||||
|
||||
- **The hard part is identity, not the download.** Bazarr keys on the *arr's id
|
||||
(`radarrid`, Sonarr's `episodeid`) and Emby knows neither, so `bazarrMovieFor` /
|
||||
`bazarrSeriesFor` / `bazarrEpisodeFor` match by title, year and episode number. They are
|
||||
pure and tested hard because a mismatch writes one film's subtitle next to another.
|
||||
Episodes match on *numbers*, never titles — the two disagree often enough (translations,
|
||||
differently named two-parters) to reject correct matches — and season 0 is specials, a
|
||||
real season, not "no season".
|
||||
- **The provider row is opaque.** `subtitle` is a provider-specific token that must be
|
||||
handed back verbatim on the download call; it round-trips through the television
|
||||
untouched rather than living in a server-side cache, so a viewer reading the list by
|
||||
remote cannot have their choice expire underneath them.
|
||||
- **The operator's switches are `store.SubtitlePolicy`, not environment variables.** A
|
||||
provider is offered when the `subtitle_download` feature is on *and* it is configured
|
||||
*and* it is switched on — `subtitleSources`, one place. Bazarr's address stays an
|
||||
environment variable because it is a service the household runs; OpenSubtitles is an
|
||||
account, so its key and login live in that document and the console's Subtitles page can
|
||||
enter, replace or remove them without a redeployment. The store refuses to record
|
||||
OpenSubtitles as on with no key, so the console can never show a switch that does
|
||||
nothing. Nothing on that page ever returns a credential — only whether one is saved,
|
||||
the stance the MDBList page takes.
|
||||
- **A candidate carries its `Source` and the download dispatches on it.** The two tokens
|
||||
are opaque in different ways and handing one to the other is a mistake nothing
|
||||
downstream could detect. Empty means Bazarr, because an app built before there was a
|
||||
second provider sends no source and all its rows came from one place.
|
||||
- **The two providers make opposite trades on identity, which is the hard part.** Bazarr
|
||||
keys on the *arr's id (`radarrid`, Sonarr's `episodeid`) and Emby knows neither, so
|
||||
`bazarrMovieFor` / `bazarrSeriesFor` / `bazarrEpisodeFor` match by title, year and
|
||||
episode number. They are pure and tested hard because a mismatch writes one film's
|
||||
subtitle next to another. Episodes match on *numbers*, never titles — the two disagree
|
||||
often enough (translations, differently named two-parters) to reject correct matches —
|
||||
and season 0 is specials, a real season, not "no season". OpenSubtitles keys on an imdb
|
||||
or tmdb id, which Memby already holds because the library import asks Emby for
|
||||
`ProviderIds` so external ratings can be looked up, so there is **no guessing on that
|
||||
path at all**. An episode is searched by its series' id plus season and episode number
|
||||
whenever the episode carries no id of its own, since a show has one far more often than
|
||||
each of its episodes does.
|
||||
- **`resolveSubtitleTarget` reads the item once and fails per provider.** A film Bazarr
|
||||
has never heard of may still have an imdb id, and a title with no provider id may still
|
||||
be in Bazarr's list; only both failing is a failure. `providerSubtitles` then searches
|
||||
both at once — a manual search is a live provider query measured in seconds — and a
|
||||
provider that fails is dropped rather than failing the search.
|
||||
- **The provider row is opaque.** The token is provider-specific and must be handed back
|
||||
verbatim on the download call; it round-trips through the television untouched rather
|
||||
than living in a server-side cache, so a viewer reading the list by remote cannot have
|
||||
their choice expire underneath them.
|
||||
- **A machine translation is offered, and says so.** It is a real answer and sometimes the
|
||||
only one, so `rankMergedCandidates` sinks it below everything a person wrote rather than
|
||||
hiding it, and `MachineOnly` is on the wire because it is the one property that changes
|
||||
whether a viewer wants the row at all. The provider is now *named* on a row, where the
|
||||
single-provider version deliberately did not name it: with two backends the same
|
||||
language appears twice and "which of these is which" is a question the row has to answer.
|
||||
- **The exhausted-allowance case keeps its own wording** (`opensubtitles.QuotaError` →
|
||||
`subtitleFailureMessage` / `subtitleDownloadFailureMessage`). It is the only failure
|
||||
where pressing the button again is definitely not the answer, and a television has no
|
||||
log and no support channel — that sentence is the whole diagnosis.
|
||||
- **A stored subtitle's id is derived, not random** (`storedSubtitleID`), so fetching the
|
||||
same language for the same title twice replaces the file rather than growing a second
|
||||
track a viewer has to tell apart by guessing. Its `gw:` prefix is what keeps it out of
|
||||
Emby's namespace, which is stream indices — plain numbers — because the player matches a
|
||||
track on the id it was handed.
|
||||
- **The gateway sends a *path* for a subtitle it serves, never an address.** It does not
|
||||
reliably know its externally reachable name and the television does, because it is the
|
||||
thing talking to it. `EmbyRepository.resolveSubtitleUrls` puts the base and the `t=`
|
||||
token on it, exactly as `imageUrl` does for artwork and for the same reason: media3
|
||||
fetches a sidecar as a plain URL with none of Memby's headers attached. A URL that is
|
||||
already absolute is left alone, so it is safe over every playback response.
|
||||
- **`subtitleDownloadAvailable` rides the playback response**, not `/v1/status`: the
|
||||
drop-up is the only thing that asks and it already holds that response, where the status
|
||||
poll is made by every open TV every ten seconds. It is `bazarr != nil` **and** the
|
||||
`subtitle_download` feature, and the client default is **false** — a missing field must
|
||||
never conjure a row that leads to a request the backend cannot answer.
|
||||
poll is made by every open TV every ten seconds. The client default is **false** — a
|
||||
missing field must never conjure a row that leads to a request the backend cannot answer.
|
||||
- **A title with no subtitles is told so, in the track section** (`SubtitleTracksState`).
|
||||
A list holding nothing but "Off" is indistinguishable from a menu that failed to load,
|
||||
and the row that could fix it sits below a rule under a heading the eye has no reason to
|
||||
travel to — so the notice goes under the SUBTITLES heading and the focus ring opens on
|
||||
the search row instead of on "Off", which is the state the viewer is already in. Where
|
||||
no provider can be asked it says that instead, once, rather than leaving somebody
|
||||
looking for an option that is not there. `SubtitleMenuScreenshotTest` covers both.
|
||||
- **Client-side it is a second screen, not a third section.** The drop-up is 344dp by about
|
||||
a third of a 720p screen; stacking a track list, the size chips and search results
|
||||
squeezed the tracks to one visible row. `SubtitleDownloadState.expanded` swaps
|
||||
@@ -960,6 +1148,17 @@ decoded. Four things exist to hold it down, and each is easy to give back:
|
||||
same answer. `MainActivity` keeps two states for this — `launchingItem` is the gate that
|
||||
stops a second Play press stacking a second player, `resolvingItem` is the loading screen
|
||||
and belongs only to the route that waits.
|
||||
- **The two launch forms are two places a `Playable` has to be unpacked, and only one of
|
||||
them is obvious.** `adoptPlayable` handles the request form; on the URL form — the cold
|
||||
start, and *any* launch where `readyPlayableForLaunch` had a warm prefetch in hand — the
|
||||
intent is the only thing that will ever carry an answer, and a field with no
|
||||
`putExtra`/`getExtra` pair silently takes its default. That is how `trickplayAvailable`,
|
||||
`skipIntroAvailable` and `endCreditsAvailable` were all shipped switched off: each
|
||||
defaults to **false** on purpose, so the omission produced no error, no log line and no
|
||||
request — the gateway saw zero `/trickplay` and zero `/intro` calls across seventy-seven
|
||||
playbacks. A new "is it worth asking the backend" boolean must be added in **four**
|
||||
places: the `Playable`, the intent's parameter list, its `putExtra`, and `onCreate`'s
|
||||
`getBooleanExtra`. `subtitleDownloadAvailable` is the worked example of all four.
|
||||
- **`ui/player/PlaybackTrace.kt` says where the time went.** "Playback is slow" is not
|
||||
actionable; `event=first_frame … activity=…(+…) player=… stream=… prepared=… ready=…
|
||||
first_frame=…` is. Marks are cumulative from the Play press, a repeated stage keeps the
|
||||
@@ -1038,10 +1237,13 @@ television. Read the first few kilobytes and every frame's byte range is known,
|
||||
thumbnail costs a ranged request of about seven kilobytes rather than a download nobody
|
||||
would wait through mid-seek. Things to preserve:
|
||||
|
||||
- **Emby answers ranges on that route and does not say so.** The response carries
|
||||
`Accept-Ranges: none` and a `Content-Length` borrowed from the media file. Trust the 206;
|
||||
both `emby.TrickplayBytes` and `TrickplayClient` cap the read anyway, because being wrong
|
||||
about that must not turn a press of Right into a five-megabyte download.
|
||||
- **Trust the 206, not the headers.** Emby 4.10.0.21 answers ranges on that route properly
|
||||
and says so — a 206 with `Accept-Ranges: bytes` and a `Content-Range` naming the BIF's own
|
||||
length — but earlier builds were reported to serve the range while advertising
|
||||
`Accept-Ranges: none` and a `Content-Length` borrowed from the media file. So both
|
||||
`emby.TrickplayBytes` and `TrickplayClient` cap the read at what was asked for regardless,
|
||||
because being wrong about that must not turn a press of Right into a five-megabyte
|
||||
download.
|
||||
- **A zero-frame BIF is an answer, not a fault.** Emby returns a perfectly well-formed
|
||||
72-byte file for a title whose thumbnails it has not generated, and for a width it does
|
||||
not hold — which is why `trickplayWidth` is not a free parameter and the client's
|
||||
@@ -1070,6 +1272,20 @@ would wait through mid-seek. Things to preserve:
|
||||
- **Cancelling the in-flight frame is load-bearing**, the same property `collectLatest` gives
|
||||
search: presses arrive faster than a fetch completes, and without it a slow response for a
|
||||
frame already skipped past lands on screen after the one being waited for.
|
||||
- **Scrubbing the transport shows them too**, not only the Left/Right chip. Those are the two
|
||||
ways to move through a film, and previews that stopped the moment somebody pressed a button
|
||||
to look at the controls read as the feature having broken rather than as a deliberate line.
|
||||
With the transport up the presses are the time bar's — `seekControlsActive` stands down —
|
||||
so `bindScrubPreview` hangs a `TimeBar.OnScrubListener` off `exo_progress` and draws into
|
||||
`player_scrub_preview`, a strip above the bar in the *controller* layout, which is what
|
||||
makes it disappear with the controls and need no visibility of its own. Things to preserve:
|
||||
it is the same `TrickplayPreview` instance as the chip, so one layout is fetched per title
|
||||
and one cache of frames serves both; it carries no wording, because the transport is already
|
||||
printing the position a caption would repeat; its horizontal place is computed from the
|
||||
scrubber in window coordinates and clamped to the bar, since a thumbnail parked mid-screen
|
||||
while the scrubber is at the far end is a picture of some other moment, and one pushed past
|
||||
the end is one overscan cuts; and it goes down on `onScrubStop` rather than on a timer, so
|
||||
it can never outlive the scrub that raised it.
|
||||
- **The cache holds JPEG bytes, not bitmaps**, and is the player's own rather than Coil's.
|
||||
Decoded, forty frames would be most of a megabyte; as bytes they are a couple of hundred
|
||||
kilobytes, and decoding one costs a millisecond off the main thread. Keeping them out of
|
||||
@@ -1137,6 +1353,85 @@ not land somewhere different depending on whether the container is up. Things to
|
||||
`build/screenshots/skip-intro/`. There is no scrim under this button, so a capture over
|
||||
black would prove nothing.
|
||||
|
||||
**The closing credits are read out of the same chapter list as the intro.** `markersFor` in
|
||||
`server/internal/api/intro.go` reads one `Fields=Chapters` response and answers for both
|
||||
features, and `EmbyRepository.chapterMarkers` caches one reading that `introSegment` and
|
||||
`creditsStartMs` both read. That is **the whole reason this is affordable** — adding a second
|
||||
lookup anywhere in that path gives away the only performance claim the feature has. From the
|
||||
result, `PlayerActivity` scales the picture into the left half, ramps it to 2× and puts what
|
||||
is on next in the right half.
|
||||
|
||||
**Two sources, and the one it was built on does not exist.** `CreditsStart` is in Emby's
|
||||
`MarkerType` enumeration, which is what this was originally written against — but a survey of
|
||||
the 20,000-item library it ships to found `Chapter`, `IntroStart` and `IntroEnd` and **no
|
||||
`CreditsStart` at all** on Emby 4.10. The enum having a value is not the detector populating
|
||||
it. What that survey did find was 216 items carrying a chapter *named* like credits, clustered
|
||||
at 90–98% of runtime and consistent within a show, and that is where every bit of the
|
||||
feature's coverage comes from today (roughly 5% of items, but effectively all episodes of the
|
||||
shows that have it). The marker is still read first, so the day a version writes one this
|
||||
needs no change. Things to preserve:
|
||||
|
||||
- **The position floor is the load-bearing guard**
|
||||
(`creditsMinimumPositionFraction` / `CREDITS_MINIMUM_POSITION_FRACTION`, 0.75). Chapter
|
||||
names are not a vocabulary anybody agreed on, and real media carries **"Opening Credits"** —
|
||||
Belfast at 1% of runtime, Game of Thrones at 0%. A name match without a position test starts
|
||||
the pane in the *first minute* of a film and runs its opening at double speed, which is the
|
||||
worst thing this feature could do. Three quarters is deliberately far below the evidence
|
||||
rather than near it: every genuine roll in that survey began at 90% or later. The name
|
||||
exclusions beside it are belt-and-braces — a position test catches wordings nobody thought
|
||||
of, a word list only catches the listed ones.
|
||||
- **The rule exists twice** — `creditsFromChapters` (Go) and `creditsStartFrom`
|
||||
(`data/Credits.kt`) — pinned by deliberately parallel tests carrying the real library's
|
||||
cases, the usual reason: with no gateway there is nobody to ask.
|
||||
- **The two sources resolve in opposite directions, and that is not an inconsistency.** For a
|
||||
*marker*, the last wins (as the intro rule takes the first): two starts mean the markers are
|
||||
untrustworthy, so each rule picks whichever risks least, and the two features are damaged in
|
||||
opposite directions — an intro skip firing late throws somebody past the story, while the
|
||||
credits pane firing early runs the last scene past them at double speed. For a *name*, the
|
||||
earliest qualifying chapter wins: several credits-named chapters are ordinary rather than
|
||||
suspicious ("The Pitt" carries both "Credits" and "End Credits") and they describe one roll,
|
||||
which begins at the first of them.
|
||||
- **A marker below the floor gives way to a name; with no runtime at all, the marker is
|
||||
honoured and the name is not.** An explicit marker is Emby asserting a position, so it gets
|
||||
the benefit of the doubt on wording — but not on position, and never enough to skip the one
|
||||
test that separates an opening sequence from a closing one.
|
||||
- **`RunTimeTicks` rides the chapter lookup on both paths** for exactly that test. It is a
|
||||
default field on the response, so it costs nothing; do not drop it to tidy the query.
|
||||
- **The duration guard is a second, separate refusal.** `creditsWorthShowing` asks whether
|
||||
enough of the roll is left to be worth moving the picture for (`CREDITS_MINIMUM_TAIL_MS`),
|
||||
and lives client-side because the player has the decoder's exact duration.
|
||||
- **2× is a ceiling that falls, never a speed that is defended.** Doubling the speed doubles
|
||||
the bitrate pulled from Emby over HTTP, and a high-bitrate file on a remote server may not
|
||||
sustain it. `STATE_BUFFERING` calls `stepDownCreditsSpeed`, `creditsCeilingAfterStall` only
|
||||
ever goes down, and it is never re-armed inside one roll — a marginal stream that could
|
||||
climb back would oscillate between stuttering and recovering for the length of the credits.
|
||||
Reaching 1× leaves the pane up: what is on next is still worth showing, and only the
|
||||
speeding up failed.
|
||||
- **It is the next-up banner's replacement, not a second thing beside it.** Both shrink the
|
||||
same picture and both say what is on next, so `updateNextUpFromPlayhead` returns early
|
||||
while the pane is up and the countdown moves into it — that countdown only appears inside
|
||||
the last minute, where it was always the banner's job. The pane also rides the banner's
|
||||
250 ms tick and its `nextEpisode` guard, which is exactly the right gate: a film and the
|
||||
last episode of a season both correctly get nothing. It therefore inherits auto-play's
|
||||
switch, since nothing resolves a next episode when that is off — deliberate, because this
|
||||
*is* the auto-advance experience.
|
||||
- **The transform is a scale, never a reparent.** The pre-roll moves the `PlayerView` between
|
||||
parents; doing that mid-playback tears the SurfaceView down and flashes black over
|
||||
somebody's credits. `CREDITS_VIDEO_SCALE`/`CREDITS_VIDEO_SHIFT_X` are public so
|
||||
`EndCreditsScreenshotTest` can place its stand-in picture at exactly the transform the
|
||||
activity applies — a capture that guessed the split would prove nothing about whether the
|
||||
two halves balance, which is the only thing worth looking at. Neither is a round number:
|
||||
0.5 and 0.25 put the picture's left edge at exactly x=0, which is the first thing overscan
|
||||
cuts.
|
||||
- **`speedUpCredits` is a synced per-profile toggle**, defaulting to **on** — a different
|
||||
trade from `skipIntroMode`'s, which defaults to the button rather than the automatic seek.
|
||||
This one is visible, reversible and over in a minute, so somebody who dislikes it turns it
|
||||
off having watched exactly what it does. `maskMarkers` withholds the half of a reading whose
|
||||
feature an operator has turned off, on the way *out* rather than on the way in, so the cache
|
||||
keeps the truth and a feature switched back on takes effect on the next playback.
|
||||
- Screenshots are `EndCreditsScreenshotTest` → `build/screenshots/end-credits/`, over a
|
||||
deliberately bright frame: there is no scrim between the credits and the panel.
|
||||
|
||||
**Performance instrumentation.** `PerformanceMonitor` (JankStats) is debug-only and logs to
|
||||
tag `EmbyClientPerf`. `benchmark/` is a `com.android.test` macrobenchmark module targeting
|
||||
the release variants the `androidx.baselineprofile` plugin generates, so its numbers are
|
||||
@@ -1252,6 +1547,117 @@ and two secondary greys, which is visible the moment a detail page opens from a
|
||||
colour or radius belongs in the token file, or is a considered exception — not a fifth
|
||||
value.
|
||||
|
||||
**Those colours are the server's answer, not constants.** Every token in `DesignTokens.kt`
|
||||
is now a `get()` over one process-wide `mutableStateOf(MembyPalette)`, and `applyMembyPalette`
|
||||
is what repaints the app. Two things follow and both are easy to undo: an alias must be a
|
||||
getter too (`HomeComponents`' `EmbyGreen`, `DetailPageComponents`' `Detail*`, the settings
|
||||
sheet's own `EmbyGreen`), because a `val` captures whichever theme was loaded when its class
|
||||
initialised and never changes again; and `MembyTheme` builds its `darkColorScheme` per
|
||||
composition rather than holding one, which is how a theme change reaches every component
|
||||
that never names a colour. Shape and punctuation are deliberately *not* themeable — a
|
||||
palette that could move a corner radius could make a layout wrong from the server, and the
|
||||
whole safety of this feature is that the worst a bad theme does is look bad.
|
||||
|
||||
**Themes** are `server/internal/api/themes.go`, and there are two kinds. A **selectable**
|
||||
theme is the viewer's own choice, held as the ordinary synced preference `themeId` and
|
||||
picked in Settings → Appearance. A **seasonal** theme (Halloween, Christmas, Easter) is not
|
||||
a choice at all: it is in force for its dates and nothing on the television can decline it,
|
||||
because a per-person opt-out is a thing somebody turns off in October and never reconsiders,
|
||||
which is the same as the feature not existing. The only switch is the operator's
|
||||
`seasonal_themes` feature flag, for the whole house. Things to preserve:
|
||||
|
||||
- **`resolveTheme` is the whole rule and it is pure**: a season outranks the viewer, the
|
||||
viewer outranks the default, and the operator's per-user allowlist narrows the *choice* but
|
||||
never a season. There is no argument a television can send that suppresses one, which is
|
||||
what "cannot be controlled by the user" means in code. The viewer's own pick is still
|
||||
reported as `chosen` underneath a season, or the picker would show nothing selected for a
|
||||
fortnight and read as having forgotten it.
|
||||
- **The two windows resolve their edges deliberately.** Halloween opens on 25 October (a
|
||||
theme nobody sees until the evening of the 31st is one nobody sees) and Christmas closes on
|
||||
Boxing Day (the tree is down; red-and-green on the 30th reads as a server nobody maintains).
|
||||
Easter is the anonymous Gregorian computus in `easterSunday` — computed, because a
|
||||
hard-coded table is a feature with an expiry date on it — over Good Friday to Easter Monday.
|
||||
`seasonalThemeFor` takes the time rather than reading the clock, so every edge is tested.
|
||||
- **The revision is a hash of the resolved theme, not a counter.** There is no write to
|
||||
attach a counter to: nobody writes anything at midnight on 1 December, the answer simply
|
||||
becomes different. `/v1/status` carries `theme: {id, revision, locked, seasonal}` — the
|
||||
`preferencesRevision` precedent — and the TV fetches `/v1/theme` only when it moves. It
|
||||
rides the poll rather than the sign-in because that *is* the feature: a season has to reach
|
||||
a set that is already switched on.
|
||||
- **The allowlist is `user_themes`, and absence is permissive.** No row exists for anybody
|
||||
until an operator restricts somebody, so an empty list means "unrestricted"; reading it the
|
||||
other way would empty every picker in the house on the day it shipped.
|
||||
`normalizeThemeAllowlist` stores "every box ticked" as the empty list for the same reason —
|
||||
they are the same decision — and drops seasonal ids, which are not grantable per person.
|
||||
It is a separate table from `user_preferences` because that document is the viewer's own
|
||||
choices and every television they own writes it; this is policy *about* them and only the
|
||||
console writes it.
|
||||
- **The available list is per viewer and comes from the server.** `/v1/theme` sends only the
|
||||
themes that person may pick, so a withheld scheme is a row the television was never sent
|
||||
rather than a greyed one — the client has no catalogue of its own to fall back to, and the
|
||||
picker is simply not drawn when fewer than two arrive.
|
||||
- **Nothing repaints from the local choice.** `setThemeId` writes the preference and stops;
|
||||
the palette arrives through `ThemeSync` when the gateway has resolved it. That is what makes
|
||||
a choice a season covers, or one the operator has since withdrawn, visibly not take effect
|
||||
rather than take effect and be yanked back a second later.
|
||||
- **`ThemeSync` paints from the cached palette before any request**, the promise `HomeCache`
|
||||
makes about the rows, and clears the cache on a profile switch (a different person, a
|
||||
different scheme). The palette cache is device state; only `themeId` syncs.
|
||||
- **There is no second copy of the rule on the direct path**, unlike subtitles, intros and
|
||||
Continue Watching. Those exist twice because the direct path would otherwise *behave*
|
||||
differently; here it behaves as it always did — the default palette. A television deciding
|
||||
from its own clock that it is Halloween, while the household's gateway has seasons switched
|
||||
off, would be the feature failing rather than degrading. `data/Themes.kt` is only hex
|
||||
parsing, and it refuses anything it cannot read so the app's own token stands in.
|
||||
**Seasonal decorations** are `ui/seasonal/SeasonalDecorations.kt`: snow, bats or blossom
|
||||
drifting over the launcher for the few days a season is on. A palette on its own is a thin
|
||||
idea of Christmas — the colours change and nothing says why — and this is the half that
|
||||
does. It is also the most expensive thing in the app, the only animation that runs
|
||||
continuously while somebody is merely browsing, so:
|
||||
|
||||
- **Nothing about it recomposes.** One `Canvas`, one animated `State<Float>` never read in a
|
||||
composable body, every position derived arithmetically inside the draw lambda. A full field
|
||||
costs zero recompositions and one draw pass. The palette colours *are* read in composition,
|
||||
deliberately, so a theme change repaints this node.
|
||||
- **A particle is `index` and `progress` and nothing else** — no array, no per-particle state
|
||||
to allocate or re-seed. `drawSeasonalField` is therefore a pure function of one number,
|
||||
which is what lets a screenshot capture an exact frame with no animation clock.
|
||||
- **Every cycle count is a whole number**, so at the instant the driving value rolls 1 → 0
|
||||
the entire field is exactly where it was. Without that it visibly jumps once a minute.
|
||||
`decoration-snow-0.png` and `decoration-snow-100.png` must be identical; that is what they
|
||||
are for.
|
||||
- **Placement is stratified, not hashed.** Each particle owns a slice of the axis and the
|
||||
hash only jitters within it. Twenty-six samples is far too few for a hash to look evenly
|
||||
spread — the first version put visible bands and a bare patch through the middle, and the
|
||||
eye finds a clump in a snowfield instantly.
|
||||
- **The slug comes from the gateway** (`decoration` on the theme), never derived from the
|
||||
theme id: an operator turning `seasonal_decorations` off sends an empty one, and a set
|
||||
holding a cached Christmas palette must stop snowing. It is a *second* switch from
|
||||
`seasonal_themes` because the palette and the animation have quite different costs on a
|
||||
weak box. An unknown slug draws nothing.
|
||||
- **Launcher only.** Not over playback — a film is the one thing nothing may drift across —
|
||||
and not over the settings sheet. It is a sibling of `HomeScreen` rather than inside it, so
|
||||
an arriving theme cannot invalidate the rows.
|
||||
- **The platform's "remove animations" setting is honoured.** A season is deliberately not
|
||||
the viewer's to decline, but an accessibility choice is not a preference.
|
||||
- **The alpha was tuned by looking, not reasoned about.** The layer sits over an opaque
|
||||
surface, so a flake occasionally lands on the Play button; at this value that reads as snow
|
||||
in front of the screen and a few points higher it reads as a rendering fault.
|
||||
`decoration-over-content.png` exists for exactly that judgement.
|
||||
|
||||
- **Screenshots are the only real test this feature has.** A unit test can check that a hex
|
||||
string parses; it cannot check whether Easter's pale accent is legible on its own
|
||||
near-black or whether Forest still has a hairline. `ThemeScreenshotTest` renders one series
|
||||
page under every palette → `build/screenshots/themes/`, deliberately one screen across nine
|
||||
themes rather than nine screens on one, so the images differ in nothing but colour. It
|
||||
composes once and repaints by assigning the palette, which is both the only thing
|
||||
`setContent` allows and the more honest picture — that is exactly what a set already
|
||||
showing the page does when a season begins. The palettes are a *fixture* copied from the
|
||||
catalogue, not a second copy of the rule: nothing derives a colour from it. It caught the
|
||||
chip row overflowing at six themes, which is why that row is a `FlowRow` where every other
|
||||
choice row on the page is a `Row`, and it is why the blossom is a five-petal flower — a
|
||||
single petal rendered as a grey seed, recognisable as *something falling* and nothing else.
|
||||
|
||||
**One button language.** `ui/MembyButtons.kt` — `MembyPlayButton` (focusable),
|
||||
`MembyPlayChip` (the same surface as decoration inside an already-focusable parent, for the
|
||||
home hero) and `MembyChoiceChip`. There were three: this one, a hand-rolled copy in the hero
|
||||
|
||||
Reference in New Issue
Block a user