App v0.2.27 and gateway 0.1.23

Skip Intro from Emby's own chapter markers, trickplay seek previews from
BIF files, a server-composed home hero ranked on Radarr/Sonarr dates and
review scores, and My Alerts as its own page behind the user picker.

Related titles now degrade at every step instead of returning empty, and
the "+" is back on Manage users so a second viewer can be added from the
launcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-07 10:44:17 +12:00
co-authored by Claude Opus 5
parent 4a4df7a73c
commit 80c304d86b
62 changed files with 6095 additions and 255 deletions
+8
View File
@@ -1,3 +1,11 @@
## 0.2.27 — 2026-08-06
- Added: Skip Intro button
- Fixed: Player improvements & bug fixes
- Fixed: Gateway bug fixes & improvements
- Fixed: Homepage hero relevancy
- Fixed: Related titles showing up empty often
- Fixed: You can add another user to the television again, from Manage users
## 0.2.26 — 2026-08-05
- Improved: Ratings are more accurate and easier to read.
- Fixed: Continue Watching now keeps the right order as you watch.
+214 -4
View File
@@ -391,6 +391,30 @@ the alert that announced it has long since fallen out of its window. Things to p
— unauthenticated on purpose, since a probe needing a token would report a stale session
as a server outage.
**My Alerts belongs to a person, so it lives in the user picker.** A service alert is the
house being told something; these are one viewer's own news (a followed show returning),
stored per user on the gateway and following them to whichever television they sign into.
`ui/alerts/AlertsPage.kt` is the full page and the user menu in `UserSwitcherOverlay` is the
way in, beside Manage users. It replaced a bell in the corner of the launcher, which was
drawn only on Home and cost a focus target on every set whether or not there was anything
behind it. Things to preserve:
- **The badge counts alerts, not unread ones** (`alertBadgeLabel`, pure and tested). An
alert that has been read but not dismissed is still sitting there, and a badge that
cleared itself the moment somebody glanced at the page would never agree with the list
underneath it. `AlertBadgeMax` is what stops the pill growing wider than its row.
- **The badge is drawn twice on purpose** — on the "Switch user" rail item and on the My
Alerts row inside the picker. The page is one level in now, so without the mark out on the
rail nothing on the launcher would ever say there was news waiting.
- **A press dismisses, and the focused row says so.** This is the only page whose whole job
is emptying itself; a confirmation press per alert is what made the panel it replaced not
worth opening. Focus marks read, so nothing has to be pressed to clear the "new" flag.
"Dismiss all" is the same per-alert call in a loop — the gateway has no bulk route — and
empties the list optimistically, or a row lingers under a thumb that will press it again.
- **The page is stateless**, like `SignInContent` and the detail panes: `MainActivity` owns
the list and the requests, which is what lets `AlertsPageScreenshotTest` render it (and
the user menu carrying its badge) with no server → `build/screenshots/my-alerts/`.
**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)` →
@@ -518,6 +542,22 @@ rows come from the `r:<userId>:rows` cache, and a miss triggers a deduplicated b
rebuild while home returns immediately. That key is intentionally outside the `u:`
namespace that mutations wipe; only a finished playback retires it.
**"Because you watched …" rotates, because the top of a watch history does not.** The rows
were anchored to the two most recent seeds, and the head of history is a resumable title
plus whichever series the household is part-way through — neither of which moves for weeks,
so the same two rows came back day after day. `selectSeeds` (pure, tested) instead cuts the
most recent `SeedPool` seeds into `MaxSimilarRows` equal bands and draws one from each by
`dailySeed(userID)`. Things to preserve: it is a rotation *within recency bands*, never a
shuffle of the window, so the first row is still anchored to something watched lately and
the rows below it reach further back; the same day always yields the same seeds, because
rows are rebuilt on every cache miss and a set that re-picked each time would change under
somebody browsing; the last band takes the remainder, so a pool that does not divide evenly
still reaches its oldest entry; and a history shorter than the pool falls back to plain
recency rather than pretending to rotate. `similarRow` also runs `diversifyRanked` over its
cards with the same daily variation, the way curated shelves do — a row that keeps its seed
across two days must not present the same posters in the same order. `MEMBY_RECOMMEND_TTL`
(24h) is what makes the rotation daily in practice: the seed only changes at a rebuild.
**`EmbyRepository`** is the only place that talks to Emby. It keeps a `@Volatile` `snapshot`
of `Settings` collected from DataStore so synchronous callers (URL builders,
`rotationIntervalMillis`) don't suspend, and it caches the Retrofit `EmbyApi` instance,
@@ -611,7 +651,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 seventeen
into everyone else's account. `SettingsStore.applyRemotePreferences` writes all eighteen
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.
@@ -985,6 +1025,118 @@ gateway's catalogue. Things to preserve:
`SEEK_LOADING_GRACE_MS` puts the overlay up after all if the seek is still buffering
6 seconds later, because past that it is not a skip landing, it is a film that stopped.
**And it shows the frame it will land on.** The idea and the shape are borrowed from
[Wholphin](https://github.com/damontecres/Wholphin), under the same GPL-2.0 licence, the
way `PlayerEngine`'s HTTP stack was; what differs is the format underneath. Jellyfin serves
tile sheets, so Wholphin crops a sub-image out of a grid. **Emby serves BIF files**
(`/Videos/{id}/index.bif?Width=320`): a 64-byte header, one 8-byte (timestamp, offset)
entry per frame plus a terminator, then the JPEGs laid end to end — 320×172 every ten
seconds, about five megabytes for a two-hour film.
The index sitting at the *front* of the file is the whole reason this is affordable on a
television. Read the first few kilobytes and every frame's byte range is known, so one
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.
- **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
`TRICKPLAY_WIDTH` must match it. The gateway caches that no for
`trickplayMissingTTL` (shorter than the index's day, since thumbnails are generated on a
schedule) or every press on such a title is a fresh round trip for the same answer.
- **The parsing exists twice**, in `data/Trickplay.kt` and `server/internal/trickplay`,
pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`) — the usual reason:
with no gateway there is nobody to ask. What differs between the paths is *where the
reading happens*, not what is read. In gateway mode the television holds no Emby
credential, so the gateway reads the file and serves a frame at a time from
`/v1/items/{id}/trickplay/{n}.jpg`; on the direct path the TV range-reads Emby's file
itself. `Trickplay.bif` being null is what distinguishes them.
- **The manifest is its own request, deliberately not a field on `/v1/items/{id}/playback`.**
Reading the index costs the gateway a round trip to Emby, and that response is the one
thing standing between a Play press and a decoder starting. Only the boolean
`trickplayAvailable` rides there — the `subtitleDownloadAvailable` precedent — so an older
or deliberately-configured-off gateway is never asked. It is fetched from
`startPlaybackSession`, beside `loadCast()`, for the same reason that one is.
- **Nothing about it may be on the path of a press.** The chip has always said where the
skip lands and still says it with no thumbnail: a title with no previews, a server that
will not answer and the moment before the first frame arrives are all the same wordless
chip, which is what `SeekIndicatorScreenshotTest`'s empty case exists to hold. Every
failure is silent, and `handleTrickplay` answers trouble with "no previews" rather than an
error nobody could act on and everybody would log once per press.
- **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.
- **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
Coil matters too — a burst of presses walks through dozens, and letting that churn through
the artwork cache would evict the backdrops the launcher is about to want back.
**Skipping the opening titles is Emby's own answer, not a detector.** Emby finds intros
itself and writes them into an episode's chapter list as two markers, `IntroStart` and
`IntroEnd`, interleaved with the ordinary chapters in playback order — so there is nothing
to detect on either end and nothing to store: reading them is one `Fields=Chapters` lookup.
`introFromChapters` (`server/internal/api/intro.go`) and `introSegmentFrom`
(`data/Intro.kt`) are the pure rule, pinned by deliberately parallel tests (`intro_test.go`,
`IntroTest`) for the usual reason — with no gateway there is nobody to ask, and a skip must
not land somewhere different depending on whether the container is up. Things to preserve:
- **Most of the rule is about refusing to answer.** Half a pair, a pair out of order, a
segment under 5 s or over 5 min all produce nothing, and nothing is a good answer: the
player simply never offers the button. A wrong skip costs somebody the opening of a scene,
which is far worse than not being offered one. The first `IntroStart` wins — two starts
mean the markers are already untrustworthy, and the later one is the larger, more damaging
skip.
- **The segment is its own request** (`/v1/items/{id}/intro`), the `trickplay` precedent:
reading it costs a round trip to Emby and the playback response is the one thing standing
between a Play press and a decoder starting. Only the boolean `skipIntroAvailable` rides
there, and the client default is **false**. It is fetched from `startPlaybackSession`
beside `loadCast()`, which is safe because the earliest intro in a typical library starts
a couple of minutes in. "No intro" is cached (server and client) — most of a library has
no markers, and without it the same no would be fetched on every playback.
- **`skipIntroMode` is a synced per-profile setting** (`prompt` / `auto` / `off`), with the
vocabulary duplicated in `data/SkipIntroPreference.kt` and the gateway's catalogue like
the seek interval's. It normalises to **`prompt`**, never `auto`: costing a set its button
because it cannot read a value is recoverable, jumping through somebody's episode on a
string this build cannot parse is not.
- **The ring counts the offer, not the title sequence.** `SkipIntroCountdownView` draws a
draining arc with the figure inside it, advanced from the playhead by
`updateSkipIntroCountdown` — so pausing during the titles holds it and seeking moves it,
neither of which a wall-clock timer could do. It runs from where the button appears to
where it goes, `SKIP_INTRO_TAIL_MS` short of the end of the intro. The two are seconds
apart and only one can be drawn honestly: a ring measuring the whole sequence would stop
with a sliver left and vanish mid-sweep, which reads as a broken countdown rather than as
a lapsed offer. Three things to preserve — the view takes its colours from its own
drawable state and the layout feeds it `duplicateParentState`, because the pill inverts to
white on focus and a ring that did not follow would draw white on white; it refuses to
redraw for movement under a degree, which over a two-minute opening is most of the ticks;
and `formatRemaining` switches to `1:58` over a minute with the text sized from the
string's length, since a title sequence is commonly long enough to be counted in minutes
and "118" would print over its own arc.
- **The button takes focus and the notice does not.** A remote has no other way to say
"press this", so it is focusable and `centrePausesPlayback` stands down while it is up —
otherwise the one button on screen is unpressable. It never appears over the transport,
the drop-up, the cast panel or the next-up banner, which already own the remote. An
automatic skip instead swaps the same view into "Intro skipped" wearing the timing cues'
quiet plate (`dressSkipIntro`), because a picture that jumps for no visible reason reads
as the stream glitching, and a notice that still looks like a button gets pressed. The
ring goes with it rather than freezing at zero: there is nothing left to press and nothing
left to run out.
- **`skipIntroTaken` is never re-armed within an episode**, unlike `skipIntroDismissed`.
Rewinding to before the titles offers the button again — somebody who went back there did
it on purpose — but in automatic mode re-arming would drag them forward again the moment
they reached the opening they had just returned for.
- **The seek goes through `seekBuffering`**, the same door a press of Right uses, so the
couple of seconds it takes to decode at the new position is treated as a skip landing
rather than as a film that has stopped.
- `SkipIntroScreenshotTest` renders it over a deliberately *bright* fake scene →
`build/screenshots/skip-intro/`. There is no scrim under this button, so a capture over
black would prove nothing.
**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
@@ -1149,7 +1301,7 @@ slot ("POPULAR", "NEW RELEASE", "TRENDING" by index) while the selection interle
and falls back to every movie in the response, so it routinely lied. Only the **minis** carry
that caption now: they have no fact line, so the label is the only reason the card gives,
where on the featured card it sat above a line already printing the year and cost the height
that broke the button.
that broke the button — the featured card says why in a sentence instead (see below).
**Play is measured before the words.** The featured card is a fixed height, and a Column
gives each child what the ones before it left — so the chip, being last, was handed the
@@ -1161,8 +1313,66 @@ Keep that inversion. The `titleLines == 1` rule that stands the synopsis down is
having — it means the give usually costs nothing visible — but it is a tidiness, not the
guarantee. `HomeMovieHeroScreenshotTest` renders the wrapping-title case for exactly this.
**The hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)` takes a
count of local days and rotates the starting point of each candidate list; `MainActivity`
**The hero is composed by the gateway** (`server/internal/api/hero.go`), because the three
things worth ranking it by are three things the television cannot see. **Radarr knows when a
film actually came out** — `digitalRelease` is the date the household could first have
watched it, where Emby's `PremiereDate` is the theatrical date when it is right at all and a
metadata agent's guess when it is not, so ranking "new releases" by it produced an order
with nothing to do with when anything became watchable. **Sonarr knows a premiere from an
ordinary episode**, so a new show or a returning season can lead where before a series could
only reach the hero as a random card off a shelf. And **the review scores are already
attached to the cards** by `decorateHomeRatings`, so a well-received release can outrank a
fresher one nobody liked at no cost. `rankHeroCandidates` is the pure rule
(`heroRecencyWeight`/`heroRatingWeight`, `hero_test.go`); `selectHomeHeroMovies`'s original
row-interleaving rule survives underneath as the **direct path's** hero and the fallback for
a gateway older than the feature, which is why `serverHeroPicks` is consulted first and
returns nothing rather than throwing. Things to preserve:
- **It asks Emby for nothing.** The movie candidates are the rows already assembled and
their ratings are already attached, so the expensive half of the launcher is reused
rather than repeated. The two *arr calendars it does read are cached for the day behind a
shared lock, like the schedule rows' — one household pays one miss each per day — and the
three lookups run concurrently, because this is the tail of a response every television
in the house is waiting on.
- **Every card it produces is playable.** A premiere the household has not downloaded, a
film Radarr is still waiting on, a synthetic schedule card — all are news for the schedule
row, and a lead card that does nothing when pressed is worse than no lead card at all.
`sonarrPremieres` requires `HasFile` *and* an Emby series id for exactly this.
- **A premiere is the first episode of a season**, S01E01 or S05E01 alike, and season 0 is
specials rather than a premiere. One card per series, the newest season winning, or a show
that premiered and returned inside one window appears twice.
- **An unrated title is not a bad title** (`heroUnratedScore`, deliberately mid-scale). On a
household that has not configured MDBList that is every title, and burying them would
empty the hero; `heroRatingOf` falls back to Emby's `CommunityRating`, which the client is
still forbidden from *drawing* — ordering four cards by a score claims nothing to anybody,
where printing it beside a provider's name that was never asked is a lie.
- **The captions and the reason are the gateway's wording** (`MembyHeroLabel`,
`MembyHeroReason`), the `MembyAirLabel` precedent, so a kind of hero card invented
tomorrow reads correctly on today's build. `labelTint` matches them as strings for the
same reason, and an unknown one gets the neutral wash rather than nothing.
- **A label is a claim that has been earned**, and `heroReason` returns empty rather than
inventing one — the captions this replaced were the card's *slot*, which is how a 2019
film came to be announced as new.
- **The row is consumed, never drawn.** `serverHomeRows` drops `kind == "hero"`; without
that the four featured titles print a second time as an unnamed row of posters directly
beneath the hero they are already in. `supportsHomeHero` gates it at 0.2.27 for the same
reason — an older television has no idea the kind is special. That floor is the version
the feature shipped *in* rather than one after it, so a 0.2.27 build predating it would
draw the duplicate row; moving the floor up is the fix if that ever bites.
- **No daily rotation on the server's hero.** The facts behind it already change daily, and
rotating a merit ranking is exactly how the best-reviewed release of the week lands in the
fourth slot. The rotation below belongs to the direct path, which has no merit to rank by.
**The reason sits above the ratings strip**, and that order is load-bearing. The featured
card's text column is what gives way when a title wraps onto two lines, so whatever is last
in it is cut — with the reason below the strip, the one line explaining why this card leads
the launcher was silently dropped on exactly the long-titled films most likely to be leading
it. The scores are also on the detail page the card opens; the reason is nowhere else. It
takes the synopsis's place rather than adding a line, so preferring it can only make the
card shorter, and there is still no eyebrow above the title.
**The direct path's hero changes daily, at local midnight.** `selectHomeHeroMovies(rows, day)`
takes a count of local days and rotates the starting point of each candidate list; `MainActivity`
keys its `remember` on `rememberHomeHeroDay()`, which sleeps until the next local midnight
rather than polling. Three properties are load-bearing and unit-tested. It is a *rotation*,
not a shuffle: the server's ranking is still the order, so what it thinks is worth leading
+3 -1
View File
@@ -13,7 +13,9 @@ Wholphin
Portions of Memby's Android playback capability probing, server-side
device-profile generation, and ExoPlayer construction (constant-bitrate
seeking and sourcing media over the application's own OkHttp client, in
app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt) are
app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerEngine.kt), and the
design of its seek preview thumbnails
(app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt), are
adapted from Wholphin, an Android TV client licensed under GNU GPL version 2:
https://github.com/damontecres/Wholphin
+1 -1
View File
@@ -42,7 +42,7 @@ val projectNoticeText =
// A release workflow can derive the app version from its Git tag without editing the
// source tree. Local builds keep using the checked-in default.
val defaultVersionName = "0.2.26"
val defaultVersionName = "0.2.27"
val membyVersionName: String =
(project.findProperty("memby.versionName") as String?)
?.trim()
@@ -22,6 +22,7 @@ import com.ponzischeme89.memby.data.remote.EmbyApi
import com.ponzischeme89.memby.data.remote.EmbyServiceFactory
import com.ponzischeme89.memby.data.remote.GatewayApi
import com.ponzischeme89.memby.data.remote.GatewayServiceFactory
import com.ponzischeme89.memby.data.remote.TrickplayClient
import com.ponzischeme89.memby.data.playback.devicePlaybackCapabilities
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
@@ -106,6 +107,20 @@ data class Playable(
* turned the feature off, so the player never offers a row that cannot do anything.
*/
val subtitleDownloadAvailable: Boolean = false,
/**
* Whether it is worth asking the backend for seek previews. On the direct path the
* television reads Emby's preview file itself, so this is always true there; in
* gateway mode it is the gateway saying whether it will answer, which keeps an older
* or a deliberately-configured-off container from being asked once per playback.
*/
val trickplayAvailable: Boolean = false,
/**
* Whether it is worth asking the backend where this title's opening titles are. True
* on the direct path, where the television reads Emby's chapter markers itself; in
* gateway mode it is the gateway saying whether it will answer, which keeps an older
* or a deliberately-configured-off container from being asked once per playback.
*/
val skipIntroAvailable: Boolean = false,
)
/**
@@ -189,6 +204,19 @@ class EmbyRepository(private val settings: SettingsStore) {
private val playableMutex = Mutex()
private val playableCache = LinkedHashMap<String, CachedPlayable>(16, 0.75f, true)
private val playableInFlight = mutableMapOf<String, Deferred<Playable>>()
/**
* Preview layouts, per title, for as long as the process lives. A title's thumbnails
* only change when its media does, and the entry is what saves a round trip on every
* press of Left or Right.
*/
private val trickplayCache =
LinkedHashMap<String, CachedTrickplay>(TRICKPLAY_CACHE_SIZE, 0.75f, true)
/**
* Where each episode's opening titles are, for as long as the process lives. Markers
* only change when the media is re-analysed, and an evening of one show asks for this
* once per episode — including the ones auto-advance rolls into.
*/
private val introCache = LinkedHashMap<String, CachedIntro>(INTRO_CACHE_SIZE, 0.75f, true)
private val seriesEpisodesMutex = Mutex()
private val seriesEpisodesCache =
LinkedHashMap<String, CachedSeriesEpisodes>(SERIES_EPISODE_CACHE_SIZE, 0.75f, true)
@@ -1195,6 +1223,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
)
}
@@ -1219,6 +1249,8 @@ class EmbyRepository(private val settings: SettingsStore) {
playSessionId = playback.playSessionId,
playMethod = playback.playMethod,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
)
}
@@ -1251,6 +1283,8 @@ class EmbyRepository(private val settings: SettingsStore) {
episodeCode = playback.episodeCode.ifBlank { null },
runtimeMs = playback.runtimeMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
)
}
val discovery = directPlayback(
@@ -1272,6 +1306,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
)
}
@@ -1329,6 +1365,8 @@ class EmbyRepository(private val settings: SettingsStore) {
prerollEnabled = playback.prerollEnabled,
prerollDurationMs = playback.prerollDurationMs,
subtitleDownloadAvailable = playback.subtitleDownloadAvailable,
trickplayAvailable = playback.trickplayAvailable,
skipIntroAvailable = playback.skipIntroAvailable,
)
}
if (item.isSeries) {
@@ -1352,6 +1390,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
overview = episode.overview,
episodeCode = episodeCode(episode),
runtimeMs = episode.runTimeTicks?.div(10_000L)?.coerceAtLeast(0L) ?: 0L,
@@ -1370,6 +1410,8 @@ class EmbyRepository(private val settings: SettingsStore) {
mediaSourceId = discovery.mediaSourceId,
playSessionId = discovery.playSessionId,
playMethod = discovery.playMethod,
trickplayAvailable = true,
skipIntroAvailable = true,
overview = item.overview,
episodeCode = item.episodeCode,
runtimeMs = item.runtimeMs,
@@ -1461,6 +1503,148 @@ class EmbyRepository(private val settings: SettingsStore) {
}.getOrNull()
}
/**
* Where this title's opening titles sit, or null when it has none.
*
* Dual-path, and the two halves read the same thing from the same place: Emby's chapter
* markers. What differs is who does the reading — in gateway mode the television holds
* no Emby credential, so the gateway looks them up and hands back the segment; on the
* direct path the item is fetched here with `Fields=Chapters` and
* [introSegmentFrom] applies the rule.
*
* An episode with no markers is the ordinary case, not a failure, and so is a server
* that will not answer: both come back as null and the player simply never offers the
* button. Nothing about this is on the path of a Play press.
*/
suspend fun introSegment(itemId: String): IntroSegment? {
if (itemId.isBlank()) return null
introCache[itemId]?.let { return it.value }
val resolved = runCatching {
if (ServerConfig.isGateway) gatewayIntro(itemId) else directIntro(itemId)
}.getOrNull()
// A null is cached too. Most of a library has no intro markers — every film, every
// special, every episode Emby has not analysed yet — and without this the same no
// would be fetched again on each playback and each auto-advance.
introCache[itemId] = CachedIntro(resolved)
while (introCache.size > INTRO_CACHE_SIZE) {
introCache.entries.iterator().run {
next()
remove()
}
}
return resolved
}
private suspend fun gatewayIntro(itemId: String): IntroSegment? {
val intro = requireGateway().intro(itemId)
if (!intro.available || intro.endMs <= intro.startMs) return null
return IntroSegment(startMs = intro.startMs, endMs = intro.endMs)
}
private suspend fun directIntro(itemId: String): IntroSegment? {
val userId = snapshot.userId ?: return null
// Chapters and nothing else. This is the one query in the app that asks for them,
// and adding them to a shared Fields list would put a couple of dozen entries per
// item into every row response to render nothing.
return introSegmentFrom(requireApi().getItem(userId, itemId, "Chapters").chapters)
}
/**
* How this title's seek previews are laid out, or null when it has none.
*
* Dual-path like everything else, but the two halves differ in *where the reading
* happens* rather than in what is read. In gateway mode the television holds no Emby
* credential, so the gateway reads the file and serves a frame at a time; on the
* direct path there is nobody to ask, so the index is read here — one ranged request
* off the front of Emby's file, which is where the format puts it.
*
* A title with no previews is the ordinary case, not a failure, and so is a server
* that will not answer: both come back as null and the seek indicator keeps the
* wordless form it has always had.
*/
suspend fun trickplay(itemId: String): Trickplay? {
if (itemId.isBlank()) return null
trickplayCache[itemId]?.let { return it.value }
val resolved = runCatching {
if (ServerConfig.isGateway) gatewayTrickplay(itemId) else directTrickplay(itemId)
}.getOrNull()
// A null is cached too. A title whose previews have not been generated is common
// in a library still catching up, and without this every press of Right on one
// would be a fresh round trip for the same no.
trickplayCache[itemId] = CachedTrickplay(resolved)
while (trickplayCache.size > TRICKPLAY_CACHE_SIZE) {
trickplayCache.entries.iterator().run {
next()
remove()
}
}
return resolved
}
/**
* One thumbnail's JPEG bytes, or null if it could not be fetched.
*
* Decoding is left to the caller: this is asked for from a player that already owns a
* bitmap cache, and handing back bytes keeps the decode on the caller's terms.
*/
suspend fun trickplayFrame(track: Trickplay, index: Int): ByteArray? {
if (index < 0 || index >= track.count) return null
val bif = track.bif
if (bif != null) {
val url = track.bifUrl ?: return null
val range = bif.frame(index) ?: return null
return TrickplayClient.fetch(url, range)
}
val gateway = ServerConfig.gatewayUrl ?: return null
val token = snapshot.token
if (token.isNullOrBlank()) return null
// The ".jpg" carries no meaning to the gateway. It is there for anything
// downstream that reads a URL rather than a content type.
val url = "${gateway.trimEnd('/')}/v1/items/${track.itemId}/trickplay/$index.jpg" +
"?t=${encode(token)}"
return TrickplayClient.fetch(url)
}
private suspend fun gatewayTrickplay(itemId: String): Trickplay? {
val manifest = requireGateway().trickplay(itemId)
if (!manifest.available || manifest.count <= 0 || manifest.intervalMs <= 0L) return null
return Trickplay(
itemId = itemId,
intervalMs = manifest.intervalMs,
count = manifest.count,
width = manifest.width,
height = manifest.height,
)
}
private suspend fun directTrickplay(itemId: String): Trickplay? {
val base = activeServerUrl ?: return null
val token = snapshot.token
if (token.isNullOrBlank()) return null
val url = "${base.trimEnd('/')}/Videos/$itemId/index.bif" +
"?Width=$TRICKPLAY_WIDTH&api_key=${encode(token)}"
val head = TrickplayClient.fetch(url, 0L until TRICKPLAY_INDEX_WINDOW) ?: return null
val index = when (val parsed = parseBifIndex(head)) {
is BifParse.Parsed -> parsed.index
is BifParse.NotBif -> return null
// A title long enough that its index runs past the window. The count is known
// now, so the second read is exact.
is BifParse.NeedMore -> {
val full = TrickplayClient.fetch(url, 0L until parsed.bytes.toLong()) ?: return null
(parseBifIndex(full) as? BifParse.Parsed)?.index ?: return null
}
}
if (index.count <= 0) return null
return Trickplay(
itemId = itemId,
intervalMs = index.intervalMs,
count = index.count,
bif = index,
bifUrl = url,
)
}
/**
* Ask the gateway for subtitles this title does not have.
*
@@ -1911,6 +2095,15 @@ private data class CachedRelated(
val expiresAtMs: Long,
)
/**
* A wrapper rather than the value itself, because a null [value] is a real answer — this
* title has no previews — and a map cannot tell that from an absent entry.
*/
private data class CachedTrickplay(val value: Trickplay?)
/** Null is a real answer here — most titles have no intro markers — so it is cached too. */
private data class CachedIntro(val value: IntroSegment?)
private const val PLAYABLE_CACHE_SIZE = 16
private const val PLAYABLE_CACHE_TTL_MS = 5L * 60L * 1_000L
internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
@@ -1920,6 +2113,30 @@ internal const val PLAYABLE_PREFETCH_MAX_AGE_MS = 15_000L
// continuePlayLookback.
private const val CONTINUE_PLAY_LOOKBACK = 120
/**
* A viewing session touches a handful of titles — what is playing, and what it advances
* to. Twelve is generous for that and each entry is a few kilobytes of byte offsets.
*/
private const val TRICKPLAY_CACHE_SIZE = 12
// A season's worth, so working through one show in an evening never asks twice.
private const val INTRO_CACHE_SIZE = 24
/**
* The thumbnail width to ask Emby for on the direct path. It is not a free parameter:
* Emby generates previews at the widths its own settings name and answers any other with a
* well-formed file containing no frames. 320 is what Emby writes by default, and it must
* match the gateway's own `trickplayWidth` or the two paths would show different previews.
*/
private const val TRICKPLAY_WIDTH = 320
/**
* How much of the front of a BIF to read while looking for its index. This covers a title
* of about twenty-two hours at ten seconds a frame, so in practice one request settles it;
* anything longer costs a second, exact read rather than being refused.
*/
private const val TRICKPLAY_INDEX_WINDOW = 64L * 1024L
private const val SERIES_EPISODE_CACHE_SIZE = 6
private const val SERIES_EPISODE_CACHE_TTL_MS = 5L * 60L * 1_000L
private const val RELATED_CACHE_SIZE = 12
@@ -0,0 +1,80 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
/**
* Where an episode's opening titles sit, in milliseconds from the start of the file.
*
* Emby finds these itself and writes them into the item's chapter list as two markers,
* `IntroStart` and `IntroEnd`, interleaved with the ordinary chapters in playback order.
* So there is nothing to detect on this end and nothing to store: the answer is already in
* the library, and reading it is one field on a request that was going to be made anyway.
*/
data class IntroSegment(val startMs: Long, val endMs: Long) {
/**
* Whether the playhead is inside the titles, with [lead] of the end held back.
*
* The lead is what stops the button appearing for the last half-second of a sequence,
* where pressing it would be indistinguishable from doing nothing — and, in automatic
* mode, what stops a seek to a point the film has already reached.
*/
fun contains(positionMs: Long, lead: Long = 0L): Boolean =
positionMs >= startMs && positionMs < endMs - lead
}
/**
* The shortest span worth calling an intro. Emby occasionally writes a pair a couple of
* seconds apart on a title whose opening it half-recognised, and a button that skips two
* seconds is worse than no button: somebody presses it, the picture does not visibly move,
* and the feature reads as broken.
*/
private const val INTRO_MINIMUM_MS = 5_000L
/**
* The longest. A pair minutes apart is a mis-detection — a recap, a cold open, or two
* unrelated markers read as a range — and honouring it would throw a viewer past the start
* of the story.
*/
private const val INTRO_MAXIMUM_MS = 5 * 60 * 1_000L
private const val MARKER_INTRO_START = "IntroStart"
private const val MARKER_INTRO_END = "IntroEnd"
/**
* Finds the title sequence in an item's chapter list.
*
* The rule exists twice — the gateway's copy is `introFromChapters` in
* `server/internal/api/intro.go` — and the two are pinned by deliberately parallel tests
* (`IntroTest`, `intro_test.go`). With no gateway there is nobody to ask, and a skip must
* not land somewhere different depending on whether the container is up.
*
* Most of this is about refusing to answer. A pair that is out of order, too short, too
* long, or missing half of itself produces null, and null is a perfectly good answer: the
* player simply never offers the button. A wrong skip costs somebody the opening of a
* scene, which is far worse than not being offered one.
*/
fun introSegmentFrom(chapters: List<EmbyChapter>): IntroSegment? {
var startMs = -1L
for (chapter in chapters) {
when (chapter.markerType) {
MARKER_INTRO_START ->
// The first start wins, and a second is ignored rather than replacing it.
// Two starts mean the markers are already untrustworthy; taking the later
// one would pick the larger, more damaging skip of the two.
if (startMs < 0L && chapter.startPositionTicks >= 0L) {
startMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
}
MARKER_INTRO_END -> {
// An end before any start is a stray marker, not the close of a segment.
if (startMs < 0L) continue
val endMs = chapter.startPositionTicks / TICKS_PER_MILLISECOND
val length = endMs - startMs
if (length < INTRO_MINIMUM_MS || length > INTRO_MAXIMUM_MS) return null
return IntroSegment(startMs = startMs, endMs = endMs)
}
}
}
return null
}
private const val TICKS_PER_MILLISECOND = 10_000L
@@ -317,6 +317,9 @@ data class Settings(
// How far one press of Left or Right moves the film. Per-profile and synced for the
// same reason subtitles are: it is a habit, not a property of the room.
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
// What to do when an episode reaches its opening titles: offer a button, skip without
// asking, or nothing. Per-profile and synced for the same reason the two above are.
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
// Foreground colour of the slide-progress ring, as an RRGGBB hex string.
val ringColorHex: String = DEFAULT_RING_COLOR,
val lastBackdropUrl: String? = null,
@@ -417,6 +420,7 @@ data class EmbyProfile(
val subtitlesEnabled: Boolean = true,
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
)
class SettingsStore(private val context: Context) {
@@ -454,6 +458,7 @@ class SettingsStore(private val context: Context) {
val SUBTITLES_ENABLED = booleanPreferencesKey("subtitles_enabled")
val SUBTITLE_LANGUAGE = stringPreferencesKey("subtitle_language")
val SEEK_INTERVAL_SECONDS = intPreferencesKey("seek_interval_seconds")
val SKIP_INTRO_MODE = stringPreferencesKey("skip_intro_mode")
val RING_COLOR = stringPreferencesKey("ring_color")
val LAST_BACKDROP_URL = stringPreferencesKey("last_backdrop_url")
val HOME_SECTIONS = stringPreferencesKey("home_sections")
@@ -605,11 +610,21 @@ class SettingsStore(private val context: Context) {
}
}
/** Normalised on the way in, so a mode this build has no vocabulary for never reaches
* the player as an instruction. */
suspend fun setSkipIntroMode(mode: String) {
val normalized = normalizeSkipIntroMode(mode)
context.dataStore.edit { preferences ->
preferences[Keys.SKIP_INTRO_MODE] = normalized
updateActiveProfile(preferences) { it.copy(skipIntroMode = normalized) }
}
}
/**
* Adopts the server's document for the signed-in viewer, in one write.
*
* One write is the point. Preferences DataStore rewrites and fsyncs the whole file per
* edit, and this touches seventeen keys plus the profiles blob — doing it through the
* edit, and this touches eighteen keys plus the profiles blob — doing it through the
* individual setters would be seventeen rewrites for one sync, on a TV that has just
* started up.
*
@@ -638,6 +653,7 @@ class SettingsStore(private val context: Context) {
store[Keys.SUBTITLE_LANGUAGE] = preferences.subtitleLanguage
store[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds)
store[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(preferences.skipIntroMode)
store[Keys.FOR_YOU_MINUTES] = preferences.forYouMinutes
store[Keys.HOME_ROW_ORDER] = preferences.homeRowOrder.joinToString("\n")
store[Keys.HOME_PINNED_ROWS] = preferences.homePinnedRows.joinToString("\n")
@@ -659,6 +675,7 @@ class SettingsStore(private val context: Context) {
subtitleLanguage = preferences.subtitleLanguage,
seekIntervalSeconds =
normalizeSeekIntervalSeconds(preferences.seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(preferences.skipIntroMode),
forYouMinutes = preferences.forYouMinutes,
homeRowOrder = preferences.homeRowOrder.joinToString("\n"),
homePinnedRows = preferences.homePinnedRows.joinToString("\n"),
@@ -1015,6 +1032,7 @@ class SettingsStore(private val context: Context) {
subtitleLanguage = previous?.subtitleLanguage ?: SUBTITLE_LANGUAGE_AUTO,
seekIntervalSeconds = previous?.seekIntervalSeconds
?: DEFAULT_SEEK_INTERVAL_SECONDS,
skipIntroMode = previous?.skipIntroMode ?: DEFAULT_SKIP_INTRO_MODE,
)
profiles.removeAll { it.id == id }
profiles.add(profile)
@@ -1154,6 +1172,7 @@ class SettingsStore(private val context: Context) {
preferences[Keys.SUBTITLE_LANGUAGE] = profile.subtitleLanguage
preferences[Keys.SEEK_INTERVAL_SECONDS] =
normalizeSeekIntervalSeconds(profile.seekIntervalSeconds)
preferences[Keys.SKIP_INTRO_MODE] = normalizeSkipIntroMode(profile.skipIntroMode)
preferences[Keys.PREFERENCES_REVISION] = profile.preferencesRevision
preferences.remove(Keys.LAST_BACKDROP_URL)
}
@@ -1195,6 +1214,7 @@ class SettingsStore(private val context: Context) {
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
)
}
@@ -1228,6 +1248,7 @@ class SettingsStore(private val context: Context) {
seekIntervalSeconds = normalizeSeekIntervalSeconds(
preferences[Keys.SEEK_INTERVAL_SECONDS] ?: DEFAULT_SEEK_INTERVAL_SECONDS,
),
skipIntroMode = normalizeSkipIntroMode(preferences[Keys.SKIP_INTRO_MODE]),
ringColorHex =preferences[Keys.RING_COLOR] ?: Settings.DEFAULT_RING_COLOR,
lastBackdropUrl = preferences[Keys.LAST_BACKDROP_URL],
homeSections = preferences[Keys.HOME_SECTIONS] ?: Settings.DEFAULT_HOME_SECTIONS,
@@ -0,0 +1,33 @@
package com.ponzischeme89.memby.data
/**
* What happens when an episode reaches its opening titles.
*
* The vocabulary lives beside the settings it is stored in rather than in the player,
* because three things read it — the store, the settings row and the player — and the
* gateway's own catalogue (`skipIntroMode` in `internal/api/preferences.go`) holds the
* matching list. Keep the two in step: a value this build does not recognise is normalised
* to the default rather than honoured, so an operator pushing a mode a television has
* never heard of costs that set a button it already understood, never an unexplained jump
* through somebody's episode.
*
* [SKIP_INTRO_PROMPT] is the default deliberately. Skipping automatically is a jump in a
* film nobody asked for, and it must be chosen rather than arrived at.
*/
/** Offer a button. Nothing moves until somebody presses it. */
const val SKIP_INTRO_PROMPT = "prompt"
/** Jump past the titles as soon as playback reaches them, once per episode. */
const val SKIP_INTRO_AUTO = "auto"
/** Leave the titles alone. */
const val SKIP_INTRO_OFF = "off"
/** The modes a viewer may choose between, in the order the settings row offers them. */
val SKIP_INTRO_MODES: List<String> = listOf(SKIP_INTRO_PROMPT, SKIP_INTRO_AUTO, SKIP_INTRO_OFF)
const val DEFAULT_SKIP_INTRO_MODE: String = SKIP_INTRO_PROMPT
fun normalizeSkipIntroMode(mode: String?): String =
mode?.trim()?.lowercase()?.takeIf { it in SKIP_INTRO_MODES } ?: DEFAULT_SKIP_INTRO_MODE
@@ -0,0 +1,160 @@
package com.ponzischeme89.memby.data
/**
* The seek preview thumbnails for one title, and — on the direct path — where each one's
* bytes live inside Emby's file.
*
* Emby stores them as a BIF: a header, an index of (timestamp, offset) pairs, then one
* JPEG per entry laid end to end, at one frame every ten seconds. The index sitting at the
* *front* of the file is what makes previews affordable on a television. A two-hour film's
* BIF is five megabytes and nobody can download that to show one thumbnail, but reading
* the first few kilobytes gives every frame's byte range, so a preview costs one ranged
* request of about seven kilobytes.
*
* The parsing exists twice on purpose — here and in the gateway's `internal/trickplay` —
* and is pinned by deliberately parallel tests (`TrickplayTest`, `bif_test.go`). With no
* gateway there is nobody to ask, and a preview must not depend on whether the container
* is up. In gateway mode the television has no Emby credential to range-read a file with,
* so the server does the reading and serves a frame at a time; [bif] is null there.
*/
data class Trickplay(
val itemId: String,
val intervalMs: Long,
val count: Int,
val width: Int = 0,
val height: Int = 0,
internal val bif: BifIndex? = null,
internal val bifUrl: String? = null,
) {
/**
* Which thumbnail covers a moment in the title.
*
* It clamps rather than refusing. This is read while somebody is still moving a seek
* target about, and a position a second past the last frame should show the last
* frame — a preview that blanks at the end of a film reads as broken.
*/
fun frameAt(positionMs: Long): Int {
if (count <= 0 || intervalMs <= 0L) return 0
if (positionMs <= 0L) return 0
val frame = positionMs / intervalMs
return if (frame >= count) count - 1 else frame.toInt()
}
/**
* How wide to draw the preview for a given height. Falls back to 16:9 until the frames'
* real shape is known, so the plate is laid out at about the right size on the first
* press rather than growing a thumbnail-shaped hole when one arrives.
*/
fun widthFor(heightPx: Int): Int =
if (width > 0 && height > 0) heightPx * width / height else heightPx * 16 / 9
}
/** Where each frame's bytes are, read off the front of a BIF. */
data class BifIndex(
val count: Int,
val intervalMs: Long,
/** [count] + 1 values, the last being the end of the final frame. */
val offsets: List<Long>,
) {
/** The half-open byte range of one thumbnail, ready for a Range header. */
fun frame(index: Int): LongRange? {
if (index < 0 || index >= count || offsets.size <= count) return null
val start = offsets[index]
val end = offsets[index + 1]
return if (end > start) start until end else null
}
}
/**
* What reading the front of a BIF came to.
*
* [NeedMore] is the case that earns this its own type: a caller reads a fixed window off
* the front of the file, and a long title legitimately has an index that runs past it. That
* must be answerable — read this much and try again — rather than looking like a bad file.
*/
sealed interface BifParse {
data class Parsed(val index: BifIndex) : BifParse
data class NeedMore(val bytes: Int) : BifParse
data object NotBif : BifParse
}
/** The fixed preamble: magic, version, frame count, timestamp multiplier. */
const val BIF_HEADER_SIZE = 64
private const val BIF_ENTRY_SIZE = 8
private const val BIF_DEFAULT_MULTIPLIER = 1_000L
/**
* The file's first eight bytes. The leading 0x89 and the CR/LF pair are the trick PNG uses:
* a file mangled by a text-mode transfer stops matching.
*/
private val BIF_MAGIC = byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a)
/** How many bytes of the file hold the header and the whole index. */
fun bifIndexLength(count: Int): Int = BIF_HEADER_SIZE + (count + 1) * BIF_ENTRY_SIZE
/**
* Reads the header and index out of the front of a BIF. [bytes] may be longer than the
* index; the rest is ignored.
*
* A count of zero is not a failure. It is what Emby serves for a title whose thumbnails
* have not been generated — a perfectly well-formed 72-byte file — and it must read as
* "this title has no previews" or every such title looks like a broken one.
*/
fun parseBifIndex(bytes: ByteArray): BifParse {
if (bytes.size < BIF_HEADER_SIZE) return BifParse.NeedMore(BIF_HEADER_SIZE)
for (i in BIF_MAGIC.indices) {
if (bytes[i] != BIF_MAGIC[i]) return BifParse.NotBif
}
val count = readLittleEndian(bytes, 12).toInt()
if (count < 0) return BifParse.NotBif
val multiplier = readLittleEndian(bytes, 16).takeIf { it > 0L } ?: BIF_DEFAULT_MULTIPLIER
if (count == 0) {
return BifParse.Parsed(BifIndex(count = 0, intervalMs = multiplier, offsets = emptyList()))
}
val length = bifIndexLength(count)
if (bytes.size < length) return BifParse.NeedMore(length)
val offsets = ArrayList<Long>(count + 1)
var firstTimestamp = 0L
var secondTimestamp = 0L
for (entry in 0..count) {
val at = BIF_HEADER_SIZE + entry * BIF_ENTRY_SIZE
val timestamp = readLittleEndian(bytes, at)
offsets.add(readLittleEndian(bytes, at + 4))
when (entry) {
0 -> firstTimestamp = timestamp
1 -> secondTimestamp = timestamp
}
}
// A frame that starts inside the index, or before the one ahead of it, means the file
// is not laid out the way the format says. Reading a byte range from it would decode
// whatever happened to be there.
if (offsets[0] < length.toLong()) return BifParse.NotBif
for (entry in 1..count) {
if (offsets[entry] < offsets[entry - 1]) return BifParse.NotBif
}
// Emby writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the interval
// is ten seconds, and taking the multiplier for it would be right only by accident.
val interval = if (count >= 2 && secondTimestamp > firstTimestamp) {
(secondTimestamp - firstTimestamp) * multiplier
} else {
multiplier
}
return BifParse.Parsed(
BifIndex(
count = count,
intervalMs = if (interval > 0L) interval else BIF_DEFAULT_MULTIPLIER,
offsets = offsets,
),
)
}
private fun readLittleEndian(bytes: ByteArray, at: Int): Long =
(bytes[at].toLong() and 0xFF) or
((bytes[at + 1].toLong() and 0xFF) shl 8) or
((bytes[at + 2].toLong() and 0xFF) shl 16) or
((bytes[at + 3].toLong() and 0xFF) shl 24)
@@ -43,6 +43,8 @@ data class UserPreferences(
val subtitleLanguage: String = SUBTITLE_LANGUAGE_AUTO,
/** How far Left and Right move the film, in seconds. One of [SEEK_INTERVAL_SECONDS]. */
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
/** What happens at an episode's opening titles. One of [SKIP_INTRO_MODES]. */
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val forYouMinutes: Int = 0,
val homeRowOrder: List<String> = emptyList(),
val homePinnedRows: List<String> = emptyList(),
@@ -73,6 +75,7 @@ fun Settings.toUserPreferences(): UserPreferences = UserPreferences(
subtitlesEnabled = subtitlesEnabled,
subtitleLanguage = subtitleLanguage,
seekIntervalSeconds = normalizeSeekIntervalSeconds(seekIntervalSeconds),
skipIntroMode = normalizeSkipIntroMode(skipIntroMode),
forYouMinutes = forYouMinutes,
homeRowOrder = homeRowOrder.decodeLineList(),
homePinnedRows = homePinnedRows.decodeLineList(),
@@ -117,6 +120,11 @@ fun decodeUserPreferences(
seekIntervalSeconds = normalizeSeekIntervalSeconds(
json.int("seekIntervalSeconds", fallback.seekIntervalSeconds),
),
// Normalised for the same reason the interval above is: a mode this build has no
// vocabulary for must cost the viewer the default button, never an unexplained jump.
skipIntroMode = normalizeSkipIntroMode(
json.string("skipIntroMode", fallback.skipIntroMode),
),
forYouMinutes = json.int("forYouMinutes", fallback.forYouMinutes),
homeRowOrder = json.stringList("homeRowOrder", fallback.homeRowOrder),
homePinnedRows = json.stringList("homePinnedRows", fallback.homePinnedRows),
@@ -138,6 +146,7 @@ fun UserPreferences.encode(): JsonObject = buildJsonObject {
put("subtitlesEnabled", subtitlesEnabled)
put("subtitleLanguage", subtitleLanguage)
put("seekIntervalSeconds", seekIntervalSeconds)
put("skipIntroMode", skipIntroMode)
put("forYouMinutes", forYouMinutes)
putJsonArray("homeRowOrder") { homeRowOrder.forEach { add(JsonPrimitive(it)) } }
putJsonArray("homePinnedRows") { homePinnedRows.forEach { add(JsonPrimitive(it)) } }
@@ -308,6 +308,18 @@ data class EmbyPerson(
val isCastMember: Boolean get() = type.equals("Actor", ignoreCase = true)
}
/**
* One entry of Emby's chapter list. Only two of its fields matter here: intro markers are
* written as ordinary chapters carrying a [markerType], in playback order beside the real
* ones, which is why finding the titles costs no request of its own.
*/
@Serializable
data class EmbyChapter(
@SerialName("StartPositionTicks") val startPositionTicks: Long = 0L,
@SerialName("MarkerType") val markerType: String = "",
@SerialName("Name") val name: String = "",
)
@Serializable
data class BaseItem(
@SerialName("Id") val id: String,
@@ -348,6 +360,12 @@ data class BaseItem(
@SerialName("ParentLogoItemId") val parentLogoItemId: String? = null,
@SerialName("ParentLogoImageTag") val parentLogoImageTag: String? = null,
@SerialName("UserData") val userData: UserItemData? = null,
/**
* Chapter markers, which is where Emby records an episode's opening titles. Only the
* direct path's intro lookup asks for them — every other query would be paying for a
* couple of dozen entries per item to render nothing.
*/
@SerialName("Chapters") val chapters: List<EmbyChapter> = emptyList(),
// Server-authored schedule metadata. These fields are absent on normal Emby items.
@SerialName("MembySource") val membySource: String? = null,
@SerialName("MembyEpisodeTitle") val membyEpisodeTitle: String? = null,
@@ -386,6 +404,13 @@ data class BaseItem(
// an item the gateway has never looked up carries none and the dedicated ratings
// request still fills it in. Defaulted, so a cached home payload decodes unchanged.
@SerialName("MembyRatings") val membyRatings: List<MediaRating> = emptyList(),
// Why this card is leading the launcher, decided by the gateway from evidence the
// television does not have — Radarr's digital release date, Sonarr's premieres and
// the stored review scores. The wording is the server's for the usual reason: a kind
// of hero card added tomorrow reads correctly on a build that predates it. Both are
// absent on the direct path, where the client picks the hero itself.
@SerialName("MembyHeroLabel") val membyHeroLabel: String? = null,
@SerialName("MembyHeroReason") val membyHeroReason: String? = null,
) {
val isMovie: Boolean get() = type.equals("Movie", ignoreCase = true)
val isSeries: Boolean get() = type.equals("Series", ignoreCase = true)
@@ -377,6 +377,44 @@ data class GatewayPlayback(
// that asks and it already holds this. Absent on an older gateway, and the default
// must stay false: a missing field must never conjure a row that cannot do anything.
val subtitleDownloadAvailable: Boolean = false,
// Whether it is worth asking this gateway for seek previews at all. Only the answer
// rides here — the layout itself is its own request, off the critical path of
// starting playback. Absent on an older gateway, and the default must stay false: a
// missing field must never conjure a request the backend would 404.
val trickplayAvailable: Boolean = false,
// Whether it is worth asking this gateway where the title sequence is. Same shape and
// same reasoning as the previews above: the segment itself is its own request, and a
// missing field must never conjure one this backend would not answer.
val skipIntroAvailable: Boolean = false,
)
/**
* Where an episode's opening titles sit, as the gateway found them in Emby's markers.
*
* [available] is explicit rather than implied by a zero pair: an intro can legitimately
* begin at the very start of the file, and that must stay distinguishable from an episode
* that has no markers at all.
*/
@Serializable
data class GatewayIntro(
val available: Boolean = false,
val startMs: Long = 0L,
val endMs: Long = 0L,
)
/**
* How a title's seek previews are laid out, as the gateway describes them.
*
* Frame URLs are not listed. There are hundreds of them and they are formed by a rule the
* client already knows, so a list would be most of the response.
*/
@Serializable
data class GatewayTrickplay(
val available: Boolean = false,
val intervalMs: Long = 0L,
val count: Int = 0,
val width: Int = 0,
val height: Int = 0,
)
/** One subtitle a viewer can choose to download, as the gateway offers it. */
@@ -186,6 +186,31 @@ interface GatewayApi {
@Query("forceTranscode") forceTranscode: Boolean = false,
): GatewayPlayback
/**
* Where this episode's opening titles are, if Emby has marked them.
*
* Its own request for the same reason [trickplay] is: reading the markers costs the
* gateway a round trip to Emby, and nothing about a skip button is needed before the
* first frame — the earliest intro in a typical library starts a couple of minutes in.
*/
@GET("v1/items/{id}/intro")
suspend fun intro(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayIntro
/**
* How this title's seek previews are laid out, if it has any.
*
* Deliberately its own request rather than a field on [playback]: reading the layout
* costs the gateway a round trip to Emby, and the playback response is the one thing
* standing between a Play press and a decoder starting. This is asked for once the
* first frame is up.
*/
@GET("v1/items/{id}/trickplay")
suspend fun trickplay(
@Path("id") itemId: String,
): com.ponzischeme89.memby.data.model.GatewayTrickplay
/**
* Ask the subtitle service for tracks this title does not have. This is a live query
* against subtitle providers, so it is slow by nature — seconds, not milliseconds.
@@ -77,6 +77,12 @@ internal val MEMBY_CAPABILITIES = listOf(
// Declares that this build can show the install-permission step. An older app never
// receives the feature, so the operator cannot push a screen it does not have.
"install_permission_v1",
// Declares that this build can draw seek preview thumbnails, so the admin console
// reports the feature honestly against an older app that would never ask for them.
"trickplay_v1",
// Declares that this build can offer to skip an episode's opening titles, so the admin
// console reports the feature honestly against an older app that would never ask.
"skip_intro_v1",
)
internal const val HEVC_DECODE_CAPABILITY = "video_hevc_decode"
@@ -0,0 +1,74 @@
package com.ponzischeme89.memby.data.remote
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Request
import java.io.ByteArrayOutputStream
import java.util.concurrent.TimeUnit
/**
* Reads seek preview thumbnails, which are the one thing in the app fetched as raw bytes
* rather than through Retrofit or Coil.
*
* Retrofit is the wrong shape because the direct path wants a *byte range* of a file, and
* Coil is the wrong home because these are small, transient and asked for in bursts: a
* viewer holding Right walks through dozens of them, and letting that churn through the
* artwork cache would evict the backdrops and posters the launcher is about to want again.
* The player keeps its own small cache instead.
*/
internal object TrickplayClient {
/**
* A preview is worth having only while somebody is still pressing. Eight seconds is
* already far past the point where the thumbnail would have answered the question, and
* the seek it is describing has committed and moved on.
*/
private const val TIMEOUT_SECONDS = 8L
/** A frame is a few kilobytes and an index a few more. Nothing here is megabytes. */
private const val MAX_BYTES = 4L * 1024L * 1024L
private const val CHUNK_BYTES = 16 * 1024
private val http by lazy {
HttpStack.base.newBuilder()
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build()
}
/**
* Fetches [url], optionally only the bytes in [range].
*
* Returns null on any failure. Nothing above this can do anything useful with the
* reason: a preview that does not arrive leaves the seek indicator in the wordless form
* it has always had, which is a complete answer rather than a degraded one.
*
* A server that ignores the range answers 200 with the whole file. Emby does honour it
* on the BIF route while advertising `Accept-Ranges: none`, so the request is made on
* the strength of what comes back — but the read is capped either way, because being
* wrong about that must not turn a seek into a five-megabyte download.
*/
suspend fun fetch(url: String, range: LongRange? = null): ByteArray? =
withContext(Dispatchers.IO) {
val builder = Request.Builder().url(url)
range?.let { builder.header("Range", "bytes=${it.first}-${it.last}") }
val limit = (range?.let { it.last - it.first + 1 } ?: MAX_BYTES)
.coerceIn(0L, MAX_BYTES)
runCatching {
http.newCall(builder.build()).execute().use { response ->
val stream = response.body?.byteStream()
if (!response.isSuccessful || stream == null) return@use null
val collected = ByteArrayOutputStream()
val chunk = ByteArray(CHUNK_BYTES)
while (collected.size() < limit) {
val wanted = minOf(chunk.size.toLong(), limit - collected.size()).toInt()
val read = stream.read(chunk, 0, wanted)
if (read <= 0) break
collected.write(chunk, 0, read)
}
collected.toByteArray().takeIf { it.isNotEmpty() }
}
}.getOrNull()
}
}
@@ -96,6 +96,7 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.LiveTv
import androidx.compose.material.icons.filled.Movie
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.PlayCircleFilled
@@ -118,6 +119,7 @@ import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.ui.alerts.alertBadgeLabel
import com.ponzischeme89.memby.ui.detail.UHD_MIN_WIDTH
import com.ponzischeme89.memby.ui.detail.dynamicRangeLabel
import com.ponzischeme89.memby.ui.detail.formatRuntime
@@ -221,6 +223,7 @@ fun TvNavigationRail(
onRailFocusChanged: (Boolean) -> Unit,
onDestinationSelected: (BrowseDestination) -> Unit,
modifier: Modifier = Modifier,
alertCount: Int = 0,
) {
var railHasFocus by remember { mutableStateOf(false) }
val logoScale = remember { Animatable(0.72f) }
@@ -335,6 +338,13 @@ fun TvNavigationRail(
modifier = if (destination == selected) Modifier.focusRequester(navigationFocusRequester) else Modifier,
onFocused = {},
onClick = { onDestinationSelected(destination) },
// My Alerts is one level in, behind the user picker. Without a mark out
// here nothing on the launcher would ever say there was news waiting.
badge = if (destination == BrowseDestination.PROFILES) {
alertBadgeLabel(alertCount)
} else {
null
},
)
Spacer(Modifier.height(4.dp))
}
@@ -359,10 +369,12 @@ fun UserSwitcherOverlay(
onManageProfiles: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
alertCount: Int = 0,
onOpenAlerts: () -> Unit = {},
) {
val profileIds = profiles.map(EmbyProfile::id)
val focusRequesters = remember(profileIds) {
List(profiles.size + 1) { FocusRequester() }
List(profiles.size + UserSwitcherActionCount) { FocusRequester() }
}
val profileListState = remember(profileIds) { LazyListState() }
val focusScope = rememberCoroutineScope()
@@ -408,6 +420,7 @@ fun UserSwitcherOverlay(
currentIndex = focusedIndex,
profileCount = profiles.size,
direction = direction,
actionCount = UserSwitcherActionCount,
)
focusedIndex = next
if (next < profiles.size) {
@@ -475,19 +488,38 @@ fun UserSwitcherOverlay(
.background(Color.White.copy(alpha = 0.07f)),
)
Spacer(Modifier.height(6.dp))
// My Alerts lives here rather than on the launcher: these alerts belong to a
// person and follow them between televisions, so the menu that already answers
// "who is watching" is where somebody looks for their own news. The badge is
// what replaces the bell that used to sit in the corner of Home.
UserSwitcherAction(
label = "Manage users",
label = "My Alerts",
icon = Icons.Default.Notifications,
badge = alertBadgeLabel(alertCount),
modifier = Modifier
.focusRequester(focusRequesters[profiles.size])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size
},
onClick = onOpenAlerts,
)
UserSwitcherAction(
label = "Manage users",
icon = Icons.Default.Settings,
modifier = Modifier
.focusRequester(focusRequesters[profiles.size + 1])
.onFocusChanged {
if (it.isFocused) focusedIndex = profiles.size + 1
},
onClick = onManageProfiles,
)
}
}
}
/** My Alerts, then Manage users. See [userSwitcherNextIndex]. */
private const val UserSwitcherActionCount = 2
@Composable
private fun UserSwitcherProfileItem(
profile: EmbyProfile,
@@ -554,7 +586,9 @@ private fun UserSwitcherProfileItem(
@Composable
private fun UserSwitcherAction(
label: String,
icon: ImageVector,
modifier: Modifier = Modifier,
badge: String? = null,
onClick: () -> Unit,
) {
var focused by remember { mutableStateOf(false) }
@@ -566,12 +600,14 @@ private fun UserSwitcherAction(
.clip(RoundedCornerShape(MembyChipCorner))
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.clickable(onClick = onClick)
.semantics { contentDescription = label }
.semantics {
contentDescription = badge?.let { "$label, $it waiting" } ?: label
}
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Default.Settings,
icon,
contentDescription = null,
tint = if (focused) Color.White else QuietText,
modifier = Modifier.size(17.dp),
@@ -583,9 +619,24 @@ private fun UserSwitcherAction(
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold,
)
if (badge != null) {
Spacer(Modifier.weight(1f))
Text(
badge,
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.background(AlertBadgeRed, CircleShape)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
/** The one red a waiting-alert badge is drawn in, on the rail and in the user menu alike. */
private val AlertBadgeRed = Color(0xFFE04747)
@Composable
fun ExpandableNavigationItem(
destination: BrowseDestination,
@@ -594,6 +645,7 @@ fun ExpandableNavigationItem(
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
badge: String? = null,
) {
var focused by remember { mutableStateOf(false) }
// Not `by`: both colours are read in the draw phase / at the point of use, so the
@@ -627,7 +679,10 @@ fun ExpandableNavigationItem(
.clip(RoundedCornerShape(8.dp))
.drawBehind { drawRect(background.value) }
.clickable(onClick = onClick)
.semantics { contentDescription = destination.label }
.semantics {
contentDescription = badge?.let { "${destination.label}, $it waiting" }
?: destination.label
}
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
@@ -643,6 +698,20 @@ fun ExpandableNavigationItem(
.background(EmbyGreen, RoundedCornerShape(2.dp)),
)
}
// Over the icon rather than after the label: the rail spends most of its life
// collapsed to 54dp, where there is no label to sit beside.
if (badge != null) {
Text(
badge,
color = Color.White,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopEnd)
.background(AlertBadgeRed, CircleShape)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}
if (expanded) {
Text(
@@ -51,6 +51,7 @@ import com.ponzischeme89.memby.data.floorMod
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.ui.detail.heroFacts
import com.ponzischeme89.memby.ui.theme.FactSeparator
import com.ponzischeme89.memby.ui.theme.MembyAccent
@@ -101,22 +102,65 @@ internal fun shouldShowHomeMovieHero(hasMovies: Boolean, listAtTop: Boolean): Bo
hasMovies && listAtTop
/**
* A hero card and the reason it is there.
* A hero card, the caption it wears and when the gateway composed it the one line
* saying why it is there.
*
* The label used to be the card's *position* `listOf("POPULAR", "NEW RELEASE",
* "TRENDING")[index]` while the selection below interleaves two sources and then falls
* back to every movie in the response, so a 2025 title was captioned NEW RELEASE and a 2026
* one POPULAR. A caption that can be wrong is worse than no caption.
*/
internal data class HomeHeroPick(val item: BaseItem, val label: String)
internal data class HomeHeroPick(
val item: BaseItem,
val label: String,
val reason: String? = null,
)
private const val LABEL_NEW = "NEW RELEASE"
private const val LABEL_POPULAR = "POPULAR"
private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
/** The `kind` of the server-composed hero row. It is consumed here, never drawn as a row. */
internal const val SERVER_HERO_ROW_KIND = "hero"
/**
* The hero the gateway composed, if it sent one.
*
* This is the preferred path and it is deliberately a *verbatim* read: the server has
* Radarr's digital release dates, Sonarr's premieres and the stored review scores, none of
* which reach the television, so second-guessing its order here would only ever be able to
* throw that evidence away. No day rotation either the underlying facts already change
* daily, and rotating a merit ranking is precisely how the best-reviewed release of the
* week ends up in the fourth slot.
*
* Each card carries its own caption, so a kind of hero the server invents tomorrow reads
* correctly on this build. A card that somehow arrives without one falls back to the
* neutral library caption rather than to a claim nobody made.
*/
internal fun serverHeroPicks(rows: List<HomeRow>): List<HomeHeroPick> =
rows.asSequence()
.filter { it.kind == SERVER_HERO_ROW_KIND }
.flatMap { it.items.asSequence() }
.filter { it.membyPlayable }
.distinctBy(BaseItem::id)
.take(4)
.map { item ->
HomeHeroPick(
item = item,
label = item.membyHeroLabel?.takeIf(String::isNotBlank) ?: LABEL_LIBRARY,
reason = item.membyHeroReason?.takeIf(String::isNotBlank),
)
}
.toList()
/**
* Picks a deliberate mix of fresh and popular movies while preserving server ranking.
*
* This is the **direct path's** rule, and the fallback whenever the gateway sends no hero
* row a container that is down, or one older than the feature. It ranks on the only
* evidence a television has, which is which shelf a title was drawn from; where the
* gateway is answering, [serverHeroPicks] wins because it can see rather more than that.
*
* [day] is a count of local days (see [localEpochDay]) and rotates the starting point in
* each candidate list, so a household that leaves the launcher on the same four films for
* a fortnight instead sees a different set every morning. It is a rotation rather than a
@@ -129,7 +173,10 @@ private const val LABEL_LIBRARY = "FROM YOUR LIBRARY"
internal fun selectHomeHeroMovies(
rows: List<HomeBrowseRow>,
day: Long = 0L,
serverRows: List<HomeRow> = emptyList(),
): List<HomeHeroPick> {
serverHeroPicks(serverRows).takeIf(List<HomeHeroPick>::isNotEmpty)?.let { return it }
fun HomeBrowseRow.matches(vararg words: String): Boolean {
val label = "$id $title".lowercase()
return words.any(label::contains)
@@ -243,10 +290,20 @@ internal fun HomeMovieHero(
}
}
/** A wash over the artwork, keyed to why the card is on the shelf rather than to its slot. */
/**
* A wash over the artwork, keyed to why the card is on the shelf rather than to its slot.
*
* The gateway's captions are matched here as strings rather than as an enum for the same
* reason their wording lives on the server: a caption this build has never heard of gets
* the neutral wash and reads correctly, where a `when` over a sealed type would have to be
* taught every new one in an app release.
*/
private fun labelTint(label: String): Color = when (label) {
LABEL_NEW -> Color(0x667253B7)
LABEL_POPULAR -> Color(0x66499BD5)
// A premiere is a different kind of news from a film, and reads as one.
"SERIES PREMIERE", "NEW SEASON" -> Color(0x664F9E7A)
"HIGHLY RATED" -> Color(0x66B8873F)
else -> Color(0x66C67A42)
}
@@ -262,7 +319,9 @@ private fun FeaturedMovieCard(
FocusScaleContainer(
onFocused = onFocused,
onClick = onClick,
contentDescription = "Featured movie, ${item.name}",
// Not "Featured movie": a series premiere can lead, and a screen reader announcing
// one as a movie is worse than one announcing it by the caption it is wearing.
contentDescription = "Featured, ${pick.label}, ${item.name}",
modifier = modifier.clip(RoundedCornerShape(MembyPanelCorner)),
) { focused ->
Box(Modifier.fillMaxSize().background(Color(0xFF151B20))) {
@@ -327,12 +386,40 @@ private fun FeaturedMovieCard(
)
Spacer(Modifier.height(9.dp))
HeroFactLine(item)
// The reason takes the synopsis's place rather than adding a line to
// it. It is why this card is leading — "Well reviewed, released
// yesterday" — which is more use here than the first two lines of a
// plot the detail page prints in full, and one line where the synopsis
// is two, so preferring it can only make the card shorter. There is
// still no eyebrow above the title: that was the line that pushed a
// wrapped title into the button.
//
// It sits *above* the ratings strip, and that order is load-bearing.
// This column is the one that gives way when a title wraps onto two
// lines, and whatever is last in it is what gets cut — with the reason
// below the strip, the one line explaining why this card is leading
// the launcher was silently dropped on exactly the long-titled films
// most likely to be leading it. The scores are also on the detail page
// this card opens; the reason is not anywhere else.
val reason = pick.reason?.takeIf(String::isNotBlank)
if (reason != null) {
Spacer(Modifier.height(7.dp))
Text(
reason,
color = MembyAccent,
fontSize = 13.sp,
lineHeight = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
ItemRatingsStrip(
item = item,
load = true,
modifier = Modifier.padding(top = 6.dp).fillMaxWidth(),
)
if (titleLines == 1) {
if (reason == null && titleLines == 1) {
item.overview?.takeIf(String::isNotBlank)?.let { overview ->
Spacer(Modifier.height(9.dp))
Text(
@@ -125,8 +125,10 @@ import com.ponzischeme89.memby.data.model.HomeRow
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationsResponse
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.data.model.RecommendationPerson
import com.ponzischeme89.memby.data.ServerConfig
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
import com.ponzischeme89.memby.ui.detail.AiringNotice
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub
@@ -805,10 +807,7 @@ private fun ProfileChooser(
removingProfileId: String?,
onSelect: (EmbyProfile) -> Unit,
onRemove: (EmbyProfile) -> Unit,
// Null from the launcher on purpose: adding a viewer belongs to setting the TV up, not
// to browsing it. Most households here have one account, and a permanent "+" tile beside
// their own name reads as a thing they are supposed to do.
onAddProfile: (() -> Unit)?,
onAddProfile: () -> Unit,
onClose: (() -> Unit)?,
) {
val firstFocus = remember { FocusRequester() }
@@ -871,20 +870,18 @@ private fun ProfileChooser(
)
}
}
if (onAddProfile != null) {
ProfileTile(
name = "Add another user",
current = false,
enabled = switchingProfileId == null && removingProfileId == null,
symbol = "+",
onClick = onAddProfile,
modifier = if (orderedProfiles.isEmpty()) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
},
)
}
ProfileTile(
name = "Add another user",
current = false,
enabled = switchingProfileId == null && removingProfileId == null,
symbol = "+",
onClick = onAddProfile,
modifier = if (orderedProfiles.isEmpty()) {
Modifier.focusRequester(firstFocus)
} else {
Modifier
},
)
}
if (onClose != null) {
Spacer(Modifier.height(30.dp))
@@ -1334,6 +1331,10 @@ private fun HomeScreen(
var showSettings by remember { mutableStateOf(false) }
var showProfiles by remember { mutableStateOf(false) }
// Reached only from the manage-users page, which is itself two presses in behind the
// user picker — so the "+" is where somebody looking to add a viewer is already
// standing, and it is nowhere near the launcher.
var addingProfile by remember { mutableStateOf(false) }
var userSwitcherVisible by remember { mutableStateOf(false) }
var switchingProfileId by remember { mutableStateOf<String?>(null) }
var removingProfileId by remember { mutableStateOf<String?>(null) }
@@ -1420,6 +1421,7 @@ private fun HomeScreen(
showSettings = false
showProfiles = false
userSwitcherVisible = false
showNotifications = false
detailsItem = null
detailsAiringNotice = null
quickMenuItem = null
@@ -1513,9 +1515,12 @@ private fun HomeScreen(
// Keyed on the day as well as the rows, so the feature changes when the date does and
// not merely when the launcher happens to be rebuilt.
val heroDay = rememberHomeHeroDay()
val homeHeroMovies = remember(rows, selectedDestination, heroDay) {
// The server rows are passed in *unfiltered*, beside the browse rows the launcher
// draws: serverHomeRows drops the hero row, because it is consumed here rather than
// rendered as a shelf, so this is the only thing that can still see it.
val homeHeroMovies = remember(rows, homeContent.rows, selectedDestination, heroDay) {
if (selectedDestination == BrowseDestination.HOME) {
selectHomeHeroMovies(rows, heroDay)
selectHomeHeroMovies(rows, heroDay, homeContent.rows)
} else {
emptyList()
}
@@ -1626,6 +1631,7 @@ private fun HomeScreen(
}
}
},
alertCount = notificationState.notifications.size,
)
androidx.compose.foundation.layout.BoxWithConstraints(
modifier = Modifier
@@ -1967,19 +1973,6 @@ private fun HomeScreen(
.align(Alignment.BottomEnd)
.padding(end = 24.dp, bottom = 18.dp),
)
if (selectedDestination == BrowseDestination.HOME && !showNotifications) {
NotificationBell(
unreadCount = notificationState.notifications.count { it.unread },
onClick = {
showNotifications = true
scope.launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
}
},
modifier = Modifier.align(Alignment.TopEnd).padding(top = 20.dp, end = 24.dp),
)
}
if (userSwitcherVisible) {
BackHandler {
userSwitcherVisible = false
@@ -2012,6 +2005,16 @@ private fun HomeScreen(
navigationExpanded = false
showProfiles = true
},
alertCount = notificationState.notifications.size,
onOpenAlerts = {
userSwitcherVisible = false
navigationExpanded = false
showNotifications = true
scope.launch {
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
}
},
onDismiss = {
userSwitcherVisible = false
scope.launch {
@@ -2094,11 +2097,19 @@ private fun HomeScreen(
removingProfileId = null
}
},
// Settings → Accounts is where a second viewer is added.
onAddProfile = null,
onAddProfile = { addingProfile = true },
onClose = { showProfiles = false },
)
}
// Over the manage-users page rather than instead of it: cancelling comes straight
// back to the list the "+" was pressed from. A successful sign-in makes the new
// viewer active, which re-keys HomeScreen and takes both flags with it.
if (addingProfile) {
SetupScreen(
onCancel = { addingProfile = false },
onSignedIn = { addingProfile = false },
)
}
detailsItem?.let { selected ->
BackHandler {
val previous = detailsTrail.lastOrNull()
@@ -2184,8 +2195,17 @@ private fun HomeScreen(
)
}
if (showNotifications) {
BackHandler { showNotifications = false }
NotificationsOverlay(
// Reached from the user picker, so leaving it goes back to the rail rather than
// to whatever card happened to hold focus on the launcher behind it.
val closeAlerts: () -> Unit = {
showNotifications = false
scope.launch {
kotlinx.coroutines.delay(16L)
runCatching { navigationFocusRequester.requestFocus() }
}
}
BackHandler(onBack = closeAlerts)
MyAlertsPage(
notifications = notificationState.notifications,
preferences = notificationState.preferences,
onToggleEnabled = {
@@ -2217,7 +2237,7 @@ private fun HomeScreen(
}
}
},
onDismissNotification = { notification ->
onDismiss = { notification ->
scope.launch {
runCatching { repo.dismissNotification(notification.id) }.onSuccess {
notificationState = notificationState.copy(
@@ -2228,7 +2248,20 @@ private fun HomeScreen(
}
}
},
onClose = { showNotifications = false },
// The gateway has no bulk route, so this is the same call per alert. The
// list is emptied optimistically: the page is judged on emptying itself,
// and a row that lingered while its request was in flight would be pressed
// a second time.
onDismissAll = {
val pending = notificationState.notifications.map(UserNotification::id)
notificationState = notificationState.copy(notifications = emptyList())
scope.launch {
pending.forEach { id -> runCatching { repo.dismissNotification(id) } }
runCatching { repo.getNotifications() }
.onSuccess { notificationState = it }
}
},
onClose = closeAlerts,
)
}
quickMenuItem?.let { selected ->
@@ -2669,6 +2702,10 @@ internal fun serverHomeRows(state: HomeUiState, settings: Settings): List<HomeBr
"continue", "nextup" -> "continue" in enabledSections
"favorites" -> "favorites" in enabledSections
"latest" -> "latest" in enabledSections
// The hero row is the four featured cards. It is drawn above the shelves
// by HomeMovieHero, so letting it through here would print the same four
// titles a second time as an unnamed row of posters directly beneath it.
SERVER_HERO_ROW_KIND -> false
else -> true
}
}
@@ -11,25 +11,14 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.NotificationsOff
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -39,7 +28,6 @@ import androidx.compose.ui.focus.focusProperties
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
@@ -48,14 +36,10 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import coil.compose.AsyncImage
import com.ponzischeme89.memby.data.EmbyRepository
import com.ponzischeme89.memby.data.model.MyShow
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@@ -295,147 +279,6 @@ private fun StatusLine(label: String, value: String) {
}
}
@Composable
internal fun NotificationBell(
unreadCount: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
Card(
onClick = onClick,
modifier = modifier
.size(52.dp)
.onFocusChanged { focused = it.hasFocus }
.graphicsLayer {
scaleX = if (focused) 1.08f else 1f
scaleY = if (focused) 1.08f else 1f
},
) {
Box(
Modifier.fillMaxSize().background(if (focused) Color.White else Color(0xCC20262B)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Notifications,
contentDescription = "Notifications",
tint = if (focused) Color.Black else Color.White,
modifier = Modifier.size(25.dp),
)
if (unreadCount > 0) {
Text(
unreadCount.coerceAtMost(99).toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier
.align(Alignment.TopEnd)
.background(Color(0xFFE04747), CircleShape)
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
}
}
@Composable
internal fun NotificationsOverlay(
notifications: List<UserNotification>,
preferences: NotificationPreferences,
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismissNotification: (UserNotification) -> Unit,
onClose: () -> Unit,
) {
Box(Modifier.fillMaxSize().zIndex(8f).background(Color(0xF5090B0D))) {
Column(
modifier = Modifier.fillMaxSize().padding(horizontal = 72.dp, vertical = 48.dp),
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column {
Text("Notifications", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(
"Show-return alerts are saved for this user on every Memby TV.",
color = Color(0xFFAEB7BF),
fontSize = 15.sp,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onToggleEnabled) {
Icon(
if (preferences.enabled) Icons.Default.Notifications else Icons.Default.NotificationsOff,
contentDescription = null,
)
Spacer(Modifier.width(8.dp))
Text(if (preferences.enabled) "Alerts on" else "Alerts off")
}
Button(onClick = onToggleShowReturns, enabled = preferences.enabled) {
Text(
if (preferences.showReturnAlerts) {
"Return alerts on"
} else {
"Return alerts off"
},
)
}
Button(onClick = onClose) {
Icon(Icons.Default.Close, contentDescription = null)
Spacer(Modifier.width(6.dp))
Text("Close")
}
}
}
if (notifications.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("Youre all caught up.", color = Color(0xFFD0D6DB), fontSize = 20.sp)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
items(notifications, key = UserNotification::id) { notification ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (notification.unread) Color(0xFF202B25) else Color(0xFF171B1E),
RoundedCornerShape(12.dp),
)
.padding(18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Box(
Modifier.size(10.dp).background(
if (notification.unread) Color(0xFF52B54B) else Color.Transparent,
CircleShape,
),
)
Column(Modifier.weight(1f)) {
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
)
Text(notification.message, color = Color(0xFFD0D6DB), fontSize = 15.sp)
}
if (notification.unread) {
Button(onClick = { onRead(notification) }) { Text("Mark read") }
}
Button(onClick = { onDismissNotification(notification) }) { Text("Dismiss") }
}
}
}
}
}
}
}
internal fun formatMyShowDate(value: String?): String {
if (value.isNullOrBlank()) return "Not announced"
return runCatching {
@@ -3,8 +3,9 @@ package com.ponzischeme89.memby.ui
internal enum class UserSwitcherDirection { UP, DOWN }
/**
* Profiles occupy [0, profileCount); the pinned Manage users action is always the final
* index. Keeping this arithmetic outside Compose makes remote navigation deterministic.
* Profiles occupy [0, profileCount); the pinned actions My Alerts, then Manage users
* follow them in order. Keeping this arithmetic outside Compose makes remote navigation
* deterministic.
*/
internal fun userSwitcherInitialIndex(
profileIds: List<String>,
@@ -15,11 +16,13 @@ internal fun userSwitcherNextIndex(
currentIndex: Int,
profileCount: Int,
direction: UserSwitcherDirection,
actionCount: Int = 1,
): Int {
val manageIndex = profileCount.coerceAtLeast(0)
val current = currentIndex.coerceIn(0, manageIndex)
val lastIndex = (profileCount.coerceAtLeast(0) + actionCount.coerceAtLeast(1) - 1)
.coerceAtLeast(0)
val current = currentIndex.coerceIn(0, lastIndex)
return when (direction) {
UserSwitcherDirection.UP -> (current - 1).coerceAtLeast(0)
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(manageIndex)
UserSwitcherDirection.DOWN -> (current + 1).coerceAtMost(lastIndex)
}
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui.alerts
/**
* The wording and the counting behind My Alerts, kept pure so the badge a viewer sees in the
* user picker and the summary line on the page itself are the same arithmetic tested once.
*
* The badge answers "is there anything waiting for me", which is why it counts *alerts* and
* not unread ones: an alert that has been read but not dismissed is still sitting there, and
* a badge that cleared itself the moment somebody glanced at the page would be a badge that
* never agreed with the list underneath it.
*/
/** Above this the badge stops counting and says so, or the pill grows wider than its row. */
internal const val AlertBadgeMax = 9
internal fun alertBadgeLabel(count: Int): String? = when {
count <= 0 -> null
count > AlertBadgeMax -> "$AlertBadgeMax+"
else -> count.toString()
}
/**
* The line under the page heading. "New" is the unread half worth naming, since it is the
* only thing distinguishing two otherwise identical rows but it is never claimed on its
* own, because a page saying "2 new" above three alerts reads as having lost one.
*/
internal fun alertsSummary(total: Int, unread: Int): String = when {
total <= 0 -> "Nothing waiting for you"
unread <= 0 -> if (total == 1) "1 alert" else "$total alerts"
else -> {
val alerts = if (total == 1) "1 alert" else "$total alerts"
"$alerts · $unread new"
}
}
@@ -0,0 +1,313 @@
package com.ponzischeme89.memby.ui.alerts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.NotificationsActive
import androidx.compose.material.icons.filled.Tv
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.tv.material3.Icon
import androidx.tv.material3.Text
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.MembyChoiceChip
import com.ponzischeme89.memby.ui.formatMyShowDate
import com.ponzischeme89.memby.ui.theme.MembyAccent
import com.ponzischeme89.memby.ui.theme.MembyCardCorner
import com.ponzischeme89.memby.ui.theme.MembyHairline
import com.ponzischeme89.memby.ui.theme.MembyMutedText
import com.ponzischeme89.memby.ui.theme.MembyQuietText
import com.ponzischeme89.memby.ui.theme.MembySurface
import kotlinx.coroutines.delay
/**
* My Alerts a full page rather than the panel it used to be, reached from the user menu in
* the user picker.
*
* It moved there because these alerts belong to a *person*, not to a television: they follow
* whoever is signed in, so the place that already answers "who is watching" is where somebody
* looks for their own news. The bell it replaced sat in the corner of the launcher, was only
* drawn on Home, and cost a focus target on every set in the house whether or not there was
* anything behind it.
*
* It reads like Settings on purpose black canvas, flat rows on a shared 16dp inset with
* hairlines between them, and the row under focus the only lit surface on the page.
*
* Two things are worth preserving. **A press dismisses**, with the focused row saying so, and
* the hint is what makes that safe: this is the only page whose whole job is emptying itself,
* and a second confirmation press on every alert is what made the old panel not worth
* opening. And **focus marks read** a row can only be read by being looked at, so nothing
* has to be pressed to clear the "new" flag on it.
*
* Stateless by design: the caller owns the list and the requests, so this can be previewed
* and screenshotted with no server.
*/
@Composable
fun MyAlertsPage(
notifications: List<UserNotification>,
preferences: NotificationPreferences,
onToggleEnabled: () -> Unit,
onToggleShowReturns: () -> Unit,
onRead: (UserNotification) -> Unit,
onDismiss: (UserNotification) -> Unit,
onDismissAll: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
val listFocusRequester = remember { FocusRequester() }
val actionsFocusRequester = remember { FocusRequester() }
val hasAlerts = notifications.isNotEmpty()
LaunchedEffect(hasAlerts) {
// One frame for the list to place its first row; an empty page has nothing below
// the actions to land on, so the chips take the remote instead.
delay(16)
runCatching {
if (hasAlerts) listFocusRequester.requestFocus() else actionsFocusRequester.requestFocus()
}
}
Box(modifier.fillMaxSize().zIndex(9f).background(MembySurface)) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 56.dp)
.padding(top = 40.dp, bottom = 28.dp),
) {
AlertsHeader(
total = notifications.size,
unread = notifications.count(UserNotification::unread),
)
Spacer(Modifier.height(20.dp))
Row(
modifier = Modifier.fillMaxWidth().focusGroup(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
MembyChoiceChip(
label = if (preferences.enabled) "Alerts on" else "Alerts off",
selected = preferences.enabled,
onClick = onToggleEnabled,
modifier = Modifier.focusRequester(actionsFocusRequester),
)
MembyChoiceChip(
label = if (preferences.showReturnAlerts) "Show returns on" else "Show returns off",
selected = preferences.enabled && preferences.showReturnAlerts,
onClick = { if (preferences.enabled) onToggleShowReturns() },
)
Spacer(Modifier.width(1.dp))
if (hasAlerts) {
MembyChoiceChip(
label = "Dismiss all",
selected = false,
onClick = onDismissAll,
)
}
Spacer(Modifier.weight(1f))
MembyChoiceChip(label = "Close", selected = false, onClick = onClose)
}
Spacer(Modifier.height(18.dp))
Box(Modifier.fillMaxWidth().height(1.dp).background(MembyHairline))
if (!hasAlerts) {
AlertsEmptyState(enabled = preferences.enabled)
} else {
LazyColumn(
modifier = Modifier.fillMaxWidth().weight(1f),
contentPadding = PaddingValues(vertical = 6.dp),
) {
items(notifications, key = UserNotification::id) { notification ->
AlertRow(
notification = notification,
modifier = if (notification.id == notifications.first().id) {
Modifier.focusRequester(listFocusRequester)
} else {
Modifier
},
onFocused = { if (notification.unread) onRead(notification) },
onClick = { onDismiss(notification) },
)
if (notification.id != notifications.last().id) {
Box(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.background(MembyHairline),
)
}
}
}
}
}
}
}
@Composable
private fun AlertsHeader(total: Int, unread: Int) {
Column(
modifier = Modifier.padding(start = 16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text("My Alerts", color = Color.White, fontSize = 32.sp, fontWeight = FontWeight.Bold)
Text(alertsSummary(total, unread), color = MembyQuietText, fontSize = 14.sp)
}
}
@Composable
private fun AlertsEmptyState(enabled: Boolean) {
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Youre all caught up.", color = MembyMutedText, fontSize = 20.sp, fontWeight = FontWeight.SemiBold)
Spacer(Modifier.height(8.dp))
Text(
if (enabled) {
"Alerts about the shows you follow will show up here."
} else {
"Alerts are switched off, so nothing new will arrive here."
},
color = MembyQuietText,
fontSize = 14.sp,
)
}
}
@Composable
private fun AlertRow(
notification: UserNotification,
onFocused: () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(MembyCardCorner)
Row(
modifier = modifier
.fillMaxWidth()
.onFocusChanged {
focused = it.isFocused
if (it.isFocused) onFocused()
}
.clip(shape)
.background(if (focused) Color.White.copy(alpha = 0.11f) else Color.Transparent)
.border(
width = if (focused) 2.dp else 1.dp,
color = if (focused) Color.White.copy(alpha = 0.88f) else Color.Transparent,
shape = shape,
)
.clickable(onClick = onClick)
.semantics {
contentDescription = "${notification.title}. ${notification.message}. Press to dismiss."
}
.padding(horizontal = 16.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Box(
Modifier.size(38.dp).clip(CircleShape).background(
if (notification.unread) MembyAccent.copy(alpha = 0.18f) else Color.White.copy(alpha = 0.05f),
),
contentAlignment = Alignment.Center,
) {
Icon(
alertIcon(notification.kind),
contentDescription = null,
tint = if (notification.unread) MembyAccent else MembyQuietText,
modifier = Modifier.size(19.dp),
)
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
Text(
notification.title,
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (notification.unread) {
Text(
"NEW",
color = MembyAccent,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.sp,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(MembyAccent.copy(alpha = 0.14f))
.padding(horizontal = 5.dp, vertical = 2.dp),
)
}
}
Text(
notification.message,
color = MembyMutedText,
fontSize = 14.sp,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
notification.eventAt?.takeIf { it.isNotBlank() }?.let {
Text(formatMyShowDate(it), color = MembyQuietText, fontSize = 12.sp, maxLines = 1)
}
}
// The hint is the whole reason a single press is allowed to dismiss: it is stated on
// the row about to go, and only on the row under focus.
Box(Modifier.width(112.dp), contentAlignment = Alignment.CenterEnd) {
if (focused) {
Text(
"OK to dismiss",
color = Color.White,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
maxLines = 1,
)
}
}
}
}
private fun alertIcon(kind: String): ImageVector = when {
kind.contains("return", ignoreCase = true) -> Icons.Default.Tv
kind.contains("series", ignoreCase = true) -> Icons.Default.Tv
else -> Icons.Default.NotificationsActive
}
@@ -20,6 +20,7 @@ import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.view.animation.LinearInterpolator
import android.widget.Button
import android.widget.FrameLayout
import android.widget.GridLayout
import android.widget.ImageView
@@ -50,6 +51,7 @@ import androidx.media3.ui.SubtitleView
import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
import coil.imageLoader
import coil.load
@@ -57,14 +59,18 @@ import coil.request.ImageRequest
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.IntroSegment
import com.ponzischeme89.memby.data.NextEpisode
import com.ponzischeme89.memby.data.Playable
import com.ponzischeme89.memby.data.PlayableSubtitle
import com.ponzischeme89.memby.data.PlaybackRequest
import com.ponzischeme89.memby.data.PlaybackSession
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SUBTITLE_LANGUAGE_AUTO
import com.ponzischeme89.memby.data.SubtitleCandidate
import com.ponzischeme89.memby.data.normalizeSeekIntervalSeconds
import com.ponzischeme89.memby.data.normalizeSkipIntroMode
import com.ponzischeme89.memby.data.selectSubtitleId
import com.ponzischeme89.memby.data.model.EmbyPerson
import com.ponzischeme89.memby.data.model.GatewayPrerollEntry
@@ -236,6 +242,26 @@ class PlayerActivity : ComponentActivity() {
private var nextUpDismissed = false
private var advancing = false
// Skipping the opening titles. [skipIntroSegment] is where Emby says they are, fetched
// once playback has settled; the rest is what this episode's viewer has done about it.
private var skipIntroAvailable = false
private var skipIntroView: View? = null
private var skipIntroButton: View? = null
private var skipIntroLabel: TextView? = null
private var skipIntroCountdown: SkipIntroCountdownView? = null
private var skipIntroSegment: IntroSegment? = null
private var skipIntroJob: Job? = null
private var skipIntroLookupJob: Job? = null
/**
* This episode's titles have been dealt with skipped by hand, or skipped for the
* viewer in automatic mode. Deliberately never reset while the same episode is
* playing, unlike [skipIntroDismissed]: in automatic mode a viewer who rewinds to the
* start of an episode has to be able to sit through the opening they just went back
* for, and re-arming would drag them forward again the moment they got there.
*/
private var skipIntroTaken = false
private var skipIntroDismissed = false
// The ten-minute lower third. Shown once per item — a viewer who has been told is
// told; re-announcing it every time they seek would be nagging, not informing.
private var timeRemainingCue: View? = null
@@ -255,6 +281,20 @@ class PlayerActivity : ComponentActivity() {
private var seekCommitJob: Job? = null
private var seekHideJob: Job? = null
/**
* The frame the skip will land on, drawn beside the words in the same chip. It is a
* decoration on an indicator that already works without it, so nothing here is on the
* path of a press: see [TrickplayPreview].
*/
private var trickplayAvailable = false
private val trickplayPreview by lazy {
TrickplayPreview(
scope = lifecycleScope,
loadTrack = { ServiceLocator.repository.trickplay(it) },
loadFrame = { track, frame -> ServiceLocator.repository.trickplayFrame(track, frame) },
)
}
/**
* A seek is landing, so the buffering it causes belongs to the skip and not to the
* film. While this is set the loading overlay is withheld: the viewer asked to move
@@ -484,6 +524,7 @@ class PlayerActivity : ComponentActivity() {
setUpSubtitleOverlay()
setUpCastOverlay()
setUpNextUpBanner()
setUpSkipIntro()
setUpTimeRemainingCue()
setUpSeasonFinaleCue()
setUpPlaybackError()
@@ -588,6 +629,8 @@ class PlayerActivity : ComponentActivity() {
subtitlePreference = playable.subtitlesEnabled
serverSubtitleId = playable.selectedSubtitleId
subtitleDownloadAvailable = playable.subtitleDownloadAvailable
trickplayAvailable = playable.trickplayAvailable
skipIntroAvailable = playable.skipIntroAvailable
subtitleAutoSelectionAttempted = false
initialResumePositionMs = playable.resumePositionMs.coerceAtLeast(0L)
playable.runtimeMs.takeIf { it > 0L }?.let { prerollRuntimeMs = it }
@@ -812,6 +855,16 @@ class PlayerActivity : ComponentActivity() {
// episode to ask about — but the request used to go out during onCreate, competing
// for the connection pool with the stream nobody has seen a frame of yet.
loadCast()
// Same reasoning as the cast, and the same moment: the seek previews are wanted by
// the first press of Left or Right, which cannot come before there is a picture, so
// reading their layout during onCreate would only have competed with the decoder
// for the connection pool.
itemId?.let { trickplayPreview.prepare(it, trickplayAvailable) }
// And the same moment again, for the same reason: the opening titles are minutes
// away and the markers that describe them are worth nothing until there is a
// picture to skip forward in.
loadIntroSegment()
startSkipIntroWatch()
reportStarted(playback.currentPosition)
startProgressReporting()
startPlaybackStartCueWatch()
@@ -1629,8 +1682,10 @@ class PlayerActivity : ComponentActivity() {
seekGlyph = it.findViewById(R.id.player_seek_glyph)
seekAmountView = it.findViewById(R.id.player_seek_amount)
seekPositionView = it.findViewById(R.id.player_seek_position)
trickplayPreview.bind(it.findViewById(R.id.player_seek_preview))
} ?: return
val rewinding = preview.offsetMs < 0L
trickplayPreview.show(preview.targetMs, forward = !rewinding)
seekGlyph?.setImageResource(
if (rewinding) R.drawable.ic_player_rewind else R.drawable.ic_player_forward,
)
@@ -1660,6 +1715,9 @@ class PlayerActivity : ComponentActivity() {
.withEndAction {
indicator.visibility = View.GONE
indicator.alpha = 1f
// The chip is coming back for the next press, and it must come back
// wordless rather than carrying the frame from the last one.
trickplayPreview.hide()
}
.start()
}
@@ -1680,6 +1738,277 @@ class PlayerActivity : ComponentActivity() {
visibility = View.GONE
alpha = 1f
}
// This runs when the item underneath changes, so the outgoing episode's thumbnails
// must go with it — the next press is about a different programme.
trickplayPreview.reset()
}
// --- Skipping the opening titles ------------------------------------------------
private fun setUpSkipIntro() {
skipIntroView = findViewById(R.id.player_skip_intro)
skipIntroLabel = findViewById(R.id.player_skip_intro_label)
skipIntroCountdown = findViewById(R.id.player_skip_intro_countdown)
skipIntroButton = findViewById<View>(R.id.player_skip_intro_button)?.also { button ->
button.setOnClickListener { performIntroSkip(automatic = false) }
}
}
/**
* Asks where this episode's titles are, in the background.
*
* Deliberately not on the path of the Play press. Reading the markers costs a round
* trip to the gateway, which reads them from Emby, or to Emby directly and nothing
* about a skip button is wanted before the first frame: the earliest intro in a typical
* library starts a couple of minutes in. So this runs beside the cast lookup and the
* preview manifest, once there is a picture.
*
* An episode with no markers, a film, and a server that will not answer are all the
* same answer null and every one of them simply means no button.
*/
private fun loadIntroSegment() {
skipIntroLookupJob?.cancel()
skipIntroSegment = null
if (!skipIntroAvailable) return
val id = itemId?.takeIf(String::isNotBlank) ?: return
skipIntroLookupJob = lifecycleScope.launch {
val segment = runCatching { ServiceLocator.repository.introSegment(id) }.getOrNull()
// Auto-advance may have moved on to the next episode while this was in flight,
// and its titles are somewhere else entirely.
if (itemId == id) skipIntroSegment = segment
}
}
/**
* Watches the playhead rather than a timer of its own, the way the next-up countdown
* does. Pausing inside the titles leaves the offer up, and seeking out of them takes it
* down both of which fall out of reading the position rather than counting seconds.
*/
private fun startSkipIntroWatch() {
skipIntroJob?.cancel()
skipIntroJob = lifecycleScope.launch {
while (isActive) {
delay(SKIP_INTRO_TICK_MS)
updateSkipIntroFromPlayhead()
}
}
}
private fun updateSkipIntroFromPlayhead() {
if (advancing || returningHomeAfterCompletion) return
val playback = player ?: return
val segment = skipIntroSegment ?: return
val mode = normalizeSkipIntroMode(ServiceLocator.settings.current?.skipIntroMode)
if (mode == SKIP_INTRO_OFF) {
hideSkipIntro()
return
}
val positionMs = playback.currentPosition
// The tail is what stops the offer appearing for the last moment of a sequence,
// where pressing it would be indistinguishable from doing nothing.
if (!segment.contains(positionMs, lead = SKIP_INTRO_TAIL_MS)) {
hideSkipIntro()
// Rewinding to before the titles offers them again: somebody who went back
// there did it on purpose, and the button they dismissed a minute ago is the
// one they now want. Only a dismissal is re-armed — see [skipIntroTaken].
if (positionMs < segment.startMs) skipIntroDismissed = false
return
}
when {
skipIntroTaken || skipIntroDismissed -> Unit
mode == SKIP_INTRO_AUTO -> performIntroSkip(automatic = true)
skipIntroCanShow() -> {
showSkipIntro()
// Every tick, not only on the first: the ring is the offer's own clock and
// showSkipIntro does nothing once the button is already up.
updateSkipIntroCountdown(segment, positionMs)
}
// Something else owns the screen — the transport, an overlay, the pre-roll.
// The offer waits rather than being spent behind whatever is in front of it.
else -> hideSkipIntro()
}
}
/**
* Advances the ring on the button.
*
* It counts down the *offer*, not the title sequence: from where the button appears to
* where it goes, which is [SKIP_INTRO_TAIL_MS] short of the end of the intro. The two
* are seconds apart and only one of them can be drawn honestly a ring measuring the
* whole sequence would stop with a sliver left and vanish mid-sweep, which reads as the
* countdown having broken rather than as the offer having lapsed. What it says is
* therefore exactly what it looks like it says: how long is left to press this.
*/
private fun updateSkipIntroCountdown(segment: IntroSegment, positionMs: Long) {
val countdown = skipIntroCountdown ?: return
val offerEndMs = segment.endMs - SKIP_INTRO_TAIL_MS
countdown.setRemaining(
remainingMs = offerEndMs - positionMs,
totalMs = offerEndMs - segment.startMs,
)
}
/**
* True when there is nothing else the viewer is looking at.
*
* The button takes focus, so it must never appear over something that already has it:
* the transport row, the subtitle drop-up, the cast panel and the next-up banner all
* own the remote while they are up, and stealing focus out from under any of them is
* how a press lands somewhere nobody aimed it.
*/
private fun skipIntroCanShow(): Boolean =
playbackStarted &&
!prerollActive &&
playerView?.isControllerFullyVisible != true &&
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
private fun showSkipIntro() {
val view = skipIntroView ?: return
if (view.isVisible) return
dressSkipIntro(asNotice = false)
view.alpha = 0f
view.visibility = View.VISIBLE
view.animate()
.alpha(1f)
.setDuration(SKIP_INTRO_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
// Focus is the only way a remote can say "press this". It is taken as the button
// appears and handed back to the video the moment it goes.
skipIntroButton?.requestFocus()
}
private fun hideSkipIntro(returnFocus: Boolean = true) {
val view = skipIntroView ?: return
if (!view.isVisible) return
val hadFocus = skipIntroButton?.isFocused == true
view.animate()
.alpha(0f)
.setDuration(SKIP_INTRO_ANIMATION_MS)
.withEndAction {
view.visibility = View.GONE
view.alpha = 1f
}
.start()
// Only if this was holding it. Taking focus back off whatever the viewer has since
// opened would be worse than leaving it where they put it.
if (returnFocus && hadFocus) playerView?.requestFocus()
}
/**
* Jumps to the end of the titles.
*
* The seek goes through the same door a press of Right does claiming [seekBuffering]
* before asking for it so the couple of seconds it takes to decode a frame at the new
* position is treated as a skip landing rather than as a film that has stopped, and the
* loading overlay stays down.
*/
private fun performIntroSkip(automatic: Boolean) {
val playback = player ?: return
val segment = skipIntroSegment ?: return
if (skipIntroTaken) return
skipIntroTaken = true
val wasPlaying = playback.playWhenReady
// Claimed before the seek is asked for, or the state change lands first and puts
// the loading overlay up anyway.
seekBuffering = true
seekLoadingFallbackJob?.cancel()
seekLoadingFallbackJob = lifecycleScope.launch {
delay(SEEK_LOADING_GRACE_MS)
if (seekBuffering) showPlaybackLoading()
}
playback.seekTo(segment.endMs)
if (wasPlaying) playback.play()
if (automatic) {
// An automatic skip is a jump nobody pressed a button for, so it says what it
// did. Otherwise the picture changes for no visible reason, which reads as the
// stream glitching rather than as a setting working.
announceAutomaticIntroSkip()
} else {
hideSkipIntro()
}
Log.i(
PLAYBACK_LOG_TAG,
"event=skip_intro item=${itemId.orEmpty()} automatic=$automatic " +
"fromMs=${segment.startMs} toMs=${segment.endMs}",
)
}
/**
* Dresses the one view as either the offer or the notice.
*
* They are the same view in the same corner, and they must not look the same. The offer
* is the green pill every other action in this app wears and takes focus; the notice is
* the quiet near-black plate the timing cues use and takes nothing a notice that looks
* pressable is a viewer pressing it to find out what it does.
*/
private fun dressSkipIntro(asNotice: Boolean) {
val button = skipIntroButton ?: return
skipIntroLabel?.apply {
setText(if (asNotice) R.string.player_skip_intro_skipped else R.string.player_skip_intro)
if (asNotice) {
setTextColor(Color.WHITE)
} else {
// A state list, not a colour: the focused pill goes white, so its label has
// to go dark with it.
ContextCompat.getColorStateList(this@PlayerActivity, R.color.next_up_button_text)
?.let(::setTextColor)
}
}
button.setBackgroundResource(
if (asNotice) R.drawable.time_remaining_cue_background
else R.drawable.next_up_primary_button,
)
// The ring counts down an offer, and a notice is not one — there is nothing left to
// press and nothing left to run out. It goes rather than freezing at zero, which
// would read as a countdown that had stalled.
skipIntroCountdown?.isVisible = !asNotice
button.isFocusable = !asNotice
}
/** Says an automatic skip happened, in the same corner the button would have been. */
private fun announceAutomaticIntroSkip() {
val view = skipIntroView ?: return
if (!skipIntroCanShow()) return
dressSkipIntro(asNotice = true)
view.alpha = 0f
view.visibility = View.VISIBLE
view.animate()
.alpha(1f)
.setDuration(SKIP_INTRO_ANIMATION_MS)
.setInterpolator(DecelerateInterpolator())
.start()
lifecycleScope.launch {
delay(SKIP_INTRO_NOTICE_MS)
// Never focused: nothing about a notice is meant to be pressed, and taking the
// remote to say "done" would be worse than saying nothing.
hideSkipIntro(returnFocus = false)
}
}
/** Called when the offer is refused, and when the item underneath changes. */
private fun dismissSkipIntro() {
skipIntroDismissed = true
hideSkipIntro()
}
private fun resetSkipIntro() {
skipIntroJob?.cancel()
skipIntroJob = null
skipIntroLookupJob?.cancel()
skipIntroLookupJob = null
skipIntroSegment = null
skipIntroTaken = false
skipIntroDismissed = false
skipIntroView?.apply {
animate().cancel()
visibility = View.GONE
alpha = 1f
}
}
// --- Next up ------------------------------------------------------------------
@@ -1926,6 +2255,9 @@ class PlayerActivity : ComponentActivity() {
resetTimeRemainingCue()
// A skip aimed at the outgoing episode must not land in the incoming one.
resetSeekControls()
// Nor may the outgoing episode's title-sequence markers: every show times its
// opening differently, and the next episode's are a fresh lookup.
resetSkipIntro()
nextEpisode = null
nextUpDismissed = false
requestStartedAtMs = SystemClock.elapsedRealtime()
@@ -2005,6 +2337,9 @@ class PlayerActivity : ComponentActivity() {
collapseSubtitleDownloads()
subtitleOverlay?.isVisible == true -> hideSubtitleOverlay()
nextUpBanner?.isVisible == true -> dismissNextUp()
// Back refuses the offer rather than leaving the film: one press per
// level, the same contract the banner above and the drop-up have.
skipIntroView?.isVisible == true -> dismissSkipIntro()
playerView?.isControllerFullyVisible == true -> playerView?.hideController()
else -> finish()
}
@@ -2054,6 +2389,10 @@ class PlayerActivity : ComponentActivity() {
castOverlay?.isVisible != true &&
subtitleOverlay?.isVisible != true &&
nextUpBanner?.isVisible != true &&
// The skip button holds focus while it is up, and the centre key is how a
// remote presses the thing it is focused on. Pausing instead would leave the
// one button on screen unpressable.
skipIntroView?.isVisible != true &&
loadingView?.isVisible != true &&
errorView?.isVisible != true
@@ -3032,6 +3371,26 @@ class PlayerActivity : ComponentActivity() {
private const val NEXT_UP_VIDEO_SHIFT_Y = 0.15f
private const val NEXT_UP_IMAGE_PREFETCH_WIDTH = 640
private const val NEXT_UP_IMAGE_PREFETCH_HEIGHT = 360
/**
* How often the playhead is compared against the title-sequence markers, and so how
* often the ring on the button advances. The same rate the next-up countdown reads
* at, and for the same reason: four times a second is what makes a counter look like
* it is running rather than stepping. It costs a position read and, on most ticks,
* nothing else [SkipIntroCountdownView] refuses to redraw for movement smaller
* than a degree, which over a two-minute opening is most of them.
*/
private const val SKIP_INTRO_TICK_MS = 250L
/**
* How much of the end of the sequence the offer is held back for. A button that
* skips the last second of an opening moves the picture imperceptibly, which reads
* as a broken button rather than as a short skip.
*/
private const val SKIP_INTRO_TAIL_MS = 2_000L
/** How long "Intro skipped" stays up after an automatic skip. */
private const val SKIP_INTRO_NOTICE_MS = 2_400L
private const val SKIP_INTRO_ANIMATION_MS = 200L
private const val CONTROLLER_TIMEOUT_MS = 6_000
private const val NO_TRACE = -1
private const val FRESH_STREAM_RETRY_ATTEMPT = 2
@@ -0,0 +1,144 @@
package com.ponzischeme89.memby.ui.player
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Typeface
import android.util.AttributeSet
import android.view.View
import com.ponzischeme89.memby.R
import kotlin.math.ceil
import kotlin.math.min
/**
* The ring on the skip-intro button: how long is left to press it, drawn as a draining arc
* with the figure inside.
*
* Like [PrerollCountdownView] it runs no animator of its own. PlayerActivity advances it
* from the playhead, which is what keeps it honest pausing during the titles holds the
* ring where it is, and seeking moves it to wherever the film now is, neither of which a
* timer counting wall-clock seconds could do.
*
* It takes its colours from its own drawable state rather than from a setter. The button
* around it is a state-list pill green with white text, white with dark text once
* focused so the ring has to change with it or it disappears into the fill the moment
* somebody's remote reaches it. `duplicateParentState` in the layout is what feeds that
* state down; without it this draws focused colours never.
*/
class SkipIntroCountdownView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
private val density = resources.displayMetrics.density
private val ringBounds = RectF()
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeWidth = 2.5f * density
}
private val progressPaint = Paint(trackPaint)
private val figurePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
typeface = Typeface.create("sans-serif", Typeface.BOLD)
}
private var figure = ""
private var progress = 1f
/**
* How much of the offer is left, and how long it ran for.
*
* Redraws only when the drawn result would actually differ. This is advanced several
* times a second for two minutes at a stretch, and a two-minute ring moves by a
* fraction of a degree per tick invalidating on every one of them would be a couple
* of hundred pointless draws per episode on a box that has a decoder to feed.
*/
fun setRemaining(remainingMs: Long, totalMs: Long) {
val remaining = remainingMs.coerceAtLeast(0L)
val nextFigure = formatRemaining(remaining)
val nextProgress = if (totalMs > 0L) {
(remaining.toFloat() / totalMs.toFloat()).coerceIn(0f, 1f)
} else {
0f
}
// A degree is about the smallest movement worth a redraw; below that the arc lands
// on the same pixels.
val moved = kotlin.math.abs(nextProgress - progress) * 360f >= 1f
if (nextFigure == figure && !moved) return
figure = nextFigure
progress = nextProgress
contentDescription = context.getString(R.string.player_skip_intro_countdown, nextFigure)
invalidate()
}
override fun drawableStateChanged() {
super.drawableStateChanged()
// The focused pill is white, so everything on it has to go dark — the same
// inversion `next_up_button_text` makes for the label beside this.
val focused = isFocused || drawableState.contains(android.R.attr.state_focused)
val ink = if (focused) FOCUSED_INK else Color.WHITE
// The unspent part of the ring has to stay visible without competing with the arc.
// Dark ink on the focused white pill needs more of itself than white does on green,
// where the fill is already doing half the separating.
trackPaint.color = Color.argb(
if (focused) 92 else 72,
Color.red(ink),
Color.green(ink),
Color.blue(ink),
)
progressPaint.color = ink
figurePaint.color = ink
invalidate()
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val strokeInset = trackPaint.strokeWidth / 2f
val diameter = min(width, height).toFloat()
val left = (width - diameter) / 2f + strokeInset
val top = (height - diameter) / 2f + strokeInset
ringBounds.set(
left,
top,
left + diameter - trackPaint.strokeWidth,
top + diameter - trackPaint.strokeWidth,
)
canvas.drawOval(ringBounds, trackPaint)
if (progress > 0f) {
// Anticlockwise from the top, so the ring empties the way a clock hand would
// sweep back rather than filling up as the thing it measures runs out.
canvas.drawArc(ringBounds, -90f, -360f * progress, false, progressPaint)
}
if (figure.isEmpty()) return
figurePaint.textSize = figureTextSize(diameter, figure.length)
val baseline = height / 2f - (figurePaint.ascent() + figurePaint.descent()) / 2f
canvas.drawText(figure, width / 2f, baseline, figurePaint)
}
/**
* The figure has to fit inside the ring, and "1:58" is twice the width of "12". Sizing
* from the string's own length is what stops a two-minute opening printing over its own
* arc an intro is commonly long enough to be counted in minutes, so this is the
* ordinary case rather than the edge one.
*/
private fun figureTextSize(diameter: Float, characters: Int): Float =
diameter * if (characters >= 4) 0.30f else 0.42f
private companion object {
val FOCUSED_INK = Color.rgb(11, 14, 17)
}
}
/**
* "1:58" over a minute, "58" under it. Never "0:58": a bare figure is read at a glance in a
* ring this size, and the colon is only worth its width once there are minutes to separate.
*/
internal fun formatRemaining(remainingMs: Long): String {
val seconds = ceil(remainingMs.coerceAtLeast(0L) / 1_000.0).toInt()
if (seconds < 60) return seconds.toString()
return "${seconds / 60}:${(seconds % 60).toString().padStart(2, '0')}"
}
@@ -0,0 +1,176 @@
package com.ponzischeme89.memby.ui.player
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.view.View
import android.widget.ImageView
import androidx.core.view.isVisible
import com.ponzischeme89.memby.data.Trickplay
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* The frame a skip will land on, drawn into the seek indicator.
*
* The idea and the shape of it are borrowed from
* [Wholphin](https://github.com/damontecres/Wholphin), a Jellyfin television client under
* the same GPL-2.0 licence. What differs is the format underneath: Jellyfin serves tile
* sheets and Emby serves BIF files, so where Wholphin crops a sub-image out of a grid,
* this asks for one frame's bytes (see [com.ponzischeme89.memby.data.Trickplay]).
*
* Everything here is arranged around one property: a preview must never be the reason a
* press feels slow. Nothing blocks, every failure is silent, and the thumbnail is composed
* *beside* the words rather than in place of them the chip has always said where the skip
* lands, and it still says it on a title with no previews, on a server that will not answer,
* and in the moment before the first frame arrives.
*/
class TrickplayPreview(
private val scope: CoroutineScope,
/** The layout for a title, or null when it has none. Called once per item. */
private val loadTrack: suspend (String) -> Trickplay?,
/** One thumbnail's JPEG bytes, or null. */
private val loadFrame: suspend (Trickplay, Int) -> ByteArray?,
) {
private var view: ImageView? = null
private var itemId: String? = null
private var track: Trickplay? = null
private var trackJob: Job? = null
private var frameJob: Job? = null
private var shownFrame = NO_FRAME
/**
* The bytes of frames already fetched, rather than the bitmaps.
*
* A JPEG this size is a few kilobytes and its decoded form is a few hundred, so
* holding pixels would be most of a megabyte for a handful of thumbnails on a device
* that has better uses for it. Decoding one takes a millisecond or two, and it happens
* off the main thread anyway.
*/
private val frames = object : LinkedHashMap<Int, ByteArray>(FRAME_CACHE_SIZE, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Int, ByteArray>) =
size > FRAME_CACHE_SIZE
}
/** Attaches the view once the seek indicator has been inflated. */
fun bind(preview: ImageView) {
view = preview
applyAspect()
}
/**
* Begins on a title. [available] is the backend saying whether it will answer at all,
* so an older or deliberately-configured-off gateway is never asked once per playback.
*
* The layout is fetched now rather than on the first press, because the first press is
* exactly when it must already be there but it is fetched in the background and
* nothing waits on it.
*/
fun prepare(itemId: String, available: Boolean) {
if (this.itemId == itemId) return
reset()
this.itemId = itemId
if (!available || itemId.isBlank()) return
trackJob = scope.launch {
val resolved = loadTrack(itemId)
if (this@TrickplayPreview.itemId == itemId) track = resolved
}
}
/**
* Draws the frame covering [positionMs], if there is one.
*
* [forward] is the direction the viewer is travelling, which is used only to warm the
* frame they are most likely to ask for next. Presses come in bursts at a fixed step,
* so the one after this is a good guess and a wrong guess costs a few kilobytes.
*/
fun show(positionMs: Long, forward: Boolean) {
val current = track ?: return
val preview = view ?: return
val frame = current.frameAt(positionMs)
if (frame == shownFrame && preview.isVisible) return
// Cancelling the previous load is the load-bearing part. Presses arrive faster than
// a fetch completes, and without this a slow response for a frame the viewer has
// already skipped past would land on screen after the one they are waiting for —
// the same reason the search pipeline uses collectLatest.
frameJob?.cancel()
frameJob = scope.launch {
val bytes = frames[frame]
?: loadFrame(current, frame)?.also { frames[frame] = it }
?: return@launch
val bitmap = decode(bytes) ?: return@launch
shownFrame = frame
applyAspect()
preview.setImageBitmap(bitmap)
preview.visibility = View.VISIBLE
warm(current, frame + if (forward) 1 else -1)
}
}
/** Takes the preview down without forgetting anything. */
fun hide() {
frameJob?.cancel()
frameJob = null
shownFrame = NO_FRAME
view?.let {
it.visibility = View.GONE
it.setImageDrawable(null)
}
}
/**
* Forgets the title entirely. The player advances between episodes inside the running
* player, so this is what stops one episode's thumbnails being shown over the next.
*/
fun reset() {
trackJob?.cancel()
trackJob = null
track = null
itemId = null
frames.clear()
hide()
}
private suspend fun decode(bytes: ByteArray): Bitmap? = withContext(Dispatchers.Default) {
runCatching { BitmapFactory.decodeByteArray(bytes, 0, bytes.size) }.getOrNull()
}
private fun warm(current: Trickplay, frame: Int) {
if (frame < 0 || frame >= current.count || frames.containsKey(frame)) return
scope.launch {
val bytes = loadFrame(current, frame) ?: return@launch
if (track === current) frames[frame] = bytes
}
}
/**
* Gives the preview the shape the frames actually are.
*
* The layout carries a 16:9 box so the chip is about the right size on the very first
* press, before anything is known; a title whose thumbnails are a wider crop would
* otherwise be letterboxed inside it for the life of the playback.
*/
private fun applyAspect() {
val preview = view ?: return
val current = track ?: return
val params = preview.layoutParams ?: return
val width = current.widthFor(params.height)
if (params.height <= 0 || params.width == width) return
params.width = width
preview.layoutParams = params
}
private companion object {
const val NO_FRAME = -1
/**
* A burst of presses walks through frames in one direction and a viewer often walks
* back over the same ones. Forty entries is a couple of hundred kilobytes of JPEG
* and about seven minutes of a title at ten seconds a frame.
*/
const val FRAME_CACHE_SIZE = 40
}
}
@@ -86,7 +86,11 @@ import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.BuildConfig
import com.ponzischeme89.memby.ServiceLocator
import com.ponzischeme89.memby.data.DEFAULT_SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.DEFAULT_SKIP_INTRO_MODE
import com.ponzischeme89.memby.data.SEEK_INTERVAL_SECONDS
import com.ponzischeme89.memby.data.SKIP_INTRO_AUTO
import com.ponzischeme89.memby.data.SKIP_INTRO_OFF
import com.ponzischeme89.memby.data.SKIP_INTRO_PROMPT
import com.ponzischeme89.memby.data.Settings
import com.ponzischeme89.memby.data.model.GatewayDevice
import com.ponzischeme89.memby.ui.PreviewSurface
@@ -121,6 +125,13 @@ private val SeekIntervalOptions = SEEK_INTERVAL_SECONDS.map {
ChoiceOption(it.toString(), "$it seconds")
}
// The wording is what a viewer chooses between, not the mode names the wire carries.
private val SkipIntroOptions = listOf(
ChoiceOption(SKIP_INTRO_PROMPT, "Ask me"),
ChoiceOption(SKIP_INTRO_AUTO, "Skip it"),
ChoiceOption(SKIP_INTRO_OFF, "Leave it"),
)
private val ArtworkOptions = listOf(
ChoiceOption("automatic", "Automatic"),
ChoiceOption("poster", "Posters"),
@@ -174,6 +185,7 @@ internal data class SettingsPanelState(
val autoPlayNext: Boolean = true,
val showTenMinuteReminder: Boolean = true,
val seekIntervalSeconds: Int = DEFAULT_SEEK_INTERVAL_SECONDS,
val skipIntroMode: String = DEFAULT_SKIP_INTRO_MODE,
val ringColor: String = "52B54B",
val homeSections: Set<String> = setOf("continue", "favorites", "latest"),
val cardDensity: String = "standard",
@@ -202,6 +214,7 @@ internal data class SettingsPanelActions(
val onAutoPlayNextChanged: (Boolean) -> Unit = {},
val onShowTenMinuteReminderChanged: (Boolean) -> Unit = {},
val onSeekIntervalChanged: (Int) -> Unit = {},
val onSkipIntroModeChanged: (String) -> Unit = {},
val onRingColorChanged: (String) -> Unit = {},
val onHomeSectionChanged: (String, Boolean) -> Unit = { _, _ -> },
val onCardDensityChanged: (String) -> Unit = {},
@@ -244,6 +257,7 @@ fun SettingsSheet(
var autoPlayNext by rememberSaveable { mutableStateOf(settings.autoPlayNextEpisode) }
var showTenMinuteReminder by rememberSaveable { mutableStateOf(settings.showTenMinuteReminder) }
var seekInterval by rememberSaveable { mutableStateOf(settings.seekIntervalSeconds) }
var skipIntroMode by rememberSaveable { mutableStateOf(settings.skipIntroMode) }
var ringColor by rememberSaveable { mutableStateOf(settings.ringColorHex) }
var homeSections by rememberSaveable { mutableStateOf(settings.homeSections.split(',').toSet()) }
var cardDensity by rememberSaveable { mutableStateOf(settings.homeCardDensity) }
@@ -303,12 +317,14 @@ fun SettingsSheet(
settings.autoPlayNextEpisode,
settings.showTenMinuteReminder,
settings.seekIntervalSeconds,
settings.skipIntroMode,
settings.welcomeQuoteStyle,
) {
showLogo = settings.showTitleLogo
autoPlayNext = settings.autoPlayNextEpisode
showTenMinuteReminder = settings.showTenMinuteReminder
seekInterval = settings.seekIntervalSeconds
skipIntroMode = settings.skipIntroMode
ringColor = settings.ringColorHex
homeSections = settings.homeSections.split(',').toSet()
cardDensity = settings.homeCardDensity
@@ -332,6 +348,7 @@ fun SettingsSheet(
autoPlayNext = autoPlayNext,
showTenMinuteReminder = showTenMinuteReminder,
seekIntervalSeconds = seekInterval,
skipIntroMode = skipIntroMode,
ringColor = ringColor,
homeSections = homeSections,
cardDensity = cardDensity,
@@ -370,6 +387,10 @@ fun SettingsSheet(
seekInterval = it
scope.launch { store.setSeekIntervalSeconds(it) }
},
onSkipIntroModeChanged = {
skipIntroMode = it
scope.launch { store.setSkipIntroMode(it) }
},
onRingColorChanged = {
ringColor = it
scope.launch { store.setRingColor(it) }
@@ -637,6 +658,14 @@ internal fun SettingsPanelContent(
onCheckedChange = actions.onAutoPlayNextChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Opening titles",
description = "What to do when an episode reaches its intro.",
options = SkipIntroOptions,
selected = state.skipIntroMode,
onSelected = actions.onSkipIntroModeChanged,
)
SettingDivider()
SettingsChoiceRow(
title = "Skip with left and right",
description = "How far one press moves what you are watching.",
@@ -27,6 +27,11 @@
appears once matters more than one that appears on every press. -->
<include layout="@layout/player_seek_indicator" />
<!-- The offer to skip the opening titles. Declared here so the banners and overlays
below cover it: it lives in the bottom-end corner and belongs to the first two
minutes of an episode, where none of them has anything to say yet. -->
<include layout="@layout/player_skip_intro" />
<!-- The ten-minute cue. Declared before the next-up banner so that banner covers it
if the two ever coincide: what comes next matters more than how long is left. -->
<include layout="@layout/player_time_remaining" />
@@ -17,44 +17,71 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/time_remaining_cue_background"
android:gravity="center_vertical"
android:orientation="horizontal"
android:gravity="center_horizontal"
android:orientation="vertical"
android:paddingStart="18dp"
android:paddingTop="12dp"
android:paddingEnd="22dp"
android:paddingBottom="12dp">
<!-- The frame the skip will land on, from Emby's preview thumbnails. It is gone
rather than blank when a title has none, or before the first one arrives: the
chip below it is a complete answer on its own, and a thumbnail-shaped hole
over somebody's film says only that something is missing.
The height is what settles the size, and it is a compromise with the source:
Emby generates these 320px wide, so 120dp is about a third larger than native
on a 1080p set — big enough to read a scene across a room, and not so big that
the upscaling is what the viewer notices. The width here is only the 16:9
placeholder the chip is laid out at before the frames' real shape is known. -->
<ImageView
android:id="@+id/player_seek_glyph"
android:layout_width="26dp"
android:layout_height="26dp"
android:id="@+id/player_seek_preview"
android:layout_width="214dp"
android:layout_height="120dp"
android:layout_marginBottom="12dp"
android:background="#FF000000"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/ic_player_forward" />
android:visibility="gone" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:orientation="vertical">
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/player_seek_amount"
<ImageView
android:id="@+id/player_seek_glyph"
android:layout_width="26dp"
android:layout_height="26dp"
android:contentDescription="@null"
android:scaleType="fitCenter"
android:src="@drawable/ic_player_forward" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Forward 30 seconds" />
android:layout_marginStart="14dp"
android:orientation="vertical">
<TextView
android:id="@+id/player_seek_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="13sp"
tools:text="12:34 / 1:45:00" />
<TextView
android:id="@+id/player_seek_amount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="#FFFFFFFF"
android:textSize="18sp"
android:textStyle="bold"
tools:text="Forward 30 seconds" />
<TextView
android:id="@+id/player_seek_position"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textColor="#99FFFFFF"
android:textSize="13sp"
tools:text="12:34 / 1:45:00" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The offer to jump past an episode's opening titles.
It sits over somebody's film, so it is one control and nothing else: no panel, no
scrim, no explanation. The bottom-end corner is where every television client puts
this, which is most of why it needs no label saying what it is — and it clears the
transport row's own 48dp inset and 72dp strip, so raising the controls does not put
the two on top of each other.
The whole view is gone rather than invisible while it has nothing to offer, because a
button-shaped hole in the corner of a film says only that something is missing. -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/player_skip_intro"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginEnd="48dp"
android:layout_marginBottom="112dp"
android:visibility="gone">
<!-- The row is what is focusable, not the pieces in it: a remote has one idea of
"this", and two focusable children would be two stops on the way past a single
button. Focusable at all, unlike the timing cues and the alert bar beside it,
because this one is meant to be pressed and focus is the only way a remote can say
so. PlayerActivity gives it focus as it appears and hands focus back to the video
when it goes. -->
<LinearLayout
android:id="@+id/player_skip_intro_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/next_up_primary_button"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="14dp"
android:paddingTop="11dp"
android:paddingEnd="24dp"
android:paddingBottom="11dp">
<!-- How long is left to press it. duplicateParentState is load-bearing: the pill
inverts to white on focus, and without the parent's state reaching this the
ring would keep drawing white on white. -->
<com.ponzischeme89.memby.ui.player.SkipIntroCountdownView
android:id="@+id/player_skip_intro_countdown"
android:layout_width="30dp"
android:layout_height="30dp"
android:layout_marginEnd="12dp"
android:duplicateParentState="true" />
<TextView
android:id="@+id/player_skip_intro_label"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:duplicateParentState="true"
android:text="@string/player_skip_intro"
android:textColor="@color/next_up_button_text"
android:textSize="16sp"
android:textStyle="bold"
tools:text="Skip intro" />
</LinearLayout>
</FrameLayout>
+6
View File
@@ -59,6 +59,12 @@
</plurals>
<string name="player_seek_forward">Forward %1$s</string>
<string name="player_seek_back">Back %1$s</string>
<!-- "Intro" rather than "the opening titles": it is the word every other television
client uses for this button, and a button is read at a glance or not at all. -->
<string name="player_skip_intro">Skip intro</string>
<string name="player_skip_intro_skipped">Intro skipped</string>
<!-- Spoken for the ring, which is a shape and says nothing on its own. -->
<string name="player_skip_intro_countdown">%1$s left</string>
<string name="next_up_label">NEXT UP</string>
<string name="next_up_play_now">Play now</string>
<string name="next_up_dismiss">Dismiss</string>
@@ -10,6 +10,8 @@ import com.ponzischeme89.memby.data.model.GatewayPlaybackReportResponse
import com.ponzischeme89.memby.data.model.GatewaySearchHistory
import com.ponzischeme89.memby.data.model.GatewayPreferences
import com.ponzischeme89.memby.data.model.GatewayServiceStatus
import com.ponzischeme89.memby.data.model.GatewayIntro
import com.ponzischeme89.memby.data.model.GatewayTrickplay
import com.ponzischeme89.memby.data.model.GatewayFeatures
import com.ponzischeme89.memby.data.model.RecommendationOnboarding
import kotlinx.serialization.json.Json
@@ -63,6 +65,49 @@ class GatewayPayloadTest {
assertTrue(bare.membyRatings.isEmpty())
}
/**
* The hero row's caption and reason. Both are the gateway's wording, so this only has
* to carry them through and both must be absent-safe, because the direct path picks
* the hero itself and every home cache written before the feature has neither.
*/
@Test
fun `decodes the hero row's caption and reason and defaults them when absent`() {
val payload = """
{
"rows":[{
"id":"hero",
"title":"Featured",
"kind":"hero",
"items":[{
"Id":"emby-9",
"Name":"The Return",
"Type":"Series",
"MembyHeroLabel":"SERIES PREMIERE",
"MembyHeroReason":"A new series premiered yesterday"
}]
}],
"continueWatching":[],
"nextUp":[],
"favorites":[],
"latestMovies":[],
"partial":false
}
""".trimIndent()
val row = json.decodeFromString<GatewayHome>(payload).rows.single()
assertEquals("hero", row.kind)
val item = row.items.single()
assertEquals("SERIES PREMIERE", item.membyHeroLabel)
assertEquals("A new series premiered yesterday", item.membyHeroReason)
// A hero card is playable by construction; the field is defaulted so a payload
// that omits it is not mistaken for a schedule card the viewer cannot press.
assertTrue(item.membyPlayable)
val bare = json.decodeFromString<BaseItem>("""{"Id":"7","Name":"Arrival","Type":"Movie"}""")
assertEquals(null, bare.membyHeroLabel)
assertEquals(null, bare.membyHeroReason)
}
private val json = Json {
ignoreUnknownKeys = true
coerceInputValues = true
@@ -424,6 +469,57 @@ class GatewayPayloadTest {
assertEquals(false, playback.prerollEnabled)
assertEquals(4_000L, playback.prerollDurationMs)
assertTrue(playback.url.startsWith("https://emby.example/Videos/9/stream"))
// Absent on a gateway that predates seek previews, and the default must stay false:
// a missing field must never conjure a request the backend would answer with 404.
assertEquals(false, playback.trickplayAvailable)
// And the same for the skip button, for the same reason.
assertEquals(false, playback.skipIntroAvailable)
}
@Test
fun `decodes an intro segment`() {
val intro = json.decodeFromString<GatewayIntro>(
"""{"available":true,"startMs":463000,"endMs":583000}""",
)
assertTrue(intro.available)
assertEquals(463_000L, intro.startMs)
assertEquals(583_000L, intro.endMs)
}
@Test
fun `a title with no intro markers decodes as unavailable`() {
// An episode Emby has not analysed, a film, and a feature the operator has turned
// off are all the same empty object — and all three mean no button.
val intro = json.decodeFromString<GatewayIntro>("{}")
assertEquals(false, intro.available)
assertEquals(0L, intro.startMs)
assertEquals(0L, intro.endMs)
}
@Test
fun `decodes a seek preview layout`() {
val trickplay = json.decodeFromString<GatewayTrickplay>(
"""{"available":true,"intervalMs":10000,"count":817,"width":320,"height":172}""",
)
assertTrue(trickplay.available)
assertEquals(10_000L, trickplay.intervalMs)
assertEquals(817, trickplay.count)
assertEquals(320, trickplay.width)
assertEquals(172, trickplay.height)
}
@Test
fun `a title with no seek previews decodes as unavailable`() {
// The gateway answers a title with no thumbnails, a feature the operator has turned
// off and an Emby that would not say with the same empty object. All three mean the
// same thing to a television, and none of them is an error it should render.
val trickplay = json.decodeFromString<GatewayTrickplay>("""{"available":false}""")
assertEquals(false, trickplay.available)
assertEquals(0, trickplay.count)
}
@Test
@@ -0,0 +1,156 @@
package com.ponzischeme89.memby.data
import com.ponzischeme89.memby.data.model.EmbyChapter
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The intro rule, pinned against the same cases as the gateway's copy (`intro_test.go`).
*
* The two implementations exist separately because with no gateway there is nobody to ask,
* and a skip must not land somewhere different depending on whether the container is up
* so when one of these changes, the other has to change with it.
*
* The cases are taken from what Emby actually writes for FROM: intro markers arrive as
* ordinary chapters carrying a marker type, in playback order beside the real ones.
*/
class IntroTest {
private fun chapter(seconds: Long, marker: String) = EmbyChapter(
startPositionTicks = seconds * 1_000L * 10_000L,
markerType = marker,
name = marker,
)
@Test
fun `finds the segment in a real episode`() {
val segment = introSegmentFrom(
listOf(
chapter(0, "Chapter"),
chapter(300, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
chapter(600, "Chapter"),
),
)
assertEquals(IntroSegment(startMs = 463_000L, endMs = 583_000L), segment)
}
@Test
fun `an intro can begin at the very start of the file`() {
val segment = introSegmentFrom(
listOf(chapter(0, "IntroStart"), chapter(95, "IntroEnd")),
)
// Which is exactly why availability is never inferred from a zero start.
assertEquals(IntroSegment(startMs = 0L, endMs = 95_000L), segment)
}
@Test
fun `an episode with no markers has no segment`() {
assertNull(introSegmentFrom(listOf(chapter(0, "Chapter"), chapter(300, "Chapter"))))
assertNull(introSegmentFrom(emptyList()))
}
@Test
fun `half a pair is not a segment`() {
// There is no honest end to skip to, and guessing one is how a viewer lands in the
// middle of a scene.
assertNull(introSegmentFrom(listOf(chapter(112, "IntroStart"), chapter(300, "Chapter"))))
assertNull(introSegmentFrom(listOf(chapter(0, "Chapter"), chapter(246, "IntroEnd"))))
}
@Test
fun `a pair in the wrong order is refused`() {
assertNull(introSegmentFrom(listOf(chapter(300, "IntroStart"), chapter(120, "IntroEnd"))))
}
@Test
fun `a segment too short to notice is refused`() {
// A button that moves the picture imperceptibly reads as a broken button, so there
// is deliberately no button at all.
assertNull(introSegmentFrom(listOf(chapter(100, "IntroStart"), chapter(103, "IntroEnd"))))
}
@Test
fun `a segment too long to be an intro is refused`() {
// Far more likely two unrelated markers read as a range than a ten-minute title
// sequence, and honouring it would throw the viewer past the story.
assertNull(introSegmentFrom(listOf(chapter(60, "IntroStart"), chapter(660, "IntroEnd"))))
}
@Test
fun `the first start wins`() {
// Two starts mean the markers are already untrustworthy, and taking the later one
// would pick the larger, more damaging skip of the two.
val segment = introSegmentFrom(
listOf(
chapter(100, "IntroStart"),
chapter(160, "IntroStart"),
chapter(220, "IntroEnd"),
),
)
assertEquals(IntroSegment(startMs = 100_000L, endMs = 220_000L), segment)
}
@Test
fun `a later pair is ignored once one has been found`() {
val segment = introSegmentFrom(
listOf(
chapter(100, "IntroStart"),
chapter(220, "IntroEnd"),
chapter(1_800, "IntroStart"),
chapter(1_900, "IntroEnd"),
),
)
assertEquals(IntroSegment(startMs = 100_000L, endMs = 220_000L), segment)
}
@Test
fun `credit markers are not intros`() {
assertNull(
introSegmentFrom(
listOf(chapter(2_800, "CreditsStart"), chapter(2_900, "CreditsEnd")),
),
)
}
@Test
fun `the window holds the playhead only while the titles are running`() {
val segment = IntroSegment(startMs = 60_000L, endMs = 180_000L)
assertFalse(segment.contains(59_999L))
assertTrue(segment.contains(60_000L))
assertTrue(segment.contains(179_999L))
assertFalse(segment.contains(180_000L))
}
@Test
fun `the tail keeps the offer off the last moment of the sequence`() {
val segment = IntroSegment(startMs = 60_000L, endMs = 180_000L)
// Offering a two-second skip is offering nothing; the button has to disappear
// before it becomes indistinguishable from doing nothing.
assertTrue(segment.contains(177_000L, lead = 2_000L))
assertFalse(segment.contains(178_500L, lead = 2_000L))
}
@Test
fun `an unknown mode falls back to the button rather than to a silent skip`() {
// The gateway's catalogue may grow a mode before every set in the house has the
// release that understands it. Costing that set its button is recoverable; jumping
// through somebody's episode on a value this build cannot read is not.
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode("immediately"))
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode(null))
assertEquals(SKIP_INTRO_PROMPT, normalizeSkipIntroMode(""))
assertEquals(SKIP_INTRO_AUTO, normalizeSkipIntroMode("auto"))
// Case and whitespace are the operator's, not the viewer's problem.
assertEquals(SKIP_INTRO_OFF, normalizeSkipIntroMode(" OFF "))
}
}
@@ -0,0 +1,151 @@
package com.ponzischeme89.memby.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The direct path's copy of the gateway's BIF reader. It exists so the two agree the
* matching Go tests are in `server/internal/trickplay/bif_test.go`, and the cases here are
* deliberately the same ones. With no gateway there is nobody to ask, and a viewer must
* not get different seek previews depending on whether the container is up.
*/
class TrickplayTest {
/**
* Assembles a file in the shape Emby writes one, so the tests exercise the arithmetic
* the real parser will meet rather than a convenient fiction.
*/
private fun buildBif(
count: Int,
multiplier: Long = 10_000L,
frameSizes: List<Int> = emptyList(),
): ByteArray {
val length = bifIndexLength(count)
val file = ByteArray(length)
byteArrayOf(0x89.toByte(), 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a).copyInto(file)
writeLittleEndian(file, 12, count.toLong())
writeLittleEndian(file, 16, multiplier)
var offset = length
val body = ArrayList<Byte>()
for (entry in 0 until count) {
val at = BIF_HEADER_SIZE + entry * 8
writeLittleEndian(file, at, entry.toLong())
writeLittleEndian(file, at + 4, offset.toLong())
val size = frameSizes.getOrElse(entry) { 100 }
offset += size
repeat(size) { body.add(0) }
}
val terminator = BIF_HEADER_SIZE + count * 8
writeLittleEndian(file, terminator, 0xFFFFFFFFL)
writeLittleEndian(file, terminator + 4, offset.toLong())
return file + body.toByteArray()
}
private fun writeLittleEndian(bytes: ByteArray, at: Int, value: Long) {
for (i in 0..3) bytes[at + i] = ((value shr (8 * i)) and 0xFF).toByte()
}
private fun parsed(bytes: ByteArray): BifIndex =
(parseBifIndex(bytes) as BifParse.Parsed).index
@Test
fun `reads the layout Emby writes`() {
// Emby 4.10 writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the
// interval is ten seconds, and reading the multiplier as the interval would be
// right only by accident. The multiplication is the part worth pinning.
val index = parsed(buildBif(count = 3, frameSizes = listOf(500, 600, 700)))
assertEquals(3, index.count)
assertEquals(10_000L, index.intervalMs)
val frame = index.frame(1)!!
assertEquals((bifIndexLength(3) + 500).toLong(), frame.first)
assertEquals(600L, frame.last - frame.first + 1)
}
@Test
fun `a title with no thumbnails is an answer, not a failure`() {
// This is what Emby serves for a title it has not generated previews for: a
// well-formed 72-byte header with a count of zero. It must read as "this title has
// none", never as a broken file, or every such title looks like a fault.
val index = parsed(buildBif(count = 0))
assertEquals(0, index.count)
assertEquals(null, index.frame(0))
}
@Test
fun `refuses what is not a BIF`() {
// Emby's error pages come back on this route with a 200, so "not a BIF" is a real
// answer the reader has to give rather than a theoretical one.
assertEquals(
BifParse.NotBif,
parseBifIndex(("<html><body>Item not found</body></html>" + " ".repeat(64)).toByteArray()),
)
val mangled = buildBif(count = 2)
mangled[3] = 'X'.code.toByte()
assertEquals(BifParse.NotBif, parseBifIndex(mangled))
}
@Test
fun `refuses frames that point back into the index`() {
// An offset inside the index would have the player decode a slice of the index
// itself as a JPEG. Refuse the file rather than draw nonsense over somebody's film.
val file = buildBif(count = 2)
writeLittleEndian(file, BIF_HEADER_SIZE + 4, 8L)
assertEquals(BifParse.NotBif, parseBifIndex(file))
}
@Test
fun `asks for more rather than failing on a short read`() {
// The index is read as a fixed window off the front of the file, so a long title
// legitimately arrives cut short. That must be answerable — read this much and try
// again — rather than looking like a bad file.
val file = buildBif(count = 40)
val short = parseBifIndex(file.copyOfRange(0, BIF_HEADER_SIZE + 16))
assertTrue(short is BifParse.NeedMore)
assertEquals(bifIndexLength(40), (short as BifParse.NeedMore).bytes)
val header = parseBifIndex(file.copyOfRange(0, 20))
assertEquals(BifParse.NeedMore(BIF_HEADER_SIZE), header)
}
@Test
fun `frameAt clamps rather than refusing`() {
// The preview is drawn while somebody is still moving a seek target about, so a
// position past the last frame must show the last frame. Nothing is worse here than
// the thumbnail blanking at exactly the end of a film.
val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
assertEquals(0, track.frameAt(-5_000L))
assertEquals(0, track.frameAt(0L))
assertEquals(0, track.frameAt(9_999L))
assertEquals(1, track.frameAt(10_000L))
assertEquals(2, track.frameAt(25_000L))
assertEquals(2, track.frameAt(9_000_000L))
}
@Test
fun `refuses a frame index out of range`() {
val index = parsed(buildBif(count = 2))
for (n in listOf(-1, 2, 99)) {
assertEquals("frame $n was served", null, index.frame(n))
}
}
@Test
fun `falls back to a sensible shape until the frames are measured`() {
// The gateway measures the frames and says so; the direct path does not, and the
// chip still has to be laid out at about the right size on the very first press.
val unmeasured = Trickplay(itemId = "1", intervalMs = 10_000L, count = 3)
assertEquals(213, unmeasured.widthFor(120))
val measured = unmeasured.copy(width = 320, height = 172)
assertEquals(120 * 320 / 172, measured.widthFor(120))
}
}
@@ -29,6 +29,7 @@ class UserPreferencesTest {
autoPlayNextEpisode = false,
showTenMinuteReminder = false,
seekIntervalSeconds = 30,
skipIntroMode = SKIP_INTRO_AUTO,
forYouMinutes = 60,
homeRowOrder = listOf("recommended", "latest"),
homePinnedRows = listOf("continue"),
@@ -86,6 +87,27 @@ class UserPreferencesTest {
)
}
/**
* The same rule for the intro mode, and the stake is higher: an unreadable value that
* fell through to "auto" would jump through somebody's episode on the strength of a
* string this build cannot parse. It falls back to the button instead.
*/
@Test
fun `an unknown intro mode falls back to the button`() {
assertEquals(
DEFAULT_SKIP_INTRO_MODE,
decodeUserPreferences(json("""{"skipIntroMode":"immediately"}""")).skipIntroMode,
)
assertEquals(
SKIP_INTRO_AUTO,
decodeUserPreferences(json("""{"skipIntroMode":"auto"}""")).skipIntroMode,
)
assertEquals(
DEFAULT_SKIP_INTRO_MODE,
Settings(skipIntroMode = "sometimes").toUserPreferences().skipIntroMode,
)
}
/** An empty row selection would be a launcher with nothing on it. */
@Test
fun `an empty section list falls back rather than emptying the launcher`() {
@@ -79,6 +79,67 @@ class HomeMovieHeroScreenshotTest {
)
}
/**
* The hero the gateway composes. Two things are only checkable by looking: the reason
* takes the synopsis's place rather than adding a line above the Play chip, and a
* series premiere leading the launcher has to read as deliberate rather than as a TV
* show that wandered into the movie hero.
*/
@Test
fun `home hero composed by the gateway`() {
capture(
"df_home-movie-hero-server",
listOf(
HomeHeroPick(
series(
"The Quiet Coast",
2026,
"A harbour town's constable is the only one who noticed the tide change.",
8.6,
),
"SERIES PREMIERE",
"A new series premiered yesterday",
),
HomeHeroPick(
movie("Midnight Signal", 2026, "A city hears tomorrow's emergency broadcast.", 8.2),
"NEW RELEASE",
"Well reviewed, released on Monday",
),
HomeHeroPick(
series("Harbour Lights", 2024, "The fourth season opens on an empty pier.", 8.1),
"NEW SEASON",
"A new season started today",
),
HomeHeroPick(
movie("After the Fire", 2026, "Two strangers cross a country waking from winter.", 8.4),
"HIGHLY RATED",
"One of the best-reviewed titles in your library",
),
),
)
}
/** The reason must not be what breaks the layout the wrapping-title case pins. */
@Test
fun `home hero with a wrapping title and a reason`() {
capture(
"df_home-movie-hero-long-title-reason",
listOf(
HomeHeroPick(
movie(
"The Longest Northbound Winter",
2026,
"A cartographer chasing a river that no longer exists finds the last " +
"village on the map still waiting for him.",
8.4,
),
"NEW RELEASE",
"Well reviewed, released yesterday",
),
) + movies.drop(1),
)
}
private fun capture(name: String, movies: List<HomeHeroPick>) {
val artwork = requireNotNull(javaClass.getResourceAsStream("/home_hero_preview_art.png"))
.use(BitmapFactory::decodeStream)
@@ -178,6 +239,9 @@ class HomeMovieHeroScreenshotTest {
),
)
private fun series(name: String, year: Int, overview: String, rating: Double) =
movie(name, year, overview, rating).copy(type = "Series")
private fun movie(name: String, year: Int, overview: String, rating: Double) = BaseItem(
id = name.lowercase().replace(' ', '-'),
name = name,
@@ -4,6 +4,7 @@ import androidx.compose.ui.unit.dp
import com.ponzischeme89.memby.data.localEpochDay
import com.ponzischeme89.memby.data.millisUntilNextLocalDay
import com.ponzischeme89.memby.data.model.BaseItem
import com.ponzischeme89.memby.data.model.HomeRow
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -66,6 +67,117 @@ class HomeMovieHeroTest {
assertEquals(4, selectHomeHeroMovies(rows).map { it.item.id }.distinct().size)
}
// --- The gateway's hero ------------------------------------------------------
/**
* The server can see Radarr's digital release dates, Sonarr's premieres and the
* review scores; the television can see which shelf a title came off. So where the
* gateway has composed a hero, its order wins outright reordering it here could
* only ever throw that evidence away.
*/
@Test
fun `the server's hero row wins over the local rule`() {
val browseRows = listOf(row("latest-movies", "Recently Added Movies", "local-1", "local-2"))
val serverRows = listOf(
heroRow(
heroItem("server-1", "SERIES PREMIERE", "A new series premiered yesterday"),
heroItem("server-2", "NEW RELEASE", "Well reviewed, released on Monday"),
),
)
val picks = selectHomeHeroMovies(browseRows, day = 5, serverRows = serverRows)
assertEquals(listOf("server-1", "server-2"), picks.map { it.item.id })
assertEquals(listOf("SERIES PREMIERE", "NEW RELEASE"), picks.map { it.label })
assertEquals("A new series premiered yesterday", picks.first().reason)
}
/**
* No day rotation on the server's hero. The facts behind it already change daily, and
* rotating a merit ranking is exactly how the best-reviewed release of the week ends
* up in the fourth slot.
*/
@Test
fun `the server's hero keeps its ranking on every day`() {
val serverRows = listOf(
heroRow(
heroItem("first", "NEW RELEASE", null),
heroItem("second", "NEW RELEASE", null),
heroItem("third", "HIGHLY RATED", null),
),
)
(0L..7L).forEach { day ->
assertEquals(
"day $day",
listOf("first", "second", "third"),
selectHomeHeroMovies(emptyList(), day, serverRows).map { it.item.id },
)
}
}
/** The direct path has no gateway to ask, and an older one sends no hero row. */
@Test
fun `the local rule is the fallback when no hero row arrives`() {
val browseRows = listOf(row("latest-movies", "Recently Added Movies", "new-1", "new-2"))
assertEquals(
selectHomeHeroMovies(browseRows, day = 0),
selectHomeHeroMovies(browseRows, day = 0, serverRows = listOf(heroRow())),
)
assertEquals(
listOf("new-1", "new-2"),
selectHomeHeroMovies(browseRows, day = 0, serverRows = emptyList())
.map { it.item.id },
)
}
/** A card that cannot be pressed is news for the schedule row, not a hero. */
@Test
fun `the server's hero drops anything that is not playable`() {
val serverRows = listOf(
heroRow(
heroItem("upcoming", "NEW RELEASE", null).copy(membyPlayable = false),
heroItem("playable", "NEW RELEASE", null),
),
)
assertEquals(
listOf("playable"),
selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.item.id },
)
}
/**
* A caption is the gateway's wording, so this build must survive one it has never
* heard of and a card that somehow arrives with none falls back to the neutral
* caption rather than claiming something nobody said.
*/
@Test
fun `an unfamiliar or missing caption still draws a card`() {
val serverRows = listOf(
heroRow(
heroItem("future", "STAFF PICK OF THE WEEK", null),
heroItem("bare", null, null),
),
)
assertEquals(
listOf("STAFF PICK OF THE WEEK", "FROM YOUR LIBRARY"),
selectHomeHeroMovies(emptyList(), 0, serverRows).map { it.label },
)
}
/** Four cards is what the hero draws, however many the row carries. */
@Test
fun `the server's hero is capped at four cards`() {
val serverRows = listOf(
heroRow(*(1..8).map { heroItem("item-$it", "NEW RELEASE", null) }.toTypedArray()),
)
assertEquals(4, selectHomeHeroMovies(emptyList(), 0, serverRows).size)
}
// --- Daily variants ----------------------------------------------------------
@Test
@@ -180,6 +292,21 @@ class HomeMovieHeroTest {
const val DAY_MS = 24L * HOUR_MS
}
private fun heroItem(id: String, label: String?, reason: String?) = BaseItem(
id = id,
name = id,
type = "Movie",
membyHeroLabel = label,
membyHeroReason = reason,
)
private fun heroRow(vararg items: BaseItem) = HomeRow(
id = "hero",
title = "Featured",
kind = SERVER_HERO_ROW_KIND,
items = items.toList(),
)
private fun row(id: String, title: String, vararg ids: String) = HomeBrowseRow(
id = id,
title = title,
@@ -361,6 +361,27 @@ class ServerHomeRowsTest {
)
}
/**
* The hero row is the four featured cards above the shelves. HomeMovieHero consumes
* it, so letting it through here would print the same four titles a second time as an
* unnamed row of posters directly beneath the hero they are already in.
*/
@Test
fun `the hero row is consumed by the hero, never drawn as a shelf`() {
val rows = serverHomeRows(
HomeUiState(
rows = listOf(
row("hero", SERVER_HERO_ROW_KIND, "featured"),
row("latest-movies", "latest", "d"),
),
loading = emptySet(),
),
Settings(),
)
assertEquals(listOf("latest-movies"), rows.map { it.id })
}
@Test
fun `an unknown row kind from a newer server still renders`() {
val rows = serverHomeRows(
@@ -37,4 +37,22 @@ class UserSwitcherNavigationTest {
assertEquals(0, userSwitcherInitialIndex(emptyList(), null))
assertEquals(0, userSwitcherNextIndex(0, 0, UserSwitcherDirection.DOWN))
}
@Test
fun `both pinned actions are reachable below the profiles`() {
val profileCount = 3
// …the last profile, then My Alerts, then Manage users, and no further.
assertEquals(3, userSwitcherNextIndex(2, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(3, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(4, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.DOWN, 2))
assertEquals(3, userSwitcherNextIndex(4, profileCount, UserSwitcherDirection.UP, 2))
}
@Test
fun `pinned actions stay reachable with a single profile`() {
assertEquals(1, userSwitcherNextIndex(0, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(2, userSwitcherNextIndex(1, 1, UserSwitcherDirection.DOWN, 2))
assertEquals(2, userSwitcherNextIndex(9, 1, UserSwitcherDirection.DOWN, 2))
}
}
@@ -0,0 +1,34 @@
package com.ponzischeme89.memby.ui.alerts
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AlertsFormatTest {
@Test
fun `no alerts wears no badge`() {
assertNull(alertBadgeLabel(0))
assertNull(alertBadgeLabel(-1))
}
@Test
fun `a small count is drawn as itself`() {
assertEquals("1", alertBadgeLabel(1))
assertEquals("9", alertBadgeLabel(9))
}
@Test
fun `a large count stops counting rather than widening the pill`() {
assertEquals("9+", alertBadgeLabel(10))
assertEquals("9+", alertBadgeLabel(240))
}
@Test
fun `the summary counts alerts, and names the new ones only when there are some`() {
assertEquals("Nothing waiting for you", alertsSummary(total = 0, unread = 0))
assertEquals("1 alert", alertsSummary(total = 1, unread = 0))
assertEquals("4 alerts", alertsSummary(total = 4, unread = 0))
assertEquals("4 alerts · 2 new", alertsSummary(total = 4, unread = 2))
assertEquals("1 alert · 1 new", alertsSummary(total = 1, unread = 1))
}
}
@@ -0,0 +1,169 @@
package com.ponzischeme89.memby.ui.alerts
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onRoot
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.data.EmbyProfile
import com.ponzischeme89.memby.data.model.NotificationPreferences
import com.ponzischeme89.memby.data.model.UserNotification
import com.ponzischeme89.memby.ui.UserSwitcherOverlay
import com.ponzischeme89.memby.ui.theme.MembyTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* Renders My Alerts to PNGs under `build/screenshots/my-alerts/`, so the page can be looked
* at without deploying to a TV.
*
* ```powershell
* .\gradlew.bat :app:testDebugUnitTest --tests "*AlertsPageScreenshotTest"
* ```
*
* The empty case is the one worth keeping. This is a page whose whole job is emptying
* itself, so what it looks like with nothing on it is the state a viewer reaches most often
* and the only one a unit test cannot describe.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class AlertsPageScreenshotTest {
@get:Rule
val compose = createComposeRule()
@Test
fun `alerts waiting`() {
capture("my-alerts-populated", sampleAlerts)
}
/** Nothing new: the "NEW" flags are gone and the rows read as a list, not as news. */
@Test
fun `everything already read`() {
capture("my-alerts-all-read", sampleAlerts.map { it.copy(readAt = "2026-08-06T09:00:00Z") })
}
@Test
fun `nothing waiting`() {
capture("my-alerts-empty", emptyList())
}
/** Alerts switched off has its own empty wording — and both toggles read as off. */
@Test
fun `alerts switched off`() {
capture(
"my-alerts-disabled",
emptyList(),
NotificationPreferences(enabled = false, showReturnAlerts = false),
)
}
/** A long title and a two-line message: the row's own wrapping case. */
@Test
fun `long wording`() {
capture(
"my-alerts-long-wording",
listOf(
UserNotification(
id = 1,
kind = "series_return",
title = "A Very Long Programme Title That Will Not Fit On One Line",
message = "Season 4 of this show returns on Thursday, and the first two " +
"episodes will be in Emby that morning if the download lands.",
eventAt = "2026-08-13T08:30:00Z",
),
),
)
}
/**
* The way in: the user menu in the user picker, with the badge that replaced the bell
* on the launcher. Captured here rather than beside the profile switcher's own tests
* because the badge and the page are one feature if they disagree about what counts,
* this is the pair that shows it.
*/
@Test
fun `user menu carrying the badge`() {
capturePicker("my-alerts-user-menu", alertCount = 3)
}
@Test
fun `user menu with nothing waiting`() {
capturePicker("my-alerts-user-menu-quiet", alertCount = 0)
}
/** Past the cap the badge stops counting rather than widening the row. */
@Test
fun `user menu with a great many alerts`() {
capturePicker("my-alerts-user-menu-many", alertCount = 42)
}
private fun capturePicker(name: String, alertCount: Int) {
compose.setContent {
MembyTheme {
UserSwitcherOverlay(
profiles = listOf(
EmbyProfile("a", "https://memby.example", "t", "u1", "Matt"),
EmbyProfile("b", "https://memby.example", "t", "u2", "Charlotte"),
),
activeProfileId = "a",
onProfileSelected = {},
onManageProfiles = {},
onDismiss = {},
alertCount = alertCount,
onOpenAlerts = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
}
private fun capture(
name: String,
notifications: List<UserNotification>,
preferences: NotificationPreferences = NotificationPreferences(),
) {
compose.setContent {
MembyTheme {
MyAlertsPage(
notifications = notifications,
preferences = preferences,
onToggleEnabled = {},
onToggleShowReturns = {},
onRead = {},
onDismiss = {},
onDismissAll = {},
onClose = {},
)
}
}
compose.onRoot().captureRoboImage("build/screenshots/my-alerts/$name.png")
}
private val sampleAlerts = listOf(
UserNotification(
id = 1,
kind = "series_return",
title = "Northbound returns on Thursday",
message = "Season 3 starts on 13 August. Memby will add it as soon as it lands.",
eventAt = "2026-08-13T20:30:00Z",
),
UserNotification(
id = 2,
kind = "series_return",
title = "The Long Dark is back",
message = "Season 2 started yesterday and the first episode is in Emby now.",
eventAt = "2026-08-06T20:00:00Z",
),
UserNotification(
id = 3,
kind = "library",
title = "24 titles added",
message = "Memby has finished refreshing your library.",
readAt = "2026-08-05T11:00:00Z",
),
)
}
@@ -0,0 +1,128 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import com.ponzischeme89.memby.data.Trickplay
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The seek chip with and without a preview frame, captured from the real player XML at TV
* resolution `build/screenshots/seek-indicator/`.
*
* The case worth having is the *empty* one. A preview is a decoration on an indicator that
* has always worked without it a title with no thumbnails, a gateway that will not answer
* and the moment before the first frame arrives all have to leave the chip looking exactly
* as it did before this existed, and that is a property no assertion can check.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SeekIndicatorScreenshotTest {
@Test
fun `skipping forward with a preview frame`() {
capture(
name = "seek-forward-with-preview",
amount = "Forward 1 min 30 secs",
position = "1:12:40 / 2:04:00",
forward = true,
withPreview = true,
)
}
@Test
fun `skipping back with a preview frame`() {
capture(
name = "seek-back-with-preview",
amount = "Back 30 seconds",
position = "0:41:10 / 2:04:00",
forward = false,
withPreview = true,
)
}
@Test
fun `a title with no previews keeps the chip it always had`() {
capture(
name = "seek-forward-no-preview",
amount = "Forward 30 seconds",
position = "1:12:40 / 2:04:00",
forward = true,
withPreview = false,
)
}
private fun capture(
name: String,
amount: String,
position: String,
forward: Boolean,
withPreview: Boolean,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply {
background = GradientDrawable(
GradientDrawable.Orientation.TL_BR,
intArrayOf(Color.rgb(42, 57, 70), Color.rgb(16, 25, 33), Color.rgb(4, 8, 12)),
)
}
val chip = LayoutInflater.from(activity).inflate(R.layout.player_seek_indicator, root, false)
chip.visibility = View.VISIBLE
chip.findViewById<ImageView>(R.id.player_seek_glyph).setImageResource(
if (forward) R.drawable.ic_player_forward else R.drawable.ic_player_rewind,
)
chip.findViewById<TextView>(R.id.player_seek_amount).text = amount
chip.findViewById<TextView>(R.id.player_seek_position).text = position
if (withPreview) {
val preview = chip.findViewById<ImageView>(R.id.player_seek_preview)
// Sized the way the real one is: from the frames' own shape rather than the
// 16:9 placeholder the layout carries, which is what stops a 320x172 thumbnail
// being letterboxed inside its own box.
val track = Trickplay(itemId = "1", intervalMs = 10_000L, count = 700, width = 320, height = 172)
preview.layoutParams = preview.layoutParams.apply {
width = track.widthFor(height)
}
preview.setImageBitmap(thumbnail())
preview.visibility = View.VISIBLE
}
root.addView(chip)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/seek-indicator/$name.png")
}
/** Stands in for a frame of a film: the point is the chip around it, not the picture. */
private fun thumbnail(): Bitmap {
val bitmap = Bitmap.createBitmap(320, 172, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawPaint(
Paint().apply {
shader = LinearGradient(
0f, 0f, 320f, 172f,
intArrayOf(Color.rgb(96, 84, 62), Color.rgb(38, 44, 58), Color.rgb(10, 12, 18)),
null,
Shader.TileMode.CLAMP,
)
},
)
return bitmap
}
}
@@ -0,0 +1,39 @@
package com.ponzischeme89.memby.ui.player
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The figure inside the countdown ring.
*
* Pure, and worth pinning separately from the view that draws it: the ring is about 30dp
* across, so the difference between "118" and "1:58" is the difference between a figure
* that fits and one that prints over its own arc.
*/
class SkipIntroCountdownTest {
@Test
fun `seconds under a minute, minutes and seconds over it`() {
assertEquals("59", formatRemaining(59_000L))
assertEquals("1:00", formatRemaining(60_000L))
assertEquals("1:58", formatRemaining(118_000L))
assertEquals("2:13", formatRemaining(133_000L))
}
@Test
fun `a part second still counts as a second until it is gone`() {
// Rounded up, so the ring never shows a figure the viewer has already spent. "0"
// belongs to the moment the offer lapses and to nothing before it.
assertEquals("7", formatRemaining(6_400L))
assertEquals("1", formatRemaining(1L))
assertEquals("0", formatRemaining(0L))
}
@Test
fun `a position past the end never draws a negative figure`() {
// The playhead legitimately overshoots between ticks, and a ring reading "-1"
// would be the last thing a viewer saw of this feature.
assertEquals("0", formatRemaining(-500L))
assertEquals("0", formatRemaining(-90_000L))
}
}
@@ -0,0 +1,173 @@
package com.ponzischeme89.memby.ui.player
import android.app.Activity
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.RadialGradient
import android.graphics.Shader
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.core.view.isVisible
import com.github.takahirom.roborazzi.captureRoboImage
import com.ponzischeme89.memby.R
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.GraphicsMode
/**
* The skip-intro offer over a stand-in for a film, captured from the real player XML at TV
* resolution `build/screenshots/skip-intro/`.
*
* The background is fake on purpose and its only job is to be *bright*: this button sits on
* somebody's episode with no scrim and no panel behind it, so the thing worth looking at is
* whether it still reads against a lit scene. A capture over black would prove nothing
* everything reads against black.
*
* Both focus states are captured because the button is focusable and takes focus as it
* appears, and the pill inverts to white when it does which the ring inside it has to
* follow, or it draws white on white. That inversion is the case a unit test cannot see.
* The countdown is captured at both ends of an opening: a two-minute figure, which is the
* ordinary case for a title sequence and the one that has to fit inside the ring, and a
* few seconds left, where the arc is nearly gone.
*/
@RunWith(RobolectricTestRunner::class)
@GraphicsMode(GraphicsMode.Mode.NATIVE)
@Config(sdk = [34], qualifiers = "w960dp-h540dp-television-xhdpi")
class SkipIntroScreenshotTest {
@Test
fun `the offer as a viewer sees it`() {
capture(
name = "skip-intro-focused",
label = R.string.player_skip_intro,
focused = true,
remainingMs = 118_000L,
)
}
@Test
fun `the offer the moment it lands`() {
capture(
name = "skip-intro-idle",
label = R.string.player_skip_intro,
focused = false,
remainingMs = 118_000L,
)
}
@Test
fun `the last seconds of the offer`() {
// The arc is nearly spent and the figure has dropped to a single digit. It reaches
// zero exactly as the button goes, which is the whole reason the ring counts the
// offer rather than the title sequence.
capture(
name = "skip-intro-running-out",
label = R.string.player_skip_intro,
focused = true,
remainingMs = 7_000L,
)
}
@Test
fun `an automatic skip says what it did`() {
// The same view in the same corner, wearing the timing cues' quiet plate instead of
// the green pill, with no ring and never focused. This is the case worth looking at:
// a notice that still reads as a button is a viewer pressing it to find out what it
// does, and a ring on it would be a countdown to nothing.
capture(
name = "skip-intro-automatic-notice",
label = R.string.player_skip_intro_skipped,
focused = false,
remainingMs = 0L,
asNotice = true,
)
}
private fun capture(
name: String,
label: Int,
focused: Boolean,
remainingMs: Long,
asNotice: Boolean = false,
) {
val activity = Robolectric.buildActivity(Activity::class.java).setup().get()
val root = FrameLayout(activity).apply { background = fakeScene() }
val offer = LayoutInflater.from(activity)
.inflate(R.layout.player_skip_intro, root, false)
offer.visibility = View.VISIBLE
offer.findViewById<TextView>(R.id.player_skip_intro_label).apply {
setText(label)
if (asNotice) setTextColor(Color.WHITE)
}
offer.findViewById<SkipIntroCountdownView>(R.id.player_skip_intro_countdown).apply {
isVisible = !asNotice
// A whole FROM opening, so the arc and the figure are at the size they run at.
setRemaining(remainingMs, OFFER_LENGTH_MS)
}
// The same two lines PlayerActivity's dressSkipIntro applies, so the capture is of
// the real notice rather than of a button with different words in it.
offer.findViewById<View>(R.id.player_skip_intro_button).apply {
if (asNotice) setBackgroundResource(R.drawable.time_remaining_cue_background)
isFocusable = !asNotice
isFocusableInTouchMode = !asNotice
if (focused) requestFocus()
}
root.addView(offer)
activity.setContentView(root)
root.captureRoboImage("build/screenshots/skip-intro/$name.png")
}
/**
* Stands in for a frame of an episode: a lit corner where the button sits, so the
* capture answers "is this still legible over picture" rather than over a flat colour.
*/
private fun fakeScene(): Drawable = object : GradientDrawable(
Orientation.TL_BR,
intArrayOf(Color.rgb(28, 46, 66), Color.rgb(74, 62, 44), Color.rgb(150, 132, 96)),
) {
override fun draw(canvas: Canvas) {
super.draw(canvas)
val width = bounds.width().toFloat()
val height = bounds.height().toFloat()
// A bright pool of light behind the bottom-end corner — the hardest case for a
// button with no scrim under it.
canvas.drawPaint(
Paint().apply {
shader = RadialGradient(
width * 0.78f, height * 0.74f, width * 0.42f,
intArrayOf(Color.argb(210, 255, 238, 205), Color.TRANSPARENT),
null,
Shader.TileMode.CLAMP,
)
},
)
// And a soft horizon, so the frame reads as a scene rather than as a swatch.
canvas.drawRect(
0f, height * 0.62f, width, height,
Paint().apply {
shader = LinearGradient(
0f, height * 0.62f, 0f, height,
Color.argb(120, 12, 16, 22), Color.argb(220, 6, 8, 12),
Shader.TileMode.CLAMP,
)
},
)
}
}
private companion object {
/** An episode of FROM: a two-minute opening, less the tail the offer stops short of. */
const val OFFER_LENGTH_MS = 118_000L
}
}
+3
View File
@@ -160,6 +160,9 @@ func (s *Server) Routes() http.Handler {
v1.Handle("GET /v1/items/{id}/subtitles/search", s.authed(s.handleSubtitleSearch))
v1.Handle("POST /v1/items/{id}/subtitles/download", s.authed(s.handleSubtitleDownload))
v1.Handle("GET /v1/items/{id}/trailer", s.authed(s.handleTrailer))
v1.Handle("GET /v1/items/{id}/intro", s.authed(s.handleIntro))
v1.Handle("GET /v1/items/{id}/trickplay", s.authed(s.handleTrickplay))
v1.Handle("GET /v1/items/{id}/trickplay/{frame}", s.authed(s.handleTrickplayFrame))
v1.Handle("POST /v1/playback/{phase}", s.authed(s.handlePlaybackReport))
v1.Handle("POST /v1/analytics/rows", s.authed(s.handleRowAnalytics))
+16
View File
@@ -19,6 +19,8 @@ const (
featureHEVCDirectPlay = "hevc_direct_play"
featureInstallPermission = "install_permission_prompt"
featureSubtitleDownload = "subtitle_download"
featureTrickplay = "trickplay"
featureSkipIntro = "skip_intro"
)
type featureDefinition struct {
@@ -64,6 +66,20 @@ var featureCatalogue = []featureDefinition{
DefaultEnabled: true, MinimumProtocol: 1, Capability: "subtitle_download_v1",
Recovery: "Takes effect the next time playback starts; the option simply stops being offered.",
},
{
Key: featureTrickplay, Name: "Seek preview thumbnails", Area: "Playback",
Description: "Show the frame a skip will land on, from the preview images Emby " +
"generates. Turn it off to stop the gateway reading them.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "trickplay_v1",
Recovery: "Takes effect the next time playback starts; the preview simply stops appearing.",
},
{
Key: featureSkipIntro, Name: "Skip the title sequence", Area: "Playback",
Description: "Offer to jump past an episode's opening titles, from the intro " +
"markers Emby writes. Turn it off to stop the gateway reading them.",
DefaultEnabled: true, MinimumProtocol: 1, Capability: "skip_intro_v1",
Recovery: "Takes effect the next time playback starts; the button simply stops appearing.",
},
{
Key: featureInstallPermission, Name: "Ask TVs for install permission", Area: "Setup",
Description: "Ask a signed-in TV that cannot install its own updates to grant the " +
+836
View File
@@ -0,0 +1,836 @@
package api
// The home hero — the four cards above the launcher's rows, and the one place the server
// says "this, tonight" rather than "here is a shelf".
//
// It used to be chosen on the television: take the first movies off whichever rows looked
// new or popular, rotate the starting point once a day. That was as good as the evidence
// the client had, which is very little. Emby's PremiereDate is frequently whatever a
// metadata agent guessed, nothing on the wire said whether a title was any good, and a
// series could only ever reach the hero as a random show off a shelf. So a poorly
// reviewed film imported last Tuesday led the launcher over the best-received release of
// the month, and the return of a household's favourite show passed unremarked.
//
// The gateway has the three pieces of evidence the television does not:
//
// - **Radarr knows when a film actually came out.** `digitalRelease` is the date the
// household could first have watched it, which is what a viewer means by "new". The
// schedule row already prefers it over Emby's; the hero reads the same answer over a
// backwards window instead of a forwards one.
// - **Sonarr knows a premiere from an ordinary episode.** S01E01 is a new show, S02E01
// is a returning one, and both are news in a way that the fourth episode of a show
// somebody is already halfway through is not.
// - **MDBList knows whether it is worth the evening.** By the point this runs those
// scores are already attached to the cards, so ranking by them costs nothing.
//
// Two properties are what stop this becoming a second recommendation engine, and both
// are easy to give away:
//
// - **It asks Emby for nothing.** The movie candidates are the rows already assembled
// and their ratings are already attached, so the expensive half of the launcher is
// reused rather than repeated. What it does read is the two *arr calendars, and those
// are cached for the day behind a shared lock like the schedule rows' — one household
// pays one miss each per day, and the three reads run together rather than in turn
// because this is the tail of a response every television is waiting on.
// - **Every card it produces is playable.** A premiere the household has not downloaded
// yet, or a film Radarr is still waiting on, is news for the schedule row — the hero
// exists to be pressed, and a lead card that does nothing is worse than no lead card.
import (
"context"
"encoding/json"
"errors"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
const (
heroRowID = "hero"
heroRowKind = "hero"
// How far back a release can be and still be the reason a card leads. Digital
// releases and premieres arrive in bursts, and a hero that empties out in a quiet
// fortnight is a hero that falls back to the library — three weeks keeps it
// populated without billing a six-week-old film as new.
heroWindowDays = 21
// The television draws four cards. The row carries a few more so one it cannot draw
// — no artwork, a type this build predates — costs a card rather than a gap.
heroRowLimit = 8
// Candidates are capped before scoring. A launcher is a few hundred cards; this is a
// guard against a future row type offering a thousand, not a limit anything reaches.
heroCandidateLimit = 240
)
// The ranking. Recency and quality are deliberately close in weight: the request this
// answers is that a well-received release should be able to beat a fresher one that
// nobody liked, which needs quality to be worth roughly as much as a fortnight of age.
const (
heroRecencyWeight = 0.55
heroRatingWeight = 0.45
// What a title nobody has rated is worth. Deliberately near the middle rather than
// zero: a good score is meant to *lift* a title above the merely recent, not to bury
// everything MDBList has never been asked about — which, on a household that has just
// turned ratings on, is the entire library.
heroUnratedScore = 0.55
// Radarr's cinema + 30 days is a guess (see effectiveRadarrRelease), and a guess
// should not outrank a date somebody published.
heroEstimatedPenalty = 0.12
// Where "well reviewed" starts, for the one label that claims it.
heroAcclaimedRating = 0.75
)
// Caption wording is the gateway's, like MembyAirLabel and MembyLifecycleText. The
// television renders the string it is handed, so a new kind of hero card reads correctly
// on a build that predates it.
const (
heroLabelSeriesPremiere = "SERIES PREMIERE"
heroLabelSeasonPremiere = "NEW SEASON"
heroLabelNewRelease = "NEW RELEASE"
heroLabelAcclaimed = "HIGHLY RATED"
heroLabelLibrary = "FROM YOUR LIBRARY"
)
// The fields the hero adds to an item payload. Emby's JSON is otherwise forwarded
// verbatim; these are injected the way MembyRatings is.
const (
heroLabelField = "MembyHeroLabel"
heroReasonField = "MembyHeroReason"
)
const heroReleasedCachePrefix = "radarr:released:v1:"
const heroPremiereCachePrefix = "sonarr:premieres:v1:"
type heroKind int
const (
heroMovie heroKind = iota
heroSeriesPremiere
heroSeasonPremiere
)
// heroCandidate is one title the hero could lead with, and everything the ranking needs
// to decide whether it should.
type heroCandidate struct {
ID string
Name string
Kind heroKind
Item json.RawMessage
// ReleasedAt is the *effective* release: Radarr's digital date for a film, the
// premiere's air date for a show, and Emby's PremiereDate only when nothing better
// is known. Zero means nothing is known at all, which is an answer — such a title
// ranks on quality alone rather than being excluded.
ReleasedAt time.Time
Estimated bool
// Rating is normalised onto 0..1 across whatever providers answered. Rated is false
// when none did, which is a different thing from a score of zero.
Rating float64
Rated bool
}
// heroRecency decays linearly across the window.
//
// A release in the future scores zero rather than more than one. The hero is a thing to
// be pressed, and a title that has not come out yet belongs to the schedule row — this
// only ever sees such a date because Radarr publishes a digital date before it arrives.
func heroRecency(released, now time.Time) float64 {
if released.IsZero() || released.After(now) {
return 0
}
age := now.Sub(released).Hours() / 24
if age >= heroWindowDays {
return 0
}
return 1 - age/heroWindowDays
}
func heroScore(candidate heroCandidate, now time.Time) float64 {
rating := heroUnratedScore
if candidate.Rated {
rating = candidate.Rating
}
score := heroRecencyWeight*heroRecency(candidate.ReleasedAt, now) + heroRatingWeight*rating
if candidate.Estimated {
score -= heroEstimatedPenalty
}
return score
}
// rankHeroCandidates orders the hero and is the whole of the feature that can be reasoned
// about without a network.
//
// The sort is stable and the tie-break is the order it was given, so the caller's own
// preference survives two titles the scorer cannot separate. Deduplication keeps the
// first appearance: a film that is both a Radarr release and a library card is the
// release, which is the more specific thing to say about it.
func rankHeroCandidates(candidates []heroCandidate, now time.Time, limit int) []heroCandidate {
if limit <= 0 {
return nil
}
type ranked struct {
candidate heroCandidate
position int
score float64
}
seen := make(map[string]bool, len(candidates))
scored := make([]ranked, 0, len(candidates))
for position, candidate := range candidates {
if candidate.ID == "" || seen[candidate.ID] || len(candidate.Item) == 0 {
continue
}
seen[candidate.ID] = true
scored = append(scored, ranked{
candidate: candidate,
position: position,
score: heroScore(candidate, now),
})
}
sort.SliceStable(scored, func(i, j int) bool {
if scored[i].score != scored[j].score {
return scored[i].score > scored[j].score
}
return scored[i].position < scored[j].position
})
if len(scored) > limit {
scored = scored[:limit]
}
out := make([]heroCandidate, 0, len(scored))
for _, entry := range scored {
out = append(out, entry.candidate)
}
return out
}
// heroLabel is the caption the card wears, and it may only say what is actually known.
//
// The labels it replaced were the card's *position* — the first slot was captioned NEW
// RELEASE whatever was in it — which is how a 2019 film came to be announced as new. Each
// of these is a claim the candidate has already satisfied.
func heroLabel(candidate heroCandidate, now time.Time) string {
switch candidate.Kind {
case heroSeriesPremiere:
return heroLabelSeriesPremiere
case heroSeasonPremiere:
return heroLabelSeasonPremiere
}
if heroRecency(candidate.ReleasedAt, now) > 0 {
return heroLabelNewRelease
}
if candidate.Rated && candidate.Rating >= heroAcclaimedRating {
return heroLabelAcclaimed
}
return heroLabelLibrary
}
// heroReason is the second line: why this, over the rest of the library. It is allowed to
// be empty, and is empty precisely when there is nothing true to say — a card with no
// evidence behind it says nothing rather than inventing a reason.
func heroReason(candidate heroCandidate, now time.Time, location *time.Location) string {
acclaimed := candidate.Rated && candidate.Rating >= heroAcclaimedRating
fresh := heroRecency(candidate.ReleasedAt, now) > 0
switch {
case candidate.Kind == heroSeriesPremiere && acclaimed:
return "A well-reviewed new series, " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeriesPremiere:
return "A new series premiered " + heroWhen(candidate.ReleasedAt, now, location)
case candidate.Kind == heroSeasonPremiere:
return "A new season started " + heroWhen(candidate.ReleasedAt, now, location)
case fresh && acclaimed && candidate.Estimated:
return "Well reviewed, and expected to have landed " +
heroWhen(candidate.ReleasedAt, now, location)
case fresh && acclaimed:
return "Well reviewed, released " + heroWhen(candidate.ReleasedAt, now, location)
case fresh && candidate.Estimated:
return "Expected to have landed " + heroWhen(candidate.ReleasedAt, now, location)
case fresh:
return "Released " + heroWhen(candidate.ReleasedAt, now, location)
case acclaimed:
return "One of the best-reviewed titles in your library"
default:
return ""
}
}
// heroWhen words a date the way somebody would say it out loud. Nothing here is more
// precise than the evidence: a digital release date carries no time of day, so a card
// never claims an hour.
func heroWhen(released, now time.Time, location *time.Location) string {
if location == nil {
location = time.UTC
}
released = released.In(location)
today := localDayStart(now, location)
day := localDayStart(released, location)
switch days := int(today.Sub(day).Hours() / 24); {
case days <= 0:
return "today"
case days == 1:
return "yesterday"
case days < 7:
return "on " + released.Format("Monday")
case days < 14:
return "last week"
default:
return "this month"
}
}
// heroRatingOf reads the scores already attached to the card.
//
// It is the *mean* of what the household's chosen providers said, normalised onto 0..1.
// The sources disagree about scale and about films — IMDb is generous, Rotten Tomatoes'
// critics are not — and averaging them is a better answer than nominating a favourite and
// letting one provider's blind spot decide what leads the launcher.
func heroRatingOf(raw json.RawMessage) (float64, bool) {
var payload struct {
Ratings []movieRating `json:"MembyRatings"`
CommunityRating *float64 `json:"CommunityRating"`
}
if json.Unmarshal(raw, &payload) != nil {
return 0, false
}
var sum float64
var count int
for _, rating := range payload.Ratings {
source, known := movieRatingSources[strings.ToLower(strings.TrimSpace(rating.Source))]
if !known || source.Maximum <= 0 {
continue
}
value, err := strconv.ParseFloat(strings.TrimSpace(rating.Score), 64)
if err != nil || value <= 0 || value > source.Maximum {
continue
}
sum += value / source.Maximum
count++
}
if count > 0 {
return sum / float64(count), true
}
// Emby's own CommunityRating is the fallback, and only here. The client is forbidden
// from *drawing* it (a card naming a provider that was never asked is a lie), but
// ordering four cards by it claims nothing to anybody — and it is what lets the hero
// rank sensibly on a household that has not configured MDBList at all.
if payload.CommunityRating != nil && *payload.CommunityRating > 0 && *payload.CommunityRating <= 10 {
return *payload.CommunityRating / 10, true
}
return 0, false
}
// heroItemFacts pulls what the ranking needs out of an ordinary Emby item payload.
type heroItemFacts struct {
ID string
Name string
Type string
Premiere time.Time
Playable bool
}
func heroFactsOf(raw json.RawMessage) (heroItemFacts, bool) {
var payload struct {
ID string `json:"Id"`
Name string `json:"Name"`
Type string `json:"Type"`
PremiereDate string `json:"PremiereDate"`
Source string `json:"MembySource"`
Playable *bool `json:"MembyPlayable"`
}
if json.Unmarshal(raw, &payload) != nil || strings.TrimSpace(payload.ID) == "" {
return heroItemFacts{}, false
}
facts := heroItemFacts{
ID: payload.ID,
Name: strings.TrimSpace(payload.Name),
Type: payload.Type,
// A synthetic schedule card carries MembySource and is explicitly not playable.
// Anything from Emby carries neither field, and is.
Playable: strings.TrimSpace(payload.Source) == "" &&
(payload.Playable == nil || *payload.Playable),
}
if parsed, err := parseEmbyDate(payload.PremiereDate); err == nil {
facts.Premiere = parsed
}
return facts, true
}
// parseEmbyDate accepts the shapes Emby writes a date in. A date it will not parse is
// simply unknown, which the ranking already has a behaviour for.
func parseEmbyDate(value string) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, errNoDate
}
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, nil
}
}
return time.Time{}, errNoDate
}
var errNoDate = errors.New("hero: unparsable date")
// injectHeroFields writes the caption and the reason onto one card, the way
// injectItemRatings writes the scores: through a map, so a field this build knows nothing
// about survives the round trip.
func injectHeroFields(raw json.RawMessage, label, reason string) json.RawMessage {
if label == "" && reason == "" {
return raw
}
var members map[string]json.RawMessage
if json.Unmarshal(raw, &members) != nil || members == nil {
return raw
}
if label != "" {
if encoded, err := json.Marshal(label); err == nil {
members[heroLabelField] = encoded
}
}
if reason != "" {
if encoded, err := json.Marshal(reason); err == nil {
members[heroReasonField] = encoded
}
}
out, err := json.Marshal(members)
if err != nil {
return raw
}
return out
}
// heroReleaseIndex answers "when did this film actually come out" for the candidates.
//
// It is keyed two ways because neither key is reliable on its own: a TMDB id is exact but
// only exists for a library item the import has resolved, and a normalised title/year is
// always available but can be wrong about a remake. The id is consulted first.
type heroReleaseIndex struct {
byTMDB map[string]radarrRelease
byTitle map[string]radarrRelease
}
func newHeroReleaseIndex(movies []radarr.Movie) heroReleaseIndex {
index := heroReleaseIndex{
byTMDB: make(map[string]radarrRelease, len(movies)),
byTitle: make(map[string]radarrRelease, len(movies)*2),
}
for _, movie := range movies {
release, ok := effectiveRadarrRelease(movie)
if !ok {
continue
}
if movie.TMDBID > 0 {
index.byTMDB[strconv.Itoa(movie.TMDBID)] = release
}
title := normalizedShowTitle(movie.Title)
if title == "" {
continue
}
// Year-qualified first and never overwritten, so a remake cannot claim the
// original's release date — the same rule seriesIndex applies.
if movie.Year > 0 {
if _, seen := index.byTitle[seriesIndexKey(title, movie.Year)]; !seen {
index.byTitle[seriesIndexKey(title, movie.Year)] = release
}
}
if _, seen := index.byTitle[title]; !seen {
index.byTitle[title] = release
}
}
return index
}
func (index heroReleaseIndex) lookup(tmdbID, title string, year int) (radarrRelease, bool) {
if tmdbID != "" {
if release, ok := index.byTMDB[tmdbID]; ok {
return release, true
}
}
key := normalizedShowTitle(title)
if key == "" {
return radarrRelease{}, false
}
if year > 0 {
if release, ok := index.byTitle[seriesIndexKey(key, year)]; ok {
return release, true
}
}
release, ok := index.byTitle[key]
return release, ok
}
// heroPremiere is one thing Sonarr calls a premiere, resolved onto the Emby series the
// household can actually play.
type heroPremiere struct {
EmbySeriesID string
SeasonNumber int
AiredAt time.Time
}
// sonarrPremieres picks the premieres out of a calendar window.
//
// A premiere is the *first episode of a season* — S01E01 is a new show and S02E01 is a
// returning one, and the answered design question is that both are news where the fourth
// episode of something already in Continue Watching is not. Three filters do the work and
// each removes a card that would misfire:
//
// - Season 0 is specials. A Christmas special is not a premiere.
// - HasFile is required, because a hero card exists to be pressed.
// - The show must be one Emby holds, or the card has no page and no artwork.
//
// The most recent premiere per series wins: a show that premiered and then returned
// inside one window is one card about its newer season, not two.
func sonarrPremieres(
episodes []sonarr.Episode,
series seriesIndex,
from, until time.Time,
) []heroPremiere {
best := map[string]heroPremiere{}
order := make([]string, 0, len(episodes))
for _, episode := range episodes {
if episode.EpisodeNumber != 1 || episode.SeasonNumber < 1 || !episode.HasFile {
continue
}
if episode.AirDateUTC == nil {
continue
}
aired := *episode.AirDateUTC
if aired.Before(from) || aired.After(until) {
continue
}
embyID := series.lookup(episode.Series.Title, episode.Series.Year)
if embyID == "" {
continue
}
existing, seen := best[embyID]
if !seen {
order = append(order, embyID)
}
if seen && !aired.After(existing.AiredAt) {
continue
}
best[embyID] = heroPremiere{
EmbySeriesID: embyID,
SeasonNumber: episode.SeasonNumber,
AiredAt: aired,
}
}
out := make([]heroPremiere, 0, len(order))
for _, embyID := range order {
out = append(out, best[embyID])
}
sort.SliceStable(out, func(i, j int) bool { return out[i].AiredAt.After(out[j].AiredAt) })
return out
}
// heroRow composes the row. It is the only impure part of the feature, and every failure
// inside it costs a signal rather than the hero: a Radarr that will not answer means
// films fall back to Emby's premiere dates, a Sonarr that will not answer means no
// premieres, and neither means the launcher gets the ranking it had before.
func (s *Server) heroRow(
ctx context.Context,
rows []recommend.Row,
now time.Time,
) *recommend.Row {
location := s.cfg.RadarrLocation
if location == nil {
location = time.Local
}
candidates := s.heroCandidates(ctx, rows, now)
ranked := rankHeroCandidates(candidates, now, heroRowLimit)
if len(ranked) == 0 {
return nil
}
items := make([]json.RawMessage, 0, len(ranked))
for _, candidate := range ranked {
items = append(items, injectHeroFields(
candidate.Item,
heroLabel(candidate, now),
heroReason(candidate, now, location),
))
}
return &recommend.Row{
ID: heroRowID,
Title: "Featured",
Kind: heroRowKind,
Items: items,
}
}
// heroCandidates gathers everything eligible, premieres first.
//
// Premieres lead the input order so that they win a tie against a film of identical
// score — a returning show is the more time-sensitive piece of news, and the scorer
// cannot see that.
func (s *Server) heroCandidates(
ctx context.Context,
rows []recommend.Row,
now time.Time,
) []heroCandidate {
movies, facts := heroMovieCandidates(rows)
// Three independent reads, and on the one cache miss a day two of them are *arr round
// trips. They run together rather than in turn because this is the tail of the home
// response: every television in the house is waiting on it, and there is no reason for
// Sonarr's answer to be behind Radarr's.
var (
releases heroReleaseIndex
premieres []heroCandidate
providers map[string]string
wg sync.WaitGroup
)
wg.Add(3)
go func() { defer wg.Done(); releases = s.heroReleaseIndex(ctx, now) }()
go func() { defer wg.Done(); premieres = s.heroPremiereCandidates(ctx, now) }()
go func() { defer wg.Done(); providers = s.heroProviderIDs(ctx, facts) }()
wg.Wait()
for index := range movies {
fact := facts[movies[index].ID]
// Radarr's digital date is preferred over Emby's PremiereDate wherever there is
// one. That preference is the point of the feature: Emby's date is the
// theatrical release where it is right at all, and is a metadata agent's guess
// where it is not, so ranking "new releases" by it puts films in an order that
// has nothing to do with when the household could first watch them.
if release, ok := releases.lookup(providers[movies[index].ID], fact.Name, heroYearOf(fact)); ok {
movies[index].ReleasedAt = release.at
movies[index].Estimated = release.estimated
}
}
return append(premieres, movies...)
}
// heroMovieCandidates reads the assembled rows. Nothing is fetched: these are the same
// payloads the launcher is about to be sent, ratings already attached.
func heroMovieCandidates(rows []recommend.Row) ([]heroCandidate, map[string]heroItemFacts) {
candidates := make([]heroCandidate, 0, heroCandidateLimit)
facts := make(map[string]heroItemFacts, heroCandidateLimit)
seen := make(map[string]bool, heroCandidateLimit)
for _, row := range rows {
// Continue Watching is what somebody is already in the middle of, which is the
// opposite of what a hero is for; the schedule rows are not playable at all.
if row.Kind == "continue" || row.Kind == "schedule" || row.Kind == "movie-schedule" {
continue
}
for _, raw := range row.Items {
if len(candidates) >= heroCandidateLimit {
return candidates, facts
}
fact, ok := heroFactsOf(raw)
if !ok || seen[fact.ID] || !fact.Playable ||
!strings.EqualFold(fact.Type, "Movie") {
continue
}
seen[fact.ID] = true
facts[fact.ID] = fact
rating, rated := heroRatingOf(raw)
candidates = append(candidates, heroCandidate{
ID: fact.ID,
Name: fact.Name,
Kind: heroMovie,
Item: raw,
ReleasedAt: fact.Premiere,
Rating: rating,
Rated: rated,
})
}
}
return candidates, facts
}
func heroYearOf(fact heroItemFacts) int {
if fact.Premiere.IsZero() {
return 0
}
return fact.Premiere.Year()
}
// heroProviderIDs resolves the candidates onto TMDB ids so Radarr can be matched exactly.
// A failure costs the exact match and leaves the title/year fallback.
func (s *Server) heroProviderIDs(
ctx context.Context,
facts map[string]heroItemFacts,
) map[string]string {
out := make(map[string]string, len(facts))
if s.store == nil || len(facts) == 0 {
return out
}
ids := make([]string, 0, len(facts))
for id := range facts {
ids = append(ids, id)
}
refs, err := s.store.LibraryProviderIDs(ctx, ids)
if err != nil {
s.loggerFor(ctx).Warn("hero provider ids unavailable", "error", err)
return out
}
for id, ref := range refs {
if tmdb := strings.TrimSpace(providerID(ref.ProviderIDs, "tmdb")); tmdb != "" {
out[id] = tmdb
}
}
return out
}
// heroReleaseIndex reads Radarr over the window that has already happened, cached for the
// day beside the schedule row's forward-looking one.
func (s *Server) heroReleaseIndex(ctx context.Context, now time.Time) heroReleaseIndex {
empty := heroReleaseIndex{
byTMDB: map[string]radarrRelease{},
byTitle: map[string]radarrRelease{},
}
if s.radarr == nil {
return empty
}
location := s.cfg.RadarrLocation
if location == nil {
location = time.Local
}
dayStart := localDayStart(now.In(location), location)
key := heroReleasedCachePrefix + dayStart.Format("2006-01-02")
if raw, err := s.cache.Get(ctx, key); err == nil {
var movies []radarr.Movie
if json.Unmarshal(raw, &movies) == nil {
return newHeroReleaseIndex(movies)
}
}
s.radarrMu.Lock()
defer s.radarrMu.Unlock()
if raw, err := s.cache.Get(ctx, key); err == nil {
var movies []radarr.Movie
if json.Unmarshal(raw, &movies) == nil {
return newHeroReleaseIndex(movies)
}
}
// The cinema fallback is cinema + 30 days, so a film whose digital date is unknown
// but which is inside the window had its cinema date up to 30 days before that.
movies, err := s.radarr.Calendar(
ctx,
dayStart.AddDate(0, 0, -(heroWindowDays+radarrTheatricalDelayDays)),
dayStart.AddDate(0, 0, 1),
)
if err != nil {
s.loggerFor(ctx).Warn("hero release calendar failed", "error", err)
return empty
}
if body, marshalErr := json.Marshal(movies); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.RadarrTTL); cacheErr != nil {
s.loggerFor(ctx).Warn("hero release cache write failed", "error", cacheErr)
}
}
return newHeroReleaseIndex(movies)
}
// heroPremiereCandidates reads Sonarr's recent calendar and turns each premiere into the
// Emby series card the household can play.
func (s *Server) heroPremiereCandidates(ctx context.Context, now time.Time) []heroCandidate {
if s.sonarr == nil || s.store == nil {
return nil
}
location := s.cfg.SonarrLocation
if location == nil {
location = time.Local
}
dayStart := localDayStart(now.In(location), location)
key := heroPremiereCachePrefix + dayStart.Format("2006-01-02")
var episodes []sonarr.Episode
if raw, err := s.cache.Get(ctx, key); err == nil {
_ = json.Unmarshal(raw, &episodes)
}
if episodes == nil {
s.sonarrMu.Lock()
if raw, err := s.cache.Get(ctx, key); err == nil {
_ = json.Unmarshal(raw, &episodes)
}
if episodes == nil {
fetched, err := s.sonarr.Calendar(
ctx,
dayStart.AddDate(0, 0, -heroWindowDays),
dayStart.AddDate(0, 0, 1),
)
if err != nil {
s.sonarrMu.Unlock()
s.loggerFor(ctx).Warn("hero premiere calendar failed", "error", err)
return nil
}
episodes = fetched
if body, marshalErr := json.Marshal(episodes); marshalErr == nil {
if cacheErr := s.cache.Set(ctx, key, body, s.cfg.SonarrTTL); cacheErr != nil {
s.loggerFor(ctx).Warn("hero premiere cache write failed", "error", cacheErr)
}
}
}
s.sonarrMu.Unlock()
}
premieres := sonarrPremieres(
episodes,
s.embySeriesIndex(ctx),
now.AddDate(0, 0, -heroWindowDays),
now,
)
if len(premieres) == 0 {
return nil
}
ids := make([]string, 0, len(premieres))
for _, premiere := range premieres {
ids = append(ids, premiere.EmbySeriesID)
}
payloads, err := s.store.LibraryItemsByID(ctx, ids)
if err != nil {
s.loggerFor(ctx).Warn("hero premiere series unavailable", "error", err)
return nil
}
// The imported catalogue is shared by the household and deliberately carries no user
// data, so these cards arrive without ratings. Decorating them is one indexed read
// and is what lets a premiere be ranked on the same terms as a film.
s.decorateItemRatings(ctx, payloads)
byID := make(map[string]json.RawMessage, len(payloads))
for _, raw := range payloads {
if id := itemIDOf(raw); id != "" {
byID[id] = raw
}
}
candidates := make([]heroCandidate, 0, len(premieres))
for _, premiere := range premieres {
raw, ok := byID[premiere.EmbySeriesID]
if !ok {
continue
}
fact, ok := heroFactsOf(raw)
if !ok {
continue
}
kind := heroSeasonPremiere
if premiere.SeasonNumber == 1 {
kind = heroSeriesPremiere
}
rating, rated := heroRatingOf(raw)
candidates = append(candidates, heroCandidate{
ID: fact.ID,
Name: fact.Name,
Kind: kind,
Item: raw,
ReleasedAt: premiere.AiredAt,
Rating: rating,
Rated: rated,
})
}
return candidates
}
+412
View File
@@ -0,0 +1,412 @@
package api
import (
"encoding/json"
"testing"
"time"
"github.com/ponzischeme89/memby/server/internal/radarr"
"github.com/ponzischeme89/memby/server/internal/recommend"
"github.com/ponzischeme89/memby/server/internal/sonarr"
)
var heroNow = time.Date(2026, 8, 7, 20, 0, 0, 0, time.UTC)
func heroDaysAgo(days int) time.Time { return heroNow.AddDate(0, 0, -days) }
func heroItem(id, name, itemType string) json.RawMessage {
raw, err := json.Marshal(map[string]any{"Id": id, "Name": name, "Type": itemType})
if err != nil {
panic(err)
}
return raw
}
func heroMovieCandidate(id string, released time.Time, rating float64, rated bool) heroCandidate {
return heroCandidate{
ID: id,
Name: id,
Kind: heroMovie,
Item: heroItem(id, id, "Movie"),
ReleasedAt: released,
Rating: rating,
Rated: rated,
}
}
// The request this whole feature answers: a well-received release should be able to lead
// over a fresher one nobody liked, rather than the launcher being a list sorted only by
// whichever date happened to be attached.
func TestHeroRankingPrefersAWellRatedReleaseOverAFresherPoorOne(t *testing.T) {
candidates := []heroCandidate{
heroMovieCandidate("fresh-and-bad", heroDaysAgo(1), 0.30, true),
heroMovieCandidate("recent-and-good", heroDaysAgo(6), 0.92, true),
}
ranked := rankHeroCandidates(candidates, heroNow, 4)
if len(ranked) != 2 || ranked[0].ID != "recent-and-good" {
t.Fatalf("expected the well-reviewed release to lead, got %+v", heroIDs(ranked))
}
}
// The other half of the same rule: quality must not be able to overturn recency
// altogether, or the hero becomes a list of the library's best films and stops being
// about what is new at all.
func TestHeroRankingKeepsAFreshReleaseAheadOfAnOldAcclaimedOne(t *testing.T) {
candidates := []heroCandidate{
heroMovieCandidate("old-masterpiece", heroDaysAgo(400), 1.0, true),
heroMovieCandidate("new-and-decent", heroDaysAgo(2), 0.70, true),
}
ranked := rankHeroCandidates(candidates, heroNow, 4)
if ranked[0].ID != "new-and-decent" {
t.Fatalf("expected the new release to lead, got %+v", heroIDs(ranked))
}
}
// An unrated title is not a bad title. On a household that has never configured MDBList
// this is every title, and burying them would leave the hero empty.
func TestHeroRankingDoesNotBuryUnratedTitles(t *testing.T) {
candidates := []heroCandidate{
heroMovieCandidate("rated-poorly", heroDaysAgo(3), 0.20, true),
heroMovieCandidate("unrated", heroDaysAgo(3), 0, false),
}
ranked := rankHeroCandidates(candidates, heroNow, 4)
if ranked[0].ID != "unrated" {
t.Fatalf("expected the unrated title to outrank the poorly rated one, got %+v", heroIDs(ranked))
}
}
// Radarr's cinema + 30 days is a guess, and a guess must not outrank a published date.
func TestHeroRankingPenalisesAnEstimatedRelease(t *testing.T) {
known := heroMovieCandidate("known", heroDaysAgo(4), 0.60, true)
estimated := heroMovieCandidate("estimated", heroDaysAgo(4), 0.60, true)
estimated.Estimated = true
ranked := rankHeroCandidates([]heroCandidate{estimated, known}, heroNow, 4)
if ranked[0].ID != "known" {
t.Fatalf("expected the published date to lead, got %+v", heroIDs(ranked))
}
}
// A release Radarr has dated but which has not happened yet belongs to the schedule row.
// The hero exists to be pressed.
func TestHeroRecencyIgnoresFutureReleases(t *testing.T) {
if score := heroRecency(heroNow.AddDate(0, 0, 3), heroNow); score != 0 {
t.Fatalf("expected a future release to score 0, got %v", score)
}
if score := heroRecency(time.Time{}, heroNow); score != 0 {
t.Fatalf("expected an unknown release to score 0, got %v", score)
}
if score := heroRecency(heroDaysAgo(heroWindowDays), heroNow); score != 0 {
t.Fatalf("expected the window edge to score 0, got %v", score)
}
if score := heroRecency(heroNow, heroNow); score != 1 {
t.Fatalf("expected a release today to score 1, got %v", score)
}
}
func TestHeroRankingDeduplicatesAndCaps(t *testing.T) {
candidates := []heroCandidate{
heroMovieCandidate("a", heroDaysAgo(1), 0.9, true),
heroMovieCandidate("a", heroDaysAgo(1), 0.9, true),
heroMovieCandidate("b", heroDaysAgo(2), 0.8, true),
heroMovieCandidate("c", heroDaysAgo(3), 0.7, true),
}
ranked := rankHeroCandidates(candidates, heroNow, 2)
if len(ranked) != 2 || ranked[0].ID != "a" || ranked[1].ID != "b" {
t.Fatalf("expected [a b], got %+v", heroIDs(ranked))
}
}
// A candidate with no payload cannot be drawn, so it must never take one of four slots.
func TestHeroRankingSkipsCandidatesWithNothingToDraw(t *testing.T) {
empty := heroMovieCandidate("empty", heroDaysAgo(1), 1.0, true)
empty.Item = nil
ranked := rankHeroCandidates(
[]heroCandidate{empty, heroMovieCandidate("real", heroDaysAgo(9), 0.4, true)},
heroNow, 4,
)
if len(ranked) != 1 || ranked[0].ID != "real" {
t.Fatalf("expected only the drawable candidate, got %+v", heroIDs(ranked))
}
}
// Labels are claims. Each one has to have been earned, which is exactly what the
// positional captions this replaced could not promise.
func TestHeroLabels(t *testing.T) {
series := heroMovieCandidate("s", heroDaysAgo(2), 0.5, true)
series.Kind = heroSeriesPremiere
season := series
season.Kind = heroSeasonPremiere
acclaimedOld := heroMovieCandidate("old", heroDaysAgo(300), 0.88, true)
quietOld := heroMovieCandidate("quiet", heroDaysAgo(300), 0.40, true)
cases := []struct {
candidate heroCandidate
want string
}{
{series, heroLabelSeriesPremiere},
{season, heroLabelSeasonPremiere},
{heroMovieCandidate("new", heroDaysAgo(2), 0.5, true), heroLabelNewRelease},
{acclaimedOld, heroLabelAcclaimed},
{quietOld, heroLabelLibrary},
}
for _, testCase := range cases {
if got := heroLabel(testCase.candidate, heroNow); got != testCase.want {
t.Fatalf("label for %q: want %q, got %q", testCase.candidate.ID, testCase.want, got)
}
}
}
// A card with nothing true to say says nothing, rather than inventing a reason.
func TestHeroReasonIsEmptyWithoutEvidence(t *testing.T) {
quiet := heroMovieCandidate("quiet", heroDaysAgo(300), 0.40, true)
if reason := heroReason(quiet, heroNow, time.UTC); reason != "" {
t.Fatalf("expected no reason, got %q", reason)
}
unknown := heroMovieCandidate("unknown", time.Time{}, 0, false)
if reason := heroReason(unknown, heroNow, time.UTC); reason != "" {
t.Fatalf("expected no reason, got %q", reason)
}
}
func TestHeroReasonWording(t *testing.T) {
premiere := heroMovieCandidate("p", heroDaysAgo(1), 0.5, true)
premiere.Kind = heroSeriesPremiere
if got, want := heroReason(premiere, heroNow, time.UTC), "A new series premiered yesterday"; got != want {
t.Fatalf("want %q, got %q", want, got)
}
estimated := heroMovieCandidate("e", heroDaysAgo(0), 0.5, true)
estimated.Estimated = true
if got, want := heroReason(estimated, heroNow, time.UTC), "Expected to have landed today"; got != want {
t.Fatalf("want %q, got %q", want, got)
}
acclaimed := heroMovieCandidate("a", heroDaysAgo(1), 0.9, true)
if got, want := heroReason(acclaimed, heroNow, time.UTC), "Well reviewed, released yesterday"; got != want {
t.Fatalf("want %q, got %q", want, got)
}
}
// Ratings are read off the card the launcher is about to be sent, and averaged across
// providers measured on different scales.
func TestHeroRatingOfAveragesAcrossScales(t *testing.T) {
raw, err := json.Marshal(map[string]any{
"Id": "x",
"MembyRatings": []map[string]string{
{"source": "imdb", "score": "8.0"},
{"source": "tomatoes", "score": "90"},
},
})
if err != nil {
t.Fatal(err)
}
rating, rated := heroRatingOf(raw)
if !rated {
t.Fatal("expected the card to be rated")
}
if want := (0.8 + 0.9) / 2; rating < want-0.0001 || rating > want+0.0001 {
t.Fatalf("want %v, got %v", want, rating)
}
}
// Emby's own score orders the hero when MDBList has never been asked. It is never drawn
// (that would name a provider nobody consulted) but ordering four cards by it claims
// nothing to anyone, and it is what makes this work before ratings are configured.
func TestHeroRatingFallsBackToCommunityRating(t *testing.T) {
raw, err := json.Marshal(map[string]any{"Id": "x", "CommunityRating": 7.5})
if err != nil {
t.Fatal(err)
}
rating, rated := heroRatingOf(raw)
if !rated || rating < 0.74 || rating > 0.76 {
t.Fatalf("want 0.75, got %v (rated=%v)", rating, rated)
}
}
func TestHeroRatingUnratedIsNotZero(t *testing.T) {
if _, rated := heroRatingOf(heroItem("x", "X", "Movie")); rated {
t.Fatal("expected an item with no scores to be unrated")
}
// An out-of-range or unparsable score is the same as no score, not a score of nil.
raw, _ := json.Marshal(map[string]any{
"Id": "x",
"MembyRatings": []map[string]string{{"source": "imdb", "score": "n/a"}},
})
if _, rated := heroRatingOf(raw); rated {
t.Fatal("expected an unparsable score to leave the card unrated")
}
}
// Radarr's digital date is the answer Emby's PremiereDate is asked for and gets wrong.
func TestHeroReleaseIndexPrefersTMDBThenTitleAndYear(t *testing.T) {
digital := heroDaysAgo(3)
cinema := heroDaysAgo(40)
index := newHeroReleaseIndex([]radarr.Movie{
{ID: 1, TMDBID: 550, Title: "Fight Club", Year: 1999, DigitalRelease: &digital},
{ID: 2, Title: "The Thing", Year: 1982, InCinemas: &cinema},
{ID: 3, Title: "The Thing", Year: 2011, InCinemas: &cinema},
})
release, ok := index.lookup("550", "", 0)
if !ok || !release.at.Equal(digital) || release.estimated {
t.Fatalf("expected the exact tmdb match, got %+v (ok=%v)", release, ok)
}
// A title with no tmdb id still resolves, and the year keeps the remake apart from
// the original.
if _, ok := index.lookup("", "The Thing", 1982); !ok {
t.Fatal("expected the title/year fallback to resolve")
}
estimated, ok := index.lookup("", "The Thing", 2011)
if !ok || !estimated.estimated {
t.Fatalf("expected a cinema date to be reported as estimated, got %+v", estimated)
}
if _, ok := index.lookup("", "Nothing Like It", 2020); ok {
t.Fatal("expected an unknown title to resolve to nothing")
}
}
func sonarrPremiereEpisode(
series string, seasonNumber, episodeNumber int, aired time.Time, hasFile bool,
) sonarr.Episode {
return sonarr.Episode{
SeasonNumber: seasonNumber,
EpisodeNumber: episodeNumber,
AirDateUTC: &aired,
HasFile: hasFile,
Series: sonarr.Series{Title: series},
}
}
// "Series premieres only, not random TV series" is this function, and most of it is
// about refusing.
func TestSonarrPremieresSelectsOnlyPlayableSeasonOpeners(t *testing.T) {
index := seriesIndex{
"newshow": "emby-new",
"returning": "emby-returning",
"notinlibrary": "",
"specials": "emby-specials",
"undownloaded": "emby-undownloaded",
}
delete(index, "notinlibrary")
episodes := []sonarr.Episode{
sonarrPremiereEpisode("New Show", 1, 1, heroDaysAgo(2), true),
sonarrPremiereEpisode("Returning", 3, 1, heroDaysAgo(5), true),
// Not a premiere: an ordinary episode of something already under way.
sonarrPremiereEpisode("Returning", 3, 4, heroDaysAgo(1), true),
// Season 0 is specials, not a premiere.
sonarrPremiereEpisode("Specials", 0, 1, heroDaysAgo(1), true),
// Not downloaded — news for the schedule row, not a card to press.
sonarrPremiereEpisode("Undownloaded", 1, 1, heroDaysAgo(1), false),
// Emby has never imported this show, so the card would have no page.
sonarrPremiereEpisode("Not In Library", 1, 1, heroDaysAgo(1), true),
// Outside the window.
sonarrPremiereEpisode("New Show", 1, 1, heroDaysAgo(90), true),
}
premieres := sonarrPremieres(episodes, index, heroDaysAgo(heroWindowDays), heroNow)
if len(premieres) != 2 {
t.Fatalf("expected 2 premieres, got %d: %+v", len(premieres), premieres)
}
if premieres[0].EmbySeriesID != "emby-new" || premieres[0].SeasonNumber != 1 {
t.Fatalf("expected the newest premiere first, got %+v", premieres[0])
}
if premieres[1].EmbySeriesID != "emby-returning" || premieres[1].SeasonNumber != 3 {
t.Fatalf("expected the returning show second, got %+v", premieres[1])
}
}
// A show that premiered and then returned inside one window is one card about its newer
// season, not two cards about the same show.
func TestSonarrPremieresKeepsOneCardPerSeries(t *testing.T) {
index := seriesIndex{"show": "emby-show"}
premieres := sonarrPremieres([]sonarr.Episode{
sonarrPremiereEpisode("Show", 1, 1, heroDaysAgo(15), true),
sonarrPremiereEpisode("Show", 2, 1, heroDaysAgo(2), true),
}, index, heroDaysAgo(heroWindowDays), heroNow)
if len(premieres) != 1 {
t.Fatalf("expected one card, got %d", len(premieres))
}
if premieres[0].SeasonNumber != 2 {
t.Fatalf("expected the newer season, got season %d", premieres[0].SeasonNumber)
}
}
// The hero draws from the rows the launcher is already being sent, and the rows it must
// not draw from are the ones whose cards cannot be pressed or are already in progress.
func TestHeroMovieCandidatesSkipUnpressableRows(t *testing.T) {
scheduleCard, _ := json.Marshal(map[string]any{
"Id": "radarr:1", "Name": "Coming Soon", "Type": "Movie",
"MembySource": "radarr", "MembyPlayable": false,
})
rows := []recommend.Row{
{ID: "continue", Kind: "continue", Items: []json.RawMessage{heroItem("resume", "Resume", "Movie")}},
{ID: "movie-schedule", Kind: "movie-schedule", Items: []json.RawMessage{scheduleCard}},
{ID: "latest", Kind: "latest", Items: []json.RawMessage{
heroItem("film", "Film", "Movie"),
heroItem("show", "Show", "Series"),
}},
}
candidates, facts := heroMovieCandidates(rows)
if len(candidates) != 1 || candidates[0].ID != "film" {
t.Fatalf("expected only the playable film, got %+v", heroIDs(candidates))
}
if _, ok := facts["film"]; !ok {
t.Fatal("expected the film's facts to be recorded for the Radarr lookup")
}
}
// Emby writes dates in more than one shape, and one it will not parse is unknown rather
// than fatal.
func TestParseEmbyDate(t *testing.T) {
for _, value := range []string{
"2026-08-01T00:00:00.0000000Z",
"2026-08-01T00:00:00Z",
"2026-08-01",
} {
parsed, err := parseEmbyDate(value)
if err != nil {
t.Fatalf("%q: %v", value, err)
}
if parsed.Year() != 2026 || parsed.Month() != time.August || parsed.Day() != 1 {
t.Fatalf("%q parsed to %v", value, parsed)
}
}
if _, err := parseEmbyDate("not a date"); err == nil {
t.Fatal("expected an error")
}
if _, err := parseEmbyDate(""); err == nil {
t.Fatal("expected an error")
}
}
// The caption and the reason ride on the item, and an unknown field must survive being
// decorated — the payload is Emby's, forwarded verbatim.
func TestInjectHeroFieldsPreservesUnknownFields(t *testing.T) {
raw, _ := json.Marshal(map[string]any{"Id": "x", "SomethingNew": "keep me"})
out := injectHeroFields(raw, heroLabelNewRelease, "Released yesterday")
var members map[string]any
if err := json.Unmarshal(out, &members); err != nil {
t.Fatal(err)
}
if members["SomethingNew"] != "keep me" {
t.Fatalf("unknown field was dropped: %v", members)
}
if members[heroLabelField] != heroLabelNewRelease {
t.Fatalf("label missing: %v", members)
}
if members[heroReasonField] != "Released yesterday" {
t.Fatalf("reason missing: %v", members)
}
// Nothing to say leaves the payload exactly as it was.
if got := injectHeroFields(raw, "", ""); string(got) != string(raw) {
t.Fatalf("expected the payload untouched, got %s", got)
}
}
func heroIDs(candidates []heroCandidate) []string {
ids := make([]string, 0, len(candidates))
for _, candidate := range candidates {
ids = append(ids, candidate.ID)
}
return ids
}
+28 -2
View File
@@ -67,10 +67,12 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
limit := queryInt(r, "limit", 24, 100)
sonarrSchedule := s.sonarr != nil && supportsSonarrSchedule(r)
radarrSchedule := s.radarr != nil && supportsRadarrSchedule(r)
hero := supportsHomeHero(r)
key := cache.UserKey(
sess.EmbyUserID,
"home:v3:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":d"+sess.DeviceID,
"home:v4:"+itoa(limit)+":s"+strconv.FormatBool(sonarrSchedule)+
":r"+strconv.FormatBool(radarrSchedule)+":h"+strconv.FormatBool(hero)+
":d"+sess.DeviceID,
)
if raw, err := s.cache.Get(ctx, key); err == nil {
@@ -282,6 +284,16 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request, sess store.S
// the launcher pays one indexed read rather than a request per poster, and a card
// shows its scores as it is drawn instead of when focus reaches it.
s.decorateHomeRatings(ctx, &out)
// The hero is composed last, from the finished rows, because that is the only point
// at which the ratings it ranks by are already attached. It is prepended rather than
// inserted: the television consumes this row instead of drawing it, so its position
// among the shelves means nothing, and being first is what lets an older reader that
// does draw it put it somewhere sensible.
if hero {
if row := s.heroRow(ctx, out.Rows, time.Now()); row != nil {
out.Rows = append([]recommend.Row{*row}, out.Rows...)
}
}
body, err := json.Marshal(out)
if err != nil {
@@ -445,6 +457,20 @@ func supportsRadarrSchedule(r *http.Request) bool {
return version != "" && appupdate.CompareVersions(version, "0.1.79") >= 0
}
// The server-composed hero ships in 0.2.27. Gating it matters more than gating a shelf:
// a television that predates it has no idea the "hero" kind is meant to be consumed
// rather than drawn, so it renders the featured cards a second time as a "Featured" row
// of posters beneath the hero it picked for itself.
//
// Note that 0.2.27 is also the version the feature was *added to* rather than a version
// after it, so any 0.2.27 build already in the field is one of the televisions this is
// meant to exclude. That is a deliberate call by the operator; if it bites, moving this
// floor to the next version is the fix, not a client change.
func supportsHomeHero(r *http.Request) bool {
version := clientVersion(r)
return version != "" && appupdate.CompareVersions(version, "0.2.27") >= 0
}
// handleScreensaver serves the backdrop pool. The pool is cached and shuffled per
// request, so the Dream still looks random without re-querying Emby every few seconds.
func (s *Server) handleScreensaver(w http.ResponseWriter, r *http.Request, sess store.Session) {
+204
View File
@@ -0,0 +1,204 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/ponzischeme89/memby/server/internal/store"
)
const (
// introMinimumMs is the shortest span worth calling an intro. Emby's detector
// occasionally writes a pair a couple of seconds apart on a title whose opening it
// half-recognised, and a button that skips two seconds is worse than no button:
// somebody presses it, the picture does not visibly move, and the feature reads as
// broken.
introMinimumMs = 5_000
// introMaximumMs is the longest. A pair minutes apart is a mis-detection — a recap, a
// cold open, or two unrelated markers read as a range — and honouring it would throw a
// viewer past the start of the story. FROM's openings run about two minutes; this is
// generous enough to cover a long title sequence and short enough to refuse nonsense.
introMaximumMs = 5 * 60 * 1_000
// introTTL keeps a found segment for a day. Chapter markers only change when the media
// is re-analysed, and every playback of an episode asks for this once.
introTTL = 24 * time.Hour
// introMissingTTL is how long "this episode has no intro markers" is remembered.
// Deliberately shorter, for the same reason the trickplay one is: Emby detects intros
// on a schedule, so an episode imported this afternoon must not be answered from a
// day-old no.
introMissingTTL = time.Hour
)
// What a viewer has asked to happen when an episode reaches its opening titles. The
// television holds the matching vocabulary in `data/SkipIntroPreference.kt`; this is the
// side that decides what a legal value is, through the preference catalogue.
const (
skipIntroPrompt = "prompt"
skipIntroAuto = "auto"
skipIntroOff = "off"
)
// introSegment is where an episode's title sequence sits, in milliseconds from the start.
type introSegment struct {
StartMs int64 `json:"startMs"`
EndMs int64 `json:"endMs"`
}
// introResponse is what a television is told. Available is explicit rather than implied by
// a zero pair: an intro legitimately starting at 0 ms must be distinguishable from a title
// that has none, and the client defaults to false so a gateway that predates this — or has
// the feature turned off — can never conjure a button.
type introResponse struct {
Available bool `json:"available"`
StartMs int64 `json:"startMs,omitempty"`
EndMs int64 `json:"endMs,omitempty"`
}
// embyChapter is one entry of Emby's Chapters field. Only two of its keys matter here.
//
// Emby writes intro markers as ordinary chapters carrying a MarkerType, interleaved with
// the real ones in playback order — so they are read out of the same array the chapter
// list comes from, and asking for Chapters is the whole of the request.
type embyChapter struct {
StartPositionTicks int64 `json:"StartPositionTicks"`
MarkerType string `json:"MarkerType"`
Name string `json:"Name"`
}
// introFromChapters finds the title sequence in a chapter list.
//
// Pure, and the piece worth testing hard: it is what stands between one bad marker and a
// viewer being thrown into the middle of a scene. The rule exists twice — the television's
// copy is `introSegmentFrom` in `data/Intro.kt` — and the two are pinned by deliberately
// parallel tests (`intro_test.go`, `IntroTest`). With no gateway there is nobody to ask,
// and a skip must not land somewhere different depending on whether the container is up.
//
// Most of the function is about refusing to answer. A pair that is out of order, too
// short, too long, or missing half of itself produces nothing at all, and nothing is a
// perfectly good answer: the player simply never offers the button.
func introFromChapters(chapters []embyChapter) (introSegment, bool) {
const (
markerStart = "IntroStart"
markerEnd = "IntroEnd"
)
start := int64(-1)
for _, chapter := range chapters {
switch chapter.MarkerType {
case markerStart:
// The first start wins, and a second one is ignored rather than replacing it.
// Two starts mean the markers are already untrustworthy; taking the later one
// would pick the larger, more damaging skip of the two.
if start < 0 && chapter.StartPositionTicks >= 0 {
start = chapter.StartPositionTicks / ticksPerMillisecond
}
case markerEnd:
// An end before any start is a stray marker, not the close of a segment.
if start < 0 {
continue
}
end := chapter.StartPositionTicks / ticksPerMillisecond
length := end - start
if length < introMinimumMs || length > introMaximumMs {
return introSegment{}, false
}
return introSegment{StartMs: start, EndMs: end}, true
}
}
return introSegment{}, false
}
// handleIntro answers where an episode's title sequence is, if it has one.
//
// It is deliberately its own request rather than a field on /v1/items/{id}/playback, the
// same call the seek previews make: reading it costs a round trip to Emby for a field
// nothing else on the playback path wants, and that response is the one thing standing
// between a Play press and a decoder starting. Nothing here is needed before the first
// frame — the earliest intro in a typical library starts about two minutes in — so the
// television asks once playback has settled.
func (s *Server) handleIntro(w http.ResponseWriter, r *http.Request, sess store.Session) {
ctx := r.Context()
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if !s.skipIntroEnabled(ctx) {
writeJSON(w, http.StatusOK, introResponse{})
return
}
segment, ok, err := s.introFor(ctx, sess, itemID)
if err != nil {
// Trouble is answered with "no intro" rather than an error. The button is an
// optional convenience on a film that is already playing, and a failure the viewer
// cannot act on is not worth a red line in the log for every episode watched.
s.loggerFor(ctx).Debug("intro markers unavailable", "item_id", itemID, "error", err)
writeJSON(w, http.StatusOK, introResponse{})
return
}
if !ok {
writeJSON(w, http.StatusOK, introResponse{})
return
}
// The same answer for everyone in the house, and it only changes when the media does.
w.Header().Set("Cache-Control", "private, max-age=3600")
writeJSON(w, http.StatusOK, introResponse{
Available: true,
StartMs: segment.StartMs,
EndMs: segment.EndMs,
})
}
func (s *Server) skipIntroEnabled(ctx context.Context) bool {
return s.emby != nil && s.featureEnabled(ctx, featureSkipIntro)
}
func introCacheKey(itemID string) string { return "intro:v1:" + itemID }
// introFor reads an item's chapter markers, remembering what they came to.
//
// "No intro" is cached as well as an intro. It is the common case — a film, a special, an
// episode Emby has not analysed yet — and without it every playback in the house would be
// a fresh request to Emby for the same no.
func (s *Server) introFor(
ctx context.Context, sess store.Session, itemID string,
) (introSegment, bool, error) {
key := introCacheKey(itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached introResponse
if json.Unmarshal(raw, &cached) == nil {
return introSegment{StartMs: cached.StartMs, EndMs: cached.EndMs}, cached.Available, nil
}
}
raw, err := s.emby.Item(ctx, credentials(sess), itemID, "Chapters")
if err != nil {
return introSegment{}, false, err
}
var parsed struct {
Chapters []embyChapter `json:"Chapters"`
}
if err := json.Unmarshal(raw, &parsed); err != nil {
return introSegment{}, false, err
}
segment, ok := introFromChapters(parsed.Chapters)
ttl := introMissingTTL
if ok {
ttl = introTTL
}
if encoded, err := json.Marshal(introResponse{
Available: ok,
StartMs: segment.StartMs,
EndMs: segment.EndMs,
}); err == nil {
_ = s.cache.Set(ctx, key, encoded, ttl)
}
return segment, ok, nil
}
+191
View File
@@ -0,0 +1,191 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/config"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The intro rule, pinned against the same cases as the television's copy
// (`IntroTest` in app/src/test). The two exist separately because with no gateway there is
// nobody to ask, and a skip must not land somewhere different depending on whether the
// container is up — so when one of these changes, the other has to change with it.
//
// The cases are taken from what Emby actually writes: markers arrive as ordinary chapters
// carrying a MarkerType, interleaved with the real ones in playback order.
func chapter(seconds int64, marker string) embyChapter {
return embyChapter{
StartPositionTicks: seconds * 1_000 * ticksPerMillisecond,
MarkerType: marker,
Name: marker,
}
}
func TestIntroFromChapters(t *testing.T) {
cases := []struct {
name string
chapters []embyChapter
want introSegment
ok bool
}{
{
// FROM S01E01 as the server actually holds it: two ordinary chapters, then the
// pair, then the rest of the chapters.
name: "a real episode",
chapters: []embyChapter{
chapter(0, "Chapter"),
chapter(300, "Chapter"),
chapter(463, "IntroStart"),
chapter(583, "IntroEnd"),
chapter(600, "Chapter"),
},
want: introSegment{StartMs: 463_000, EndMs: 583_000},
ok: true,
},
{
name: "an intro that starts at the very beginning",
chapters: []embyChapter{
chapter(0, "IntroStart"),
chapter(95, "IntroEnd"),
},
want: introSegment{StartMs: 0, EndMs: 95_000},
ok: true,
},
{
name: "no markers at all",
chapters: []embyChapter{chapter(0, "Chapter"), chapter(300, "Chapter")},
},
{
name: "no chapters at all",
chapters: nil,
},
{
// Half a pair is not a segment. There is no honest end to skip to, and
// guessing one is how a viewer lands in the middle of a scene.
name: "a start with no end",
chapters: []embyChapter{chapter(112, "IntroStart"), chapter(300, "Chapter")},
},
{
name: "an end with no start",
chapters: []embyChapter{chapter(0, "Chapter"), chapter(246, "IntroEnd")},
},
{
name: "an end before its start",
chapters: []embyChapter{chapter(300, "IntroStart"), chapter(120, "IntroEnd")},
},
{
// Emby occasionally writes a pair seconds apart on a title whose opening it
// half-recognised. A button that moves the picture imperceptibly reads as
// broken, so there is deliberately no button at all.
name: "a segment too short to be an intro",
chapters: []embyChapter{chapter(100, "IntroStart"), chapter(103, "IntroEnd")},
},
{
// Far more likely two unrelated markers read as a range than a ten-minute
// title sequence, and honouring it would throw the viewer past the story.
name: "a segment too long to be an intro",
chapters: []embyChapter{chapter(60, "IntroStart"), chapter(660, "IntroEnd")},
},
{
// The first start wins. Two starts mean the markers are already untrustworthy,
// and taking the later one would pick the larger, more damaging skip.
name: "two starts before one end",
chapters: []embyChapter{
chapter(100, "IntroStart"),
chapter(160, "IntroStart"),
chapter(220, "IntroEnd"),
},
want: introSegment{StartMs: 100_000, EndMs: 220_000},
ok: true,
},
{
name: "a later pair is ignored once one has been found",
chapters: []embyChapter{
chapter(100, "IntroStart"),
chapter(220, "IntroEnd"),
chapter(1800, "IntroStart"),
chapter(1900, "IntroEnd"),
},
want: introSegment{StartMs: 100_000, EndMs: 220_000},
ok: true,
},
{
// A credits marker is a different feature that does not exist here yet, and
// must never be mistaken for an intro.
name: "credit markers are not intros",
chapters: []embyChapter{
chapter(2800, "CreditsStart"),
chapter(2900, "CreditsEnd"),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := introFromChapters(tc.chapters)
if ok != tc.ok {
t.Fatalf("available = %v, want %v (segment %+v)", ok, tc.ok, got)
}
if ok && got != tc.want {
t.Fatalf("segment = %+v, want %+v", got, tc.want)
}
})
}
}
// A gateway with no Emby behind it — and one whose operator has turned the feature off —
// answers "no intro" rather than an error. The button is an optional convenience on a film
// that is already playing, and a 502 here would be a red line in the log for every episode
// anybody watched.
func TestIntroWithoutEmbyAnswersUnavailable(t *testing.T) {
server := testServer(config.Config{})
request := httptest.NewRequest(http.MethodGet, "/v1/items/1304864/intro", nil)
request.SetPathValue("id", "1304864")
rec := httptest.NewRecorder()
server.handleIntro(rec, request, store.Session{})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 — trouble is answered with silence here", rec.Code)
}
var body introResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body: %v", err)
}
if body.Available {
t.Fatal("a gateway with nothing to ask must never claim an intro")
}
}
// The mode a television is told to use has to be one this build knows, or a set could be
// pushed a value it silently does nothing with.
func TestSkipIntroPreferenceIsCatalogued(t *testing.T) {
definition, ok := preferenceDefinitionFor("skipIntroMode")
if !ok {
t.Fatal("skipIntroMode is missing from the preference catalogue")
}
if definition.Default != skipIntroPrompt {
t.Fatalf("default = %v, want %q — a button is the asked-for behaviour, not a silent seek",
definition.Default, skipIntroPrompt)
}
wanted := []string{skipIntroPrompt, skipIntroAuto, skipIntroOff}
if len(definition.Options) != len(wanted) {
t.Fatalf("options = %+v, want the three modes", definition.Options)
}
for i, value := range wanted {
if definition.Options[i].Value != value {
t.Fatalf("option %d = %q, want %q", i, definition.Options[i].Value, value)
}
}
// An illegal value must come back as the default rather than reaching a player.
normalised := normalizePreferences(map[string]any{"skipIntroMode": "immediately"})
if normalised["skipIntroMode"] != skipIntroPrompt {
t.Fatalf("normalised = %v, want %q", normalised["skipIntroMode"], skipIntroPrompt)
}
}
+3 -2
View File
@@ -171,8 +171,9 @@ func isPlaybackItemPath(path string) bool {
return false
}
// Fetching a subtitle is two segments deep rather than one, and matching its trailing
// "search" on its own would claim any future per-item search as playback.
if strings.Contains(path, "/subtitles/") {
// "search" on its own would claim any future per-item search as playback. A seek
// preview is the same shape: the frame number is the last segment, not the word.
if strings.Contains(path, "/subtitles/") || strings.Contains(path, "/trickplay") {
return true
}
switch path[strings.LastIndex(path, "/")+1:] {
+2
View File
@@ -47,6 +47,8 @@ func TestComponentNamesThePartOfTheAppARouteBelongsTo(t *testing.T) {
"/v1/items/42/playback": "playback",
"/v1/items/42/next": "playback",
"/v1/items/42/subtitles/search": "playback",
"/v1/items/42/trickplay": "playback",
"/v1/items/42/trickplay/12.jpg": "playback",
"/v1/playback/started": "playback",
"/v1/images/42/primary": "artwork",
"/v1/recommendations": "recommendations",
+12
View File
@@ -39,6 +39,16 @@ type playbackResponse struct {
// already holds this response, and one boolean on a request that is made once per
// playback is cheaper than a field on the poll every open TV makes every ten seconds.
SubtitleDownloadAvailable bool `json:"subtitleDownloadAvailable"`
// Whether it is worth asking this gateway for seek previews. Only the answer rides
// here; the manifest itself does not, because reading it costs a round trip to Emby
// and this response is the one thing standing between a Play press and a decoder
// starting. The television asks for the manifest once the first frame is up.
TrickplayAvailable bool `json:"trickplayAvailable"`
// Whether it is worth asking this gateway where the title sequence is. Only the answer
// rides here, for the same reason the previews' does: reading the markers costs a round
// trip to Emby, and nothing about a skip button is needed before the first frame. The
// television asks for the segment itself once playback has settled.
SkipIntroAvailable bool `json:"skipIntroAvailable"`
}
type playableSubtitle struct {
@@ -195,6 +205,8 @@ func (s *Server) handlePlayback(w http.ResponseWriter, r *http.Request, sess sto
PlaySessionID: playSessionID,
PlayMethod: playMethod,
SubtitleDownloadAvailable: s.subtitleDownloadAvailable(ctx),
TrickplayAvailable: s.trickplayEnabled(ctx),
SkipIntroAvailable: s.skipIntroEnabled(ctx),
})
}
+13
View File
@@ -130,6 +130,19 @@ var preferenceCatalogue = []preferenceDefinition{
Description: "Show the lower-third when ten minutes are left.",
Kind: preferenceToggle, Default: true,
},
{
// The vocabulary is duplicated on the television (data/SkipIntroPreference.kt),
// which normalises anything it does not recognise — so a mode added here reaches an
// older set as "prompt" rather than as silence.
Key: "skipIntroMode", Name: "Skip the title sequence", Area: "Playback",
Description: "What to do when an episode reaches its opening titles.",
Kind: preferenceChoice, Default: skipIntroPrompt,
Options: []preferenceOption{
option(skipIntroPrompt, "Offer a button"),
option(skipIntroAuto, "Skip automatically"),
option(skipIntroOff, "Do nothing"),
},
},
{
Key: "subtitlesEnabled", Name: "Subtitles", Area: "Playback",
Description: "Turn a subtitle track on automatically when the title has one.",
+236
View File
@@ -0,0 +1,236 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"image/jpeg"
"net/http"
"strconv"
"strings"
"time"
"github.com/ponzischeme89/memby/server/internal/emby"
"github.com/ponzischeme89/memby/server/internal/store"
"github.com/ponzischeme89/memby/server/internal/trickplay"
)
const (
// trickplayWidth is the thumbnail width to ask Emby for. Emby generates preview images
// at the widths its own settings name and answers any other with an empty file, so
// this is not a free parameter: 320 is what Emby writes by default and the only width
// present on this library. Asking for one it has not generated does not fail — it
// returns a well-formed BIF with no frames, which reads as "this title has no
// previews" and is indistinguishable from a title that genuinely has none.
trickplayWidth = 320
// trickplayIndexWindow is how much of the front of the file to read while looking for
// the index. It covers a title of about twenty-two hours at ten seconds a frame, so in
// practice one request settles it; anything longer costs a second, exact read rather
// than being refused.
trickplayIndexWindow = 64 << 10
// trickplayIndexTTL keeps a parsed index warm for a day. The file only changes when
// the media does, and every frame request needs it.
trickplayIndexTTL = 24 * time.Hour
// trickplayMissingTTL is how long "this title has no previews" is remembered. Shorter
// than the index, because Emby generates thumbnails on a schedule: a film imported this
// afternoon should not be answered from a day-old no.
trickplayMissingTTL = time.Hour
)
// trickplayManifest is what a television needs to draw previews: how much of the title
// each thumbnail covers, how many there are, and what shape they are.
//
// Frame URLs are not listed. There are hundreds of them, they are formed by a rule the
// client already knows, and a list of them would be most of the response.
type trickplayManifest struct {
Available bool `json:"available"`
IntervalMs int64 `json:"intervalMs,omitempty"`
Count int `json:"count,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
// handleTrickplay answers whether a title has seek previews, and how they are laid out.
//
// It is deliberately its own request rather than a field on /v1/items/{id}/playback:
// reading the index costs a round trip to Emby, and the playback response is the single
// thing standing between a Play press and a decoder starting. The television asks for this
// once the first frame is up.
func (s *Server) handleTrickplay(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
if itemID == "" {
writeError(w, http.StatusBadRequest, "item id is required")
return
}
if !s.trickplayEnabled(r.Context()) {
writeJSON(w, http.StatusOK, trickplayManifest{})
return
}
index, err := s.trickplayIndex(r.Context(), sess, itemID)
if err != nil {
// A title with no previews is the ordinary case and is answered above; reaching
// here means Emby would not say. Answer "none" rather than an error: the seek
// indicator has a perfectly good wordless form, and a failure the viewer cannot
// act on is not worth a red line in the log every time somebody presses Right.
s.loggerFor(r.Context()).Debug("trickplay index unavailable",
"item_id", itemID, "error", err)
writeJSON(w, http.StatusOK, trickplayManifest{})
return
}
if !index.Available() {
writeJSON(w, http.StatusOK, trickplayManifest{})
return
}
// Previews are worth caching on the television for as long as the index is, and the
// answer is the same for everyone in the house.
w.Header().Set("Cache-Control", "private, max-age=3600")
writeJSON(w, http.StatusOK, trickplayManifest{
Available: true,
IntervalMs: index.IntervalMs,
Count: index.Count,
Width: index.Width,
Height: index.Height,
})
}
// handleTrickplayFrame serves one thumbnail.
//
// The gateway reads the frame's byte range out of Emby's file and writes it on; it never
// holds the file and never re-encodes the image. A frame is about seven kilobytes, which
// is what makes a preview affordable while somebody is still moving the seek target.
func (s *Server) handleTrickplayFrame(w http.ResponseWriter, r *http.Request, sess store.Session) {
itemID := r.PathValue("id")
// The ".jpg" is for the benefit of anything downstream that sniffs a URL rather than a
// content type — an image loader's disk cache, a proxy — and carries no meaning here.
frame, err := strconv.Atoi(strings.TrimSuffix(r.PathValue("frame"), ".jpg"))
if itemID == "" || err != nil || frame < 0 {
writeError(w, http.StatusNotFound, "unknown frame")
return
}
if !s.trickplayEnabled(r.Context()) {
writeError(w, http.StatusNotFound, "unknown frame")
return
}
index, err := s.trickplayIndex(r.Context(), sess, itemID)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not load the preview")
return
}
start, end, ok := index.Frame(frame)
if !ok {
writeError(w, http.StatusNotFound, "unknown frame")
return
}
body, err := s.emby.TrickplayBytes(
r.Context(), credentials(sess), itemID, trickplayWidth, start, end-1,
)
if err != nil {
s.writeUpstreamError(r.Context(), w, err, "could not load the preview")
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
// A frame's bytes only change when the media is re-encoded, which also renumbers the
// index, so this is safe to keep. It is not "immutable" like a tag-addressed image:
// there is no tag in the URL to make a new file a new address.
w.Header().Set("Cache-Control", "private, max-age=86400")
w.WriteHeader(http.StatusOK)
copyImage(w, r, bytes.NewReader(body), s.loggerFor(r.Context()),
"source", "emby", "item_id", itemID, "frame", frame)
}
func (s *Server) trickplayEnabled(ctx context.Context) bool {
return s.emby != nil && s.featureEnabled(ctx, featureTrickplay)
}
func trickplayIndexKey(itemID string) string {
return "tp:v1:" + strconv.Itoa(trickplayWidth) + ":" + itemID
}
// trickplayIndex reads the index off the front of a title's BIF, remembering it.
//
// A title with no previews is cached too, as a zero-frame index. It is the common case in
// a library where thumbnails are still being generated, and without it every press of
// Right on such a title would be a fresh request to Emby for the same no.
func (s *Server) trickplayIndex(
ctx context.Context, sess store.Session, itemID string,
) (*trickplay.Index, error) {
key := trickplayIndexKey(itemID)
if raw, err := s.cache.Get(ctx, key); err == nil {
var cached trickplay.Index
if json.Unmarshal(raw, &cached) == nil {
return &cached, nil
}
}
cred := credentials(sess)
head, err := s.emby.TrickplayBytes(ctx, cred, itemID, trickplayWidth, 0, trickplayIndexWindow-1)
if err != nil {
return nil, err
}
index, err := trickplay.ParseIndex(head)
if errors.Is(err, trickplay.ErrShort) {
// A title long enough that its index runs past the window. Now the count is known,
// so the second read is exact.
count, _, headerErr := trickplay.ParseHeader(head)
if headerErr != nil {
return nil, headerErr
}
var full []byte
full, err = s.emby.TrickplayBytes(
ctx, cred, itemID, trickplayWidth, 0, int64(trickplay.IndexLength(count))-1,
)
if err != nil {
return nil, err
}
index, err = trickplay.ParseIndex(full)
}
if err != nil {
return nil, err
}
ttl := trickplayMissingTTL
if index.Available() {
ttl = trickplayIndexTTL
// The frames' dimensions are not in the header, and the television needs them to
// give the preview a place on screen before the first one has arrived — otherwise
// the seek indicator grows a thumbnail-shaped hole mid-press. One frame is read to
// find out, once per title per day.
index.Width, index.Height = s.trickplayFrameSize(ctx, cred, itemID, index)
}
if raw, err := json.Marshal(index); err == nil {
_ = s.cache.Set(ctx, key, raw, ttl)
}
return index, nil
}
// trickplayFrameSize reads the first thumbnail's dimensions.
//
// Only the JPEG's header is decoded, never its pixels. A zero pair is a perfectly usable
// answer — the client falls back to the aspect it draws by default — so a frame that will
// not parse costs the exact sizing and nothing else.
func (s *Server) trickplayFrameSize(
ctx context.Context, cred emby.Credentials, itemID string, index *trickplay.Index,
) (int, int) {
start, end, ok := index.Frame(0)
if !ok {
return 0, 0
}
body, err := s.emby.TrickplayBytes(ctx, cred, itemID, trickplayWidth, start, end-1)
if err != nil {
return 0, 0
}
config, err := jpeg.DecodeConfig(bytes.NewReader(body))
if err != nil {
return 0, 0
}
return config.Width, config.Height
}
+78
View File
@@ -0,0 +1,78 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/ponzischeme89/memby/server/internal/store"
)
// The television decodes this into GatewayTrickplay; the matching Kotlin test is
// `decodes a seek preview layout` in GatewayPayloadTest. Rename a field on either side and
// one of them fails before a TV ever sees it.
func TestTrickplayManifestShape(t *testing.T) {
raw, err := json.Marshal(trickplayManifest{
Available: true, IntervalMs: 10_000, Count: 817, Width: 320, Height: 172,
})
if err != nil {
t.Fatalf("marshal: %v", err)
}
want := `{"available":true,"intervalMs":10000,"count":817,"width":320,"height":172}`
if string(raw) != want {
t.Fatalf("manifest = %s, want %s", raw, want)
}
}
func TestTrickplayManifestOmitsTheRestWhenUnavailable(t *testing.T) {
// A title with no thumbnails, an operator who has turned the feature off and a gateway
// with no Emby all mean the same thing to a television, so they must all look the same
// on the wire — and none of them may carry a count it would try to draw from.
raw, err := json.Marshal(trickplayManifest{})
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(raw) != `{"available":false}` {
t.Fatalf("manifest = %s", raw)
}
}
func TestTrickplayAnswersUnavailableRatherThanFailing(t *testing.T) {
// A gateway with no Emby configured still answers, with 200 and "no previews". The
// seek indicator is a complete answer without a thumbnail, and an error here would be
// one nobody could act on, logged once per press of Right.
s := &Server{}
req := httptest.NewRequest(http.MethodGet, "/v1/items/42/trickplay", nil)
req.SetPathValue("id", "42")
rec := httptest.NewRecorder()
s.handleTrickplay(rec, req, store.Session{EmbyUserID: "user-1"})
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
var manifest trickplayManifest
if err := json.Unmarshal(rec.Body.Bytes(), &manifest); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if manifest.Available {
t.Fatal("a gateway with no Emby offered seek previews")
}
}
func TestTrickplayFrameRejectsAnUnreadableIndex(t *testing.T) {
s := &Server{}
for _, frame := range []string{"", "-1", "twelve", "12.png.jpg"} {
req := httptest.NewRequest(http.MethodGet, "/v1/items/42/trickplay/"+frame, nil)
req.SetPathValue("id", "42")
req.SetPathValue("frame", frame)
rec := httptest.NewRecorder()
s.handleTrickplayFrame(rec, req, store.Session{EmbyUserID: "user-1"})
if rec.Code != http.StatusNotFound {
t.Fatalf("frame %q: status = %d, want 404", frame, rec.Code)
}
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.20
0.1.23
+36
View File
@@ -14,6 +14,7 @@ import (
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -465,6 +466,41 @@ func (c *Client) ImageResponse(ctx context.Context, cred Credentials, itemID, im
return resp, nil
}
// TrickplayBytes reads part of a title's preview-thumbnail file (BIF).
//
// It is always a ranged read, because the whole file is megabytes and nothing here ever
// wants all of it: callers take the index off the front, then one frame at a time. Emby
// answers ranges on this route correctly but does not say so — the response advertises
// "Accept-Ranges: none" and a Content-Length taken from the media file — so the request is
// made on the strength of the 206 rather than on what the headers promise, and a server
// that ignored the range would simply return the head of the file, which is the index a
// caller wanted anyway.
func (c *Client) TrickplayBytes(
ctx context.Context, cred Credentials, itemID string, width int, from, to int64,
) ([]byte, error) {
params := url.Values{}
params.Set("Width", strconv.Itoa(width))
path := "/Videos/" + url.PathEscape(itemID) + "/index.bif"
req, err := c.newRequest(ctx, http.MethodGet, path, params, cred, nil)
if err != nil {
return nil, err
}
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", from, to))
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
}
// Cap the read at what was asked for. A 200 means the range was ignored and the whole
// file is on its way, which must not become a multi-megabyte read on a seek.
return io.ReadAll(io.LimitReader(resp.Body, to-from+1))
}
// StreamURL is the direct-play URL handed to the TV. It points at the *public* Emby
// address: video never flows through the gateway, only metadata does.
func (c *Client) StreamURL(cred Credentials, itemID string) string {
+74 -10
View File
@@ -101,6 +101,11 @@ type Engine struct {
// MaxSimilarRows caps "Because you watched …" rows so the home screen stays a home
// screen rather than a wall of near-duplicates.
MaxSimilarRows int
// SeedPool is how far back down the watch history those rows may be anchored. It is
// deliberately several times MaxSimilarRows: the rows are drawn from bands of this
// window and rotate within them daily, which is what keeps the launcher moving for a
// household part-way through one series.
SeedPool int
RowSize int
CuratedRows []CuratedRow
WeightedConfig WeightedConfig
@@ -491,7 +496,8 @@ func NewEngine(source Source, log *slog.Logger) *Engine {
source: source,
log: log,
MinRowItems: 4,
MaxSimilarRows: 2,
MaxSimilarRows: 3,
SeedPool: 12,
RowSize: 20,
WeightedConfig: DefaultWeightedConfig(),
CuratedRows: []CuratedRow{
@@ -630,10 +636,11 @@ func (e *Engine) BuildRowsForUser(
}
profile = contextAffinity.Contextualized(profile, e.now(), e.Location)
variation := dailySeed(cred.UserID)
rows := make([]Row, 0, e.MaxSimilarRows+1+len(e.CuratedRows))
if !profile.IsEmpty() {
for _, seed := range e.seedsFor(profile) {
row, ok := e.similarRow(ctx, cred, profile, seed)
for _, seed := range e.seedsFor(profile, variation) {
row, ok := e.similarRow(ctx, cred, profile, seed, variation)
if ok {
rows = append(rows, row)
}
@@ -643,7 +650,7 @@ func (e *Engine) BuildRowsForUser(
rows = append(rows, row)
}
}
rows = append(rows, e.buildCuratedRows(ctx, profile, dailySeed(cred.UserID))...)
rows = append(rows, e.buildCuratedRows(ctx, profile, variation)...)
return rows, nil
}
@@ -942,16 +949,72 @@ func (e *Engine) libraryCandidates(ctx context.Context, genres []string) ([]Item
return Decode(raws), true
}
func (e *Engine) seedsFor(profile Profile) []Seed {
if len(profile.Seeds) <= e.MaxSimilarRows {
return profile.Seeds
func (e *Engine) seedsFor(profile Profile, variation string) []Seed {
return selectSeeds(profile.Seeds, e.SeedPool, e.MaxSimilarRows, variation)
}
// selectSeeds chooses which watched titles anchor "Because you watched …" rows.
//
// Taking the freshest few is the obvious rule and is also why the launcher went stale: the
// head of history is a resumable title and the series somebody is part-way through, and
// neither moves for weeks — so the same two rows came back day after day. Instead the
// recent window is cut into equal bands and one seed is drawn from each by a variation
// that changes daily. Three properties are the point and are unit-tested:
//
// - The bands are in recency order, so the first row is still anchored to something
// watched lately and the rows under it reach further back. It is a rotation within
// bands, never a shuffle of the whole window.
// - The same day always yields the same seeds. Rows are rebuilt on every cache miss and
// a set of rows that re-picked each time would change under somebody browsing.
// - Nothing new has to be watched for the launcher to move on.
func selectSeeds(seeds []Seed, pool, max int, variation string) []Seed {
if max <= 0 || len(seeds) == 0 {
return nil
}
return profile.Seeds[:e.MaxSimilarRows]
if pool < max {
pool = max
}
if pool > len(seeds) {
pool = len(seeds)
}
if pool <= max {
return append([]Seed(nil), seeds[:pool]...)
}
band := pool / max
out := make([]Seed, 0, max)
for index := 0; index < max; index++ {
start := index * band
end := start + band
// The last band takes the remainder, so a pool that does not divide evenly is
// still drawn from in full rather than having its oldest entries stranded.
if index == max-1 {
end = pool
}
best := start
for candidate := start + 1; candidate < end; candidate++ {
if stableVariation(variation, seeds[candidate].ID) <
stableVariation(variation, seeds[best].ID) {
best = candidate
}
}
out = append(out, seeds[best])
}
return out
}
// similarRow asks Emby what resembles a title the user just watched. Emby's own
// similarity scoring beats anything computed here, so this only filters out the seen.
func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile Profile, seed Seed) (Row, bool) {
// similarity scoring beats anything computed here, so this only filters out the seen and
// varies the order within relevance bands, the way curated shelves do — a row anchored to
// the same seed on two consecutive days must not present the same posters in the same
// order, or rotating the seeds is the only thing that ever changes.
func (e *Engine) similarRow(
ctx context.Context,
cred emby.Credentials,
profile Profile,
seed Seed,
variation string,
) (Row, bool) {
result, err := e.source.Similar(ctx, cred, seed.ID, url.Values{
"UserId": {cred.UserID},
"Limit": {strconv.Itoa(e.RowSize * 2)},
@@ -970,6 +1033,7 @@ func (e *Engine) similarRow(ctx context.Context, cred emby.Credentials, profile
if len(items) < e.MinRowItems {
return Row{}, false
}
items = diversifyRanked(items, variation+":similar:"+seed.ID, 5)
return Row{
ID: "similar:" + seed.ID,
Title: "Because you watched " + seed.Name,
+91
View File
@@ -8,6 +8,7 @@ import (
"io"
"log/slog"
"net/url"
"slices"
"strconv"
"strings"
"sync"
@@ -875,3 +876,93 @@ func rowTitles(rows []Row) []string {
}
return out
}
func testSeeds(count int) []Seed {
seeds := make([]Seed, 0, count)
for index := 0; index < count; index++ {
id := "s" + strconv.Itoa(index)
seeds = append(seeds, Seed{ID: id, Name: "Title " + id})
}
return seeds
}
func seedIDs(seeds []Seed) []string {
out := make([]string, 0, len(seeds))
for _, seed := range seeds {
out = append(out, seed.ID)
}
return out
}
// The head of a household's history barely moves while they work through one series, so
// the seeds must move without it.
func TestSelectSeedsRotatesWithoutNewHistory(t *testing.T) {
seeds := testSeeds(12)
days := map[string]bool{}
for day := 1; day <= 14; day++ {
chosen := selectSeeds(seeds, 12, 3, "u1:2026-08-"+strconv.Itoa(day))
if len(chosen) != 3 {
t.Fatalf("day %d chose %d seeds", day, len(chosen))
}
days[strings.Join(seedIDs(chosen), ",")] = true
}
if len(days) < 4 {
t.Fatalf("a fortnight produced only %d distinct seed sets: %v", len(days), days)
}
}
// Rotation is within recency bands, never a shuffle of the whole window: the first row is
// still anchored to something watched lately.
func TestSelectSeedsKeepsRecencyBands(t *testing.T) {
seeds := testSeeds(12)
for day := 1; day <= 30; day++ {
chosen := selectSeeds(seeds, 12, 3, "u1:day-"+strconv.Itoa(day))
for index, seed := range chosen {
position, err := strconv.Atoi(strings.TrimPrefix(seed.ID, "s"))
if err != nil {
t.Fatalf("unexpected seed id %q", seed.ID)
}
if position < index*4 || position >= (index+1)*4 {
t.Fatalf("row %d drew %s from outside its band", index, seed.ID)
}
}
}
}
// Rows are rebuilt on every cache miss, so the same day must always yield the same seeds.
func TestSelectSeedsIsStableWithinADay(t *testing.T) {
seeds := testSeeds(20)
first := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07"))
for attempt := 0; attempt < 5; attempt++ {
if got := seedIDs(selectSeeds(seeds, 12, 3, "u1:2026-08-07")); !slices.Equal(got, first) {
t.Fatalf("same day produced %v then %v", first, got)
}
}
other := seedIDs(selectSeeds(seeds, 12, 3, "u2:2026-08-07"))
if slices.Equal(other, first) {
t.Log("two users may coincide; only a smoke check")
}
}
// A short history has nothing to rotate: take what there is, newest first.
func TestSelectSeedsFallsBackToRecencyWhenShort(t *testing.T) {
if got := seedIDs(selectSeeds(testSeeds(2), 12, 3, "u1:day")); !slices.Equal(got, []string{"s0", "s1"}) {
t.Fatalf("short history = %v", got)
}
if got := selectSeeds(nil, 12, 3, "u1:day"); len(got) != 0 {
t.Fatalf("no history should seed nothing, got %v", got)
}
}
// A pool that does not divide evenly must not strand its oldest entries.
func TestSelectSeedsLastBandTakesTheRemainder(t *testing.T) {
seeds := testSeeds(11)
reached := map[string]bool{}
for day := 1; day <= 40; day++ {
chosen := selectSeeds(seeds, 11, 3, "u1:day-"+strconv.Itoa(day))
reached[chosen[2].ID] = true
}
if !reached["s10"] {
t.Fatalf("the oldest seed was never reachable: %v", reached)
}
}
+170
View File
@@ -0,0 +1,170 @@
// Package trickplay reads the preview thumbnails Emby serves at /Videos/{id}/index.bif.
//
// That is the BIF format Roku published: a 64-byte header, then one 8-byte (timestamp,
// offset) entry per frame plus a terminator, then the JPEGs laid end to end. Emby 4.10
// generates one frame every ten seconds at 320px wide, which for a two-hour film is
// eight hundred images and about five megabytes.
//
// The index being at the *front* of the file is the whole reason previews are affordable
// on a television. Read the first few kilobytes and every frame's byte range is known, so
// showing one thumbnail costs a ranged request of about seven kilobytes rather than a
// five-megabyte download nobody would wait for mid-seek. Emby answers ranged requests on
// this route correctly, which it does not advertise: the response carries
// "Accept-Ranges: none" and a Content-Length borrowed from the media file. Trust the 206,
// not the headers.
package trickplay
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
)
const (
// HeaderSize is the fixed preamble: magic, version, frame count, timestamp multiplier.
HeaderSize = 64
entrySize = 8
// defaultMultiplier is what the format says a zero in the header means.
defaultMultiplier = 1000
)
// magic is the file's first eight bytes. The leading 0x89 and the CR/LF pair are the same
// trick PNG uses: a file mangled by a text-mode transfer stops matching.
var magic = []byte{0x89, 0x42, 0x49, 0x46, 0x0d, 0x0a, 0x1a, 0x0a}
var (
// ErrShort means the caller has not read enough of the file yet. It is a signal to
// fetch more, never a bad file.
ErrShort = errors.New("trickplay: not enough bytes")
// ErrNotBIF means what came back is not a BIF at all — an Emby error page, most
// likely, since the route answers 200 with an explanation for some failures.
ErrNotBIF = errors.New("trickplay: not a bif file")
)
// Index is everything needed to serve any frame of a title: how long each covers, and
// where each one's bytes start and end.
//
// Offsets holds Count+1 values, the last being the end of the final frame, so a frame's
// extent is a subtraction rather than a special case at the tail.
type Index struct {
Count int `json:"count"`
IntervalMs int64 `json:"intervalMs"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
Offsets []int64 `json:"offsets"`
}
// Available reports whether this title actually has thumbnails.
//
// A count of zero is the ordinary answer for a title Emby has not generated previews for,
// and the header it returns is otherwise perfectly well formed — so this is a question
// about the library, not about the file being readable.
func (i *Index) Available() bool { return i != nil && i.Count > 0 && len(i.Offsets) > i.Count }
// ParseHeader reads the fixed preamble. It is separate from ParseIndex because the frame
// count is what says how long the index is, and that is the number a caller needs before
// it can decide how much of the file to ask for.
func ParseHeader(b []byte) (count int, intervalMs int64, err error) {
if len(b) < HeaderSize {
return 0, 0, ErrShort
}
if !bytes.Equal(b[:8], magic) {
return 0, 0, ErrNotBIF
}
count = int(binary.LittleEndian.Uint32(b[12:16]))
multiplier := int64(binary.LittleEndian.Uint32(b[16:20]))
if multiplier <= 0 {
multiplier = defaultMultiplier
}
if count < 0 {
return 0, 0, ErrNotBIF
}
return count, multiplier, nil
}
// IndexLength is how many bytes of the file hold the header and the whole index, which is
// also the offset the first JPEG must start at.
func IndexLength(count int) int { return HeaderSize + (count+1)*entrySize }
// ParseIndex reads the header and the index out of the front of a BIF.
//
// b may be longer than the index — a caller that fetched a fixed window of the file passes
// what it got and the rest is ignored.
func ParseIndex(b []byte) (*Index, error) {
count, multiplier, err := ParseHeader(b)
if err != nil {
return nil, err
}
if count == 0 {
return &Index{Count: 0, IntervalMs: multiplier}, nil
}
if len(b) < IndexLength(count) {
return nil, ErrShort
}
offsets := make([]int64, 0, count+1)
timestamps := make([]int64, 0, count)
for entry := 0; entry <= count; entry++ {
at := HeaderSize + entry*entrySize
timestamp := int64(binary.LittleEndian.Uint32(b[at : at+4]))
offset := int64(binary.LittleEndian.Uint32(b[at+4 : at+8]))
offsets = append(offsets, offset)
if entry < count {
timestamps = append(timestamps, timestamp)
}
}
// A frame that starts inside the index, or before the one ahead of it, means the file
// is not laid out the way the format says. Serving a byte range from it would hand a
// television whatever happened to be there.
if offsets[0] < int64(IndexLength(count)) {
return nil, fmt.Errorf("%w: first frame overlaps the index", ErrNotBIF)
}
for entry := 1; entry <= count; entry++ {
if offsets[entry] < offsets[entry-1] {
return nil, fmt.Errorf("%w: frame offsets go backwards at %d", ErrNotBIF, entry)
}
}
interval := multiplier
if count >= 2 && timestamps[1] > timestamps[0] {
interval = (timestamps[1] - timestamps[0]) * multiplier
}
if interval <= 0 {
interval = defaultMultiplier
}
return &Index{Count: count, IntervalMs: interval, Offsets: offsets}, nil
}
// Frame is the half-open byte range of one thumbnail, ready for a Range header.
func (i *Index) Frame(n int) (start, end int64, ok bool) {
if !i.Available() || n < 0 || n >= i.Count {
return 0, 0, false
}
start, end = i.Offsets[n], i.Offsets[n+1]
if end <= start {
return 0, 0, false
}
return start, end, true
}
// FrameAt is which thumbnail covers a moment in the title.
//
// It clamps rather than refusing: a seek preview is asked for while somebody is still
// moving a target around, and a position a second past the end of the last frame should
// show the last frame, not nothing.
func (i *Index) FrameAt(positionMs int64) int {
if !i.Available() || i.IntervalMs <= 0 {
return 0
}
if positionMs < 0 {
return 0
}
n := positionMs / i.IntervalMs
if n >= int64(i.Count) {
return i.Count - 1
}
return int(n)
}
+149
View File
@@ -0,0 +1,149 @@
package trickplay
import (
"encoding/binary"
"errors"
"testing"
)
// buildBIF assembles a file in the shape Emby writes one, so the tests exercise the same
// arithmetic the real parser will meet rather than a convenient fiction.
func buildBIF(count int, multiplier uint32, frameSizes []int) []byte {
length := IndexLength(count)
file := make([]byte, length)
copy(file, magic)
binary.LittleEndian.PutUint32(file[12:16], uint32(count))
binary.LittleEndian.PutUint32(file[16:20], multiplier)
offset := length
for entry := 0; entry < count; entry++ {
at := HeaderSize + entry*entrySize
binary.LittleEndian.PutUint32(file[at:at+4], uint32(entry))
binary.LittleEndian.PutUint32(file[at+4:at+8], uint32(offset))
size := 100
if entry < len(frameSizes) {
size = frameSizes[entry]
}
offset += size
file = append(file, make([]byte, size)...)
}
at := HeaderSize + count*entrySize
binary.LittleEndian.PutUint32(file[at:at+4], 0xFFFFFFFF)
binary.LittleEndian.PutUint32(file[at+4:at+8], uint32(offset))
return file
}
func TestParseIndexReadsEmbysLayout(t *testing.T) {
// Emby 4.10 writes a multiplier of 10000 with timestamps counting 0, 1, 2 — so the
// interval is ten seconds, and reading the multiplier as the interval would be right
// only by accident. The multiplication is the part worth pinning.
file := buildBIF(3, 10_000, []int{500, 600, 700})
index, err := ParseIndex(file)
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
if index.Count != 3 {
t.Fatalf("Count = %d, want 3", index.Count)
}
if index.IntervalMs != 10_000 {
t.Fatalf("IntervalMs = %d, want 10000", index.IntervalMs)
}
start, end, ok := index.Frame(1)
if !ok {
t.Fatal("Frame(1) not ok")
}
if want := int64(IndexLength(3) + 500); start != want {
t.Fatalf("frame 1 starts at %d, want %d", start, want)
}
if end-start != 600 {
t.Fatalf("frame 1 is %d bytes, want 600", end-start)
}
}
func TestParseIndexAcceptsATitleWithNoThumbnails(t *testing.T) {
// This is what Emby serves for a title it has not generated previews for: a
// well-formed 72-byte header with a count of zero. It must read as "this title has
// none", never as a broken file, or every such title logs an error.
file := buildBIF(0, 10_000, nil)
index, err := ParseIndex(file)
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
if index.Available() {
t.Fatal("a zero-frame BIF reported itself as available")
}
}
func TestParseIndexRejectsWhatIsNotABIF(t *testing.T) {
if _, err := ParseIndex([]byte("<html>not found</html>")); !errors.Is(err, ErrNotBIF) &&
!errors.Is(err, ErrShort) {
t.Fatalf("err = %v, want ErrNotBIF or ErrShort", err)
}
file := buildBIF(2, 10_000, nil)
file[3] = 'X'
if _, err := ParseIndex(file); !errors.Is(err, ErrNotBIF) {
t.Fatalf("err = %v, want ErrNotBIF", err)
}
}
func TestParseIndexRejectsFramesInsideTheIndex(t *testing.T) {
// An offset pointing back into the index would have the gateway serve a slice of the
// index itself as a JPEG. Refuse the file rather than hand a television nonsense.
file := buildBIF(2, 10_000, nil)
binary.LittleEndian.PutUint32(file[HeaderSize+4:HeaderSize+8], 8)
if _, err := ParseIndex(file); !errors.Is(err, ErrNotBIF) {
t.Fatalf("err = %v, want ErrNotBIF", err)
}
}
func TestParseIndexAsksForMoreRatherThanFailing(t *testing.T) {
// A caller reads a fixed window off the front of the file, so a long title legitimately
// arrives with the index cut short. That must be answerable — fetch more — rather than
// looking like a bad file.
file := buildBIF(40, 10_000, nil)
if _, err := ParseIndex(file[:HeaderSize+16]); !errors.Is(err, ErrShort) {
t.Fatalf("err = %v, want ErrShort", err)
}
if _, _, err := ParseHeader(file[:20]); !errors.Is(err, ErrShort) {
t.Fatalf("ParseHeader err = %v, want ErrShort", err)
}
}
func TestFrameAtClampsRatherThanRefusing(t *testing.T) {
// The preview is drawn while somebody is still moving a seek target about, so a
// position past the last frame must show the last frame. Nothing is worse here than
// the thumbnail blanking at exactly the end of a film.
index, err := ParseIndex(buildBIF(3, 10_000, nil))
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
for _, tc := range []struct {
positionMs int64
want int
}{
{-5_000, 0},
{0, 0},
{9_999, 0},
{10_000, 1},
{25_000, 2},
{9_000_000, 2},
} {
if got := index.FrameAt(tc.positionMs); got != tc.want {
t.Fatalf("FrameAt(%d) = %d, want %d", tc.positionMs, got, tc.want)
}
}
}
func TestFrameRefusesAnIndexOutOfRange(t *testing.T) {
index, err := ParseIndex(buildBIF(2, 10_000, nil))
if err != nil {
t.Fatalf("ParseIndex: %v", err)
}
for _, n := range []int{-1, 2, 99} {
if _, _, ok := index.Frame(n); ok {
t.Fatalf("Frame(%d) was served", n)
}
}
}