diff --git a/CHANGELOG.md b/CHANGELOG.md index bb099a9..64ffbfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/CLAUDE.md b/CLAUDE.md index a9e1dca..e18dd15 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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::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 diff --git a/NOTICE b/NOTICE index 7c429a4..7571b78 100644 --- a/NOTICE +++ b/NOTICE @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 92d9f3c..c901cd5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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() diff --git a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt index f39e346..e2a05d8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/EmbyRepository.kt @@ -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(16, 0.75f, true) private val playableInFlight = mutableMapOf>() + /** + * 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(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(INTRO_CACHE_SIZE, 0.75f, true) private val seriesEpisodesMutex = Mutex() private val seriesEpisodesCache = LinkedHashMap(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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/Intro.kt b/app/src/main/java/com/ponzischeme89/memby/data/Intro.kt new file mode 100644 index 0000000..76d719b --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/Intro.kt @@ -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): 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt index 6162cae..aaf300e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/SettingsStore.kt @@ -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, diff --git a/app/src/main/java/com/ponzischeme89/memby/data/SkipIntroPreference.kt b/app/src/main/java/com/ponzischeme89/memby/data/SkipIntroPreference.kt new file mode 100644 index 0000000..87f20c3 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/SkipIntroPreference.kt @@ -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 = 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 diff --git a/app/src/main/java/com/ponzischeme89/memby/data/Trickplay.kt b/app/src/main/java/com/ponzischeme89/memby/data/Trickplay.kt new file mode 100644 index 0000000..59f3dfd --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/Trickplay.kt @@ -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, +) { + /** 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(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) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt index 68139e2..f56c543 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/UserPreferences.kt @@ -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 = emptyList(), val homePinnedRows: List = 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)) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt index 8620163..afe5d22 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/EmbyModels.kt @@ -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 = 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 = 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) diff --git a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt index 4f770da..9e769c4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/model/GatewayModels.kt @@ -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. */ diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt index c620ff9..ba4fbaa 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayApi.kt @@ -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. diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt index 01fd11d..5038c46 100644 --- a/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/GatewayServiceFactory.kt @@ -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" diff --git a/app/src/main/java/com/ponzischeme89/memby/data/remote/TrickplayClient.kt b/app/src/main/java/com/ponzischeme89/memby/data/remote/TrickplayClient.kt new file mode 100644 index 0000000..ee59c9d --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/data/remote/TrickplayClient.kt @@ -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() + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index 2d0edd0..238f9c6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt index 2e83d76..09ffea7 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeMovieHero.kt @@ -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): List = + 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, day: Long = 0L, + serverRows: List = emptyList(), ): List { + serverHeroPicks(serverRows).takeIf(List::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( diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt index f14cc8d..d107d2d 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MainActivity.kt @@ -125,8 +125,10 @@ import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.MyShow import com.ponzischeme89.memby.data.model.NotificationsResponse import com.ponzischeme89.memby.data.model.RecommendationOnboarding +import com.ponzischeme89.memby.data.model.UserNotification import com.ponzischeme89.memby.data.model.RecommendationPerson import com.ponzischeme89.memby.data.ServerConfig +import com.ponzischeme89.memby.ui.alerts.MyAlertsPage import com.ponzischeme89.memby.ui.detail.AiringNotice import com.ponzischeme89.memby.ui.detail.airingNoticeFor import com.ponzischeme89.memby.ui.detail.scheduleSeriesStub @@ -805,10 +807,7 @@ private fun ProfileChooser( removingProfileId: String?, onSelect: (EmbyProfile) -> Unit, onRemove: (EmbyProfile) -> Unit, - // Null from the launcher on purpose: adding a viewer belongs to setting the TV up, not - // to browsing it. Most households here have one account, and a permanent "+" tile beside - // their own name reads as a thing they are supposed to do. - onAddProfile: (() -> Unit)?, + onAddProfile: () -> Unit, onClose: (() -> Unit)?, ) { val firstFocus = remember { FocusRequester() } @@ -871,20 +870,18 @@ private fun ProfileChooser( ) } } - if (onAddProfile != null) { - ProfileTile( - name = "Add another user", - current = false, - enabled = switchingProfileId == null && removingProfileId == null, - symbol = "+", - onClick = onAddProfile, - modifier = if (orderedProfiles.isEmpty()) { - Modifier.focusRequester(firstFocus) - } else { - Modifier - }, - ) - } + ProfileTile( + name = "Add another user", + current = false, + enabled = switchingProfileId == null && removingProfileId == null, + symbol = "+", + onClick = onAddProfile, + modifier = if (orderedProfiles.isEmpty()) { + Modifier.focusRequester(firstFocus) + } else { + Modifier + }, + ) } if (onClose != null) { Spacer(Modifier.height(30.dp)) @@ -1334,6 +1331,10 @@ private fun HomeScreen( var showSettings by remember { mutableStateOf(false) } var showProfiles by remember { mutableStateOf(false) } + // Reached only from the manage-users page, which is itself two presses in behind the + // user picker — so the "+" is where somebody looking to add a viewer is already + // standing, and it is nowhere near the launcher. + var addingProfile by remember { mutableStateOf(false) } var userSwitcherVisible by remember { mutableStateOf(false) } var switchingProfileId by remember { mutableStateOf(null) } var removingProfileId by remember { mutableStateOf(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 "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 } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt index afce714..550455c 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/MyShowsNotifications.kt @@ -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, - 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 { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt index d7e99e8..409fff8 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt @@ -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, @@ -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) } } diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt new file mode 100644 index 0000000..8e06445 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsFormat.kt @@ -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" + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt new file mode 100644 index 0000000..a3ded13 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/alerts/AlertsPage.kt @@ -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, + 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 +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt index 3c87cb6..cce5076 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PlayerActivity.kt @@ -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(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 diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt new file mode 100644 index 0000000..f550176 --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownView.kt @@ -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')}" +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt new file mode 100644 index 0000000..d7b6fdc --- /dev/null +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/TrickplayPreview.kt @@ -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(FRAME_CACHE_SIZE, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = + 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 + } +} diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt index 30752da..9f44ea6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/settings/SettingsSheet.kt @@ -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 = 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.", diff --git a/app/src/main/res/layout/activity_player.xml b/app/src/main/res/layout/activity_player.xml index fc6aa54..1c612ac 100644 --- a/app/src/main/res/layout/activity_player.xml +++ b/app/src/main/res/layout/activity_player.xml @@ -27,6 +27,11 @@ appears once matters more than one that appears on every press. --> + + + diff --git a/app/src/main/res/layout/player_seek_indicator.xml b/app/src/main/res/layout/player_seek_indicator.xml index 684ab60..2c2bfd4 100644 --- a/app/src/main/res/layout/player_seek_indicator.xml +++ b/app/src/main/res/layout/player_seek_indicator.xml @@ -17,44 +17,71 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@drawable/time_remaining_cue_background" - android:gravity="center_vertical" - android:orientation="horizontal" + android:gravity="center_horizontal" + android:orientation="vertical" android:paddingStart="18dp" android:paddingTop="12dp" android:paddingEnd="22dp" android:paddingBottom="12dp"> + + android:visibility="gone" /> + android:gravity="center_vertical" + android:orientation="horizontal"> - + + + android:layout_marginStart="14dp" + android:orientation="vertical"> - + + + + diff --git a/app/src/main/res/layout/player_skip_intro.xml b/app/src/main/res/layout/player_skip_intro.xml new file mode 100644 index 0000000..aa18caf --- /dev/null +++ b/app/src/main/res/layout/player_skip_intro.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e999f84..1a1a766 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -59,6 +59,12 @@ Forward %1$s Back %1$s + + Skip intro + Intro skipped + + %1$s left NEXT UP Play now Dismiss diff --git a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt index 29e0892..4db5850 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/GatewayPayloadTest.kt @@ -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(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("""{"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( + """{"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("{}") + + assertEquals(false, intro.available) + assertEquals(0L, intro.startMs) + assertEquals(0L, intro.endMs) + } + + @Test + fun `decodes a seek preview layout`() { + val trickplay = json.decodeFromString( + """{"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("""{"available":false}""") + + assertEquals(false, trickplay.available) + assertEquals(0, trickplay.count) } @Test diff --git a/app/src/test/java/com/ponzischeme89/memby/data/IntroTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/IntroTest.kt new file mode 100644 index 0000000..34306f0 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/IntroTest.kt @@ -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 ")) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/TrickplayTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/TrickplayTest.kt new file mode 100644 index 0000000..7e6215d --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/data/TrickplayTest.kt @@ -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 = 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() + 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(("Item not found" + " ".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)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt index 4701250..be30fe0 100644 --- a/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/data/UserPreferencesTest.kt @@ -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`() { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt index 1deac74..b530713 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroScreenshotTest.kt @@ -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) { 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, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt index 0baf82f..b3ccae8 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeMovieHeroTest.kt @@ -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, diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt index b88d894..c828c6b 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ServerHomeRowsTest.kt @@ -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( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt index 6353918..c9c3b3e 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt @@ -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)) + } } diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt new file mode 100644 index 0000000..6e26d0a --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsFormatTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt new file mode 100644 index 0000000..0e541e9 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/alerts/AlertsPageScreenshotTest.kt @@ -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, + 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", + ), + ) +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/SeekIndicatorScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/SeekIndicatorScreenshotTest.kt new file mode 100644 index 0000000..d5212a3 --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/SeekIndicatorScreenshotTest.kt @@ -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(R.id.player_seek_glyph).setImageResource( + if (forward) R.drawable.ic_player_forward else R.drawable.ic_player_rewind, + ) + chip.findViewById(R.id.player_seek_amount).text = amount + chip.findViewById(R.id.player_seek_position).text = position + + if (withPreview) { + val preview = chip.findViewById(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 + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownTest.kt new file mode 100644 index 0000000..ee4821e --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/SkipIntroCountdownTest.kt @@ -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)) + } +} diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/player/SkipIntroScreenshotTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/player/SkipIntroScreenshotTest.kt new file mode 100644 index 0000000..d05eaea --- /dev/null +++ b/app/src/test/java/com/ponzischeme89/memby/ui/player/SkipIntroScreenshotTest.kt @@ -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(R.id.player_skip_intro_label).apply { + setText(label) + if (asNotice) setTextColor(Color.WHITE) + } + offer.findViewById(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(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 + } +} diff --git a/server/internal/api/api.go b/server/internal/api/api.go index 497b165..d31fafb 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -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)) diff --git a/server/internal/api/features.go b/server/internal/api/features.go index a280eb2..4e49f0d 100644 --- a/server/internal/api/features.go +++ b/server/internal/api/features.go @@ -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 " + diff --git a/server/internal/api/hero.go b/server/internal/api/hero.go new file mode 100644 index 0000000..c4397a4 --- /dev/null +++ b/server/internal/api/hero.go @@ -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 +} diff --git a/server/internal/api/hero_test.go b/server/internal/api/hero_test.go new file mode 100644 index 0000000..c9bb488 --- /dev/null +++ b/server/internal/api/hero_test.go @@ -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 +} diff --git a/server/internal/api/home.go b/server/internal/api/home.go index 3e10d33..36ed44f 100644 --- a/server/internal/api/home.go +++ b/server/internal/api/home.go @@ -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) { diff --git a/server/internal/api/intro.go b/server/internal/api/intro.go new file mode 100644 index 0000000..d39884b --- /dev/null +++ b/server/internal/api/intro.go @@ -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 +} diff --git a/server/internal/api/intro_test.go b/server/internal/api/intro_test.go new file mode 100644 index 0000000..b66af76 --- /dev/null +++ b/server/internal/api/intro_test.go @@ -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) + } +} diff --git a/server/internal/api/logcontext.go b/server/internal/api/logcontext.go index d51f403..febc036 100644 --- a/server/internal/api/logcontext.go +++ b/server/internal/api/logcontext.go @@ -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:] { diff --git a/server/internal/api/logging_test.go b/server/internal/api/logging_test.go index 69b16a0..15f10f4 100644 --- a/server/internal/api/logging_test.go +++ b/server/internal/api/logging_test.go @@ -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", diff --git a/server/internal/api/playback.go b/server/internal/api/playback.go index 8c27e63..3dd6f30 100644 --- a/server/internal/api/playback.go +++ b/server/internal/api/playback.go @@ -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), }) } diff --git a/server/internal/api/preferences.go b/server/internal/api/preferences.go index a7b1bf3..8f1c756 100644 --- a/server/internal/api/preferences.go +++ b/server/internal/api/preferences.go @@ -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.", diff --git a/server/internal/api/trickplay.go b/server/internal/api/trickplay.go new file mode 100644 index 0000000..2fb7b8f --- /dev/null +++ b/server/internal/api/trickplay.go @@ -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 +} diff --git a/server/internal/api/trickplay_test.go b/server/internal/api/trickplay_test.go new file mode 100644 index 0000000..b7e7749 --- /dev/null +++ b/server/internal/api/trickplay_test.go @@ -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) + } + } +} diff --git a/server/internal/buildinfo/VERSION b/server/internal/buildinfo/VERSION index baa9837..85cdcf9 100644 --- a/server/internal/buildinfo/VERSION +++ b/server/internal/buildinfo/VERSION @@ -1 +1 @@ -0.1.20 +0.1.23 \ No newline at end of file diff --git a/server/internal/emby/client.go b/server/internal/emby/client.go index f9dbda8..64994df 100644 --- a/server/internal/emby/client.go +++ b/server/internal/emby/client.go @@ -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 { diff --git a/server/internal/recommend/engine.go b/server/internal/recommend/engine.go index 809da77..97108b2 100644 --- a/server/internal/recommend/engine.go +++ b/server/internal/recommend/engine.go @@ -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, diff --git a/server/internal/recommend/engine_test.go b/server/internal/recommend/engine_test.go index 16f4c52..7e2cb41 100644 --- a/server/internal/recommend/engine_test.go +++ b/server/internal/recommend/engine_test.go @@ -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) + } +} diff --git a/server/internal/trickplay/bif.go b/server/internal/trickplay/bif.go new file mode 100644 index 0000000..b6ce185 --- /dev/null +++ b/server/internal/trickplay/bif.go @@ -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) +} diff --git a/server/internal/trickplay/bif_test.go b/server/internal/trickplay/bif_test.go new file mode 100644 index 0000000..bfa9ffe --- /dev/null +++ b/server/internal/trickplay/bif_test.go @@ -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("not found")); !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) + } + } +}