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:
co-authored by
Claude Opus 5
parent
4a4df7a73c
commit
80c304d86b
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +870,6 @@ private fun ProfileChooser(
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onAddProfile != null) {
|
||||
ProfileTile(
|
||||
name = "Add another user",
|
||||
current = false,
|
||||
@@ -885,7 +883,6 @@ private fun ProfileChooser(
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
if (onClose != null) {
|
||||
Spacer(Modifier.height(30.dp))
|
||||
Button(
|
||||
@@ -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("You’re 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("You’re 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,13 +17,39 @@
|
||||
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_preview"
|
||||
android:layout_width="214dp"
|
||||
android:layout_height="120dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="#FF000000"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/player_seek_glyph"
|
||||
android:layout_width="26dp"
|
||||
@@ -57,4 +83,5 @@
|
||||
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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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 " +
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:] {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 @@
|
||||
0.1.20
|
||||
0.1.23
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user